Compare commits

..

7 Commits

Author SHA1 Message Date
Kantine Wrapper
136fe7d355 dist files for v1.4.2 built 2026-02-23 08:24:51 +01:00
Kantine Wrapper
f5b3635773 dist files for v1.4.1 built 2026-02-22 22:18:41 +01:00
Kantine Wrapper
bff8669cd7 dist files for v1.4.0 built 2026-02-22 22:14:32 +01:00
Kantine Wrapper
008462e304 dist files for v1.4.0 built 2026-02-22 22:02:09 +01:00
Kantine Wrapper
9237e911d2 dist files for v1.4.0 built 2026-02-22 21:57:53 +01:00
Kantine Wrapper
5bb0e01136 dist files for v1.4.0 built 2026-02-22 21:53:16 +01:00
Kantine Wrapper
f19827ae91 dist files for v1.4.0 built 2026-02-22 21:40:41 +01:00
13 changed files with 933 additions and 45 deletions

View File

@@ -26,16 +26,15 @@ trigger: always_on
- **Interaction**: Be proactive, concise, and helpful. Focus on code value. - **Interaction**: Be proactive, concise, and helpful. Focus on code value.
## 4. Development Standards ## 4. Development Standards
**Tech Stack:**
- **Container**: Docker-based application.
- **Config**: Configurable port.
**Coding Style:** **Coding Style:**
- **Typing**: Strict typing where applicable. - **Typing**: Strict typing where applicable.
- **Comments**: Concise, English. - **Comments**: Concise, English.
- **Frontend/UX**: - **Frontend/UX**:
- Priority on Usability. - Priority on Usability.
- **MANDATORY**: Tooltips/Help texts for all interactions. - **MANDATORY**: Tooltips/Help texts for all interactions.
- **Versioning**:
- version.txt has to be increased for any implemented features or fixed bugs)
- a change summary has to be documented in changelog.md
## 5. Agentic Workflow & Artifacts ## 5. Agentic Workflow & Artifacts
**Core Philosophy**: Plan first, act second. **Core Philosophy**: Plan first, act second.

View File

@@ -38,8 +38,9 @@ Das System umfasst die Darstellung von Menüplänen in einer Wochenübersicht, d
| FR-031 | Authentifizierte Benutzer müssen eine bestehende Bestellung direkt aus der Übersicht stornieren können. | Hoch | v1.0.1 | | FR-031 | Authentifizierte Benutzer müssen eine bestehende Bestellung direkt aus der Übersicht stornieren können. | Hoch | v1.0.1 |
| FR-032 | Nach Bestellschluss (Cutoff-Zeit) dürfen keine neuen Bestellungen oder Stornierungen für diesen Tag möglich sein. | Hoch | v1.0.1 | | FR-032 | Nach Bestellschluss (Cutoff-Zeit) dürfen keine neuen Bestellungen oder Stornierungen für diesen Tag möglich sein. | Hoch | v1.0.1 |
| FR-033 | Es muss möglich sein, dasselbe Menü mehrfach zu bestellen. Bei Mehrfachbestellungen muss die Anzahl angezeigt werden. | Niedrig | v1.0.1 | | FR-033 | Es muss möglich sein, dasselbe Menü mehrfach zu bestellen. Bei Mehrfachbestellungen muss die Anzahl angezeigt werden. | Niedrig | v1.0.1 |
| **Kostentransparenz** | | | | | **Kostentransparenz & Bestellhistorie** | | | |
| FR-040 | Das System muss die Gesamtkosten aller Bestellungen einer Woche automatisch berechnen und anzeigen. | Mittel | v1.1.0 | | FR-040 | Das System muss die Gesamtkosten aller Bestellungen einer Woche automatisch berechnen und anzeigen. | Mittel | v1.1.0 |
| FR-041 | Das System muss dem Benutzer eine paginierte oder vollständige Bestellhistorie (gruppiert nach Monat und KW) mit Fortschrittsanzeige auf Abruf in einem Modal bereitstellen. | Mittel | v1.4.0 |
| **Bestell-Countdown** | | | | | **Bestell-Countdown** | | | |
| FR-050 | Das System muss vor Bestellschluss einen visuell hervorgehobenen Countdown anzeigen. | Mittel | v1.1.0 | | FR-050 | Das System muss vor Bestellschluss einen visuell hervorgehobenen Countdown anzeigen. | Mittel | v1.1.0 |
| **Menü-Flagging & Benachrichtigungen** | | | | | **Menü-Flagging & Benachrichtigungen** | | | |

1
bessa_orders_debug.json Executable file
View File

@@ -0,0 +1 @@
{"next":null,"previous":null,"results":[]}

View File

