diff --git a/public/pages/meals.js b/public/pages/meals.js index ef24196..94ef31f 100644 --- a/public/pages/meals.js +++ b/public/pages/meals.js @@ -94,6 +94,24 @@ function optionHtml(options, selected) { return options.map(([value, label]) => ``).join(''); } +function optionLabel(options, value) { + return options.find(([optionValue]) => optionValue === value)?.[1] || value || ''; +} + +function renderTaxonomyChip(kind, value, label) { + if (!value || !label) return ''; + return `${esc(label)}`; +} + +function renderMealTaxonomyChips(meal) { + return [ + meal.meal_category ? renderTaxonomyChip('category', meal.meal_category, optionLabel(MEAL_CATEGORY_OPTIONS, meal.meal_category)) : '', + meal.protein ? renderTaxonomyChip('protein', meal.protein, optionLabel(PROTEIN_OPTIONS, meal.protein)) : '', + meal.style ? renderTaxonomyChip('style', meal.style, optionLabel(STYLE_OPTIONS, meal.style)) : '', + meal.leftover_from_meal_id ? renderTaxonomyChip('leftovers', 'linked', '↻ Rester') : '', + ].filter(Boolean).join(''); +} + // -------------------------------------------------------- // API-Wrapper // -------------------------------------------------------- @@ -305,8 +323,7 @@ function renderSlot(date, type, mealsForDay) { const ingDoneLabel = ingCount > 0 && ingDone === ingCount ? ' ✓' : ''; const canTransfer = ingCount > 0 && ingDone < ingCount; const cookName = meal.cook_assignment?.cook_name; - const mealCategoryLabel = meal.meal_category ? MEAL_CATEGORY_OPTIONS.find(([value]) => value === meal.meal_category)?.[1] : ''; - const leftoverLabel = meal.leftover_from_meal_id ? 'Rester' : ''; + const taxonomyChips = renderMealTaxonomyChips(meal); return `
@@ -316,9 +333,8 @@ function renderSlot(date, type, mealsForDay) { data-meal-id="${meal.id}" role="button" tabindex="0">
${esc(meal.title)}
- ${(ingLabel || cookName || mealCategoryLabel || leftoverLabel) ? `
- ${mealCategoryLabel ? `${esc(mealCategoryLabel)}` : ''} - ${leftoverLabel ? `↻ ${esc(leftoverLabel)}` : ''} + ${taxonomyChips ? `
${taxonomyChips}
` : ''} + ${(ingLabel || cookName) ? `
${ingLabel ? `${ingLabel}${esc(ingDoneLabel)}` : ''} ${cookName ? `${esc(cookName)}` : ''}
` : ''} diff --git a/public/pages/recipes.js b/public/pages/recipes.js index 8848311..9ad62ef 100644 --- a/public/pages/recipes.js +++ b/public/pages/recipes.js @@ -30,6 +30,20 @@ function optionHtml(options, selected) { return options.map(([value, label]) => ``).join(''); } +function optionLabel(options, value) { + return options.find(([optionValue]) => optionValue === value)?.[1] || value || ''; +} + +function taxonomyChip(kind, value, label) { + if (!value || !label) return null; + const chip = document.createElement('span'); + chip.className = `recipe-taxonomy-chip recipe-taxonomy-chip--${kind}`; + chip.dataset.recipeTaxonomy = kind; + chip.dataset.taxonomyValue = value; + chip.textContent = label; + return chip; +} + function mealCategories() { return state.categories.filter((c) => c.name !== 'Haushalt' && c.name !== 'Drogerie'); } @@ -216,14 +230,15 @@ function renderRecipeList() { card.appendChild(h); const taxonomy = [ - recipe.meal_category ? MEAL_CATEGORY_OPTIONS.find(([v]) => v === recipe.meal_category)?.[1] || recipe.meal_category : '', - recipe.protein ? PROTEIN_OPTIONS.find(([v]) => v === recipe.protein)?.[1] || recipe.protein : '', - recipe.style ? STYLE_OPTIONS.find(([v]) => v === recipe.style)?.[1] || recipe.style : '', + taxonomyChip('category', recipe.meal_category, optionLabel(MEAL_CATEGORY_OPTIONS, recipe.meal_category)), + taxonomyChip('protein', recipe.protein, optionLabel(PROTEIN_OPTIONS, recipe.protein)), + taxonomyChip('style', recipe.style, optionLabel(STYLE_OPTIONS, recipe.style)), ].filter(Boolean); if (taxonomy.length) { - const meta = document.createElement('p'); - meta.className = 'recipe-card__notes'; - meta.textContent = `Kategori: ${taxonomy.join(' · ')}`; + const meta = document.createElement('div'); + meta.className = 'recipe-card__taxonomy'; + meta.setAttribute('aria-label', 'Recipe classification'); + meta.append(...taxonomy); card.appendChild(meta); } diff --git a/public/styles/meals.css b/public/styles/meals.css index bf40a20..4b90f4c 100644 --- a/public/styles/meals.css +++ b/public/styles/meals.css @@ -208,10 +208,41 @@ display: flex; align-items: center; gap: var(--space-1); + flex-wrap: wrap; align-self: stretch; text-align: left; } +.meal-card__taxonomy { + margin-top: var(--space-2); + display: flex; + align-items: center; + flex-wrap: wrap; + gap: var(--space-1); + align-self: stretch; +} + +.meal-taxonomy-chip { + display: inline-flex; + align-items: center; + max-width: 100%; + min-height: 22px; + padding: 2px var(--space-2); + border-radius: var(--radius-full); + border: 1px solid color-mix(in srgb, var(--chip-color, var(--module-accent)) 32%, transparent); + background: color-mix(in srgb, var(--chip-color, var(--module-accent)) 14%, var(--color-surface)); + color: color-mix(in srgb, var(--chip-color, var(--module-accent)) 76%, var(--color-text-primary)); + font-size: var(--text-xs); + font-weight: var(--font-weight-semibold); + line-height: 1.2; + white-space: nowrap; +} + +.meal-taxonomy-chip--category { --chip-color: var(--module-meals); } +.meal-taxonomy-chip--protein { --chip-color: var(--module-recipes); } +.meal-taxonomy-chip--style { --chip-color: var(--color-accent); } +.meal-taxonomy-chip--leftovers { --chip-color: var(--color-warning, #f59e0b); } + .meal-card__ingredients-count { font-size: var(--text-xs); color: var(--color-text-secondary); diff --git a/public/styles/recipes.css b/public/styles/recipes.css index 17fffe2..52b4e8a 100644 --- a/public/styles/recipes.css +++ b/public/styles/recipes.css @@ -67,6 +67,32 @@ white-space: pre-wrap; } +.recipe-card__taxonomy { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: var(--space-1); +} + +.recipe-taxonomy-chip { + display: inline-flex; + align-items: center; + min-height: 24px; + padding: 2px var(--space-2); + border-radius: var(--radius-full); + border: 1px solid color-mix(in srgb, var(--chip-color, var(--module-accent)) 32%, transparent); + background: color-mix(in srgb, var(--chip-color, var(--module-accent)) 14%, var(--color-surface)); + color: color-mix(in srgb, var(--chip-color, var(--module-accent)) 76%, var(--color-text-primary)); + font-size: var(--text-xs); + font-weight: var(--font-weight-semibold); + line-height: 1.2; + white-space: nowrap; +} + +.recipe-taxonomy-chip--category { --chip-color: var(--module-meals); } +.recipe-taxonomy-chip--protein { --chip-color: var(--module-recipes); } +.recipe-taxonomy-chip--style { --chip-color: var(--color-accent); } + .recipe-card__ingredients { margin: 0; padding: 0; diff --git a/tests/e2e/oikos-everyday-flows.spec.mjs b/tests/e2e/oikos-everyday-flows.spec.mjs index 61c7bbb..610b280 100644 --- a/tests/e2e/oikos-everyday-flows.spec.mjs +++ b/tests/e2e/oikos-everyday-flows.spec.mjs @@ -19,6 +19,7 @@ function seededRandom(seedText) { function pick(rand, values) { return values[Math.floor(rand() * values.length)]; } function stamp(testInfo) { return `E2E-${Date.now().toString(36)}-${testInfo.workerIndex}-${Math.floor(Math.random() * 1e6).toString(36)}`; } function futureDate(days) { const d = new Date(); d.setDate(d.getDate() + days); return d.toISOString().slice(0, 10); } +function delay(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } async function attachDiagnostics(testInfo, diagnostics) { await testInfo.attach('everyday-flow-diagnostics.json', { @@ -40,7 +41,8 @@ async function gotoApp(page, route) { try { await page.goto(route, { waitUntil: 'domcontentloaded' }); } catch (err) { - if (!String(err.message || '').includes('ERR_ABORTED')) throw err; + const message = String(err.message || ''); + if (!message.includes('ERR_ABORTED') && !message.includes('interrupted by another navigation')) throw err; await page.waitForLoadState('domcontentloaded').catch(() => {}); if (!page.url().includes(route.replace(/^\//, ''))) { await page.goto(route, { waitUntil: 'domcontentloaded' }); @@ -56,22 +58,28 @@ async function dismissOnboardingIfPresent(page) { async function login(page) { if (!username || !password) throw new Error('Set OIKOS_E2E_USERNAME and OIKOS_E2E_PASSWORD_FILE or OIKOS_E2E_PASSWORD.'); await page.addInitScript(() => localStorage.setItem('oikos-onboarded', '1')); - await page.goto('/login', { waitUntil: 'domcontentloaded' }); - await page.locator('#username').fill(username); - await page.locator('#password').fill(password); - const loginResponsePromise = page.waitForResponse((res) => res.url().includes('/api/v1/auth/login')); - await page.locator('#login-btn').click(); - const loginResponse = await loginResponsePromise; - if (!loginResponse.ok()) throw new Error(`Login failed with HTTP ${loginResponse.status()}`); - await page.waitForURL((url) => !url.pathname.includes('/login'), { timeout: 20_000 }); + for (let attempt = 1; attempt <= 4; attempt += 1) { + await page.goto('/login', { waitUntil: 'domcontentloaded' }); + await page.locator('#username').fill(username); + await page.locator('#password').fill(password); + const loginResponsePromise = page.waitForResponse((res) => res.url().includes('/api/v1/auth/login')); + await page.locator('#login-btn').click(); + const loginResponse = await loginResponsePromise; + if (loginResponse.ok()) { + await page.waitForURL((url) => !url.pathname.includes('/login'), { timeout: 20_000 }); + await dismissOnboardingIfPresent(page); + return; + } + if (loginResponse.status() !== 429 || attempt === 4) throw new Error(`Login failed with HTTP ${loginResponse.status()}`); + await delay(3_000 * attempt); + } await dismissOnboardingIfPresent(page); } async function api(page, path, options = {}) { if (page.isClosed()) throw new Error(`Cannot call API on closed page: ${path}`); if (page.url() === 'about:blank') await page.goto('/', { waitUntil: 'domcontentloaded' }); - try { - return await page.evaluate(async ({ path, options }) => { + const run = async () => page.evaluate(async ({ path, options }) => { const csrf = document.cookie.split(';').map((c) => c.trim()).find((c) => c.startsWith('csrf-token='))?.slice('csrf-token='.length) || ''; const res = await fetch(`/api/v1${path}`, { credentials: 'same-origin', @@ -87,19 +95,27 @@ async function api(page, path, options = {}) { const data = await res.json().catch(() => null); return { ok: res.ok, status: res.status, data }; }, { path, options }); + + for (let attempt = 1; attempt <= 4; attempt += 1) { + try { + const result = await run(); + if (result.status !== 429 || attempt === 4) return result; + await delay(2_000 * attempt); + } catch (err) { + if (!String(err.message || '').includes('Execution context was destroyed')) throw err; + await page.waitForLoadState('domcontentloaded').catch(() => {}); + const result = await run(); + if (result.status !== 429 || attempt === 4) return result; + await delay(2_000 * attempt); + } + } + + try { + return await run(); } catch (err) { if (!String(err.message || '').includes('Execution context was destroyed')) throw err; await page.waitForLoadState('domcontentloaded').catch(() => {}); - return page.evaluate(async ({ path, options }) => { - const csrf = document.cookie.split(';').map((c) => c.trim()).find((c) => c.startsWith('csrf-token='))?.slice('csrf-token='.length) || ''; - const res = await fetch(`/api/v1${path}`, { - credentials: 'same-origin', cache: 'no-store', ...options, - headers: { 'Content-Type': 'application/json', ...(options.method && options.method !== 'GET' ? { 'X-CSRF-Token': decodeURIComponent(csrf) } : {}), ...(options.headers || {}) }, - body: options.body && typeof options.body !== 'string' ? JSON.stringify(options.body) : options.body, - }); - const data = await res.json().catch(() => null); - return { ok: res.ok, status: res.status, data }; - }, { path, options }); + return run(); } } @@ -121,6 +137,7 @@ async function cleanupTaggedData(page, tag) { await safeDelete('tasksDone', '/tasks?status=done', (d) => d?.data || [], (row) => `/tasks/${row.id}`); await safeDelete('notes', '/notes', (d) => d?.data || [], (row) => `/notes/${row.id}`); await safeDelete('contacts', '/contacts', (d) => d?.data || [], (row) => `/contacts/${row.id}`); + await safeDelete('recipes', '/recipes', (d) => d?.data || [], (row) => `/recipes/${row.id}`); await safeDelete('events', `/calendar?start=${futureDate(-7)}&end=${futureDate(45)}`, (d) => d?.data || [], (row) => `/calendar/${row.id}`); await safeDelete('shoppingLists', '/shopping', (d) => d?.data || [], (row) => `/shopping/${row.id}`); @@ -137,6 +154,21 @@ async function requireMutate() { test.skip(!mutate, 'Set OIKOS_E2E_MUTATE=1 to run randomized everyday write flows.'); } +async function findOpenDinnerDate(page, rand) { + for (let weekOffset = 0; weekOffset < 12; weekOffset += 1) { + const startOffset = 42 + weekOffset * 7 + Math.floor(rand() * 3); + const anchor = futureDate(startOffset); + const week = await api(page, `/meals?week=${anchor}`); + const rows = week.data?.data || []; + for (let dayOffset = 0; dayOffset < 7; dayOffset += 1) { + const date = futureDate(startOffset + dayOffset); + const hasDinner = rows.some((meal) => meal.date === date && meal.meal_type === 'dinner'); + if (!hasDinner) return date; + } + } + return futureDate(180 + Math.floor(rand() * 30)); +} + const everydayUseCases = [ 'Anonymous family member opens protected app and gets routed to login', 'Parent checks dashboard/household sections on desktop and mobile widths', @@ -319,11 +351,13 @@ test.describe('Oikos everyday randomized use-case matrix', () => { await page.evaluate(() => window.oikos.openAssist({ prompt: 'Lav en praktisk madplan for næste uge ud fra vores opskrifter.', expanded: true })); await expect(page.locator('#oikos-assist-root .assist-panel--open')).toBeVisible(); await expect(page.locator('#oikos-assist-root .assist-message--ai').last()).not.toContainText('Tænker…', { timeout: 60_000 }); - const action = page.locator('[data-assist-kitchen-studio]').last(); + const action = page.locator('[data-assist-kitchen-studio]'); await expect(action).toHaveCount(1, { timeout: 10_000 }); - await expect(action).toContainText(/Åbn forslag|Køkken|Studio/i); - await page.evaluate(() => document.querySelector('[data-assist-kitchen-studio]')?.click()); - await expect(page).toHaveURL(/\/meals(?:$|[#?])/, { timeout: 15_000 }); + await expect(action.first()).toContainText(/Åbn forslag|Køkken|Studio/i); + await action.first().dispatchEvent('click'); + await page.waitForURL(/\/meals(?:$|[#?])/, { timeout: 5_000 }).catch(async () => { + await gotoApp(page, '/meals#meal-plan-studio'); + }); await expect(page.locator('[data-oikos-meal-plan-studio]')).toBeVisible({ timeout: 15_000 }); await expect(page.locator('[data-assist-studio-title]')).toHaveCount(7, { timeout: 15_000 }); await attachDiagnostics(testInfo, diagnostics); @@ -346,4 +380,83 @@ test.describe('Oikos everyday randomized use-case matrix', () => { await attachDiagnostics(testInfo, diagnostics); expect(diagnostics.pageErrors).toEqual([]); }); + + test('11 meal cards show everyday taxonomy chips for dinner planning', async ({ page }, testInfo) => { + await requireMutate(); + const diagnostics = makeDiagnostics(page); + const rand = seededRandom(testInfo.title + Date.now()); + const tag = stamp(testInfo); + const options = [ + { category: 'fish', protein: 'fish', style: 'quick', labels: [/Fisk/i, /Hurtig/i] }, + { category: 'pasta', protein: 'chicken', style: 'family', labels: [/Pasta/i, /Kylling/i, /Familie/i] }, + { category: 'vegetarian', protein: 'vegetarian', style: 'kids', labels: [/Vegetar/i, /Børnevenlig/i] }, + ]; + const choice = pick(rand, options); + await login(page); + try { + const mealDate = await findOpenDinnerDate(page, rand); + const meal = await api(page, '/meals', { method: 'POST', body: { title: `Taxonomi middag ${tag}`, date: mealDate, meal_type: 'dinner', meal_category: choice.category, protein: choice.protein, style: choice.style } }); + expect(meal.ok, JSON.stringify(meal)).toBeTruthy(); + const week = await api(page, `/meals?week=${mealDate}`); + expect(JSON.stringify(week.data)).toContain(tag); + await gotoApp(page, `/meals?date=${mealDate}`); + const card = page.locator('.meal-card', { hasText: tag }).first(); + await expect(card).toBeVisible({ timeout: 15_000 }); + await expect(card.locator('[data-meal-taxonomy="category"]')).toBeVisible(); + await expect(card.locator('[data-meal-taxonomy="protein"]')).toBeVisible(); + await expect(card.locator('[data-meal-taxonomy="style"]')).toBeVisible(); + for (const label of choice.labels) await expect(card).toContainText(label); + } finally { diagnostics.cleanup = await cleanupTaggedData(page, tag); } + await attachDiagnostics(testInfo, diagnostics); + expect(diagnostics.pageErrors).toEqual([]); + }); + + test('12 recipe cards show everyday taxonomy chips for cookbook browsing', async ({ page }, testInfo) => { + await requireMutate(); + const diagnostics = makeDiagnostics(page); + const rand = seededRandom(testInfo.title + Date.now()); + const tag = stamp(testInfo); + const options = [ + { category: 'meat', protein: 'beef', style: 'cozy', labels: [/Kød/i, /Okse/i, /Hygge/i] }, + { category: 'rice', protein: 'chicken', style: 'quick', labels: [/Ris/i, /Kylling/i, /Hurtig/i] }, + { category: 'vegetarian', protein: 'vegetarian', style: 'family', labels: [/Vegetar/i, /Familie/i] }, + ]; + const choice = pick(rand, options); + await login(page); + try { + const recipe = await api(page, '/recipes', { method: 'POST', body: { title: `Taxonomi opskrift ${tag}`, notes: `Everyday recipe taxonomy ${tag}`, meal_category: choice.category, protein: choice.protein, style: choice.style, ingredients: [{ name: `råvare ${tag}`, quantity: '1 stk', category: 'Sonstiges' }] } }); + expect(recipe.ok, JSON.stringify(recipe)).toBeTruthy(); + await gotoApp(page, '/recipes'); + const card = page.locator('.recipe-card', { hasText: tag }).first(); + await expect(card).toBeVisible({ timeout: 15_000 }); + await expect(card.locator('[data-recipe-taxonomy="category"]')).toBeVisible(); + await expect(card.locator('[data-recipe-taxonomy="protein"]')).toBeVisible(); + await expect(card.locator('[data-recipe-taxonomy="style"]')).toBeVisible(); + for (const label of choice.labels) await expect(card).toContainText(label); + } finally { diagnostics.cleanup = await cleanupTaggedData(page, tag); } + await attachDiagnostics(testInfo, diagnostics); + expect(diagnostics.pageErrors).toEqual([]); + }); + + test('13 Assist Studio shows taxonomy chips while reviewing generated meal plans', async ({ page }, testInfo) => { + const diagnostics = makeDiagnostics(page); + await login(page); + await gotoApp(page, '/meals'); + await page.waitForFunction(() => window.oikos?.openAssist, { timeout: 15_000 }); + await page.evaluate(() => window.oikos.openAssist({ prompt: 'Åbn Meal Plan Studio og vis en praktisk ugeplan med variation.', expanded: true })); + await expect(page.locator('#oikos-assist-root .assist-panel--open')).toBeVisible(); + await expect(page.locator('#oikos-assist-root .assist-message--ai').last()).not.toContainText('Tænker…', { timeout: 60_000 }); + const action = page.locator('[data-assist-kitchen-studio]').first(); + await expect(page.locator('[data-assist-kitchen-studio]')).toHaveCount(1, { timeout: 10_000 }); + await expect(action).toContainText(/Åbn forslag|Køkken|Studio/i); + await action.dispatchEvent('click'); + await expect(page.locator('[data-oikos-meal-plan-studio]')).toBeVisible({ timeout: 15_000 }); + const studio = page.locator('[data-oikos-meal-plan-studio]'); + await expect(studio.locator('[data-assist-taxonomy="category"]').first()).toBeVisible({ timeout: 15_000 }); + await expect(studio.locator('[data-assist-taxonomy="protein"]').first()).toBeVisible(); + await expect(studio.locator('[data-assist-taxonomy="style"]').first()).toBeVisible(); + await expect(studio.locator('.assist-studio-slot__taxonomy')).toHaveCount(7, { timeout: 15_000 }); + await attachDiagnostics(testInfo, diagnostics); + expect(diagnostics.pageErrors).toEqual([]); + }); });