@@ -316,9 +333,8 @@ function renderSlot(date, type, mealsForDay) {
data-meal-id="${meal.id}"
role="button" tabindex="0">
- ${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([]);
+ });
});