@@ -1,3 +1,13 @@
## v1.4.2 (2026-02-23)
- **Fix**: Das "Heute Bestellt" Menü leuchtet nun stimmig im Design-Violett statt Blau.
- **Fix**: Abfangen des GitHub API Rate Limit (403) im Versionsdialog mit einer freundlicheren Fehlermeldung, da der User-Agent im Browser nicht manuell gesetzt werden darf.
## v1.4.1 (2026-02-22)
- **UX Verbesserungen**: Bestellhistorie gruppiert nach Jahren und Monaten mittels einklappbarem Akkordeon. Monatssummen integriert und Stati farblich abgehoben (Offen, Abgeschlossen, Storniert).
## v1.4.0 (2026-02-22)
- **Feature**: Bestellhistorie per Knopfdruck abrufbar. Übersichtliche Darstellung, gruppiert nach Monaten und Kalenderwochen, inklusive Stornos. 📜✨
## v1.3.2 (2026-02-19) ## v1.3.2 (2026-02-19)
- **Fix**: Falsche Anzahl an Highlight-Menüs im "Nächste Woche"-Badge korrigiert (zählte alle Menüs statt nur Highlights). 🐛 - **Fix**: Falsche Anzahl an Highlight-Menüs im "Nächste Woche"-Badge korrigiert (zählte alle Menüs statt nur Highlights). 🐛

10
cors_server.py Executable file
View File

@@ -0,0 +1,10 @@
import http.server
import socketserver
class CORSRequestHandler(http.server.SimpleHTTPRequestHandler):
def end_headers(self):
self.send_header('Access-Control-Allow-Origin', '*')
super().end_headers()
with socketserver.TCPServer(("127.0.0.1", 8080), CORSRequestHandler) as httpd:
httpd.serve_forever()

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

25
dist/install.html vendored

File diff suppressed because one or more lines are too long

View File

