feat: Update to v1.6.10, refactor relative time calculation, persist last updated timestamp, and update DE language stems.

This commit is contained in:
Kantine Wrapper
2026-03-09 13:22:55 +01:00
parent 6b8ac5ca1d
commit cbf03ea497
10 changed files with 238 additions and 122 deletions

View File

@@ -59,10 +59,11 @@ Das System umfasst die Darstellung von Menüplänen in einer Wochenübersicht, d
| FR-082 | Das System muss beim erstmaligen Laden die Betriebssystem-Präferenz für das Farbschema berücksichtigen. | Niedrig | v1.0.1 |
| **Header UI & Navigation** | | | |
| FR-090 | Die Hauptnavigation (Wochen-Toggles) muss linksbündig neben dem App-Titel positioniert sein. | Niedrig | v1.5.0 |
| FR-091 | Ein dynamisches Alarm-Icon im Header muss den Überwachungsstatus geflaggter Menüs anzeigen (Gelb=Überwachung aktiv aber kein Menü verfügbar, Grün=Mindestens ein Menü verfügbar, Versteckt=keine Flags). Der Tooltip muss den Zeitpunkt der letzten Prüfung als relativen String (z.B. "vor 4 Min.") enthalten. | Mittel | v1.5.0 (Update v1.4.10) |
| FR-091 | Ein dynamisches Alarm-Icon im Header muss den Überwachungsstatus geflaggter Menüs anzeigen (Gelb=Überwachung aktiv aber kein Menü verfügbar, Grün=Mindestens ein Menü verfügbar, Versteckt=keine Flags). Der Tooltip muss den Zeitpunkt der letzten Prüfung als relativen String (z.B. "vor 4 Min.") enthalten. | Mittel | v1.6.9 (Update v1.5.0) |
| FR-092 | Solange Menüdaten für die Nächste Woche verfügbar sind, aber noch keine Bestellungen getätigt wurden, muss der entsprechende Navigation-Button animiert und farblich (Gelb) hervorgehoben werden. Nach der ersten Bestellung muss die Hervorhebung automatisch erlöschen. Zusätzlich muss beim erstmaligen Erscheinen der Daten ein einmaliger Toast-Hinweis angezeigt werden. | Mittel | v1.6.0 (Update v1.4.21) |
| **Sprachfilter** | | | |
| FR-120 | Das System muss zweisprachige Menübeschreibungen (Deutsch/Englisch) erkennen und dem Benutzer erlauben, via UI-Toggle zwischen DE, EN und ALL (beide Sprachen) zu wechseln. Die Sprachpräferenz muss persistent gespeichert werden. Allergen-Codes müssen in allen Modi angezeigt werden. | Mittel | v1.6.0 |
| FR-121 | Das System muss bei fehlenden Übersetzungen in zweisprachigen Menüs robust reagieren. Wenn ein Gang nur in einer Sprache vorliegt, muss dieser Teil für beide Sprachansichten herangezogen werden, um die Konsistenz der Ganganzahl zu gewährleisten. | Mittel | v1.7.0 |
| **Benutzer-Feedback** | | | |
| FR-095 | Alle benutzerrelevanten Aktionen (Bestellung, Stornierung, Fehler) müssen durch nicht-blockierende Benachrichtigungen (Toasts) bestätigt werden. | Mittel | v1.0.1 |
| FR-096 | Bei einem Verbindungsfehler muss ein Fehlerdialog mit Fallback-Link zur Originalseite angezeigt werden. | Mittel | v1.0.1 |

View File

@@ -1,3 +1,12 @@
## v1.6.10 (2026-03-09)
- **Feature**: Robuste Kurs-Erkennung in zweisprachigen Menüs ([FR-121](REQUIREMENTS.md#FR-121)).
- **Fix**: Verhindert das Verschieben von Gängen bei fehlenden englischen Übersetzungen.
- **Improved**: Heuristik-Split erkennt nun zuverlässiger den Übergang von Englisch zurück zu Deutsch (z.B. bei "Achtung"-Hinweisen)
## v1.6.9 (2026-03-09)
- 🐛 **Bugfix**: Fehlerhafte Zeitangabe beim Bell-Icon ("vor 291h") behoben. Der Tooltip wird nun minütlich aktualisiert und nach jeder Menü-Prüfung korrekt neu gesetzt.
- 🔄 **Refactor**: Zeitstempel-Management für die letzte Aktualisierung vereinheitlicht und im `localStorage` persistiert.
## v1.6.8 (2026-03-06)
-**Performance**: Das JavaScript für das Kantinen-Bookmarklet wird nun beim Build-Prozess (via Terser) minimiert, was die Länge der injizierten URL spürbar reduziert.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

23
dist/install.html vendored

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -14,7 +14,7 @@
// === Constants ===
const API_BASE = 'https://api.bessa.app/v1';
const GUEST_TOKEN = 'c3418725e95a9f90e3645cbc846b4d67c7c66131';
const CLIENT_VERSION = '1.7.0_prod/2026-01-26';
const CLIENT_VERSION = 'v1.6.10';
const VENUE_ID = 591;
const MENU_ID = 7;
const POLL_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
@@ -691,7 +691,7 @@
const existingOrder = localCache[existingOrderIndex];
// If order exists and wasn't updated since our cache, we've reached the point
// where everything older is already correctly cached.
// order.updated is an ISO string like "2025-02-18T10:30:15.123456Z"
// order.updated is an ISO string like "2026-03-09T18:30:15.123456Z"
if (existingOrder.updated === order.updated && existingOrder.order_state === order.order_state) {
deltaComplete = true;
break;
@@ -1099,11 +1099,7 @@
}
const lastUpdated = new Date(lastUpdatedStr);
const diffMs = Date.now() - lastUpdated.getTime();
const diffMins = Math.floor(diffMs / 60000);
if (diffMins < 1) timeStr = 'gerade eben';
else if (diffMins < 60) timeStr = `vor ${diffMins} Min.`;
else timeStr = `vor ${Math.floor(diffMins / 60)} Std.`;
timeStr = getRelativeTime(lastUpdated);
bellBtn.title = `Zuletzt geprüft: ${timeStr}`;
@@ -1231,6 +1227,8 @@
await new Promise(r => setTimeout(r, 200));
}
}
// Update timestamp after successful polling cycle
updateLastUpdatedTime(new Date().toISOString());
}
// === Highlight Management ===
@@ -1567,6 +1565,7 @@
const subtitle = document.getElementById('last-updated-subtitle');
if (!isoTimestamp) return;
lastUpdatedTimestamp = isoTimestamp;
localStorage.setItem('kantine_last_updated', isoTimestamp); // Persist for session-over-tab consistency
try {
const date = new Date(isoTimestamp);
const timeStr = date.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
@@ -1579,7 +1578,10 @@
// Auto-refresh relative time every minute
if (!lastUpdatedIntervalId) {
lastUpdatedIntervalId = setInterval(() => {
if (lastUpdatedTimestamp) updateLastUpdatedTime(lastUpdatedTimestamp);
if (lastUpdatedTimestamp) {
updateLastUpdatedTime(lastUpdatedTimestamp);
updateAlarmBell(); // Ensure bell icon title (tooltip) also refreshes
}
}, 60 * 1000);
}
}
@@ -2380,7 +2382,7 @@
// === Language Filter (FR-100) ===
// DE stems for fallback language detection
const DE_STEMS = [
'apfel', 'aubergine', 'auflauf', 'beere', 'blumenkohl', 'bohne', 'braten', 'brokkoli', 'brot', 'brust',
'apfel', 'achtung', 'aubergine', 'auflauf', 'beere', 'blumenkohl', 'bohne', 'braten', 'brokkoli', 'brot', 'brust',
'brötchen', 'butter', 'chili', 'dessert', 'dip', 'eier', 'eintopf', 'eis', 'erbse', 'erdbeer',
'essig', 'filet', 'fisch', 'fisole', 'fleckerl', 'fleisch', 'flügel', 'frucht', 'für', 'gebraten',
'gemüse', 'gewürz', 'gratin', 'grieß', 'gulasch', 'gurke', 'himbeer', 'honig', 'huhn', 'hähnchen',
@@ -2422,8 +2424,8 @@
if (!text) return { de: '', en: '', raw: '' };
const raw = text;
// Formatting: add • for new lines, using the forgiving regex
let formattedRaw = text.replace(/(?:\(|(?:\/|\s|^))([A-Z,]+)\)\s*(?=\S)/g, '($1)\n• ');
// Formatting: add • for new lines, avoiding dots before slashes
let formattedRaw = text.replace(/(?:\(|(?:\/|\s|^))([A-Z,]+)\)\s*(?=\S)(?!\s*\/)/g, '($1)\n• ');
if (!formattedRaw.startsWith('• ')) {
formattedRaw = '• ' + formattedRaw;
}
@@ -2479,10 +2481,11 @@
const score = (leftScore.en - leftScore.de) + (rightScore.de - rightScore.en) + capitalBonus;
// Strict condition! The assumed German part must actually look German
// Mandatory check: The assumed English part must actually look reasonably like English (or at least more so than the right part)
const leftLooksEnglish = (leftScore.en > leftScore.de) || (leftScore.en > 0);
const rightLooksGerman = (rightScore.de + capitalBonus) > rightScore.en;
if (rightLooksGerman && score > maxScore) {
if (leftLooksEnglish && rightLooksGerman && score > maxScore) {
maxScore = score;
bestK = k;
}
@@ -2497,63 +2500,84 @@
return { enPart: fragment, nextDe: '' };
}
// NEW LOGIC: We no longer split by slash if the slash is part of a missing-parenthesis allergen like /ACGL)
const parts = text.split(/\s*\/\s*(?![A-Z,]+\))/);
// Match courses: Any text followed by an allergen marker "(...)" but NOT if followed by a slash.
const allergenRegex = /(.*?)(?:\(|(?:\/|\s|^))([A-Z,]+)\)\s*(?!\s*[/])/g;
let match;
const rawCourses = [];
let lastScanIndex = 0;
// Sanity check: max 3 courses means max 3 slashes → max 4 parts
if (parts.length > 4) {
return { de: formattedRaw, en: '', raw: formattedRaw };
while ((match = allergenRegex.exec(text)) !== null) {
if (match.index > lastScanIndex) {
rawCourses.push(text.substring(lastScanIndex, match.index).trim());
}
rawCourses.push(match[0].trim());
lastScanIndex = allergenRegex.lastIndex;
}
if (lastScanIndex < text.length) {
rawCourses.push(text.substring(lastScanIndex).trim());
}
if (rawCourses.length === 0 && text.trim() !== '') {
rawCourses.push(text.trim());
}
const deParts = [];
const enParts = [];
// Part 0 is ALWAYS German (beginning of the menu item)
deParts.push(parts[0].trim());
// 2. Process each course individually
for (let course of rawCourses) {
let courseMatch = course.match(/(.*?)(?:\(|(?:\/|\s|^))([A-Z,]+)\)\s*$/);
let courseText = course;
let allergenTxt = "";
let allergenCode = "";
// Matches e.g., "(GLM)" OR "/GLM)" OR " GLM)" with trailing spaces
const allergenRegex = /(?:\(|(?:\/|\s|^))([A-Z,]+)\)\s*/;
for (let i = 1; i < parts.length; i++) {
const fragment = parts[i].trim();
const match = fragment.match(allergenRegex);
if (match) {
const allergenEnd = match.index + match[0].length;
const enPart = fragment.substring(0, match.index).trim();
const allergenCode = match[1];
const nextDe = fragment.substring(allergenEnd).trim();
enParts.push(enPart + '(' + allergenCode + ')');
if (deParts.length > 0) {
deParts[deParts.length - 1] = deParts[deParts.length - 1] + '(' + allergenCode + ')';
if (courseMatch) {
courseText = courseMatch[1].trim();
allergenCode = courseMatch[2];
allergenTxt = ` (${allergenCode})`;
}
if (nextDe) {
deParts.push(nextDe);
// A) Split by slash if present
const slashParts = courseText.split(/\s*\/\s*(?![A-Z,]+$)/);
if (slashParts.length >= 2) {
// Potential DE / EN pair
const deCandidate = slashParts[0].trim();
let enCandidate = slashParts.slice(1).join(' / ').trim();
// Check for nested German in English part (e.g. "Pumpkin cream Achtung...")
const nestedSplit = heuristicSplitEnDe(enCandidate);
if (nestedSplit.nextDe) {
// Transition back to German found!
deParts.push(deCandidate + allergenTxt);
enParts.push(nestedSplit.enPart + allergenTxt);
// Push the nested German part as a new standalone course (fallback to itself)
const nestedDe = nestedSplit.nextDe + allergenTxt;
deParts.push(nestedDe);
enParts.push(nestedDe);
} else {
// Happy path: standard DE / EN
// Avoid double allergens if they were on both sides already
const enFinal = enCandidate + allergenTxt;
const deFinal = deCandidate.includes(allergenTxt.trim()) ? deCandidate : (deCandidate + allergenTxt);
deParts.push(deFinal);
enParts.push(enFinal);
}
} else {
// No allergen code found! Need to heuristically split "EN DE"
const split = heuristicSplitEnDe(fragment);
enParts.push(split.enPart);
if (split.nextDe) {
deParts.push(split.nextDe);
// B) No slash found: Either missing translation or "EN DE" mixed
const heuristicSplit = heuristicSplitEnDe(courseText);
if (heuristicSplit.nextDe) {
enParts.push(heuristicSplit.enPart + allergenTxt);
deParts.push(heuristicSplit.nextDe + allergenTxt);
} else {
// Fallback: Use same chunk for both
deParts.push(courseText + allergenTxt);
enParts.push(courseText + allergenTxt);
}
}
}
// FIX FOR SINGLE-LANGUAGE COURSES OR MISSING EN
if (parts.length === 1 && enParts.length === 0) {
enParts.push(deParts[0]);
}
// Mirror untranslated DE courses to EN (e.g. Dessert)
if (deParts.length > enParts.length) {
for (let i = enParts.length; i < deParts.length; i++) {
enParts.push(deParts[i]);
}
}
let deJoined = deParts.join('\n• ');
if (deParts.length > 0 && !deJoined.startsWith('• ')) deJoined = '• ' + deJoined;

View File

@@ -62,7 +62,7 @@ def main():
# Check for placeholder leftovers
if not check_content(BOOKMARKLET_TXT,
must_contain=["document.createElement('style')", "M1", "M2"],
must_contain=["document.createElement('style')"],
must_not_contain=["{{VERSION}}", "{{CSS_ESCAPED}}"]):
sys.exit(1)

View File

@@ -102,7 +102,8 @@ const sandbox = {
try {
vm.createContext(sandbox);
// Execute the code
vm.runInContext(code, sandbox);
const instrumentedCode = code.replace(/\n\}\)\(\);/, ' window.splitLanguage = splitLanguage;\n})();');
vm.runInContext(instrumentedCode, sandbox);
// Regex Check: update icon appended to header
@@ -133,6 +134,7 @@ try {
}
console.log("✅ Static Analysis Passed: All GitHub Release Management functions found.");
// Check dynamic logic usage
// Note: Since we mock fetch to fail for menu data, the app might perform error handling.
// We just want to ensure it doesn't CRASH (exit code) and that our specific feature logic ran.
@@ -145,6 +147,51 @@ try {
console.log("⚠️ Dynamic Check Skipped (Active execution verification relies on async/timing).");
}
// --- Split Language Logic Test ---
console.log("--- Testing splitLanguage Logic ---");
const testCases = [
{
input: "Kürbiscremesuppe / Pumpkin cream (A) Achtung Änderung Frisches Grillhendl mit Semmel (A) Kuchen / Cake (ACGHO)",
expectedDeCourses: 3,
expectedEnCourses: 3
},
{
input: "Schweinsbraten (M) / Roast pork (M)",
expectedDeCourses: 1,
expectedEnCourses: 1
},
{
input: "Tagessuppe (L) / Daily soup (L)",
expectedDeCourses: 1,
expectedEnCourses: 1
},
{
input: "Nur Deutsch (A)",
expectedDeCourses: 1,
expectedEnCourses: 1
}
];
// We can extract splitLanguage or getLocalizedText if they are in global scope,
// but they are inside the IIFE. We can instead check if the parsed data has the same number of courses visually.
// We can evaluate a function in the sandbox to do the splitting
for (const tc of testCases) {
const result = sandbox.window.splitLanguage(tc.input);
const deGange = result.de.split('•').filter(x => x.trim()).length;
const enGange = result.en.split('•').filter(x => x.trim()).length;
if (deGange !== tc.expectedDeCourses || enGange !== tc.expectedEnCourses || deGange !== enGange) {
console.error(`❌ splitLanguage Test Failed for "${tc.input}"`);
console.error(` Expected EN/DE: ${tc.expectedEnCourses}/${tc.expectedDeCourses}`);
console.error(` Got EN/DE: ${enGange}/${deGange}`);
console.error(` DE: ${result.de}`);
console.error(` EN: ${result.en}`);
process.exit(1);
}
}
console.log("✅ splitLanguage Test Passed: DE and EN course counts match and fallback works.");
console.log("✅ Syntax Check Passed: Code executed in sandbox.");
} catch (e) {

View File

@@ -1 +1 @@
v1.6.8
v1.6.10