@@ -448,7 +448,6 @@ body {
.modal-content { .modal-content {
background: var(--bg-card); background: var(--bg-card);
/* Changed from --surface */
width: 90%; width: 90%;
max-width: 400px; max-width: 400px;
border-radius: 16px; border-radius: 16px;
@@ -457,6 +456,174 @@ body {
animation: modalSlide 0.3s ease-out; animation: modalSlide 0.3s ease-out;
} }
/* History Modal specific */
.history-modal-content {
max-width: 600px;
max-height: 85vh;
display: flex;
flex-direction: column;
}
.history-modal-content .modal-body {
overflow-y: auto;
padding: 0;
/* Padding is handled by inner elements */
}
/* History Styles */
.history-year-group {
margin-bottom: 16px;
}
.history-year-header {
background: var(--bg-card);
padding: 12px 20px;
margin: 0;
font-size: 1.2rem;
font-weight: 700;
color: var(--text-primary);
border-bottom: 2px solid var(--border-color);
position: sticky;
top: 0;
z-index: 12;
}
.history-month-group {
border-bottom: 1px solid var(--border-color);
}
.history-month-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 14px 20px;
margin: 0;
font-size: 1.05rem;
font-weight: 600;
color: var(--text-primary);
background: var(--bg-body);
cursor: pointer;
transition: background 0.2s;
}
.history-month-header:hover {
background: var(--border-color);
/* Slight hover effect */
}
.history-month-summary {
display: flex;
align-items: center;
gap: 12px;
font-size: 0.95rem;
color: var(--text-secondary);
}
.history-month-content {
display: none;
/* Collapsed by default */
background: var(--bg-card);
}
.history-month-group.open .history-month-content {
display: block;
/* Expanded when open class is present */
}
.history-month-group.open .history-month-header .material-icons-round {
transform: rotate(180deg);
}
.history-month-header .material-icons-round {
transition: transform 0.3s;
font-size: 20px;
}
.history-week-group {
padding: 12px 20px;
border-bottom: 1px dashed var(--border-color);
}
.history-week-group:last-child {
border-bottom: none;
}
.history-week-header {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.9rem;
font-weight: 600;
color: var(--text-secondary);
margin-bottom: 10px;
}
.history-week-summary {
font-size: 0.85rem;
font-weight: 500;
background: rgba(100, 116, 139, 0.1);
padding: 4px 10px;
border-radius: 12px;
}
.history-items {
display: flex;
flex-direction: column;
gap: 8px;
}
.history-item {
display: grid;
grid-template-columns: 50px 1fr auto;
align-items: center;
gap: 12px;
padding: 10px 12px;
background: var(--bg-body);
border-radius: 8px;
border: 1px solid var(--border-color);
}
.history-item-date {
font-size: 0.85rem;
color: var(--text-secondary);
font-weight: 500;
}
.history-item-details {
display: flex;
flex-direction: column;
gap: 4px;
}
.history-item-name {
font-size: 0.95rem;
font-weight: 500;
color: var(--text-primary);
}
.history-item-price {
font-weight: 600;
color: var(--text-primary);
}
.history-item-status {
font-size: 0.8rem;
font-weight: 600;
color: var(--text-primary);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.history-item-cancelled {
opacity: 0.5;
filter: grayscale(1);
}
.history-item-price-cancelled {
text-decoration: line-through;
color: var(--text-secondary);
}
@keyframes modalSlide { @keyframes modalSlide {
from { from {
transform: translateY(20px); transform: translateY(20px);
@@ -629,7 +796,7 @@ body {
.menu-item.today-ordered { .menu-item.today-ordered {
border: 2px solid var(--accent-color); border: 2px solid var(--accent-color);
box-shadow: 0 0 20px rgba(96, 165, 250, 0.4); box-shadow: 0 0 20px rgba(139, 92, 246, 0.4);
border-radius: 8px; border-radius: 8px;
padding: 1rem; padding: 1rem;
margin: 0 -1rem 1.5rem -1rem; margin: 0 -1rem 1.5rem -1rem;
@@ -641,15 +808,15 @@ body {
@keyframes pulse-glow { @keyframes pulse-glow {
0% { 0% {
box-shadow: 0 0 15px rgba(96, 165, 250, 0.3); box-shadow: 0 0 15px rgba(139, 92, 246, 0.3);
} }
50% { 50% {
box-shadow: 0 0 25px rgba(96, 165, 250, 0.6); box-shadow: 0 0 25px rgba(139, 92, 246, 0.6);
} }
100% { 100% {
box-shadow: 0 0 15px rgba(96, 165, 250, 0.3); box-shadow: 0 0 15px rgba(139, 92, 246, 0.3);
} }
} }
@@ -1679,8 +1846,31 @@ body {
// Orders endpoint // Orders endpoint
if (urlStr.includes('/user/orders/') && (!options || options.method === 'GET' || !options.method)) { if (urlStr.includes('/user/orders/') && (!options || options.method === 'GET' || !options.method)) {
console.log('[MOCK] Returning mock orders'); console.log('[MOCK] Returning mock orders');
// Formatter for history mapping
const mappedOrders = mockOrders.map(o => ({
id: o.id,
date: `${o.date}T10:00:00Z`,
order_state: o.status === 'cancelled' ? 9 : 5,
total: o.price || '6.50',
items: [{
article: o.article,
name: o.article_name,
price: o.price || '6.50',
amount: 1
}]
}));
// Handle lazy load / pagination if requesting full history
if (urlStr.includes('limit=50')) {
return Promise.resolve(new Response(JSON.stringify({
count: mappedOrders.length,
next: null,
results: mappedOrders
}), { status: 200, headers: { 'Content-Type': 'application/json' } }));
}
return Promise.resolve(new Response(JSON.stringify({ return Promise.resolve(new Response(JSON.stringify({
results: mockOrders results: mappedOrders
}), { status: 200, headers: { 'Content-Type': 'application/json' } })); }), { status: 200, headers: { 'Content-Type': 'application/json' } }));
} }
@@ -1807,7 +1997,7 @@ body {
<div class="brand"> <div class="brand">
<span class="material-icons-round logo-icon">restaurant_menu</span> <span class="material-icons-round logo-icon">restaurant_menu</span>
<div class="header-left"> <div class="header-left">
<h1>Kantinen Übersicht <small class="version-tag" style="font-size: 0.6em; opacity: 0.7; font-weight: 400; cursor: pointer;" title="Klick für Versionsmenü">v1.3.2</small></h1> <h1>Kantinen Übersicht <small class="version-tag" style="font-size: 0.6em; opacity: 0.7; font-weight: 400; cursor: pointer;" title="Klick für Versionsmenü">v1.4.2</small></h1>
<div id="last-updated-subtitle" class="subtitle"></div> <div id="last-updated-subtitle" class="subtitle"></div>
</div> </div>
</div> </div>
@@ -1819,6 +2009,9 @@ body {
<button id="btn-refresh" class="icon-btn" aria-label="Menüdaten aktualisieren" title="Menüdaten neu laden"> <button id="btn-refresh" class="icon-btn" aria-label="Menüdaten aktualisieren" title="Menüdaten neu laden">
<span class="material-icons-round">refresh</span> <span class="material-icons-round">refresh</span>
</button> </button>
<button id="btn-history" class="icon-btn" aria-label="Bestellhistorie" title="Bestellhistorie">
<span class="material-icons-round">receipt_long</span>
</button>
<button id="btn-highlights" class="icon-btn" aria-label="Persönliche Highlights verwalten" title="Persönliche Highlights verwalten"> <button id="btn-highlights" class="icon-btn" aria-label="Persönliche Highlights verwalten" title="Persönliche Highlights verwalten">
<span class="material-icons-round">label</span> <span class="material-icons-round">label</span>
</button> </button>
@@ -1909,6 +2102,30 @@ body {
</div> </div>
</div> </div>
<div id="history-modal" class="modal hidden">
<div class="modal-content history-modal-content">
<div class="modal-header">
<h2>Bestellhistorie</h2>
<button id="btn-history-close" class="icon-btn" aria-label="Close">
<span class="material-icons-round">close</span>
</button>
</div>
<div class="modal-body">
<div id="history-loading" class="hidden">
<p id="history-progress-text" style="text-align: center; margin-bottom: 1rem; color: var(--text-secondary);">Lade Historie...</p>
<div class="progress-container">
<div class="progress-bar">
<div id="history-progress-fill" class="progress-fill"></div>
</div>
</div>
</div>
<div id="history-content">
<!-- Dynamically populated -->
</div>
</div>
</div>
</div>
<div id="version-modal" class="modal hidden"> <div id="version-modal" class="modal hidden">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
@@ -1919,7 +2136,7 @@ body {
</div> </div>
<div class="modal-body"> <div class="modal-body">
<div style="margin-bottom: 1rem;"> <div style="margin-bottom: 1rem;">
<strong>Aktuell:</strong> <span id="version-current">v1.3.2</span> <strong>Aktuell:</strong> <span id="version-current">v1.4.2</span>
</div> </div>
<div class="dev-toggle"> <div class="dev-toggle">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer;"> <label style="display:flex;align-items:center;gap:8px;cursor:pointer;">
@@ -1971,17 +2188,26 @@ body {
const btnAddTag = document.getElementById('btn-add-tag'); const btnAddTag = document.getElementById('btn-add-tag');
const tagInput = document.getElementById('tag-input'); const tagInput = document.getElementById('tag-input');
btnHighlights.addEventListener('click', () => { // History Modal
highlightsModal.classList.remove('hidden'); const btnHistory = document.getElementById('btn-history');
renderTagsList(); const historyModal = document.getElementById('history-modal');
tagInput.focus(); const btnHistoryClose = document.getElementById('btn-history-close');
btnHistory.addEventListener('click', () => {
if (!authToken) {
loginModal.classList.remove('hidden');
return;
}
historyModal.classList.remove('hidden');
fetchFullOrderHistory();
}); });
btnHighlightsClose.addEventListener('click', () => { btnHistoryClose.addEventListener('click', () => {
highlightsModal.classList.add('hidden'); historyModal.classList.add('hidden');
}); });
window.addEventListener('click', (e) => { window.addEventListener('click', (e) => {
if (e.target === historyModal) historyModal.classList.add('hidden');
if (e.target === highlightsModal) highlightsModal.classList.add('hidden'); if (e.target === highlightsModal) highlightsModal.classList.add('hidden');
}); });
@@ -2249,6 +2475,202 @@ body {
} }
} }
// === History Modal Flow ===
let fullOrderHistoryCache = null;
async function fetchFullOrderHistory() {
const historyLoading = document.getElementById('history-loading');
const historyContent = document.getElementById('history-content');
const progressFill = document.getElementById('history-progress-fill');
const progressText = document.getElementById('history-progress-text');
if (fullOrderHistoryCache) {
renderHistory(fullOrderHistoryCache);
return;
}
if (!authToken) return;
historyContent.innerHTML = '';
historyLoading.classList.remove('hidden');
progressFill.style.width = '0%';
progressText.textContent = 'Lade Bestellhistorie...';
let nextUrl = `${API_BASE}/user/orders/?venue=${VENUE_ID}&ordering=-created&limit=50`;
let allOrders = [];
let totalCount = 0;
try {
while (nextUrl) {
const response = await fetch(nextUrl, { headers: apiHeaders(authToken) });
if (!response.ok) throw new Error(`Fetch failed: ${response.status}`);
const data = await response.json();
if (data.count && totalCount === 0) {
totalCount = data.count;
}
allOrders = allOrders.concat(data.results || []);
// Update progress
if (totalCount > 0) {
const pct = Math.round((allOrders.length / totalCount) * 100);
progressFill.style.width = `${pct}%`;
progressText.textContent = `Lade Bestellung ${allOrders.length} von ${totalCount}...`;
} else {
progressText.textContent = `Lade Bestellung ${allOrders.length}...`;
}
nextUrl = data.next;
}
fullOrderHistoryCache = allOrders;
renderHistory(fullOrderHistoryCache);
} catch (error) {
console.error('Error fetching full history:', error);
historyContent.innerHTML = `<p style="color:var(--error-color);text-align:center;">Fehler beim Laden der Historie.</p>`;
} finally {
historyLoading.classList.add('hidden');
}
}
function renderHistory(orders) {
const content = document.getElementById('history-content');
if (!orders || orders.length === 0) {
content.innerHTML = '<p style="text-align:center;color:var(--text-secondary);padding:20px;">Keine Bestellungen gefunden.</p>';
return;
}
// Group by Year -> Month -> Week Number (KW)
const groups = {};
orders.forEach(order => {
const d = new Date(order.date);
const y = d.getFullYear();
const m = d.getMonth();
const monthKey = `${y}-${m.toString().padStart(2, '0')}`;
const monthName = d.toLocaleString('de-AT', { month: 'long' }); // Only month name
const kw = getISOWeek(d);
if (!groups[y]) {
groups[y] = { year: y, months: {} };
}
if (!groups[y].months[monthKey]) {
groups[y].months[monthKey] = { name: monthName, year: y, monthIndex: m, count: 0, total: 0, weeks: {} };
}
if (!groups[y].months[monthKey].weeks[kw]) {
groups[y].months[monthKey].weeks[kw] = { label: `KW ${kw}`, items: [], count: 0, total: 0 };
}
const items = order.items || [];
items.forEach(item => {
const itemPrice = parseFloat(item.price || order.total || 0);
groups[y].months[monthKey].weeks[kw].items.push({
date: order.date,
name: item.name || 'Menü',
price: itemPrice,
state: order.order_state // 9 is cancelled, 5 is active, 8 is completed
});
if (order.order_state !== 9) {
groups[y].months[monthKey].weeks[kw].count++;
groups[y].months[monthKey].weeks[kw].total += itemPrice;
groups[y].months[monthKey].count++;
groups[y].months[monthKey].total += itemPrice;
}
});
});
// Generate HTML
const sortedYears = Object.keys(groups).sort((a, b) => b - a);
let html = '';
sortedYears.forEach(yKey => {
const yearGroup = groups[yKey];
html += `<div class="history-year-group">
<h2 class="history-year-header">${yearGroup.year}</h2>`;
const sortedMonths = Object.keys(yearGroup.months).sort((a, b) => b.localeCompare(a));
sortedMonths.forEach(mKey => {
const monthGroup = yearGroup.months[mKey];
html += `<div class="history-month-group">
<div class="history-month-header" tabindex="0" role="button" aria-expanded="false">
<div style="display:flex; flex-direction:column; gap:4px;">
<span>${monthGroup.name}</span>
<div class="history-month-summary">
<span>${monthGroup.count} Bestellungen &bull; <strong>€${monthGroup.total.toFixed(2)}</strong></span>
</div>
</div>
<span class="material-icons-round">expand_more</span>
</div>
<div class="history-month-content">`;
const sortedKWs = Object.keys(monthGroup.weeks).sort((a, b) => parseInt(b) - parseInt(a));
sortedKWs.forEach(kw => {
const week = monthGroup.weeks[kw];
html += `<div class="history-week-group">
<div class="history-week-header">
<strong>${week.label}</strong>
<span>${week.count} Bestellungen &bull; <strong>€${week.total.toFixed(2)}</strong></span>
</div>`;
week.items.forEach(item => {
const dateObj = new Date(item.date);
const dayStr = dateObj.toLocaleDateString('de-AT', { weekday: 'short', day: '2-digit', month: '2-digit' });
let statusBadge = '';
if (item.state === 9) {
statusBadge = '<span class="history-item-status">Storniert</span>';
} else if (item.state === 8) {
statusBadge = '<span class="history-item-status">Abgeschlossen</span>';
} else {
statusBadge = '<span class="history-item-status">Übertragen</span>';
}
html += `
<div class="history-item ${item.state === 9 ? 'history-item-cancelled' : ''}">
<div style="font-size: 0.85rem; color: var(--text-secondary);">${dayStr}</div>
<div class="history-item-details">
<span class="history-item-name">${escapeHtml(item.name)}</span>
<div>${statusBadge}</div>
</div>
<div class="history-item-price ${item.state === 9 ? 'history-item-price-cancelled' : ''}">€${item.price.toFixed(2)}</div>
</div>`;
});
html += `</div>`;
});
html += `</div></div>`; // Close month-content and month-group
});
html += `</div>`; // Close year-group
});
content.innerHTML = html;
// Bind Accordion Click Events via JS
const monthHeaders = content.querySelectorAll('.history-month-header');
monthHeaders.forEach(header => {
header.addEventListener('click', () => {
const parentGroup = header.parentElement;
const isOpen = parentGroup.classList.contains('open');
// Toggle current
if (isOpen) {
parentGroup.classList.remove('open');
header.setAttribute('aria-expanded', 'false');
} else {
parentGroup.classList.add('open');
header.setAttribute('aria-expanded', 'true');
}
});
});
}
// === Place Order === // === Place Order ===
async function placeOrder(date, articleId, name, price, description) { async function placeOrder(date, articleId, name, price, description) {
if (!authToken) return; if (!authToken) return;
@@ -3277,7 +3699,12 @@ body {
: `${GITHUB_API}/releases?per_page=20`; : `${GITHUB_API}/releases?per_page=20`;
const resp = await fetch(endpoint, { headers: githubHeaders() }); const resp = await fetch(endpoint, { headers: githubHeaders() });
if (!resp.ok) throw new Error(`GitHub API ${resp.status}`); if (!resp.ok) {
if (resp.status === 403) {
throw new Error('API Rate Limit erreicht (403). Bitte später erneut versuchen.');
}
throw new Error(`GitHub API ${resp.status}`);
}
const data = await resp.json(); const data = await resp.json();
// Normalize to common format: { tag, name, url, body } // Normalize to common format: { tag, name, url, body }
@@ -3294,7 +3721,7 @@ body {
// Periodic update check (runs on init + every hour) // Periodic update check (runs on init + every hour)
async function checkForUpdates() { async function checkForUpdates() {
const currentVersion = 'v1.3.2'; const currentVersion = 'v1.4.2';
const devMode = localStorage.getItem('kantine_dev_mode') === 'true'; const devMode = localStorage.getItem('kantine_dev_mode') === 'true';
try { try {
@@ -3335,7 +3762,7 @@ body {
const modal = document.getElementById('version-modal'); const modal = document.getElementById('version-modal');
const container = document.getElementById('version-list-container'); const container = document.getElementById('version-list-container');
const devToggle = document.getElementById('dev-mode-toggle'); const devToggle = document.getElementById('dev-mode-toggle');
const currentVersion = 'v1.3.2'; const currentVersion = 'v1.4.2';
if (!modal) return; if (!modal) return;
modal.classList.remove('hidden'); modal.classList.remove('hidden');

View File

@@ -83,6 +83,9 @@
<button id="btn-refresh" class="icon-btn" aria-label="Menüdaten aktualisieren" title="Menüdaten neu laden"> <button id="btn-refresh" class="icon-btn" aria-label="Menüdaten aktualisieren" title="Menüdaten neu laden">
<span class="material-icons-round">refresh</span> <span class="material-icons-round">refresh</span>
</button> </button>
<button id="btn-history" class="icon-btn" aria-label="Bestellhistorie" title="Bestellhistorie">
<span class="material-icons-round">receipt_long</span>
</button>
<button id="btn-highlights" class="icon-btn" aria-label="Persönliche Highlights verwalten" title="Persönliche Highlights verwalten"> <button id="btn-highlights" class="icon-btn" aria-label="Persönliche Highlights verwalten" title="Persönliche Highlights verwalten">
<span class="material-icons-round">label</span> <span class="material-icons-round">label</span>
</button> </button>
@@ -173,6 +176,30 @@
</div> </div>
</div> </div>
<div id="history-modal" class="modal hidden">
<div class="modal-content history-modal-content">
<div class="modal-header">
<h2>Bestellhistorie</h2>
<button id="btn-history-close" class="icon-btn" aria-label="Close">
<span class="material-icons-round">close</span>
</button>
</div>
<div class="modal-body">
<div id="history-loading" class="hidden">
<p id="history-progress-text" style="text-align: center; margin-bottom: 1rem; color: var(--text-secondary);">Lade Historie...</p>
<div class="progress-container">
<div class="progress-bar">
<div id="history-progress-fill" class="progress-fill"></div>
</div>
</div>
</div>
<div id="history-content">
<!-- Dynamically populated -->
</div>
</div>
</div>
</div>
<div id="version-modal" class="modal hidden"> <div id="version-modal" class="modal hidden">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
@@ -235,17 +262,26 @@
const btnAddTag = document.getElementById('btn-add-tag'); const btnAddTag = document.getElementById('btn-add-tag');
const tagInput = document.getElementById('tag-input'); const tagInput = document.getElementById('tag-input');
btnHighlights.addEventListener('click', () => { // History Modal
highlightsModal.classList.remove('hidden'); const btnHistory = document.getElementById('btn-history');
renderTagsList(); const historyModal = document.getElementById('history-modal');
tagInput.focus(); const btnHistoryClose = document.getElementById('btn-history-close');
btnHistory.addEventListener('click', () => {
if (!authToken) {
loginModal.classList.remove('hidden');
return;
}
historyModal.classList.remove('hidden');
fetchFullOrderHistory();
}); });
btnHighlightsClose.addEventListener('click', () => { btnHistoryClose.addEventListener('click', () => {
highlightsModal.classList.add('hidden'); historyModal.classList.add('hidden');
}); });
window.addEventListener('click', (e) => { window.addEventListener('click', (e) => {
if (e.target === historyModal) historyModal.classList.add('hidden');
if (e.target === highlightsModal) highlightsModal.classList.add('hidden'); if (e.target === highlightsModal) highlightsModal.classList.add('hidden');
}); });
@@ -513,6 +549,202 @@
} }
} }
// === History Modal Flow ===
let fullOrderHistoryCache = null;
async function fetchFullOrderHistory() {
const historyLoading = document.getElementById('history-loading');
const historyContent = document.getElementById('history-content');
const progressFill = document.getElementById('history-progress-fill');
const progressText = document.getElementById('history-progress-text');
if (fullOrderHistoryCache) {
renderHistory(fullOrderHistoryCache);
return;
}
if (!authToken) return;
historyContent.innerHTML = '';
historyLoading.classList.remove('hidden');
progressFill.style.width = '0%';
progressText.textContent = 'Lade Bestellhistorie...';
let nextUrl = `${API_BASE}/user/orders/?venue=${VENUE_ID}&ordering=-created&limit=50`;
let allOrders = [];
let totalCount = 0;
try {
while (nextUrl) {
const response = await fetch(nextUrl, { headers: apiHeaders(authToken) });
if (!response.ok) throw new Error(`Fetch failed: ${response.status}`);
const data = await response.json();
if (data.count && totalCount === 0) {
totalCount = data.count;
}
allOrders = allOrders.concat(data.results || []);
// Update progress
if (totalCount > 0) {
const pct = Math.round((allOrders.length / totalCount) * 100);
progressFill.style.width = `${pct}%`;
progressText.textContent = `Lade Bestellung ${allOrders.length} von ${totalCount}...`;
} else {
progressText.textContent = `Lade Bestellung ${allOrders.length}...`;
}
nextUrl = data.next;
}
fullOrderHistoryCache = allOrders;
renderHistory(fullOrderHistoryCache);
} catch (error) {
console.error('Error fetching full history:', error);
historyContent.innerHTML = `<p style="color:var(--error-color);text-align:center;">Fehler beim Laden der Historie.</p>`;
} finally {
historyLoading.classList.add('hidden');
}
}
function renderHistory(orders) {
const content = document.getElementById('history-content');
if (!orders || orders.length === 0) {
content.innerHTML = '<p style="text-align:center;color:var(--text-secondary);padding:20px;">Keine Bestellungen gefunden.</p>';
return;
}
// Group by Year -> Month -> Week Number (KW)
const groups = {};
orders.forEach(order => {
const d = new Date(order.date);
const y = d.getFullYear();
const m = d.getMonth();
const monthKey = `${y}-${m.toString().padStart(2, '0')}`;
const monthName = d.toLocaleString('de-AT', { month: 'long' }); // Only month name
const kw = getISOWeek(d);
if (!groups[y]) {
groups[y] = { year: y, months: {} };
}
if (!groups[y].months[monthKey]) {
groups[y].months[monthKey] = { name: monthName, year: y, monthIndex: m, count: 0, total: 0, weeks: {} };
}
if (!groups[y].months[monthKey].weeks[kw]) {
groups[y].months[monthKey].weeks[kw] = { label: `KW ${kw}`, items: [], count: 0, total: 0 };
}
const items = order.items || [];
items.forEach(item => {
const itemPrice = parseFloat(item.price || order.total || 0);
groups[y].months[monthKey].weeks[kw].items.push({
date: order.date,
name: item.name || 'Menü',
price: itemPrice,
state: order.order_state // 9 is cancelled, 5 is active, 8 is completed
});
if (order.order_state !== 9) {
groups[y].months[monthKey].weeks[kw].count++;
groups[y].months[monthKey].weeks[kw].total += itemPrice;
groups[y].months[monthKey].count++;
groups[y].months[monthKey].total += itemPrice;
}
});
});
// Generate HTML
const sortedYears = Object.keys(groups).sort((a, b) => b - a);
let html = '';
sortedYears.forEach(yKey => {
const yearGroup = groups[yKey];
html += `<div class="history-year-group">
<h2 class="history-year-header">${yearGroup.year}</h2>`;
const sortedMonths = Object.keys(yearGroup.months).sort((a, b) => b.localeCompare(a));
sortedMonths.forEach(mKey => {
const monthGroup = yearGroup.months[mKey];
html += `<div class="history-month-group">
<div class="history-month-header" tabindex="0" role="button" aria-expanded="false">
<div style="display:flex; flex-direction:column; gap:4px;">
<span>${monthGroup.name}</span>
<div class="history-month-summary">
<span>${monthGroup.count} Bestellungen &bull; <strong>€${monthGroup.total.toFixed(2)}</strong></span>
</div>
</div>
<span class="material-icons-round">expand_more</span>
</div>
<div class="history-month-content">`;
const sortedKWs = Object.keys(monthGroup.weeks).sort((a, b) => parseInt(b) - parseInt(a));
sortedKWs.forEach(kw => {
const week = monthGroup.weeks[kw];
html += `<div class="history-week-group">
<div class="history-week-header">
<strong>${week.label}</strong>
<span>${week.count} Bestellungen &bull; <strong>€${week.total.toFixed(2)}</strong></span>
</div>`;
week.items.forEach(item => {
const dateObj = new Date(item.date);
const dayStr = dateObj.toLocaleDateString('de-AT', { weekday: 'short', day: '2-digit', month: '2-digit' });
let statusBadge = '';
if (item.state === 9) {
statusBadge = '<span class="history-item-status">Storniert</span>';
} else if (item.state === 8) {
statusBadge = '<span class="history-item-status">Abgeschlossen</span>';
} else {
statusBadge = '<span class="history-item-status">Übertragen</span>';
}
html += `
<div class="history-item ${item.state === 9 ? 'history-item-cancelled' : ''}">
<div style="font-size: 0.85rem; color: var(--text-secondary);">${dayStr}</div>
<div class="history-item-details">
<span class="history-item-name">${escapeHtml(item.name)}</span>
<div>${statusBadge}</div>
</div>
<div class="history-item-price ${item.state === 9 ? 'history-item-price-cancelled' : ''}">€${item.price.toFixed(2)}</div>
</div>`;
});
html += `</div>`;
});
html += `</div></div>`; // Close month-content and month-group
});
html += `</div>`; // Close year-group
});
content.innerHTML = html;
// Bind Accordion Click Events via JS
const monthHeaders = content.querySelectorAll('.history-month-header');
monthHeaders.forEach(header => {
header.addEventListener('click', () => {
const parentGroup = header.parentElement;
const isOpen = parentGroup.classList.contains('open');
// Toggle current
if (isOpen) {
parentGroup.classList.remove('open');
header.setAttribute('aria-expanded', 'false');
} else {
parentGroup.classList.add('open');
header.setAttribute('aria-expanded', 'true');
}
});
});
}
// === Place Order === // === Place Order ===
async function placeOrder(date, articleId, name, price, description) { async function placeOrder(date, articleId, name, price, description) {
if (!authToken) return; if (!authToken) return;
@@ -1541,7 +1773,12 @@
: `${GITHUB_API}/releases?per_page=20`; : `${GITHUB_API}/releases?per_page=20`;
const resp = await fetch(endpoint, { headers: githubHeaders() }); const resp = await fetch(endpoint, { headers: githubHeaders() });
if (!resp.ok) throw new Error(`GitHub API ${resp.status}`); if (!resp.ok) {
if (resp.status === 403) {
throw new Error('API Rate Limit erreicht (403). Bitte später erneut versuchen.');
}
throw new Error(`GitHub API ${resp.status}`);
}
const data = await resp.json(); const data = await resp.json();
// Normalize to common format: { tag, name, url, body } // Normalize to common format: { tag, name, url, body }

View File

@@ -128,8 +128,31 @@
// Orders endpoint // Orders endpoint
if (urlStr.includes('/user/orders/') && (!options || options.method === 'GET' || !options.method)) { if (urlStr.includes('/user/orders/') && (!options || options.method === 'GET' || !options.method)) {
console.log('[MOCK] Returning mock orders'); console.log('[MOCK] Returning mock orders');
// Formatter for history mapping
const mappedOrders = mockOrders.map(o => ({
id: o.id,
date: `${o.date}T10:00:00Z`,
order_state: o.status === 'cancelled' ? 9 : 5,
total: o.price || '6.50',
items: [{
article: o.article,
name: o.article_name,
price: o.price || '6.50',
amount: 1
}]
}));
// Handle lazy load / pagination if requesting full history
if (urlStr.includes('limit=50')) {
return Promise.resolve(new Response(JSON.stringify({
count: mappedOrders.length,
next: null,
results: mappedOrders
}), { status: 200, headers: { 'Content-Type': 'application/json' } }));
}
return Promise.resolve(new Response(JSON.stringify({ return Promise.resolve(new Response(JSON.stringify({
results: mockOrders results: mappedOrders
}), { status: 200, headers: { 'Content-Type': 'application/json' } })); }), { status: 200, headers: { 'Content-Type': 'application/json' } }));
} }

177
style.css
View File

@@ -437,7 +437,6 @@ body {
.modal-content { .modal-content {
background: var(--bg-card); background: var(--bg-card);
/* Changed from --surface */
width: 90%; width: 90%;
max-width: 400px; max-width: 400px;
border-radius: 16px; border-radius: 16px;
@@ -446,6 +445,174 @@ body {
animation: modalSlide 0.3s ease-out; animation: modalSlide 0.3s ease-out;
} }
/* History Modal specific */
.history-modal-content {
max-width: 600px;
max-height: 85vh;
display: flex;
flex-direction: column;
}
.history-modal-content .modal-body {
overflow-y: auto;
padding: 0;
/* Padding is handled by inner elements */
}
/* History Styles */
.history-year-group {
margin-bottom: 16px;
}
.history-year-header {
background: var(--bg-card);
padding: 12px 20px;
margin: 0;
font-size: 1.2rem;
font-weight: 700;
color: var(--text-primary);
border-bottom: 2px solid var(--border-color);
position: sticky;
top: 0;
z-index: 12;
}
.history-month-group {
border-bottom: 1px solid var(--border-color);
}
.history-month-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 14px 20px;
margin: 0;
font-size: 1.05rem;
font-weight: 600;
color: var(--text-primary);
background: var(--bg-body);
cursor: pointer;
transition: background 0.2s;
}
.history-month-header:hover {
background: var(--border-color);
/* Slight hover effect */
}
.history-month-summary {
display: flex;
align-items: center;
gap: 12px;
font-size: 0.95rem;
color: var(--text-secondary);
}
.history-month-content {
display: none;
/* Collapsed by default */
background: var(--bg-card);
}
.history-month-group.open .history-month-content {
display: block;
/* Expanded when open class is present */
}
.history-month-group.open .history-month-header .material-icons-round {
transform: rotate(180deg);
}
.history-month-header .material-icons-round {
transition: transform 0.3s;
font-size: 20px;
}
.history-week-group {
padding: 12px 20px;
border-bottom: 1px dashed var(--border-color);
}
.history-week-group:last-child {
border-bottom: none;
}
.history-week-header {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.9rem;
font-weight: 600;
color: var(--text-secondary);
margin-bottom: 10px;
}
.history-week-summary {
font-size: 0.85rem;
font-weight: 500;
background: rgba(100, 116, 139, 0.1);
padding: 4px 10px;
border-radius: 12px;
}
.history-items {
display: flex;
flex-direction: column;
gap: 8px;
}
.history-item {
display: grid;
grid-template-columns: 50px 1fr auto;
align-items: center;
gap: 12px;
padding: 10px 12px;
background: var(--bg-body);
border-radius: 8px;
border: 1px solid var(--border-color);
}
.history-item-date {
font-size: 0.85rem;
color: var(--text-secondary);
font-weight: 500;
}
.history-item-details {
display: flex;
flex-direction: column;
gap: 4px;
}
.history-item-name {
font-size: 0.95rem;
font-weight: 500;
color: var(--text-primary);
}
.history-item-price {
font-weight: 600;
color: var(--text-primary);
}
.history-item-status {
font-size: 0.8rem;
font-weight: 600;
color: var(--text-primary);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.history-item-cancelled {
opacity: 0.5;
filter: grayscale(1);
}
.history-item-price-cancelled {
text-decoration: line-through;
color: var(--text-secondary);
}
@keyframes modalSlide { @keyframes modalSlide {
from { from {
transform: translateY(20px); transform: translateY(20px);
@@ -618,7 +785,7 @@ body {
.menu-item.today-ordered { .menu-item.today-ordered {
border: 2px solid var(--accent-color); border: 2px solid var(--accent-color);
box-shadow: 0 0 20px rgba(96, 165, 250, 0.4); box-shadow: 0 0 20px rgba(139, 92, 246, 0.4);
border-radius: 8px; border-radius: 8px;
padding: 1rem; padding: 1rem;
margin: 0 -1rem 1.5rem -1rem; margin: 0 -1rem 1.5rem -1rem;
@@ -630,15 +797,15 @@ body {
@keyframes pulse-glow { @keyframes pulse-glow {
0% { 0% {
box-shadow: 0 0 15px rgba(96, 165, 250, 0.3); box-shadow: 0 0 15px rgba(139, 92, 246, 0.3);
} }
50% { 50% {
box-shadow: 0 0 25px rgba(96, 165, 250, 0.6); box-shadow: 0 0 25px rgba(139, 92, 246, 0.6);
} }
100% { 100% {
box-shadow: 0 0 15px rgba(96, 165, 250, 0.3); box-shadow: 0 0 15px rgba(139, 92, 246, 0.3);
} }
} }

View File

@@ -1 +1 @@
v1.3.2 v1.4.2