Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 29a8b2993c | |||
| 3ac82e65fe | |||
| 0bbbc1d154 | |||
| 828b43c260 | |||
| 7d4bb7b4bd | |||
| 7b1bf5f249 | |||
| 1c3c765701 | |||
| ad087df5cb | |||
| 58a76ee02d | |||
| cef366cce4 | |||
| 203456f3e0 | |||
| 37ad018cbc | |||
| 044c85563f | |||
| 3e4cc78d3e | |||
| 1828bef8f1 | |||
| ba534cb864 | |||
| cf099bb353 | |||
| 5099155c61 | |||
| 0b6603b092 | |||
| 7c118068c0 |
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,95 @@
|
||||
# Native Meal Planning Signals Patch
|
||||
|
||||
Status: local patch series, not deployed and not pushed upstream.
|
||||
|
||||
This patch series turns the Oikos Assist meal-planning sidecar concepts into a first native Oikos shape. The intent is to make meal-planning data belong to Oikos core while Assist remains a suggestion/prepare-action layer.
|
||||
|
||||
## Patch series
|
||||
|
||||
- `7c11806` — `feat: add native meal planning signal api`
|
||||
- `0b6603b` — `test: cover native meal planning schema`
|
||||
- `5099155` — `feat: expose meal cook assignments on meals`
|
||||
- `cf099bb` — `feat: add native meal cook selector`
|
||||
|
||||
## Schema
|
||||
|
||||
Migration v39 adds these tables:
|
||||
|
||||
- `meal_cooking_rules` — recurring cook rules by user, weekday, and meal type.
|
||||
- `recipe_family_preferences` — per-user recipe preferences and capability signals.
|
||||
- `recipe_variation_meta` — recipe protein/style/kid-suitability metadata.
|
||||
- `planned_meal_cooks` — assigned cook for a planned/saved meal.
|
||||
- `meal_plan_feedback` — accept/reject/edit/swap/confirm/cookbook feedback events.
|
||||
- `kids_cookbooks` — saved kid-readable cookbook previews.
|
||||
|
||||
## API surface
|
||||
|
||||
New route namespace: `/api/v1/meal-planning`.
|
||||
|
||||
Endpoints added:
|
||||
|
||||
- `GET /cooking-rules`
|
||||
- `PUT /cooking-rules`
|
||||
- `GET /recipe-signals`
|
||||
- `PUT /recipe-signals/:recipeId`
|
||||
- `GET /variation-meta`
|
||||
- `PUT /variation-meta/:recipeId`
|
||||
- `GET /cook-assignments`
|
||||
- `PUT /cook-assignments/:mealId`
|
||||
- `GET /feedback`
|
||||
- `POST /feedback`
|
||||
- `GET /kids-cookbooks`
|
||||
- `POST /kids-cookbooks`
|
||||
|
||||
OpenAPI path entries are included for the new namespace.
|
||||
|
||||
## Native meals integration
|
||||
|
||||
`/api/v1/meals` now exposes cook assignments directly:
|
||||
|
||||
- Weekly `GET /api/v1/meals` returns `cook_assignment` per meal.
|
||||
- `POST /api/v1/meals` accepts `cook_user_id` / `cookUserId` and optional `source_plan_id` / `sourcePlanId`.
|
||||
- `PUT /api/v1/meals/:id` accepts the same fields and can clear with `cook_user_id: null`.
|
||||
- Moving a meal without explicitly changing cook syncs the assignment date/type to the new slot.
|
||||
|
||||
## Native UI integration
|
||||
|
||||
`public/pages/meals.js` now:
|
||||
|
||||
- loads `/family/members`,
|
||||
- displays assigned cook on meal cards,
|
||||
- adds a cook selector to the meal create/edit modal,
|
||||
- sends `cook_user_id` through native meal create/edit calls.
|
||||
|
||||
Locale keys added in English and German:
|
||||
|
||||
- `meals.cookLabel`
|
||||
- `meals.cookNone`
|
||||
|
||||
## Verification run
|
||||
|
||||
Passed locally:
|
||||
|
||||
```bash
|
||||
node --check server/routes/meal-planning.js
|
||||
node --check server/routes/meals.js
|
||||
node --check server/index.js
|
||||
node --check server/openapi.js
|
||||
node --check public/pages/meals.js
|
||||
node -e "JSON.parse(require('fs').readFileSync('public/locales/en.json','utf8')); JSON.parse(require('fs').readFileSync('public/locales/de.json','utf8'))"
|
||||
npm run test:meal-planning
|
||||
npm run test:meals
|
||||
```
|
||||
|
||||
Focused test results:
|
||||
|
||||
- `test:meal-planning`: 15/15 passing
|
||||
- `test:meals`: 22/22 passing
|
||||
|
||||
## Known caveats before upstream PR
|
||||
|
||||
- The route currently supports the needed native shape, but should be reviewed for upstream naming conventions.
|
||||
- The OpenAPI entries are intentionally broad (`jsonBody(null)`) and should be expanded with formal schemas before a polished PR.
|
||||
- Only English/German locale keys were added in this patch. Other locales fall back, but a full upstream PR should update all supported locales or accept fallback behavior.
|
||||
- This patch does not include AI generation. That remains outside Oikos core; Assist should call these native endpoints once this lands.
|
||||
- The local working tree has unrelated Danish translation edits that are intentionally not part of this patch series.
|
||||
@@ -0,0 +1,116 @@
|
||||
# Oikos Meal Planning — Combined Roadmap
|
||||
|
||||
Status: active Friborg branch roadmap. This merges the older Assist plan, native meal-planning patch notes, structured taxonomy work, Playwright E2E plan, and upstream-readiness backlog into one source of truth.
|
||||
|
||||
## North Star
|
||||
|
||||
Meal planning should be a native Oikos kitchen workflow, not a chatbot shortcut. Assist can suggest and prefill, but the family reviews and edits in a dedicated Kitchen surface before anything is written.
|
||||
|
||||
## Current Verified Live Baseline
|
||||
|
||||
- Public app: `https://home.friborg.uk`
|
||||
- Core branch: `friborg/native-meal-planning`
|
||||
- Latest verified core commit: `58a76ee` (`feat: structure meal planning taxonomy and favorites`)
|
||||
- Latest verified Assist/Studio commit: `062211f` (`chore: remove legacy meal plan add path`)
|
||||
- Live core image: `oikos-structured-mealflow:20260512-170557`
|
||||
- DB schema: migration `41`
|
||||
- Latest live E2E: `OIKOS_E2E_MUTATE=1 npm run test:e2e:oikos-flow -- --project=chromium-desktop` → 2/2 passed, cleanup zero
|
||||
|
||||
## Completed Plan Threads
|
||||
|
||||
### Studio-first Assist handoff
|
||||
|
||||
- [x] Meal-plan prompts return a structured `studioPlan` instead of text-only drafts.
|
||||
- [x] Assist action card opens the page-level Kitchen Studio.
|
||||
- [x] Direct legacy text-plan add path removed from the widget.
|
||||
- [x] Studio lives on `/meals` as a Kitchen module.
|
||||
- [x] Confirming the Studio writes native meals through explicit user action.
|
||||
|
||||
### Native meal-planning data
|
||||
|
||||
- [x] Native `/api/v1/meal-planning/*` endpoints for rules, recipe signals, variation metadata, cook assignments, feedback, and kids cookbooks.
|
||||
- [x] Native `/api/v1/meals` accepts and returns cook assignments.
|
||||
- [x] Meal/recipe taxonomy promoted into core fields: `meal_category`, `protein`, `style`, recipe `tags_json`.
|
||||
- [x] Leftovers are first-class via `leftover_from_meal_id`.
|
||||
- [x] `source_plan_id` preserves Studio provenance.
|
||||
|
||||
### Family preference and learning loop
|
||||
|
||||
- [x] Likes/dislikes/favorite/can-cook/can-help/modified-ok/adult-only signals.
|
||||
- [x] Meal modal and recipe cards can save person-specific signals.
|
||||
- [x] Family profile cards report favorite and can-cook counts.
|
||||
- [x] Accept/reject/edit/swap/confirm feedback is recorded.
|
||||
- [x] Learning summary panel exposes signal counts.
|
||||
|
||||
### Planning quality
|
||||
|
||||
- [x] Planner considers family rules, cook assignments, preferences, weather/activity/season context.
|
||||
- [x] Generation modulators are explicit in Studio plans: season, weather, easy/busy days, guests, no-kids/adult-only context, family favorites/likes/dislikes, child age, child can-cook/can-help signals, and variety counters.
|
||||
- [x] Planner tracks category/protein/style diversity.
|
||||
- [x] Adjacent category repeats are penalized/avoided.
|
||||
- [x] Swap alternatives are ordered to preserve diversity constraints.
|
||||
- [x] Leftovers are dedicated linked slots, not note text.
|
||||
|
||||
### Kids flow
|
||||
|
||||
- [x] Kids board with large category cards.
|
||||
- [x] Drag/tap meal card assignment to days.
|
||||
- [x] Kid cookbook preview and saved cookbook storage.
|
||||
|
||||
### Verification and safety
|
||||
|
||||
- [x] Playwright smoke covers Kitchen open + Assist handoff.
|
||||
- [x] Mutating E2E covers swap/edit/confirm/native readback/cook assignments/cleanup.
|
||||
- [x] Structural E2E asserts categories, diversity, linked leftovers, favorite-signal UI, profile count reflection, and cleanup.
|
||||
- [x] Structural E2E asserts Studio plans expose modulator labels and active signal types, so planning context is not hidden in opaque AI text.
|
||||
- [x] Playwright artifacts are opt-in to reduce credential leak risk.
|
||||
|
||||
### Context-aware family logistics engine
|
||||
|
||||
- [x] Added deterministic meal-fit service for the next planning slice: day context, energy, guests, cleanup, interruption tolerance, concrete inventory/leftover use, preferences/allergies, recency, and grocery delta all affect ranking before AI gets involved.
|
||||
- [x] Added `/api/v1/meal-planning/suggestions` as a thin native API boundary returning ranked suggestions plus optional generated grocery output.
|
||||
- [x] Added `test:meal-fit` acceptance coverage proving busy/low-energy days, guest days, allergy blocks, dislike/repetition, concrete inventory matching, and grocery dedupe change outputs materially.
|
||||
- [x] Added a first native Meals-page “Smart dinner fit” panel that lets the family change day pressure, energy, dinner window, guests, and solo-parent context, then see grounded ranked suggestions from saved recipes.
|
||||
|
||||
## Remaining Combined Plan
|
||||
|
||||
### P0 — Upstream/Core polish before PR
|
||||
|
||||
- [ ] Make OpenAPI more explicit for meal-planning request/response bodies instead of broad `object` bodies.
|
||||
- [ ] Update product spec data model to include native meal-planning tables and structured meal/recipe taxonomy.
|
||||
- [ ] Keep Friborg-specific deployment details out of upstream docs/PR copy.
|
||||
- [ ] Rebase `friborg/native-meal-planning` onto current upstream `main` and resolve conflicts.
|
||||
- [ ] Prepare small upstreamable PR slices rather than one giant Friborg branch.
|
||||
|
||||
### P1 — Reduce sidecar ownership
|
||||
|
||||
- [ ] Keep moving UI state from injected Assist widget toward native Kitchen components.
|
||||
- [ ] Preserve Assist as suggestion/provider layer, not system-of-record.
|
||||
- [ ] Add native Studio API boundaries where useful before porting the full Studio UI.
|
||||
- [ ] Keep Settings → Assist limited to provider/model plumbing.
|
||||
|
||||
### P1 — UX polish
|
||||
|
||||
- [ ] Localize new structured meal fields and preference labels fully (currently several labels are English in Danish/German UI surfaces).
|
||||
- [ ] Make leftovers source selector easier to scan by week/date/category.
|
||||
- [ ] Add small visual category chips consistently across meals, recipes, and Studio.
|
||||
- [ ] Add dedicated Studio controls for one-off guests/no-kids/easy-day overrides instead of relying only on activity/event text detection.
|
||||
- [ ] Improve mobile layout for Studio slots and kids board after the data model has stabilized.
|
||||
|
||||
### P2 — Platform modularization
|
||||
|
||||
- [ ] Module registry for routes/navigation/capabilities.
|
||||
- [ ] Locale registry so supported locales, picker labels, and service worker precache come from one source.
|
||||
- [ ] Extension/overlay mechanism for deployment-specific customizations without patching core.
|
||||
|
||||
## Implementation Rule
|
||||
|
||||
Continue in verified slices:
|
||||
|
||||
1. make the smallest meaningful change,
|
||||
2. run syntax/unit gates,
|
||||
3. deploy only when needed,
|
||||
4. run live smoke/E2E when the public flow changes,
|
||||
5. verify cleanup/data health,
|
||||
6. commit and push to Gitea,
|
||||
7. update this roadmap if scope or status changes.
|
||||
@@ -0,0 +1,30 @@
|
||||
# Oikos Modularization Backlog
|
||||
|
||||
Status: low priority roadmap item.
|
||||
|
||||
## Goal
|
||||
|
||||
Convert Oikos from module-shaped code into a proper modular platform where users and contributors can add, remove, and customize modules without editing core router/navigation/settings files in several places.
|
||||
|
||||
## Desired outcome
|
||||
|
||||
- A single module manifest/registry owns route, navigation, icon, theme, stylesheet, settings visibility, permissions, and API capability metadata.
|
||||
- New modules can be added by creating a module folder plus manifest entry, not by touching many core files.
|
||||
- Existing modules can be disabled cleanly at UI and API/capability level.
|
||||
- Language packs can be added by dropping in a locale file and registering display metadata in one place.
|
||||
- Deployment-specific or family-specific customizations stay outside upstreamable core code.
|
||||
|
||||
## Candidate implementation tasks
|
||||
|
||||
1. Introduce a module registry, for example `public/modules/manifest.js` or a JSON manifest loaded during boot.
|
||||
2. Refactor `router.js` to build routes, navigation order, primary nav, theme accents, and disabled-module guards from the registry.
|
||||
3. Move Kitchen tabs to module metadata so grouped/submodules are first-class.
|
||||
4. Add a server-side module/capability registry for API exposure and OpenAPI grouping.
|
||||
5. Add a locale registry so `SUPPORTED_LOCALES`, locale picker labels, and service-worker precache entries are generated from one source.
|
||||
6. Define an extension/overlay mechanism for local deployments without patching core files.
|
||||
7. Add contributor docs: “Create a module” and “Add a language pack”.
|
||||
8. Add regression tests that verify module registration, hiding/disabling, navigation, and locale discovery.
|
||||
|
||||
## Priority
|
||||
|
||||
Low. Do after the current Kitchen / Meal Planning work is stable and after Friborg-specific customizations are separated from upstreamable platform code.
|
||||
Generated
+66
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "oikos",
|
||||
"version": "0.48.3",
|
||||
"version": "0.50.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "oikos",
|
||||
"version": "0.48.3",
|
||||
"version": "0.50.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bcrypt": "^6.0.0",
|
||||
@@ -20,6 +20,7 @@
|
||||
"node-fetch": "^3.3.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.60.0",
|
||||
"puppeteer": "^24.42.0",
|
||||
"sharp": "^0.34.5"
|
||||
},
|
||||
@@ -557,6 +558,22 @@
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz",
|
||||
"integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.60.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@puppeteer/browsers": {
|
||||
"version": "2.13.0",
|
||||
"resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.13.0.tgz",
|
||||
@@ -1754,6 +1771,21 @@
|
||||
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/function-bind": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||
@@ -2639,6 +2671,38 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.60.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz",
|
||||
"integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.60.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.60.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz",
|
||||
"integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/prebuild-install": {
|
||||
"version": "7.1.3",
|
||||
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
|
||||
|
||||
+5
-1
@@ -30,7 +30,10 @@
|
||||
"test:backup-scheduler": "node --experimental-sqlite test-backup-scheduler.js",
|
||||
"test:caldav": "node --experimental-sqlite test-caldav-sync.js",
|
||||
"test:carddav": "node --experimental-sqlite test-carddav.js",
|
||||
"test": "node --experimental-sqlite test-db.js && node --experimental-sqlite test-dashboard.js && node --experimental-sqlite test-tasks.js && node --experimental-sqlite test-multi-assignment.js && node --experimental-sqlite test-shopping.js && node --experimental-sqlite test-meals.js && node --experimental-sqlite test-calendar.js && node --experimental-sqlite test-notes-contacts-budget.js && npm run test:ux-utils && npm run test:modal-utils && npm run test:reminders && npm run test:api && npm run test:ics-parser && npm run test:ics-sub && npm run test:setup && npm run test:kitchen-tabs && npm run test:backup-scheduler && npm run test:caldav && npm run test:carddav"
|
||||
"test": "node --experimental-sqlite test-db.js && node --experimental-sqlite test-dashboard.js && node --experimental-sqlite test-tasks.js && node --experimental-sqlite test-multi-assignment.js && node --experimental-sqlite test-shopping.js && node --experimental-sqlite test-meals.js && node --experimental-sqlite test-calendar.js && node --experimental-sqlite test-notes-contacts-budget.js && npm run test:ux-utils && npm run test:modal-utils && npm run test:reminders && npm run test:api && npm run test:ics-parser && npm run test:ics-sub && npm run test:setup && npm run test:kitchen-tabs && npm run test:backup-scheduler && npm run test:caldav && npm run test:carddav",
|
||||
"test:meal-planning": "node --experimental-sqlite test-meal-planning.js",
|
||||
"test:meal-fit": "node test-meal-fit.js",
|
||||
"test:e2e:oikos-flow": "playwright test tests/e2e/oikos-kitchen-assist-flow.spec.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"bcrypt": "^6.0.0",
|
||||
@@ -49,6 +52,7 @@
|
||||
},
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.60.0",
|
||||
"puppeteer": "^24.42.0",
|
||||
"sharp": "^0.34.5"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
import fs from 'node:fs';
|
||||
|
||||
const baseURL = process.env.OIKOS_E2E_BASE_URL || 'https://home.friborg.uk';
|
||||
const keepArtifacts = process.env.OIKOS_E2E_ARTIFACTS === '1';
|
||||
const systemChromium = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH
|
||||
|| (fs.existsSync('/usr/bin/chromium-browser') ? '/usr/bin/chromium-browser' : undefined)
|
||||
|| (fs.existsSync('/snap/bin/chromium') ? '/snap/bin/chromium' : undefined);
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './tests/e2e',
|
||||
timeout: 90_000,
|
||||
expect: { timeout: 15_000 },
|
||||
fullyParallel: false,
|
||||
retries: process.env.CI ? 1 : 0,
|
||||
reporter: [['list'], ['html', { outputFolder: 'playwright-report', open: 'never' }]],
|
||||
use: {
|
||||
baseURL,
|
||||
trace: keepArtifacts ? 'retain-on-failure' : 'off',
|
||||
screenshot: keepArtifacts ? 'only-on-failure' : 'off',
|
||||
video: keepArtifacts ? 'retain-on-failure' : 'off',
|
||||
ignoreHTTPSErrors: true,
|
||||
actionTimeout: 15_000,
|
||||
navigationTimeout: 30_000,
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium-desktop',
|
||||
use: {
|
||||
...devices['Desktop Chrome'],
|
||||
launchOptions: systemChromium ? { executablePath: systemChromium } : undefined,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -319,7 +319,9 @@
|
||||
"savedRecipePlaceholder": "Rezept auswählen",
|
||||
"saveAsRecipe": "Als Rezept speichern",
|
||||
"recipeScaleLabel": "Zutaten skalieren",
|
||||
"deletedToast": "Mahlzeit gelöscht"
|
||||
"deletedToast": "Mahlzeit gelöscht",
|
||||
"cookLabel": "Koch/Köchin",
|
||||
"cookNone": "Keine Koch-Zuweisung"
|
||||
},
|
||||
"calendar": {
|
||||
"title": "Kalender",
|
||||
|
||||
@@ -313,7 +313,9 @@
|
||||
"savedRecipePlaceholder": "Select recipe",
|
||||
"saveAsRecipe": "Save as recipe",
|
||||
"recipeScaleLabel": "Scale ingredients",
|
||||
"deletedToast": "Meal deleted"
|
||||
"deletedToast": "Meal deleted",
|
||||
"cookLabel": "Cook",
|
||||
"cookNone": "No assigned cook"
|
||||
},
|
||||
"calendar": {
|
||||
"title": "Calendar",
|
||||
|
||||
+379
-8
@@ -29,6 +29,12 @@ const DAY_NAMES = () => [
|
||||
];
|
||||
|
||||
const EXCLUDED_MEAL_CATEGORY_NAMES = new Set(['Haushalt', 'Drogerie']);
|
||||
const MEAL_CATEGORY_OPTIONS = [
|
||||
['meat', 'Kød'], ['fish', 'Fisk'], ['pasta', 'Pasta'], ['rice', 'Ris'],
|
||||
['vegetarian', 'Grønt'], ['soup', 'Suppe'], ['leftovers', 'Rester'], ['cozy', 'Hygge'], ['other', 'Andet'],
|
||||
];
|
||||
const PROTEIN_OPTIONS = [['mixed', 'Blandet'], ['chicken', 'Kylling'], ['beef', 'Okse'], ['pork', 'Svin'], ['fish', 'Fisk'], ['vegetarian', 'Vegetar'], ['none', 'Ingen'], ['other', 'Andet']];
|
||||
const STYLE_OPTIONS = [['family', 'Familie'], ['quick', 'Hurtig'], ['cozy', 'Hygge'], ['grill', 'Grill'], ['vegetarian', 'Vegetar'], ['kids', 'Børnevenlig'], ['leftovers', 'Rester'], ['other', 'Andet']];
|
||||
|
||||
// --------------------------------------------------------
|
||||
// State
|
||||
@@ -38,10 +44,18 @@ let state = {
|
||||
currentWeek: null, // YYYY-MM-DD (Montag)
|
||||
meals: [],
|
||||
recipes: [],
|
||||
recipeSignals: [],
|
||||
familyMembers: [], // Familienmitglieder für Koch-Zuweisung
|
||||
lists: [], // Einkaufslisten für Transfer-Dropdown
|
||||
categories: [], // Einkaufskategorien für Zutaten
|
||||
modal: null,
|
||||
visibleMealTypes: ['breakfast', 'lunch', 'dinner', 'snack'],
|
||||
planner: {
|
||||
suggestions: [],
|
||||
selectedDate: null,
|
||||
context: { busyness: 'normal', energy: 'normal', dinnerWindowMinutes: 45, guests: false, soloParent: false },
|
||||
loading: false,
|
||||
},
|
||||
};
|
||||
|
||||
// Container-Referenz für Hilfsfunktionen (wird in render() gesetzt)
|
||||
@@ -82,6 +96,28 @@ function mealCategories() {
|
||||
return state.categories.filter((c) => !EXCLUDED_MEAL_CATEGORY_NAMES.has(c.name));
|
||||
}
|
||||
|
||||
function optionHtml(options, selected) {
|
||||
return options.map(([value, label]) => `<option value="${esc(value)}" ${value === selected ? 'selected' : ''}>${esc(label)}</option>`).join('');
|
||||
}
|
||||
|
||||
function optionLabel(options, value) {
|
||||
return options.find(([optionValue]) => optionValue === value)?.[1] || value || '';
|
||||
}
|
||||
|
||||
function renderTaxonomyChip(kind, value, label) {
|
||||
if (!value || !label) return '';
|
||||
return `<span class="meal-taxonomy-chip meal-taxonomy-chip--${esc(kind)}" data-meal-taxonomy="${esc(kind)}" data-taxonomy-value="${esc(value)}">${esc(label)}</span>`;
|
||||
}
|
||||
|
||||
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
|
||||
// --------------------------------------------------------
|
||||
@@ -126,6 +162,44 @@ async function loadRecipes() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadFamilyMembers() {
|
||||
try {
|
||||
const res = await api.get('/family/members');
|
||||
state.familyMembers = res.data;
|
||||
} catch {
|
||||
state.familyMembers = [];
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRecipeSignals() {
|
||||
try {
|
||||
const res = await api.get('/meal-planning/recipe-signals');
|
||||
state.recipeSignals = res.data;
|
||||
} catch {
|
||||
state.recipeSignals = [];
|
||||
}
|
||||
}
|
||||
|
||||
function signalFor(recipeId, userId) {
|
||||
return state.recipeSignals.find((signal) => Number(signal.recipe_id) === Number(recipeId) && Number(signal.user_id) === Number(userId));
|
||||
}
|
||||
|
||||
async function saveRecipeSignal(recipeId, userId, patch) {
|
||||
const current = signalFor(recipeId, userId) || {};
|
||||
const res = await api.put(`/meal-planning/recipe-signals/${recipeId}`, {
|
||||
user_id: userId,
|
||||
preference: current.preference || 'neutral',
|
||||
can_cook: !!current.can_cook,
|
||||
can_help_cook: !!current.can_help_cook,
|
||||
will_eat_modified: !!current.will_eat_modified,
|
||||
adult_only: !!current.adult_only,
|
||||
...patch,
|
||||
});
|
||||
state.recipeSignals = state.recipeSignals.filter((signal) => !(Number(signal.recipe_id) === Number(recipeId) && Number(signal.user_id) === Number(userId)));
|
||||
state.recipeSignals.push(res.data);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
async function loadPreferences() {
|
||||
try {
|
||||
const res = await api.get('/preferences');
|
||||
@@ -154,6 +228,47 @@ export async function render(container, { user }) {
|
||||
<i data-lucide="chevron-right" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="meal-fit-panel" id="meal-fit-panel">
|
||||
<div class="meal-fit-panel__header">
|
||||
<div>
|
||||
<p class="meal-fit-panel__eyebrow">Family logistics</p>
|
||||
<h2 class="meal-fit-panel__title">Smart dinner fit</h2>
|
||||
<p class="meal-fit-panel__subtitle">Ranks saved recipes against the actual day: pressure, energy, guests, leftovers and grocery burden.</p>
|
||||
</div>
|
||||
<button class="btn btn--primary" id="meal-fit-run" type="button">Suggest dinners</button>
|
||||
</div>
|
||||
<div class="meal-fit-panel__controls">
|
||||
<label class="meal-fit-control">Day
|
||||
<select class="form-input" id="meal-fit-date"></select>
|
||||
</label>
|
||||
<label class="meal-fit-control">Pressure
|
||||
<select class="form-input" id="meal-fit-busyness">
|
||||
<option value="normal">Normal</option>
|
||||
<option value="high">Busy / late activity</option>
|
||||
<option value="low">Calm</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="meal-fit-control">Energy
|
||||
<select class="form-input" id="meal-fit-energy">
|
||||
<option value="normal">Normal</option>
|
||||
<option value="low">Low</option>
|
||||
<option value="high">High</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="meal-fit-control">Dinner window
|
||||
<select class="form-input" id="meal-fit-window">
|
||||
<option value="25">25 min</option>
|
||||
<option value="45" selected>45 min</option>
|
||||
<option value="90">90 min</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="meal-fit-check"><input type="checkbox" id="meal-fit-guests"> Guests</label>
|
||||
<label class="meal-fit-check"><input type="checkbox" id="meal-fit-solo"> Solo parent</label>
|
||||
</div>
|
||||
<div class="meal-fit-results" id="meal-fit-results" aria-live="polite">
|
||||
<p class="meal-fit-empty">Choose a day context and run suggestions. If the ranking does not change when the day changes, we have failed.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="week-grid" id="week-grid">
|
||||
<div style="margin:auto;padding:2rem;text-align:center;color:var(--color-text-disabled)">${t('meals.loadingIndicator')}</div>
|
||||
</div>
|
||||
@@ -167,13 +282,17 @@ export async function render(container, { user }) {
|
||||
renderKitchenTabsBar(container, '/meals');
|
||||
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const monday = getMondayOf(today);
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const requestedWeek = params.get('week') || params.get('date') || today;
|
||||
const monday = getMondayOf(requestedWeek);
|
||||
|
||||
await Promise.all([loadWeek(monday), loadLists(), loadPreferences(), loadCategories(), loadRecipes()]);
|
||||
await Promise.all([loadWeek(monday), loadLists(), loadPreferences(), loadCategories(), loadRecipes(), loadFamilyMembers(), loadRecipeSignals()]);
|
||||
renderWeekGrid();
|
||||
renderMealFitPanel();
|
||||
wireNav();
|
||||
wireMealFitPanel();
|
||||
|
||||
const selectedRecipeId = Number(new URLSearchParams(window.location.search).get('recipe'));
|
||||
const selectedRecipeId = Number(params.get('recipe'));
|
||||
if (selectedRecipeId) {
|
||||
const selectedRecipe = state.recipes.find((r) => r.id === selectedRecipeId);
|
||||
if (selectedRecipe) {
|
||||
@@ -252,6 +371,8 @@ function renderSlot(date, type, mealsForDay) {
|
||||
const ingLabel = ingCount > 0 ? (ingCount !== 1 ? t('meals.ingredientCountPlural', { count: ingCount }) : t('meals.ingredientCount', { count: ingCount })) : '';
|
||||
const ingDoneLabel = ingCount > 0 && ingDone === ingCount ? ' ✓' : '';
|
||||
const canTransfer = ingCount > 0 && ingDone < ingCount;
|
||||
const cookName = meal.cook_assignment?.cook_name;
|
||||
const taxonomyChips = renderMealTaxonomyChips(meal);
|
||||
|
||||
return `
|
||||
<div class="meal-slot meal-slot--has-meal" data-meal-id="${meal.id}" data-date="${meal.date}" data-type="${type.key}">
|
||||
@@ -261,8 +382,10 @@ function renderSlot(date, type, mealsForDay) {
|
||||
data-meal-id="${meal.id}"
|
||||
role="button" tabindex="0">
|
||||
<div class="meal-card__title">${esc(meal.title)}</div>
|
||||
${ingLabel ? `<div class="meal-card__meta">
|
||||
<span class="meal-card__ingredients-count">${ingLabel}${esc(ingDoneLabel)}</span>
|
||||
${taxonomyChips ? `<div class="meal-card__taxonomy" aria-label="Meal classification">${taxonomyChips}</div>` : ''}
|
||||
${(ingLabel || cookName) ? `<div class="meal-card__meta">
|
||||
${ingLabel ? `<span class="meal-card__ingredients-count">${ingLabel}${esc(ingDoneLabel)}</span>` : ''}
|
||||
${cookName ? `<span class="meal-card__cook"><i data-lucide="chef-hat" style="width:13px;height:13px;" aria-hidden="true"></i>${esc(cookName)}</span>` : ''}
|
||||
</div>` : ''}
|
||||
<div class="meal-card__actions">
|
||||
${meal.recipe_url ? `<a class="meal-card__action-btn meal-card__action-btn--recipe"
|
||||
@@ -288,6 +411,151 @@ function renderSlot(date, type, mealsForDay) {
|
||||
`;
|
||||
}
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Smart dinner fit panel
|
||||
// --------------------------------------------------------
|
||||
|
||||
function recipeToPlannerMeal(recipe) {
|
||||
const tags = parseRecipeTags(recipe.tags_json);
|
||||
const style = recipe.style || 'family';
|
||||
const isQuick = style === 'quick' || tags.includes('quick') || tags.includes('hurtig');
|
||||
const isLeftovers = style === 'leftovers' || recipe.meal_category === 'leftovers';
|
||||
const isCozy = style === 'cozy' || tags.includes('cozy') || tags.includes('hygge');
|
||||
const guestFit = Boolean(recipe.guest_fit || recipe.batch_friendly || tags.includes('guest') || tags.includes('guests') || tags.includes('batch'));
|
||||
return {
|
||||
id: recipe.id,
|
||||
name: recipe.title,
|
||||
ingredients: recipe.ingredients || [],
|
||||
activeMinutes: Number(recipe.active_minutes || recipe.activeMinutes || (isQuick ? 15 : isCozy ? 45 : 30)),
|
||||
totalMinutes: Number(recipe.total_minutes || recipe.totalMinutes || (isQuick ? 20 : isCozy ? 75 : 45)),
|
||||
effort: isQuick || isLeftovers ? 'easy' : isCozy ? 'project' : 'normal',
|
||||
cleanup: tags.includes('one-pot') || tags.includes('traybake') || isLeftovers ? 'low' : isCozy ? 'high' : 'medium',
|
||||
interruptionTolerance: isQuick || isLeftovers || tags.includes('slow') || tags.includes('traybake') ? 'high' : 'medium',
|
||||
kidFit: recipe.style === 'kids' || tags.includes('kids') || tags.includes('børnevenlig') ? 'safe' : 'mixed',
|
||||
guestFit,
|
||||
batchFriendly: guestFit || tags.includes('batch'),
|
||||
meal_category: recipe.meal_category,
|
||||
protein: recipe.protein,
|
||||
style,
|
||||
tags,
|
||||
};
|
||||
}
|
||||
|
||||
function parseRecipeTags(value) {
|
||||
if (Array.isArray(value)) return value.map((tag) => String(tag).toLowerCase());
|
||||
if (!value) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
return Array.isArray(parsed) ? parsed.map((tag) => String(tag).toLowerCase()) : [];
|
||||
} catch {
|
||||
return String(value).split(',').map((tag) => tag.trim().toLowerCase()).filter(Boolean);
|
||||
}
|
||||
}
|
||||
|
||||
function plannerInventory() {
|
||||
return state.meals
|
||||
.filter((meal) => meal.meal_type === 'dinner')
|
||||
.slice(-8)
|
||||
.map((meal) => ({ name: meal.title, portions: 1, sourceMealId: meal.id }));
|
||||
}
|
||||
|
||||
function plannerPreferences() {
|
||||
return state.recipeSignals
|
||||
.map((signal) => {
|
||||
const recipe = state.recipes.find((candidate) => Number(candidate.id) === Number(signal.recipe_id));
|
||||
if (!recipe || !['like', 'favorite', 'dislike'].includes(signal.preference)) return null;
|
||||
return { type: signal.preference === 'favorite' ? 'like' : signal.preference, target: recipe.title, strength: signal.preference === 'favorite' ? 'high' : 'medium' };
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function selectedPlannerContext() {
|
||||
const busyness = _container.querySelector('#meal-fit-busyness')?.value || 'normal';
|
||||
const energy = _container.querySelector('#meal-fit-energy')?.value || 'normal';
|
||||
const dinnerWindowMinutes = Number(_container.querySelector('#meal-fit-window')?.value || 45);
|
||||
const guests = Boolean(_container.querySelector('#meal-fit-guests')?.checked);
|
||||
const soloParent = Boolean(_container.querySelector('#meal-fit-solo')?.checked);
|
||||
const labels = [
|
||||
busyness === 'high' ? 'late_activity' : null,
|
||||
energy === 'low' ? 'low_energy' : null,
|
||||
soloParent ? 'solo_parent' : null,
|
||||
].filter(Boolean);
|
||||
return { busyness, energy, dinnerWindowMinutes, guests, soloParent, labels };
|
||||
}
|
||||
|
||||
function renderMealFitPanel() {
|
||||
if (!_container) return;
|
||||
const dateSelect = _container.querySelector('#meal-fit-date');
|
||||
const results = _container.querySelector('#meal-fit-results');
|
||||
if (!dateSelect || !results) return;
|
||||
const days = Array.from({ length: 7 }, (_, i) => addDays(state.currentWeek, i));
|
||||
if (!state.planner.selectedDate || !days.includes(state.planner.selectedDate)) state.planner.selectedDate = days[0];
|
||||
dateSelect.innerHTML = days.map((date, idx) => `<option value="${date}" ${date === state.planner.selectedDate ? 'selected' : ''}>${esc(DAY_NAMES()[idx])} · ${esc(formatDayDate(date))}</option>`).join('');
|
||||
|
||||
if (state.planner.loading) {
|
||||
results.innerHTML = '<p class="meal-fit-empty">Scoring dinners against family reality…</p>';
|
||||
return;
|
||||
}
|
||||
if (!state.planner.suggestions.length) {
|
||||
results.innerHTML = '<p class="meal-fit-empty">Choose a day context and run suggestions. If the ranking does not change when the day changes, we have failed.</p>';
|
||||
return;
|
||||
}
|
||||
results.innerHTML = state.planner.suggestions.slice(0, 3).map(renderMealFitSuggestion).join('');
|
||||
if (window.lucide) lucide.createIcons();
|
||||
}
|
||||
|
||||
function renderMealFitSuggestion(suggestion, idx) {
|
||||
const reasons = (suggestion.reasons || []).slice(0, 3).map((reason) => `<li>${esc(reason)}</li>`).join('');
|
||||
const warnings = (suggestion.warnings || []).slice(0, 2).map((warning) => `<li>${esc(warning)}</li>`).join('');
|
||||
const grocery = suggestion.groceryDelta ? `${suggestion.groceryDelta.newItems} new grocery item${suggestion.groceryDelta.newItems === 1 ? '' : 's'}` : '';
|
||||
return `
|
||||
<article class="meal-fit-card">
|
||||
<div class="meal-fit-card__rank">#${idx + 1}</div>
|
||||
<div class="meal-fit-card__body">
|
||||
<div class="meal-fit-card__topline">
|
||||
<h3>${esc(suggestion.mealName)}</h3>
|
||||
<span class="meal-fit-card__score">Fit ${esc(String(suggestion.score))}</span>
|
||||
</div>
|
||||
<div class="meal-fit-card__chips">
|
||||
${(suggestion.fitLabels || []).slice(0, 4).map((label) => `<span>${esc(label.replaceAll('-', ' '))}</span>`).join('')}
|
||||
${grocery ? `<span>${esc(grocery)}</span>` : ''}
|
||||
</div>
|
||||
${reasons ? `<ul class="meal-fit-card__reasons">${reasons}</ul>` : ''}
|
||||
${warnings ? `<ul class="meal-fit-card__warnings">${warnings}</ul>` : ''}
|
||||
</div>
|
||||
</article>
|
||||
`;
|
||||
}
|
||||
|
||||
function wireMealFitPanel() {
|
||||
const runBtn = _container.querySelector('#meal-fit-run');
|
||||
if (!runBtn) return;
|
||||
_container.querySelector('#meal-fit-date')?.addEventListener('change', (event) => { state.planner.selectedDate = event.target.value; });
|
||||
runBtn.addEventListener('click', async () => {
|
||||
state.planner.selectedDate = _container.querySelector('#meal-fit-date')?.value || state.planner.selectedDate;
|
||||
state.planner.context = selectedPlannerContext();
|
||||
state.planner.loading = true;
|
||||
renderMealFitPanel();
|
||||
try {
|
||||
const res = await api.post('/meal-planning/suggestions', {
|
||||
meals: state.recipes.map(recipeToPlannerMeal),
|
||||
dayContext: state.planner.context,
|
||||
preferences: plannerPreferences(),
|
||||
inventory: plannerInventory(),
|
||||
recentMeals: state.meals.filter((meal) => meal.date < state.planner.selectedDate).slice(-6),
|
||||
pantryStaples: ['salt', 'pepper', 'oil', 'olive oil', 'water'],
|
||||
});
|
||||
state.planner.suggestions = res.data.suggestions || [];
|
||||
} catch (err) {
|
||||
state.planner.suggestions = [];
|
||||
window.oikos?.showToast(err.data?.error ?? 'Meal suggestions failed', 'error');
|
||||
} finally {
|
||||
state.planner.loading = false;
|
||||
renderMealFitPanel();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Event-Delegation
|
||||
// --------------------------------------------------------
|
||||
@@ -296,11 +564,13 @@ function wireNav() {
|
||||
_container.querySelector('#week-prev')?.addEventListener('click', async () => {
|
||||
await loadWeek(addDays(state.currentWeek, -7));
|
||||
renderWeekGrid();
|
||||
renderMealFitPanel();
|
||||
});
|
||||
|
||||
_container.querySelector('#week-next')?.addEventListener('click', async () => {
|
||||
await loadWeek(addDays(state.currentWeek, 7));
|
||||
renderWeekGrid();
|
||||
renderMealFitPanel();
|
||||
});
|
||||
|
||||
_container.querySelector('#week-today')?.addEventListener('click', async () => {
|
||||
@@ -308,6 +578,7 @@ function wireNav() {
|
||||
if (monday === state.currentWeek) return;
|
||||
await loadWeek(monday);
|
||||
renderWeekGrid();
|
||||
renderMealFitPanel();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -607,6 +878,9 @@ function openMealModal(opts) {
|
||||
panel.querySelector('#modal-title').value = recipe.title || '';
|
||||
panel.querySelector('#modal-notes').value = recipe.notes || '';
|
||||
panel.querySelector('#modal-recipe-url').value = recipe.recipe_url || '';
|
||||
panel.querySelector('#modal-meal-category').value = recipe.meal_category || 'other';
|
||||
panel.querySelector('#modal-protein').value = recipe.protein || 'mixed';
|
||||
panel.querySelector('#modal-style').value = recipe.style || 'family';
|
||||
|
||||
ingList.innerHTML = (recipe.ingredients || [])
|
||||
.map((ing) => {
|
||||
@@ -642,6 +916,39 @@ function openMealModal(opts) {
|
||||
if (window.lucide) lucide.createIcons();
|
||||
});
|
||||
|
||||
panel.querySelector('#modal-leftover-from-meal-id')?.addEventListener('change', (event) => {
|
||||
if (!event.target.value) return;
|
||||
panel.querySelector('#modal-meal-category').value = 'leftovers';
|
||||
panel.querySelector('#modal-protein').value = 'none';
|
||||
panel.querySelector('#modal-style').value = 'leftovers';
|
||||
});
|
||||
|
||||
panel.querySelector('[data-meal-recipe-pref-actions]')?.addEventListener('click', async (e) => {
|
||||
const btn = e.target.closest('[data-meal-recipe-pref]');
|
||||
if (!btn) return;
|
||||
const recipeId = Number(panel.querySelector('#modal-recipe-id')?.value || 0);
|
||||
const userId = Number(panel.querySelector('#modal-recipe-pref-member')?.value || 0);
|
||||
if (!recipeId || !userId) {
|
||||
window.oikos?.showToast('Choose a saved recipe and family member first.', 'error');
|
||||
return;
|
||||
}
|
||||
const kind = btn.dataset.mealRecipePref;
|
||||
const patch = kind === 'favorite' ? { preference: 'favorite' }
|
||||
: kind === 'like' ? { preference: 'like' }
|
||||
: kind === 'dislike' ? { preference: 'dislike' }
|
||||
: kind === 'canCook' ? { can_cook: true }
|
||||
: {};
|
||||
btn.disabled = true;
|
||||
try {
|
||||
await saveRecipeSignal(recipeId, userId, patch);
|
||||
window.oikos?.showToast('Meal signal saved to profile', 'success');
|
||||
} catch (err) {
|
||||
window.oikos?.showToast(err.data?.error ?? t('common.errorGeneric'), 'error');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
saveAsRecipeBtn?.addEventListener('click', async () => {
|
||||
const title = panel.querySelector('#modal-title').value.trim();
|
||||
if (!title) {
|
||||
@@ -656,10 +963,13 @@ function openMealModal(opts) {
|
||||
quantity: ing.quantity,
|
||||
category: ing.category,
|
||||
}));
|
||||
const meal_category = panel.querySelector('#modal-meal-category')?.value || 'other';
|
||||
const protein = panel.querySelector('#modal-protein')?.value || 'mixed';
|
||||
const style = panel.querySelector('#modal-style')?.value || 'family';
|
||||
|
||||
saveAsRecipeBtn.disabled = true;
|
||||
try {
|
||||
const created = await api.post('/recipes', { title, notes, recipe_url, ingredients });
|
||||
const created = await api.post('/recipes', { title, notes, recipe_url, meal_category, protein, style, ingredients });
|
||||
state.recipes.push(created.data);
|
||||
|
||||
if (recipeSelect) {
|
||||
@@ -754,6 +1064,20 @@ function buildModalContent({ mode, date, mealType, meal }) {
|
||||
...state.recipes.map((r) => `<option value="${r.id}" ${isEdit && meal.recipe_id === r.id ? 'selected' : ''}>${esc(r.title)}</option>`),
|
||||
].join('');
|
||||
|
||||
const selectedCookId = isEdit && meal.cook_assignment?.user_id ? String(meal.cook_assignment.user_id) : '';
|
||||
const cookOptions = [
|
||||
`<option value="">${t('meals.cookNone')}</option>`,
|
||||
...state.familyMembers.map((member) => `<option value="${member.id}" ${selectedCookId === String(member.id) ? 'selected' : ''}>${esc(member.display_name)}</option>`),
|
||||
].join('');
|
||||
|
||||
const leftoverOptions = [
|
||||
`<option value="">Ingen rester</option>`,
|
||||
...state.meals
|
||||
.filter((candidate) => !isEdit || candidate.id !== meal.id)
|
||||
.slice(-20)
|
||||
.map((candidate) => `<option value="${candidate.id}" ${isEdit && meal.leftover_from_meal_id === candidate.id ? 'selected' : ''}>${esc(candidate.date)} · ${esc(candidate.title)}</option>`),
|
||||
].join('');
|
||||
|
||||
return `
|
||||
<div class="modal-grid modal-grid--2">
|
||||
<div class="form-group">
|
||||
@@ -766,6 +1090,11 @@ function buildModalContent({ mode, date, mealType, meal }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="modal-cook-user-id">${t('meals.cookLabel')}</label>
|
||||
<select class="form-input" id="modal-cook-user-id">${cookOptions}</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="position:relative;">
|
||||
<label class="form-label" for="modal-title">${t('meals.titleLabel')}</label>
|
||||
<input type="text" class="form-input" id="modal-title"
|
||||
@@ -780,6 +1109,40 @@ function buildModalContent({ mode, date, mealType, meal }) {
|
||||
<select class="form-input" id="modal-recipe-id">${recipeOptions}</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group" data-meal-recipe-pref-actions>
|
||||
<label class="form-label" for="modal-recipe-pref-member">Save this meal as a person-specific signal</label>
|
||||
<select class="form-input" id="modal-recipe-pref-member">
|
||||
<option value="">Choose family member</option>
|
||||
${state.familyMembers.map((member) => `<option value="${member.id}">${esc(member.display_name)}</option>`).join('')}
|
||||
</select>
|
||||
<div class="recipe-card__actions" style="margin-top:var(--space-2)">
|
||||
<button type="button" class="btn btn--ghost" data-meal-recipe-pref="favorite">⭐ Favorite</button>
|
||||
<button type="button" class="btn btn--ghost" data-meal-recipe-pref="like">👍 Likes</button>
|
||||
<button type="button" class="btn btn--ghost" data-meal-recipe-pref="dislike">👎 Dislikes</button>
|
||||
<button type="button" class="btn btn--ghost" data-meal-recipe-pref="canCook">👩🍳 Can cook</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-grid modal-grid--3">
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="modal-meal-category">Meal category</label>
|
||||
<select class="form-input" id="modal-meal-category">${optionHtml(MEAL_CATEGORY_OPTIONS, isEdit ? (meal.meal_category || 'other') : 'other')}</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="modal-protein">Protein</label>
|
||||
<select class="form-input" id="modal-protein">${optionHtml(PROTEIN_OPTIONS, isEdit ? (meal.protein || 'mixed') : 'mixed')}</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="modal-style">Style</label>
|
||||
<select class="form-input" id="modal-style">${optionHtml(STYLE_OPTIONS, isEdit ? (meal.style || 'family') : 'family')}</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="modal-leftover-from-meal-id">Use leftovers from a specific dish</label>
|
||||
<select class="form-input" id="modal-leftover-from-meal-id">${leftoverOptions}</select>
|
||||
</div>
|
||||
|
||||
<div class="modal-grid modal-grid--2">
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="modal-recipe-scale">${t('meals.recipeScaleLabel')}</label>
|
||||
@@ -865,6 +1228,12 @@ async function saveModal(overlay) {
|
||||
const notes = overlay.querySelector('#modal-notes').value.trim() || null;
|
||||
const recipe_url = overlay.querySelector('#modal-recipe-url').value.trim() || null;
|
||||
const recipe_id = overlay.querySelector('#modal-recipe-id')?.value || null;
|
||||
const meal_category = overlay.querySelector('#modal-meal-category')?.value || 'other';
|
||||
const protein = overlay.querySelector('#modal-protein')?.value || 'mixed';
|
||||
const style = overlay.querySelector('#modal-style')?.value || 'family';
|
||||
const leftover_from_meal_id = overlay.querySelector('#modal-leftover-from-meal-id')?.value || null;
|
||||
const cookSelect = overlay.querySelector('#modal-cook-user-id');
|
||||
const cook_user_id = cookSelect?.value ? Number(cookSelect.value) : null;
|
||||
|
||||
if (!date || !isDateInputValid(dateRaw)) {
|
||||
window.oikos?.showToast(t('calendar.invalidDate'), 'error');
|
||||
@@ -884,12 +1253,14 @@ async function saveModal(overlay) {
|
||||
try {
|
||||
const { mode, meal } = state.modal;
|
||||
|
||||
const mealPayload = { date, meal_type, title, notes, recipe_url, recipe_id, meal_category, protein, style, leftover_from_meal_id, cook_user_id };
|
||||
|
||||
if (mode === 'create') {
|
||||
const res = await api.post('/meals', { date, meal_type, title, notes, recipe_url, recipe_id, ingredients });
|
||||
const res = await api.post('/meals', { ...mealPayload, ingredients });
|
||||
state.meals.push(res.data);
|
||||
} else {
|
||||
// Update meal meta
|
||||
await api.put(`/meals/${meal.id}`, { date, meal_type, title, notes, recipe_url, recipe_id });
|
||||
await api.put(`/meals/${meal.id}`, mealPayload);
|
||||
|
||||
// Sync ingredients
|
||||
const existingIds = new Set((meal.ingredients ?? []).map((i) => i.id));
|
||||
|
||||
+141
-3
@@ -8,14 +8,42 @@ import { t } from '/i18n.js';
|
||||
import { openModal as openSharedModal, closeModal as closeSharedModal } from '/components/modal.js';
|
||||
import { DEFAULT_CATEGORY_NAME, categoryLabel } from '/utils/shopping-categories.js';
|
||||
import { renderKitchenTabsBar } from '/utils/kitchen-tabs.js';
|
||||
import { esc } from '/utils/html.js';
|
||||
|
||||
let _container = null;
|
||||
|
||||
const state = {
|
||||
recipes: [],
|
||||
categories: [],
|
||||
familyMembers: [],
|
||||
recipeSignals: [],
|
||||
};
|
||||
|
||||
const MEAL_CATEGORY_OPTIONS = [
|
||||
['meat', 'Kød'], ['fish', 'Fisk'], ['pasta', 'Pasta'], ['rice', 'Ris'],
|
||||
['vegetarian', 'Grønt'], ['soup', 'Suppe'], ['leftovers', 'Rester'], ['cozy', 'Hygge'], ['other', 'Andet'],
|
||||
];
|
||||
const PROTEIN_OPTIONS = [['mixed', 'Blandet'], ['chicken', 'Kylling'], ['beef', 'Okse'], ['pork', 'Svin'], ['fish', 'Fisk'], ['vegetarian', 'Vegetar'], ['none', 'Ingen'], ['other', 'Andet']];
|
||||
const STYLE_OPTIONS = [['family', 'Familie'], ['quick', 'Hurtig'], ['cozy', 'Hygge'], ['grill', 'Grill'], ['vegetarian', 'Vegetar'], ['kids', 'Børnevenlig'], ['leftovers', 'Rester'], ['other', 'Andet']];
|
||||
|
||||
function optionHtml(options, selected) {
|
||||
return options.map(([value, label]) => `<option value="${value}" ${value === selected ? 'selected' : ''}>${label}</option>`).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');
|
||||
}
|
||||
@@ -34,6 +62,45 @@ async function loadCategories() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadFamilyMembers() {
|
||||
try {
|
||||
const res = await api.get('/family/members');
|
||||
state.familyMembers = res.data;
|
||||
} catch { state.familyMembers = []; }
|
||||
}
|
||||
|
||||
async function loadRecipeSignals() {
|
||||
try {
|
||||
const res = await api.get('/meal-planning/recipe-signals');
|
||||
state.recipeSignals = res.data;
|
||||
} catch { state.recipeSignals = []; }
|
||||
}
|
||||
|
||||
function signalsForRecipe(recipeId) {
|
||||
return state.recipeSignals.filter((signal) => Number(signal.recipe_id) === Number(recipeId));
|
||||
}
|
||||
|
||||
function signalFor(recipeId, userId) {
|
||||
return state.recipeSignals.find((signal) => Number(signal.recipe_id) === Number(recipeId) && Number(signal.user_id) === Number(userId));
|
||||
}
|
||||
|
||||
async function saveRecipeSignal(recipeId, userId, patch) {
|
||||
const current = signalFor(recipeId, userId) || {};
|
||||
const payload = {
|
||||
user_id: userId,
|
||||
preference: current.preference || 'neutral',
|
||||
can_cook: !!current.can_cook,
|
||||
can_help_cook: !!current.can_help_cook,
|
||||
will_eat_modified: !!current.will_eat_modified,
|
||||
adult_only: !!current.adult_only,
|
||||
...patch,
|
||||
};
|
||||
const res = await api.put(`/meal-planning/recipe-signals/${recipeId}`, payload);
|
||||
state.recipeSignals = state.recipeSignals.filter((signal) => !(Number(signal.recipe_id) === Number(recipeId) && Number(signal.user_id) === Number(userId)));
|
||||
state.recipeSignals.push(res.data);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function render(container) {
|
||||
_container = container;
|
||||
|
||||
@@ -75,7 +142,7 @@ export async function render(container) {
|
||||
|
||||
if (window.lucide) window.lucide.createIcons();
|
||||
|
||||
await Promise.all([loadRecipes(), loadCategories()]);
|
||||
await Promise.all([loadRecipes(), loadCategories(), loadFamilyMembers(), loadRecipeSignals()]);
|
||||
renderRecipeList();
|
||||
|
||||
addBtn.addEventListener('click', () => openRecipeModal('create'));
|
||||
@@ -107,6 +174,13 @@ export async function render(container) {
|
||||
if (actionBtn.dataset.action === 'add-to-meals') {
|
||||
window.oikos?.navigate(`/meals?recipe=${recipe.id}`);
|
||||
}
|
||||
|
||||
if (actionBtn.dataset.action === 'quick-favorite') {
|
||||
const memberId = Number(actionBtn.dataset.memberId);
|
||||
await saveRecipeSignal(recipe.id, memberId, { preference: 'favorite' });
|
||||
renderRecipeList();
|
||||
window.oikos?.showToast('Favorit gemt på profilen', 'success');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -155,6 +229,28 @@ function renderRecipeList() {
|
||||
|
||||
card.appendChild(h);
|
||||
|
||||
const taxonomy = [
|
||||
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('div');
|
||||
meta.className = 'recipe-card__taxonomy';
|
||||
meta.setAttribute('aria-label', 'Recipe classification');
|
||||
meta.append(...taxonomy);
|
||||
card.appendChild(meta);
|
||||
}
|
||||
|
||||
const recipeSignals = signalsForRecipe(recipe.id);
|
||||
const favoriteSignals = recipeSignals.filter((signal) => signal.preference === 'favorite');
|
||||
if (favoriteSignals.length) {
|
||||
const fav = document.createElement('p');
|
||||
fav.className = 'recipe-card__notes';
|
||||
fav.textContent = `⭐ Favorit hos ${favoriteSignals.map((signal) => signal.user_name).filter(Boolean).join(', ')}`;
|
||||
card.appendChild(fav);
|
||||
}
|
||||
|
||||
if (recipe.notes) {
|
||||
const notes = document.createElement('p');
|
||||
notes.className = 'recipe-card__notes';
|
||||
@@ -220,6 +316,26 @@ function renderRecipeList() {
|
||||
actions.append(addToMeals, edit, duplicate, del);
|
||||
card.appendChild(actions);
|
||||
|
||||
if (state.familyMembers.length) {
|
||||
const pref = document.createElement('div');
|
||||
pref.className = 'recipe-card__actions';
|
||||
const label = document.createElement('span');
|
||||
label.className = 'recipe-card__notes';
|
||||
label.textContent = 'Gem som favorit for:';
|
||||
pref.appendChild(label);
|
||||
for (const member of state.familyMembers.slice(0, 6)) {
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'btn btn--ghost';
|
||||
btn.type = 'button';
|
||||
btn.dataset.action = 'quick-favorite';
|
||||
btn.dataset.id = String(recipe.id);
|
||||
btn.dataset.memberId = String(member.id);
|
||||
btn.textContent = `⭐ ${member.display_name}`;
|
||||
pref.appendChild(btn);
|
||||
}
|
||||
card.appendChild(pref);
|
||||
}
|
||||
|
||||
list.appendChild(card);
|
||||
}
|
||||
}
|
||||
@@ -298,6 +414,24 @@ function openRecipeModal(mode, recipe = null) {
|
||||
<label class="form-label" for="recipe-url">${t('recipes.urlLabel')}</label>
|
||||
<input id="recipe-url" class="form-input" type="url" placeholder="${t('recipes.urlPlaceholder')}">
|
||||
</div>
|
||||
<div class="modal-grid modal-grid--3">
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="recipe-meal-category">Meal category</label>
|
||||
<select id="recipe-meal-category" class="form-input">${optionHtml(MEAL_CATEGORY_OPTIONS, recipe?.meal_category || 'other')}</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="recipe-protein">Protein</label>
|
||||
<select id="recipe-protein" class="form-input">${optionHtml(PROTEIN_OPTIONS, recipe?.protein || 'mixed')}</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="recipe-style">Style</label>
|
||||
<select id="recipe-style" class="form-input">${optionHtml(STYLE_OPTIONS, recipe?.style || 'family')}</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="recipe-tags">Tags</label>
|
||||
<input id="recipe-tags" class="form-input" type="text" placeholder="hurtig, børnevenlig, fredag" value="${esc(Array.isArray(recipe?.tags) ? recipe.tags.join(', ') : '')}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">${t('recipes.ingredientsLabel')}</label>
|
||||
<div class="recipe-ingredient-list" id="recipe-ingredient-list"></div>
|
||||
@@ -348,6 +482,10 @@ async function saveRecipe(panel, mode, recipe) {
|
||||
const title = panel.querySelector('#recipe-title')?.value.trim() || '';
|
||||
const notes = panel.querySelector('#recipe-notes')?.value.trim() || null;
|
||||
const recipe_url = panel.querySelector('#recipe-url')?.value.trim() || null;
|
||||
const meal_category = panel.querySelector('#recipe-meal-category')?.value || 'other';
|
||||
const protein = panel.querySelector('#recipe-protein')?.value || 'mixed';
|
||||
const style = panel.querySelector('#recipe-style')?.value || 'family';
|
||||
const tags = (panel.querySelector('#recipe-tags')?.value || '').split(',').map((tag) => tag.trim()).filter(Boolean);
|
||||
|
||||
if (!title) {
|
||||
window.oikos?.showToast(t('recipes.titleRequired'), 'error');
|
||||
@@ -366,10 +504,10 @@ async function saveRecipe(panel, mode, recipe) {
|
||||
|
||||
try {
|
||||
if (mode === 'create') {
|
||||
const res = await api.post('/recipes', { title, notes, recipe_url, ingredients });
|
||||
const res = await api.post('/recipes', { title, notes, recipe_url, meal_category, protein, style, tags, ingredients });
|
||||
state.recipes.push(res.data);
|
||||
} else {
|
||||
const res = await api.put(`/recipes/${recipe.id}`, { title, notes, recipe_url, ingredients });
|
||||
const res = await api.put(`/recipes/${recipe.id}`, { title, notes, recipe_url, meal_category, protein, style, tags, ingredients });
|
||||
const idx = state.recipes.findIndex((r) => r.id === recipe.id);
|
||||
if (idx >= 0) state.recipes[idx] = res.data;
|
||||
}
|
||||
|
||||
@@ -2334,6 +2334,8 @@ function memberHtml(u) {
|
||||
u.phone ? t('settings.memberPhoneMeta', { value: u.phone }) : '',
|
||||
u.email || '',
|
||||
u.birth_date ? t('settings.memberBirthdayMeta', { date: formatDate(u.birth_date) }) : '',
|
||||
Number(u.favorite_meal_count || 0) ? `⭐ ${Number(u.favorite_meal_count)} favorite meals` : '',
|
||||
Number(u.can_cook_meal_count || 0) ? `👩🍳 ${Number(u.can_cook_meal_count)} can cook` : '',
|
||||
].filter(Boolean).map(esc).join(' · ');
|
||||
return `
|
||||
<li class="settings-member" data-id="${u.id}">
|
||||
|
||||
@@ -62,6 +62,167 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------
|
||||
* Smart dinner fit panel
|
||||
* -------------------------------------------------------- */
|
||||
.meal-fit-panel {
|
||||
margin: var(--space-3) var(--space-4);
|
||||
padding: var(--space-4);
|
||||
border: 1px solid color-mix(in srgb, var(--module-accent) 28%, var(--color-border));
|
||||
border-radius: var(--radius-lg);
|
||||
background:
|
||||
radial-gradient(circle at top left, color-mix(in srgb, var(--module-accent) 16%, transparent), transparent 40%),
|
||||
var(--color-surface);
|
||||
box-shadow: var(--shadow-sm);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.meal-fit-panel__header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.meal-fit-panel__eyebrow {
|
||||
margin: 0 0 var(--space-1);
|
||||
color: var(--module-accent);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--font-weight-bold);
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.meal-fit-panel__title {
|
||||
margin: 0;
|
||||
color: var(--color-text-primary);
|
||||
font-size: var(--text-lg);
|
||||
}
|
||||
|
||||
.meal-fit-panel__subtitle {
|
||||
margin: var(--space-1) 0 0;
|
||||
max-width: 68ch;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.meal-fit-panel__controls {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
align-items: end;
|
||||
gap: var(--space-3);
|
||||
margin-top: var(--space-3);
|
||||
}
|
||||
|
||||
.meal-fit-control,
|
||||
.meal-fit-check {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.meal-fit-control {
|
||||
display: grid;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.meal-fit-check {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
min-height: var(--target-lg);
|
||||
}
|
||||
|
||||
.meal-fit-results {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
margin-top: var(--space-3);
|
||||
}
|
||||
|
||||
.meal-fit-empty {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.meal-fit-card {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3);
|
||||
border: 1px solid var(--color-border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
background: color-mix(in srgb, var(--color-surface) 84%, var(--module-accent) 6%);
|
||||
}
|
||||
|
||||
.meal-fit-card__rank {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--module-accent);
|
||||
color: var(--color-text-inverse, #fff);
|
||||
font-weight: var(--font-weight-bold);
|
||||
}
|
||||
|
||||
.meal-fit-card__topline {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.meal-fit-card__topline h3 {
|
||||
margin: 0;
|
||||
color: var(--color-text-primary);
|
||||
font-size: var(--text-base);
|
||||
}
|
||||
|
||||
.meal-fit-card__score {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.meal-fit-card__chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-1);
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
.meal-fit-card__chips span {
|
||||
padding: 2px var(--space-2);
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--color-accent-light);
|
||||
color: var(--color-accent);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.meal-fit-card__reasons,
|
||||
.meal-fit-card__warnings {
|
||||
margin: var(--space-2) 0 0;
|
||||
padding-left: var(--space-4);
|
||||
font-size: var(--text-sm);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.meal-fit-card__reasons { color: var(--color-text-secondary); }
|
||||
.meal-fit-card__warnings { color: var(--color-warning, #b45309); }
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.meal-fit-panel__header {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.meal-fit-panel__header .btn {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------
|
||||
* Wochengitter (scroll horizontal auf Mobil)
|
||||
* -------------------------------------------------------- */
|
||||
@@ -208,10 +369,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);
|
||||
@@ -461,3 +653,11 @@
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
.meal-card__cook {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
color: var(--color-text-secondary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
+3
-1
@@ -31,7 +31,9 @@ const USER_PUBLIC_COLUMNS = `
|
||||
created_at,
|
||||
(SELECT phone FROM contacts WHERE contacts.family_user_id = users.id LIMIT 1) AS phone,
|
||||
(SELECT email FROM contacts WHERE contacts.family_user_id = users.id LIMIT 1) AS email,
|
||||
(SELECT birth_date FROM birthdays WHERE birthdays.family_user_id = users.id LIMIT 1) AS birth_date
|
||||
(SELECT birth_date FROM birthdays WHERE birthdays.family_user_id = users.id LIMIT 1) AS birth_date,
|
||||
(SELECT COUNT(*) FROM recipe_family_preferences WHERE recipe_family_preferences.user_id = users.id AND preference = 'favorite') AS favorite_meal_count,
|
||||
(SELECT COUNT(*) FROM recipe_family_preferences WHERE recipe_family_preferences.user_id = users.id AND can_cook = 1) AS can_cook_meal_count
|
||||
`;
|
||||
|
||||
// --------------------------------------------------------
|
||||
|
||||
+159
@@ -1351,6 +1351,165 @@ const MIGRATIONS = [
|
||||
CREATE INDEX IF NOT EXISTS idx_calendar_attachment_document ON calendar_events(attachment_document_id);
|
||||
`,
|
||||
},
|
||||
{
|
||||
version: 39,
|
||||
description: 'Native meal planning signals',
|
||||
up: `
|
||||
CREATE TABLE IF NOT EXISTS meal_cooking_rules (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
weekday INTEGER NOT NULL CHECK(weekday BETWEEN 0 AND 6),
|
||||
meal_type TEXT NOT NULL DEFAULT 'dinner' CHECK(meal_type IN ('breakfast', 'lunch', 'dinner', 'snack')),
|
||||
priority INTEGER NOT NULL DEFAULT 100,
|
||||
created_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
||||
UNIQUE(user_id, weekday, meal_type)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS recipe_family_preferences (
|
||||
recipe_id INTEGER NOT NULL REFERENCES recipes(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
preference TEXT NOT NULL DEFAULT 'neutral' CHECK(preference IN ('neutral', 'like', 'dislike', 'favorite')),
|
||||
can_cook INTEGER NOT NULL DEFAULT 0,
|
||||
can_help_cook INTEGER NOT NULL DEFAULT 0,
|
||||
will_eat_modified INTEGER NOT NULL DEFAULT 0,
|
||||
adult_only INTEGER NOT NULL DEFAULT 0,
|
||||
swap_in_count INTEGER NOT NULL DEFAULT 0,
|
||||
swap_away_count INTEGER NOT NULL DEFAULT 0,
|
||||
created_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
||||
PRIMARY KEY(recipe_id, user_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS recipe_variation_meta (
|
||||
recipe_id INTEGER PRIMARY KEY REFERENCES recipes(id) ON DELETE CASCADE,
|
||||
protein TEXT,
|
||||
style TEXT,
|
||||
kid_suitable_confidence INTEGER NOT NULL DEFAULT 0 CHECK(kid_suitable_confidence BETWEEN 0 AND 100),
|
||||
created_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS planned_meal_cooks (
|
||||
meal_id INTEGER PRIMARY KEY REFERENCES meals(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
planned_for_date TEXT NOT NULL,
|
||||
meal_type TEXT NOT NULL DEFAULT 'dinner' CHECK(meal_type IN ('breakfast', 'lunch', 'dinner', 'snack')),
|
||||
source_plan_id TEXT,
|
||||
created_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS meal_plan_feedback (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
plan_id TEXT,
|
||||
meal_id INTEGER REFERENCES meals(id) ON DELETE SET NULL,
|
||||
recipe_id INTEGER REFERENCES recipes(id) ON DELETE SET NULL,
|
||||
slot_date TEXT,
|
||||
meal_type TEXT CHECK(meal_type IS NULL OR meal_type IN ('breakfast', 'lunch', 'dinner', 'snack')),
|
||||
action TEXT NOT NULL CHECK(action IN ('accept', 'reject', 'edit', 'swap', 'confirm', 'cookbook_save', 'cookbook_use')),
|
||||
original_title TEXT,
|
||||
final_title TEXT,
|
||||
notes TEXT,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS kids_cookbooks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
recipe_id INTEGER REFERENCES recipes(id) ON DELETE SET NULL,
|
||||
title TEXT NOT NULL,
|
||||
content_json TEXT NOT NULL,
|
||||
created_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_meal_cooking_rules_weekday ON meal_cooking_rules(weekday, meal_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_recipe_family_preferences_user ON recipe_family_preferences(user_id, preference);
|
||||
CREATE INDEX IF NOT EXISTS idx_planned_meal_cooks_date ON planned_meal_cooks(planned_for_date, meal_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_meal_plan_feedback_recipe ON meal_plan_feedback(recipe_id, action);
|
||||
CREATE INDEX IF NOT EXISTS idx_meal_plan_feedback_created ON meal_plan_feedback(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_kids_cookbooks_recipe ON kids_cookbooks(recipe_id);
|
||||
`,
|
||||
},
|
||||
{
|
||||
version: 40,
|
||||
description: 'Harmonize existing meal planning bridge tables',
|
||||
up(database) {
|
||||
const columns = (table) => new Set(database.prepare(`PRAGMA table_info(${table})`).all().map((row) => row.name));
|
||||
const addColumn = (table, name, definition) => {
|
||||
if (!columns(table).has(name)) database.exec(`ALTER TABLE ${table} ADD COLUMN ${name} ${definition}`);
|
||||
};
|
||||
|
||||
addColumn('meal_cooking_rules', 'priority', "INTEGER NOT NULL DEFAULT 100");
|
||||
addColumn('meal_cooking_rules', 'created_by', "INTEGER REFERENCES users(id) ON DELETE SET NULL");
|
||||
|
||||
addColumn('recipe_family_preferences', 'created_by', "INTEGER REFERENCES users(id) ON DELETE SET NULL");
|
||||
|
||||
addColumn('recipe_variation_meta', 'kid_suitable_confidence', "INTEGER NOT NULL DEFAULT 0");
|
||||
addColumn('recipe_variation_meta', 'created_by', "INTEGER REFERENCES users(id) ON DELETE SET NULL");
|
||||
|
||||
addColumn('planned_meal_cooks', 'planned_for_date', "TEXT");
|
||||
addColumn('planned_meal_cooks', 'source_plan_id', "TEXT");
|
||||
addColumn('planned_meal_cooks', 'created_by', "INTEGER REFERENCES users(id) ON DELETE SET NULL");
|
||||
database.exec(`UPDATE planned_meal_cooks SET planned_for_date = COALESCE(planned_for_date, meal_date) WHERE planned_for_date IS NULL AND meal_date IS NOT NULL`);
|
||||
|
||||
addColumn('meal_plan_feedback', 'plan_id', "TEXT");
|
||||
addColumn('meal_plan_feedback', 'meal_id', "INTEGER REFERENCES meals(id) ON DELETE SET NULL");
|
||||
addColumn('meal_plan_feedback', 'meal_type', "TEXT");
|
||||
addColumn('meal_plan_feedback', 'action', "TEXT NOT NULL DEFAULT 'edit'");
|
||||
addColumn('meal_plan_feedback', 'original_title', "TEXT");
|
||||
addColumn('meal_plan_feedback', 'final_title', "TEXT");
|
||||
addColumn('meal_plan_feedback', 'notes', "TEXT");
|
||||
addColumn('meal_plan_feedback', 'user_id', "INTEGER REFERENCES users(id) ON DELETE SET NULL");
|
||||
database.exec(`UPDATE meal_plan_feedback SET action = COALESCE(NULLIF(action, ''), type, 'edit') WHERE action IS NULL OR action = ''`);
|
||||
|
||||
addColumn('kids_cookbooks', 'content_json', "TEXT");
|
||||
database.exec(`UPDATE kids_cookbooks SET content_json = COALESCE(content_json, payload) WHERE content_json IS NULL AND payload IS NOT NULL`);
|
||||
|
||||
database.exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_meal_cooking_rules_weekday ON meal_cooking_rules(weekday, meal_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_recipe_family_preferences_user ON recipe_family_preferences(user_id, preference);
|
||||
CREATE INDEX IF NOT EXISTS idx_planned_meal_cooks_date ON planned_meal_cooks(planned_for_date, meal_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_meal_plan_feedback_recipe ON meal_plan_feedback(recipe_id, action);
|
||||
CREATE INDEX IF NOT EXISTS idx_meal_plan_feedback_created ON meal_plan_feedback(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_kids_cookbooks_recipe ON kids_cookbooks(recipe_id);
|
||||
`);
|
||||
},
|
||||
},
|
||||
{
|
||||
version: 41,
|
||||
description: 'Structured meal taxonomy and leftover links',
|
||||
up(database) {
|
||||
const columns = (table) => new Set(database.prepare(`PRAGMA table_info(${table})`).all().map((row) => row.name));
|
||||
const addColumn = (table, name, definition) => {
|
||||
if (!columns(table).has(name)) database.exec(`ALTER TABLE ${table} ADD COLUMN ${name} ${definition}`);
|
||||
};
|
||||
|
||||
addColumn('recipes', 'meal_category', "TEXT");
|
||||
addColumn('recipes', 'protein', "TEXT");
|
||||
addColumn('recipes', 'style', "TEXT");
|
||||
addColumn('recipes', 'tags_json', "TEXT");
|
||||
|
||||
addColumn('meals', 'meal_category', "TEXT");
|
||||
addColumn('meals', 'protein', "TEXT");
|
||||
addColumn('meals', 'style', "TEXT");
|
||||
addColumn('meals', 'leftover_from_meal_id', "INTEGER REFERENCES meals(id) ON DELETE SET NULL");
|
||||
addColumn('meals', 'source_plan_id', "TEXT");
|
||||
|
||||
database.exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_recipes_meal_taxonomy ON recipes(meal_category, protein, style);
|
||||
CREATE INDEX IF NOT EXISTS idx_meals_meal_taxonomy ON meals(meal_category, protein, style);
|
||||
CREATE INDEX IF NOT EXISTS idx_meals_leftover_from ON meals(leftover_from_meal_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_meals_source_plan ON meals(source_plan_id);
|
||||
`);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -22,6 +22,7 @@ import dashboardRouter from './routes/dashboard.js';
|
||||
import tasksRouter from './routes/tasks.js';
|
||||
import shoppingRouter from './routes/shopping.js';
|
||||
import mealsRouter from './routes/meals.js';
|
||||
import mealPlanningRouter from './routes/meal-planning.js';
|
||||
import recipesRouter from './routes/recipes.js';
|
||||
import calendarRouter from './routes/calendar.js';
|
||||
import notesRouter from './routes/notes.js';
|
||||
@@ -228,6 +229,7 @@ app.use('/api/v1/dashboard', dashboardRouter);
|
||||
app.use('/api/v1/tasks', tasksRouter);
|
||||
app.use('/api/v1/shopping', shoppingRouter);
|
||||
app.use('/api/v1/meals', mealsRouter);
|
||||
app.use('/api/v1/meal-planning', mealPlanningRouter);
|
||||
app.use('/api/v1/recipes', recipesRouter);
|
||||
app.use('/api/v1/calendar', calendarRouter);
|
||||
app.use('/api/v1/notes', notesRouter);
|
||||
|
||||
@@ -390,6 +390,36 @@ function buildPaths() {
|
||||
'/api/v1/meals/week-to-shopping-list': {
|
||||
post: op({ summary: 'Transfer weekly meal ingredients to shopping list', tag: 'Meals', stateChanging: true, requestBody: jsonBody(null) }),
|
||||
},
|
||||
'/api/v1/meal-planning/cooking-rules': {
|
||||
get: op({ summary: 'List recurring meal cook rules', tag: 'Meal Planning' }),
|
||||
put: op({ summary: 'Replace recurring meal cook rules', tag: 'Meal Planning', stateChanging: true, requestBody: jsonBody(null) }),
|
||||
},
|
||||
'/api/v1/meal-planning/recipe-signals': {
|
||||
get: op({ summary: 'List family recipe preference/capability signals', tag: 'Meal Planning' }),
|
||||
},
|
||||
'/api/v1/meal-planning/recipe-signals/{recipeId}': {
|
||||
put: op({ summary: 'Upsert family recipe preference/capability signal', tag: 'Meal Planning', params: [idParam('recipeId', 'Recipe ID')], stateChanging: true, requestBody: jsonBody(null) }),
|
||||
},
|
||||
'/api/v1/meal-planning/variation-meta': {
|
||||
get: op({ summary: 'List recipe variation metadata', tag: 'Meal Planning' }),
|
||||
},
|
||||
'/api/v1/meal-planning/variation-meta/{recipeId}': {
|
||||
put: op({ summary: 'Upsert recipe variation metadata', tag: 'Meal Planning', params: [idParam('recipeId', 'Recipe ID')], stateChanging: true, requestBody: jsonBody(null) }),
|
||||
},
|
||||
'/api/v1/meal-planning/cook-assignments': {
|
||||
get: op({ summary: 'List planned meal cook assignments', tag: 'Meal Planning' }),
|
||||
},
|
||||
'/api/v1/meal-planning/cook-assignments/{mealId}': {
|
||||
put: op({ summary: 'Upsert planned meal cook assignment', tag: 'Meal Planning', params: [idParam('mealId', 'Meal ID')], stateChanging: true, requestBody: jsonBody(null) }),
|
||||
},
|
||||
'/api/v1/meal-planning/feedback': {
|
||||
get: op({ summary: 'List meal planning feedback events', tag: 'Meal Planning' }),
|
||||
post: op({ summary: 'Record meal planning feedback event', tag: 'Meal Planning', stateChanging: true, requestBody: jsonBody(null) }),
|
||||
},
|
||||
'/api/v1/meal-planning/kids-cookbooks': {
|
||||
get: op({ summary: 'List saved kids cookbooks', tag: 'Meal Planning' }),
|
||||
post: op({ summary: 'Save kids cookbook', tag: 'Meal Planning', stateChanging: true, requestBody: jsonBody(null) }),
|
||||
},
|
||||
'/api/v1/recipes': {
|
||||
get: op({ summary: 'List recipes', tag: 'Recipes' }),
|
||||
post: op({ summary: 'Create recipe', tag: 'Recipes', stateChanging: true, requestBody: jsonBody(null) }),
|
||||
@@ -663,6 +693,7 @@ function buildOpenApiSpec(req, appVersion) {
|
||||
{ name: 'Tasks' },
|
||||
{ name: 'Shopping' },
|
||||
{ name: 'Meals' },
|
||||
{ name: 'Meal Planning' },
|
||||
{ name: 'Recipes' },
|
||||
{ name: 'Calendar' },
|
||||
{ name: 'Notes' },
|
||||
|
||||
@@ -22,6 +22,8 @@ router.get('/members', (req, res) => {
|
||||
c.phone,
|
||||
c.email,
|
||||
b.birth_date,
|
||||
(SELECT COUNT(*) FROM recipe_family_preferences p WHERE p.user_id = u.id AND p.preference = 'favorite') AS favorite_meal_count,
|
||||
(SELECT COUNT(*) FROM recipe_family_preferences p WHERE p.user_id = u.id AND p.can_cook = 1) AS can_cook_meal_count,
|
||||
u.created_at
|
||||
FROM users u
|
||||
LEFT JOIN contacts c ON c.family_user_id = u.id
|
||||
|
||||
@@ -0,0 +1,353 @@
|
||||
/**
|
||||
* Modul: Meal Planning
|
||||
* Zweck: Native Oikos API surface for Assist/Meal Plan Studio learning signals.
|
||||
* Dependencies: express, server/db.js
|
||||
*/
|
||||
|
||||
import express from 'express';
|
||||
import crypto from 'node:crypto';
|
||||
import * as db from '../db.js';
|
||||
import { createLogger } from '../logger.js';
|
||||
import { str, num, date, oneOf, collectErrors, MAX_TITLE, MAX_TEXT, MAX_SHORT } from '../middleware/validate.js';
|
||||
import { generateGroceryList, scoreMealSuggestions } from '../services/meal-fit.js';
|
||||
|
||||
const log = createLogger('MealPlanning');
|
||||
const router = express.Router();
|
||||
|
||||
const VALID_WEEKDAYS = [0, 1, 2, 3, 4, 5, 6];
|
||||
const VALID_MEAL_TYPES = ['breakfast', 'lunch', 'dinner', 'snack'];
|
||||
const VALID_PREFERENCES = ['neutral', 'like', 'dislike', 'favorite'];
|
||||
const VALID_FEEDBACK = ['accept', 'reject', 'edit', 'swap', 'confirm', 'cookbook_save', 'cookbook_use'];
|
||||
|
||||
function asBool(value) {
|
||||
return value === true || value === 1 || value === '1' ? 1 : 0;
|
||||
}
|
||||
|
||||
function parseId(value, field = 'ID') {
|
||||
const id = Number(value);
|
||||
if (!Number.isInteger(id) || id <= 0) throw Object.assign(new Error(`${field} is invalid.`), { status: 400 });
|
||||
return id;
|
||||
}
|
||||
|
||||
function ensureUser(userId) {
|
||||
const id = parseId(userId, 'User ID');
|
||||
const exists = db.get().prepare('SELECT id FROM users WHERE id = ?').get(id);
|
||||
if (!exists) throw Object.assign(new Error('User not found.'), { status: 400 });
|
||||
return id;
|
||||
}
|
||||
|
||||
function ensureRecipe(recipeId) {
|
||||
const id = parseId(recipeId, 'Recipe ID');
|
||||
const exists = db.get().prepare('SELECT id FROM recipes WHERE id = ?').get(id);
|
||||
if (!exists) throw Object.assign(new Error('Recipe not found.'), { status: 400 });
|
||||
return id;
|
||||
}
|
||||
|
||||
function ensureMeal(mealId) {
|
||||
const id = parseId(mealId, 'Meal ID');
|
||||
const exists = db.get().prepare('SELECT id FROM meals WHERE id = ?').get(id);
|
||||
if (!exists) throw Object.assign(new Error('Meal not found.'), { status: 400 });
|
||||
return id;
|
||||
}
|
||||
|
||||
function handleError(res, err, context) {
|
||||
log.error(`${context}:`, err);
|
||||
res.status(err.status || 500).json({ error: err.status ? err.message : 'Internal server error.', code: err.status || 500 });
|
||||
}
|
||||
|
||||
function currentUserId(req) {
|
||||
return req.authUserId ?? req.session?.userId ?? null;
|
||||
}
|
||||
|
||||
function tableColumns(table) {
|
||||
return new Set(db.get().prepare(`PRAGMA table_info(${table})`).all().map((row) => row.name));
|
||||
}
|
||||
|
||||
function insertWithOptionalTextId(table, columns, values) {
|
||||
const available = tableColumns(table);
|
||||
const insertColumns = [...columns];
|
||||
const insertValues = [...values];
|
||||
if (available.has('id')) {
|
||||
const idInfo = db.get().prepare(`PRAGMA table_info(${table})`).all().find((row) => row.name === 'id');
|
||||
if (String(idInfo?.type || '').toUpperCase() !== 'INTEGER') {
|
||||
insertColumns.unshift('id');
|
||||
insertValues.unshift(crypto.randomUUID());
|
||||
}
|
||||
}
|
||||
const placeholders = insertColumns.map(() => '?').join(', ');
|
||||
const result = db.get().prepare(`INSERT INTO ${table} (${insertColumns.join(', ')}) VALUES (${placeholders})`).run(...insertValues);
|
||||
return db.get().prepare(`SELECT * FROM ${table} WHERE rowid = ?`).get(result.lastInsertRowid);
|
||||
}
|
||||
|
||||
router.get('/cooking-rules', (_req, res) => {
|
||||
try {
|
||||
const rows = db.get().prepare(`
|
||||
SELECT r.*, u.display_name AS cook_name, u.avatar_color AS cook_color
|
||||
FROM meal_cooking_rules r
|
||||
LEFT JOIN users u ON u.id = r.user_id
|
||||
ORDER BY r.weekday ASC, r.meal_type ASC, r.priority DESC, u.display_name COLLATE NOCASE ASC
|
||||
`).all();
|
||||
res.json({ data: rows });
|
||||
} catch (err) { handleError(res, err, 'GET /cooking-rules'); }
|
||||
});
|
||||
|
||||
router.put('/cooking-rules', (req, res) => {
|
||||
try {
|
||||
const rules = Array.isArray(req.body?.data) ? req.body.data : Array.isArray(req.body?.rules) ? req.body.rules : [];
|
||||
const insert = db.get().prepare(`
|
||||
INSERT INTO meal_cooking_rules (user_id, weekday, meal_type, priority, created_by)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`);
|
||||
db.transaction(() => {
|
||||
db.get().prepare('DELETE FROM meal_cooking_rules').run();
|
||||
for (const rule of rules) {
|
||||
const userId = ensureUser(rule.user_id ?? rule.userId);
|
||||
const weekday = Number(rule.weekday);
|
||||
if (!VALID_WEEKDAYS.includes(weekday)) throw Object.assign(new Error('Weekday must be 0-6.'), { status: 400 });
|
||||
const mealType = VALID_MEAL_TYPES.includes(rule.meal_type || rule.mealType || 'dinner') ? (rule.meal_type || rule.mealType || 'dinner') : 'dinner';
|
||||
const priority = Number.isFinite(Number(rule.priority)) ? Number(rule.priority) : 100;
|
||||
if (tableColumns('meal_cooking_rules').has('id')) {
|
||||
const idInfo = db.get().prepare(`PRAGMA table_info(meal_cooking_rules)`).all().find((row) => row.name === 'id');
|
||||
if (String(idInfo?.type || '').toUpperCase() !== 'INTEGER') {
|
||||
db.get().prepare(`
|
||||
INSERT INTO meal_cooking_rules (id, user_id, weekday, meal_type, priority, created_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(crypto.randomUUID(), userId, weekday, mealType, priority, currentUserId(req));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
insert.run(userId, weekday, mealType, priority, currentUserId(req));
|
||||
}
|
||||
})();
|
||||
const data = db.get().prepare('SELECT * FROM meal_cooking_rules ORDER BY weekday ASC, meal_type ASC, priority DESC').all();
|
||||
res.json({ data });
|
||||
} catch (err) { handleError(res, err, 'PUT /cooking-rules'); }
|
||||
});
|
||||
|
||||
router.get('/recipe-signals', (_req, res) => {
|
||||
try {
|
||||
const data = db.get().prepare(`
|
||||
SELECT p.*, r.title AS recipe_title, u.display_name AS user_name
|
||||
FROM recipe_family_preferences p
|
||||
LEFT JOIN recipes r ON r.id = p.recipe_id
|
||||
LEFT JOIN users u ON u.id = p.user_id
|
||||
ORDER BY r.title COLLATE NOCASE ASC, u.display_name COLLATE NOCASE ASC
|
||||
`).all();
|
||||
res.json({ data });
|
||||
} catch (err) { handleError(res, err, 'GET /recipe-signals'); }
|
||||
});
|
||||
|
||||
router.put('/recipe-signals/:recipeId', (req, res) => {
|
||||
try {
|
||||
const recipeId = ensureRecipe(req.params.recipeId);
|
||||
const userId = ensureUser(req.body.user_id ?? req.body.userId);
|
||||
const preference = VALID_PREFERENCES.includes(req.body.preference) ? req.body.preference : 'neutral';
|
||||
db.get().prepare(`
|
||||
INSERT INTO recipe_family_preferences (
|
||||
recipe_id, user_id, preference, can_cook, can_help_cook, will_eat_modified, adult_only,
|
||||
swap_in_count, swap_away_count, created_by, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
||||
ON CONFLICT(recipe_id, user_id) DO UPDATE SET
|
||||
preference = excluded.preference,
|
||||
can_cook = excluded.can_cook,
|
||||
can_help_cook = excluded.can_help_cook,
|
||||
will_eat_modified = excluded.will_eat_modified,
|
||||
adult_only = excluded.adult_only,
|
||||
swap_in_count = excluded.swap_in_count,
|
||||
swap_away_count = excluded.swap_away_count,
|
||||
updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
|
||||
`).run(
|
||||
recipeId, userId, preference, asBool(req.body.can_cook ?? req.body.canCook),
|
||||
asBool(req.body.can_help_cook ?? req.body.canHelpCook),
|
||||
asBool(req.body.will_eat_modified ?? req.body.willEatModified),
|
||||
asBool(req.body.adult_only ?? req.body.adultOnly),
|
||||
Number(req.body.swap_in_count ?? req.body.swapInCount ?? 0),
|
||||
Number(req.body.swap_away_count ?? req.body.swapAwayCount ?? 0),
|
||||
currentUserId(req)
|
||||
);
|
||||
const data = db.get().prepare('SELECT * FROM recipe_family_preferences WHERE recipe_id = ? AND user_id = ?').get(recipeId, userId);
|
||||
res.json({ data });
|
||||
} catch (err) { handleError(res, err, 'PUT /recipe-signals/:recipeId'); }
|
||||
});
|
||||
|
||||
router.get('/variation-meta', (_req, res) => {
|
||||
try {
|
||||
const data = db.get().prepare(`
|
||||
SELECT v.*, r.title AS recipe_title
|
||||
FROM recipe_variation_meta v
|
||||
LEFT JOIN recipes r ON r.id = v.recipe_id
|
||||
ORDER BY r.title COLLATE NOCASE ASC
|
||||
`).all();
|
||||
res.json({ data });
|
||||
} catch (err) { handleError(res, err, 'GET /variation-meta'); }
|
||||
});
|
||||
|
||||
router.put('/variation-meta/:recipeId', (req, res) => {
|
||||
try {
|
||||
const recipeId = ensureRecipe(req.params.recipeId);
|
||||
const protein = str(req.body.protein, 'Protein', { max: MAX_SHORT, required: false });
|
||||
const style = str(req.body.style, 'Style', { max: MAX_SHORT, required: false });
|
||||
const kidConfidence = Math.max(0, Math.min(100, Number(req.body.kid_suitable_confidence ?? req.body.kidSuitableConfidence ?? 0)));
|
||||
const errors = collectErrors([protein, style]);
|
||||
if (errors.length) return res.status(400).json({ error: errors.join(' '), code: 400 });
|
||||
db.get().prepare(`
|
||||
INSERT INTO recipe_variation_meta (recipe_id, protein, style, kid_suitable_confidence, created_by, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
||||
ON CONFLICT(recipe_id) DO UPDATE SET
|
||||
protein = excluded.protein,
|
||||
style = excluded.style,
|
||||
kid_suitable_confidence = excluded.kid_suitable_confidence,
|
||||
updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
|
||||
`).run(recipeId, protein.value, style.value, kidConfidence, currentUserId(req));
|
||||
const data = db.get().prepare('SELECT * FROM recipe_variation_meta WHERE recipe_id = ?').get(recipeId);
|
||||
res.json({ data });
|
||||
} catch (err) { handleError(res, err, 'PUT /variation-meta/:recipeId'); }
|
||||
});
|
||||
|
||||
router.get('/cook-assignments', (req, res) => {
|
||||
try {
|
||||
const from = req.query.from && /^\d{4}-\d{2}-\d{2}$/.test(String(req.query.from)) ? req.query.from : null;
|
||||
const to = req.query.to && /^\d{4}-\d{2}-\d{2}$/.test(String(req.query.to)) ? req.query.to : null;
|
||||
const where = from && to ? 'WHERE a.planned_for_date BETWEEN ? AND ?' : '';
|
||||
const args = from && to ? [from, to] : [];
|
||||
const data = db.get().prepare(`
|
||||
SELECT a.*, m.title AS meal_title, u.display_name AS cook_name
|
||||
FROM planned_meal_cooks a
|
||||
LEFT JOIN meals m ON m.id = a.meal_id
|
||||
LEFT JOIN users u ON u.id = a.user_id
|
||||
${where}
|
||||
ORDER BY a.planned_for_date ASC, a.meal_type ASC
|
||||
`).all(...args);
|
||||
res.json({ data });
|
||||
} catch (err) { handleError(res, err, 'GET /cook-assignments'); }
|
||||
});
|
||||
|
||||
router.put('/cook-assignments/:mealId', (req, res) => {
|
||||
try {
|
||||
const mealId = ensureMeal(req.params.mealId);
|
||||
const userId = ensureUser(req.body.user_id ?? req.body.userId);
|
||||
const meal = db.get().prepare('SELECT date, meal_type, title FROM meals WHERE id = ?').get(mealId);
|
||||
const vDate = date(req.body.planned_for_date ?? req.body.plannedForDate ?? meal.date, 'Planned date', true);
|
||||
const mealType = VALID_MEAL_TYPES.includes(req.body.meal_type || req.body.mealType || meal.meal_type) ? (req.body.meal_type || req.body.mealType || meal.meal_type) : meal.meal_type;
|
||||
const errors = collectErrors([vDate]);
|
||||
if (errors.length) return res.status(400).json({ error: errors.join(' '), code: 400 });
|
||||
const update = db.get().prepare(`
|
||||
UPDATE planned_meal_cooks
|
||||
SET user_id = ?, planned_for_date = ?, meal_type = ?, source_plan_id = ?, created_by = COALESCE(created_by, ?), updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
|
||||
WHERE meal_id = ?
|
||||
`).run(userId, vDate.value, mealType, req.body.source_plan_id ?? req.body.sourcePlanId ?? null, currentUserId(req), mealId);
|
||||
if (update.changes === 0) {
|
||||
const columns = ['meal_id', 'user_id', 'planned_for_date', 'meal_type', 'source_plan_id', 'created_by', 'updated_at'];
|
||||
const values = [mealId, userId, vDate.value, mealType, req.body.source_plan_id ?? req.body.sourcePlanId ?? null, currentUserId(req), new Date().toISOString().replace(/\.\d{3}Z$/, 'Z')];
|
||||
const available = tableColumns('planned_meal_cooks');
|
||||
if (available.has('meal_date')) {
|
||||
columns.push('meal_date');
|
||||
values.push(vDate.value);
|
||||
}
|
||||
if (available.has('meal_title')) {
|
||||
columns.push('meal_title');
|
||||
values.push(meal.title || null);
|
||||
}
|
||||
insertWithOptionalTextId('planned_meal_cooks', columns, values);
|
||||
}
|
||||
const data = db.get().prepare('SELECT * FROM planned_meal_cooks WHERE meal_id = ?').get(mealId);
|
||||
res.json({ data });
|
||||
} catch (err) { handleError(res, err, 'PUT /cook-assignments/:mealId'); }
|
||||
});
|
||||
|
||||
router.post('/suggestions', (req, res) => {
|
||||
try {
|
||||
const meals = Array.isArray(req.body?.meals) ? req.body.meals : [];
|
||||
const selectedMeals = Array.isArray(req.body?.selectedMeals) ? req.body.selectedMeals : [];
|
||||
const context = {
|
||||
meals,
|
||||
dayContext: req.body?.dayContext || req.body?.day_context || {},
|
||||
preferences: Array.isArray(req.body?.preferences) ? req.body.preferences : [],
|
||||
inventory: Array.isArray(req.body?.inventory) ? req.body.inventory : [],
|
||||
recentMeals: Array.isArray(req.body?.recentMeals) ? req.body.recentMeals : [],
|
||||
pantryStaples: Array.isArray(req.body?.pantryStaples) ? req.body.pantryStaples : [],
|
||||
today: req.body?.today,
|
||||
};
|
||||
if (!meals.length) return res.status(400).json({ error: 'At least one meal is required.', code: 400 });
|
||||
const suggestions = scoreMealSuggestions(context);
|
||||
const groceryList = selectedMeals.length
|
||||
? generateGroceryList(selectedMeals, { inventory: context.inventory, pantryStaples: context.pantryStaples })
|
||||
: null;
|
||||
res.json({ data: { suggestions, groceryList } });
|
||||
} catch (err) { handleError(res, err, 'POST /suggestions'); }
|
||||
});
|
||||
|
||||
router.get('/feedback', (req, res) => {
|
||||
try {
|
||||
const limit = Math.max(1, Math.min(200, Number(req.query.limit || 50)));
|
||||
const data = db.get().prepare(`
|
||||
SELECT f.*, r.title AS recipe_title, u.display_name AS user_name
|
||||
FROM meal_plan_feedback f
|
||||
LEFT JOIN recipes r ON r.id = f.recipe_id
|
||||
LEFT JOIN users u ON u.id = f.user_id
|
||||
ORDER BY f.created_at DESC, f.id DESC
|
||||
LIMIT ?
|
||||
`).all(limit);
|
||||
res.json({ data });
|
||||
} catch (err) { handleError(res, err, 'GET /feedback'); }
|
||||
});
|
||||
|
||||
router.post('/feedback', (req, res) => {
|
||||
try {
|
||||
const action = oneOf(req.body.action, VALID_FEEDBACK, 'Action');
|
||||
const slotDate = date(req.body.slot_date ?? req.body.slotDate, 'Slot date', false);
|
||||
const mealType = oneOf(req.body.meal_type ?? req.body.mealType, VALID_MEAL_TYPES, 'Meal type');
|
||||
const originalTitle = str(req.body.original_title ?? req.body.originalTitle, 'Original title', { max: MAX_TITLE, required: false });
|
||||
const finalTitle = str(req.body.final_title ?? req.body.finalTitle, 'Final title', { max: MAX_TITLE, required: false });
|
||||
const notes = str(req.body.notes, 'Notes', { max: MAX_TEXT, required: false });
|
||||
const errors = collectErrors([action, slotDate, mealType, originalTitle, finalTitle, notes]);
|
||||
if (errors.length) return res.status(400).json({ error: errors.join(' '), code: 400 });
|
||||
const recipeId = req.body.recipe_id || req.body.recipeId ? ensureRecipe(req.body.recipe_id ?? req.body.recipeId) : null;
|
||||
const mealId = req.body.meal_id || req.body.mealId ? ensureMeal(req.body.meal_id ?? req.body.mealId) : null;
|
||||
const columns = ['plan_id', 'meal_id', 'recipe_id', 'slot_date', 'meal_type', 'action', 'original_title', 'final_title', 'notes', 'user_id'];
|
||||
const values = [
|
||||
req.body.plan_id ?? req.body.planId ?? null, mealId, recipeId, slotDate.value, mealType.value,
|
||||
action.value, originalTitle.value, finalTitle.value, notes.value, currentUserId(req),
|
||||
];
|
||||
if (tableColumns('meal_plan_feedback').has('type')) {
|
||||
columns.push('type');
|
||||
values.push(action.value);
|
||||
}
|
||||
const data = insertWithOptionalTextId('meal_plan_feedback', columns, values);
|
||||
res.status(201).json({ data });
|
||||
} catch (err) { handleError(res, err, 'POST /feedback'); }
|
||||
});
|
||||
|
||||
router.get('/kids-cookbooks', (_req, res) => {
|
||||
try {
|
||||
const data = db.get().prepare(`
|
||||
SELECT k.*, r.title AS recipe_title, u.display_name AS creator_name
|
||||
FROM kids_cookbooks k
|
||||
LEFT JOIN recipes r ON r.id = k.recipe_id
|
||||
LEFT JOIN users u ON u.id = k.created_by
|
||||
ORDER BY k.updated_at DESC, k.id DESC
|
||||
`).all().map((row) => ({ ...row, content: row.content_json ? JSON.parse(row.content_json) : null }));
|
||||
res.json({ data });
|
||||
} catch (err) { handleError(res, err, 'GET /kids-cookbooks'); }
|
||||
});
|
||||
|
||||
router.post('/kids-cookbooks', (req, res) => {
|
||||
try {
|
||||
const recipeId = req.body.recipe_id || req.body.recipeId ? ensureRecipe(req.body.recipe_id ?? req.body.recipeId) : null;
|
||||
const title = str(req.body.title, 'Title', { max: MAX_TITLE });
|
||||
const content = req.body.content && typeof req.body.content === 'object' ? req.body.content : null;
|
||||
const errors = collectErrors([title]);
|
||||
if (errors.length) return res.status(400).json({ error: errors.join(' '), code: 400 });
|
||||
if (!content) return res.status(400).json({ error: 'Content object is required.', code: 400 });
|
||||
const columns = ['recipe_id', 'title', 'content_json', 'created_by'];
|
||||
const values = [recipeId, title.value, JSON.stringify(content), currentUserId(req)];
|
||||
if (tableColumns('kids_cookbooks').has('payload')) {
|
||||
columns.push('payload');
|
||||
values.push(JSON.stringify(content));
|
||||
}
|
||||
const data = insertWithOptionalTextId('kids_cookbooks', columns, values);
|
||||
res.status(201).json({ data: { ...data, content: JSON.parse(data.content_json) } });
|
||||
} catch (err) { handleError(res, err, 'POST /kids-cookbooks'); }
|
||||
});
|
||||
|
||||
export default router;
|
||||
+157
-12
@@ -6,6 +6,7 @@
|
||||
|
||||
import { createLogger } from '../logger.js';
|
||||
import express from 'express';
|
||||
import crypto from 'node:crypto';
|
||||
import * as db from '../db.js';
|
||||
import { str, oneOf, date, num, collectErrors, MAX_TITLE, MAX_TEXT, MAX_SHORT, DATE_RE } from '../middleware/validate.js';
|
||||
|
||||
@@ -14,6 +15,9 @@ const log = createLogger('Meals');
|
||||
const router = express.Router();
|
||||
|
||||
const VALID_MEAL_TYPES = ['breakfast', 'lunch', 'dinner', 'snack'];
|
||||
const VALID_MEAL_CATEGORIES = ['meat', 'fish', 'pasta', 'rice', 'vegetarian', 'soup', 'leftovers', 'cozy', 'breakfast', 'snack', 'other'];
|
||||
const VALID_PROTEINS = ['mixed', 'chicken', 'beef', 'pork', 'fish', 'vegetarian', 'none', 'other'];
|
||||
const VALID_STYLES = ['family', 'quick', 'cozy', 'grill', 'vegetarian', 'kids', 'leftovers', 'other'];
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Hilfsfunktionen
|
||||
@@ -41,6 +45,98 @@ function weekEnd(dateStr) {
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function loadCookAssignments(mealIds) {
|
||||
if (!mealIds.length) return {};
|
||||
const placeholders = mealIds.map(() => '?').join(',');
|
||||
const rows = db.get().prepare(`
|
||||
SELECT a.*, u.display_name AS cook_name, u.avatar_color AS cook_color
|
||||
FROM planned_meal_cooks a
|
||||
LEFT JOIN users u ON u.id = a.user_id
|
||||
WHERE a.meal_id IN (${placeholders})
|
||||
`).all(...mealIds);
|
||||
return Object.fromEntries(rows.map((row) => [row.meal_id, row]));
|
||||
}
|
||||
|
||||
function attachCookAssignment(meal, cookMap) {
|
||||
return {
|
||||
...meal,
|
||||
cook_assignment: cookMap[meal.id] || null,
|
||||
};
|
||||
}
|
||||
|
||||
function validateCookUserId(raw) {
|
||||
if (raw === undefined) return { present: false, value: null, error: null };
|
||||
if (raw === null || raw === '') return { present: true, value: null, error: null };
|
||||
const id = Number(raw);
|
||||
if (!Number.isInteger(id) || id <= 0) return { present: true, value: null, error: 'Cook user ID is invalid.' };
|
||||
const exists = db.get().prepare('SELECT id FROM users WHERE id = ?').get(id);
|
||||
if (!exists) return { present: true, value: null, error: 'Cook user not found.' };
|
||||
return { present: true, value: id, error: null };
|
||||
}
|
||||
|
||||
function currentUserId(req) {
|
||||
return req.authUserId ?? req.session?.userId ?? null;
|
||||
}
|
||||
|
||||
function normalizeEnum(value, allowed, fallback = null) {
|
||||
const normalized = String(value || '').trim().toLowerCase();
|
||||
return allowed.includes(normalized) ? normalized : fallback;
|
||||
}
|
||||
|
||||
function validateLeftoverMealId(raw) {
|
||||
if (raw === undefined) return { present: false, value: null, error: null };
|
||||
if (raw === null || raw === '') return { present: true, value: null, error: null };
|
||||
const id = Number(raw);
|
||||
if (!Number.isInteger(id) || id <= 0) return { present: true, value: null, error: 'Leftover source meal ID is invalid.' };
|
||||
const exists = db.get().prepare('SELECT id FROM meals WHERE id = ?').get(id);
|
||||
if (!exists) return { present: true, value: null, error: 'Leftover source meal not found.' };
|
||||
return { present: true, value: id, error: null };
|
||||
}
|
||||
|
||||
function tableColumns(table) {
|
||||
return new Set(db.get().prepare(`PRAGMA table_info(${table})`).all().map((row) => row.name));
|
||||
}
|
||||
|
||||
function saveCookAssignment(meal, cookUserId, sourcePlanId, createdBy) {
|
||||
if (cookUserId === null) {
|
||||
db.get().prepare('DELETE FROM planned_meal_cooks WHERE meal_id = ?').run(meal.id);
|
||||
return;
|
||||
}
|
||||
|
||||
const update = db.get().prepare(`
|
||||
UPDATE planned_meal_cooks
|
||||
SET user_id = ?, planned_for_date = ?, meal_type = ?, source_plan_id = ?, created_by = COALESCE(created_by, ?), updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
|
||||
WHERE meal_id = ?
|
||||
`).run(cookUserId, meal.date, meal.meal_type, sourcePlanId || null, createdBy, meal.id);
|
||||
if (update.changes > 0) return;
|
||||
|
||||
const columns = tableColumns('planned_meal_cooks');
|
||||
const insertColumns = ['meal_id', 'user_id', 'planned_for_date', 'meal_type', 'source_plan_id', 'created_by', 'updated_at'];
|
||||
const values = [meal.id, cookUserId, meal.date, meal.meal_type, sourcePlanId || null, createdBy, new Date().toISOString().replace(/\.\d{3}Z$/, 'Z')];
|
||||
if (columns.has('meal_date')) {
|
||||
insertColumns.push('meal_date');
|
||||
values.push(meal.date);
|
||||
}
|
||||
if (columns.has('meal_title')) {
|
||||
insertColumns.push('meal_title');
|
||||
values.push(meal.title || null);
|
||||
}
|
||||
if (columns.has('id')) {
|
||||
insertColumns.unshift('id');
|
||||
values.unshift(crypto.randomUUID());
|
||||
}
|
||||
const placeholders = insertColumns.map(() => '?').join(', ');
|
||||
db.get().prepare(`INSERT INTO planned_meal_cooks (${insertColumns.join(', ')}) VALUES (${placeholders})`).run(...values);
|
||||
}
|
||||
|
||||
function syncCookAssignmentSlot(meal) {
|
||||
db.get().prepare(`
|
||||
UPDATE planned_meal_cooks
|
||||
SET planned_for_date = ?, meal_type = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
|
||||
WHERE meal_id = ?
|
||||
`).run(meal.date, meal.meal_type, meal.id);
|
||||
}
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Routen - Mahlzeiten-Vorschläge (vor dynamischen Routen!)
|
||||
// --------------------------------------------------------
|
||||
@@ -126,10 +222,11 @@ router.get('/', (req, res) => {
|
||||
}
|
||||
}
|
||||
|
||||
const result = meals.map((m) => ({
|
||||
const cookMap = loadCookAssignments(mealIds);
|
||||
const result = meals.map((m) => attachCookAssignment({
|
||||
...m,
|
||||
ingredients: ingredientMap[m.id] || [],
|
||||
}));
|
||||
}, cookMap));
|
||||
|
||||
res.json({ data: result, weekStart: from, weekEnd: to });
|
||||
} catch (err) {
|
||||
@@ -145,7 +242,7 @@ router.get('/', (req, res) => {
|
||||
/**
|
||||
* POST /api/v1/meals
|
||||
* Neue Mahlzeit anlegen.
|
||||
* Body: { date, meal_type, title, notes?, ingredients?: [{ name, quantity? }] }
|
||||
* Body: { date, meal_type, title, notes?, meal_category?, protein?, style?, leftover_from_meal_id?, cook_user_id?, source_plan_id?, ingredients?: [{ name, quantity? }] }
|
||||
* Response: { data: Meal }
|
||||
*/
|
||||
router.post('/', (req, res) => {
|
||||
@@ -157,10 +254,21 @@ router.post('/', (req, res) => {
|
||||
const vNotes = str(req.body.notes, 'Notizen', { max: MAX_TEXT, required: false });
|
||||
const vRecipeUrl = str(req.body.recipe_url, 'Rezept-URL', { max: MAX_TEXT, required: false });
|
||||
const vRecipeId = num(req.body.recipe_id, 'Rezept-ID', { required: false });
|
||||
const errors = collectErrors([vDate, vType, vTitle, vNotes, vRecipeUrl, vRecipeId]);
|
||||
const cookUserRaw = Object.hasOwn(req.body, 'cook_user_id') ? req.body.cook_user_id : req.body.cookUserId;
|
||||
const vCookUserId = validateCookUserId(cookUserRaw);
|
||||
const leftoverRaw = Object.hasOwn(req.body, 'leftover_from_meal_id') ? req.body.leftover_from_meal_id : req.body.leftoverFromMealId;
|
||||
const vLeftoverFromMealId = validateLeftoverMealId(leftoverRaw);
|
||||
const vSourcePlanId = str(req.body.source_plan_id ?? req.body.sourcePlanId, 'Plan-ID', { max: MAX_SHORT, required: false });
|
||||
const errors = collectErrors([vDate, vType, vTitle, vNotes, vRecipeUrl, vRecipeId, vSourcePlanId]);
|
||||
if (vCookUserId.error) errors.push(vCookUserId.error);
|
||||
if (vLeftoverFromMealId.error) errors.push(vLeftoverFromMealId.error);
|
||||
if (!req.body.meal_type) errors.push('Mahlzeit-Typ ist erforderlich.');
|
||||
if (errors.length) return res.status(400).json({ error: errors.join(' '), code: 400 });
|
||||
|
||||
const mealCategory = normalizeEnum(req.body.meal_category ?? req.body.mealCategory, VALID_MEAL_CATEGORIES, vLeftoverFromMealId.value ? 'leftovers' : 'other');
|
||||
const protein = normalizeEnum(req.body.protein, VALID_PROTEINS, vLeftoverFromMealId.value ? 'none' : 'mixed');
|
||||
const style = normalizeEnum(req.body.style, VALID_STYLES, vLeftoverFromMealId.value ? 'leftovers' : 'family');
|
||||
|
||||
if (vRecipeId.value !== null) {
|
||||
const recipeExists = db.get().prepare('SELECT id FROM recipes WHERE id = ?').get(vRecipeId.value);
|
||||
if (!recipeExists) return res.status(400).json({ error: 'Rezept nicht gefunden.', code: 400 });
|
||||
@@ -168,9 +276,9 @@ router.post('/', (req, res) => {
|
||||
|
||||
const meal = db.transaction(() => {
|
||||
const result = db.get().prepare(`
|
||||
INSERT INTO meals (date, meal_type, title, notes, recipe_url, recipe_id, created_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(vDate.value, vType.value, vTitle.value, vNotes.value, vRecipeUrl.value, vRecipeId.value, req.session.userId);
|
||||
INSERT INTO meals (date, meal_type, title, notes, recipe_url, recipe_id, meal_category, protein, style, leftover_from_meal_id, source_plan_id, created_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(vDate.value, vType.value, vTitle.value, vNotes.value, vRecipeUrl.value, vRecipeId.value, mealCategory, protein, style, vLeftoverFromMealId.value, vSourcePlanId.value, currentUserId(req));
|
||||
|
||||
const mealId = result.lastInsertRowid;
|
||||
|
||||
@@ -185,12 +293,18 @@ router.post('/', (req, res) => {
|
||||
if (name) insertIng.run(mealId, name, qty, category);
|
||||
}
|
||||
|
||||
return db.get().prepare(`
|
||||
const createdMeal = db.get().prepare(`
|
||||
SELECT m.*, u.display_name AS creator_name, u.avatar_color AS creator_color
|
||||
FROM meals m
|
||||
LEFT JOIN users u ON u.id = m.created_by
|
||||
WHERE m.id = ?
|
||||
`).get(mealId);
|
||||
|
||||
if (vCookUserId.present && vCookUserId.value !== null) {
|
||||
saveCookAssignment(createdMeal, vCookUserId.value, vSourcePlanId.value, currentUserId(req));
|
||||
}
|
||||
|
||||
return createdMeal;
|
||||
});
|
||||
|
||||
// Zutaten anhängen
|
||||
@@ -198,7 +312,8 @@ router.post('/', (req, res) => {
|
||||
'SELECT * FROM meal_ingredients WHERE meal_id = ? ORDER BY id ASC'
|
||||
).all(meal.id);
|
||||
|
||||
res.status(201).json({ data: { ...meal, ingredients: ings } });
|
||||
const cookMap = loadCookAssignments([meal.id]);
|
||||
res.status(201).json({ data: attachCookAssignment({ ...meal, ingredients: ings }, cookMap) });
|
||||
} catch (err) {
|
||||
log.error('', err);
|
||||
res.status(500).json({ error: 'Interner Fehler', code: 500 });
|
||||
@@ -224,7 +339,14 @@ router.put('/:id', (req, res) => {
|
||||
if (req.body.notes !== undefined) checks.push(str(req.body.notes, 'Notizen', { max: MAX_TEXT, required: false }));
|
||||
if (req.body.recipe_url !== undefined) checks.push(str(req.body.recipe_url, 'Rezept-URL', { max: MAX_TEXT, required: false }));
|
||||
if (req.body.recipe_id !== undefined) checks.push(num(req.body.recipe_id, 'Rezept-ID', { required: false }));
|
||||
const errors = collectErrors(checks);
|
||||
const cookUserRaw = Object.hasOwn(req.body, 'cook_user_id') ? req.body.cook_user_id : req.body.cookUserId;
|
||||
const vCookUserId = validateCookUserId(cookUserRaw);
|
||||
const leftoverRaw = Object.hasOwn(req.body, 'leftover_from_meal_id') ? req.body.leftover_from_meal_id : req.body.leftoverFromMealId;
|
||||
const vLeftoverFromMealId = validateLeftoverMealId(leftoverRaw);
|
||||
const vSourcePlanId = str(req.body.source_plan_id ?? req.body.sourcePlanId, 'Plan-ID', { max: MAX_SHORT, required: false });
|
||||
const errors = collectErrors([...checks, vSourcePlanId]);
|
||||
if (vCookUserId.error) errors.push(vCookUserId.error);
|
||||
if (vLeftoverFromMealId.error) errors.push(vLeftoverFromMealId.error);
|
||||
if (errors.length) return res.status(400).json({ error: errors.join(' '), code: 400 });
|
||||
|
||||
if (req.body.recipe_id !== undefined && req.body.recipe_id !== null && req.body.recipe_id !== '') {
|
||||
@@ -232,6 +354,12 @@ router.put('/:id', (req, res) => {
|
||||
if (!recipeExists) return res.status(400).json({ error: 'Rezept nicht gefunden.', code: 400 });
|
||||
}
|
||||
|
||||
const mealCategory = req.body.meal_category !== undefined || req.body.mealCategory !== undefined
|
||||
? normalizeEnum(req.body.meal_category ?? req.body.mealCategory, VALID_MEAL_CATEGORIES, meal.meal_category || 'other')
|
||||
: meal.meal_category;
|
||||
const protein = req.body.protein !== undefined ? normalizeEnum(req.body.protein, VALID_PROTEINS, meal.protein || 'mixed') : meal.protein;
|
||||
const style = req.body.style !== undefined ? normalizeEnum(req.body.style, VALID_STYLES, meal.style || 'family') : meal.style;
|
||||
|
||||
db.get().prepare(`
|
||||
UPDATE meals
|
||||
SET date = COALESCE(?, date),
|
||||
@@ -239,7 +367,12 @@ router.put('/:id', (req, res) => {
|
||||
title = COALESCE(?, title),
|
||||
notes = ?,
|
||||
recipe_url = ?,
|
||||
recipe_id = ?
|
||||
recipe_id = ?,
|
||||
meal_category = ?,
|
||||
protein = ?,
|
||||
style = ?,
|
||||
leftover_from_meal_id = ?,
|
||||
source_plan_id = ?
|
||||
WHERE id = ?
|
||||
`).run(
|
||||
req.body.date ?? null,
|
||||
@@ -248,6 +381,11 @@ router.put('/:id', (req, res) => {
|
||||
req.body.notes !== undefined ? (req.body.notes || null) : meal.notes,
|
||||
req.body.recipe_url !== undefined ? (req.body.recipe_url || null) : meal.recipe_url,
|
||||
req.body.recipe_id !== undefined ? (req.body.recipe_id || null) : meal.recipe_id,
|
||||
mealCategory,
|
||||
protein,
|
||||
style,
|
||||
vLeftoverFromMealId.present ? vLeftoverFromMealId.value : meal.leftover_from_meal_id,
|
||||
req.body.source_plan_id !== undefined || req.body.sourcePlanId !== undefined ? vSourcePlanId.value : meal.source_plan_id,
|
||||
id
|
||||
);
|
||||
|
||||
@@ -257,11 +395,18 @@ router.put('/:id', (req, res) => {
|
||||
WHERE m.id = ?
|
||||
`).get(id);
|
||||
|
||||
if (vCookUserId.present) {
|
||||
saveCookAssignment(updated, vCookUserId.value, vSourcePlanId.value, currentUserId(req));
|
||||
} else {
|
||||
syncCookAssignmentSlot(updated);
|
||||
}
|
||||
|
||||
const ings = db.get().prepare(
|
||||
'SELECT * FROM meal_ingredients WHERE meal_id = ? ORDER BY id ASC'
|
||||
).all(id);
|
||||
const cookMap = loadCookAssignments([id]);
|
||||
|
||||
res.json({ data: { ...updated, ingredients: ings } });
|
||||
res.json({ data: attachCookAssignment({ ...updated, ingredients: ings }, cookMap) });
|
||||
} catch (err) {
|
||||
log.error('', err);
|
||||
res.status(500).json({ error: 'Interner Fehler', code: 500 });
|
||||
|
||||
@@ -12,6 +12,22 @@ import { str, num, collectErrors, MAX_TITLE, MAX_TEXT, MAX_SHORT } from '../midd
|
||||
const log = createLogger('Recipes');
|
||||
const router = express.Router();
|
||||
|
||||
const VALID_MEAL_CATEGORIES = ['meat', 'fish', 'pasta', 'rice', 'vegetarian', 'soup', 'leftovers', 'cozy', 'breakfast', 'snack', 'other'];
|
||||
const VALID_PROTEINS = ['mixed', 'chicken', 'beef', 'pork', 'fish', 'vegetarian', 'none', 'other'];
|
||||
const VALID_STYLES = ['family', 'quick', 'cozy', 'grill', 'vegetarian', 'kids', 'leftovers', 'other'];
|
||||
|
||||
function normalizeEnum(value, allowed, fallback = null) {
|
||||
const normalized = String(value || '').trim().toLowerCase();
|
||||
return allowed.includes(normalized) ? normalized : fallback;
|
||||
}
|
||||
|
||||
function normalizeTags(value) {
|
||||
const tags = Array.isArray(value)
|
||||
? value
|
||||
: String(value || '').split(',');
|
||||
return tags.map((tag) => String(tag || '').trim().toLowerCase()).filter(Boolean).slice(0, 12);
|
||||
}
|
||||
|
||||
function loadRecipeWithIngredients(id) {
|
||||
const recipe = db.get().prepare(`
|
||||
SELECT r.*, u.display_name AS creator_name, u.avatar_color AS creator_color
|
||||
@@ -75,11 +91,16 @@ router.post('/', (req, res) => {
|
||||
const errors = collectErrors([vTitle, vNotes, vRecipeUrl]);
|
||||
if (errors.length) return res.status(400).json({ error: errors.join(' '), code: 400 });
|
||||
|
||||
const mealCategory = normalizeEnum(req.body.meal_category ?? req.body.mealCategory, VALID_MEAL_CATEGORIES, 'other');
|
||||
const protein = normalizeEnum(req.body.protein, VALID_PROTEINS, 'mixed');
|
||||
const style = normalizeEnum(req.body.style, VALID_STYLES, 'family');
|
||||
const tagsJson = JSON.stringify(normalizeTags(req.body.tags ?? req.body.tags_json));
|
||||
|
||||
const recipeId = db.transaction(() => {
|
||||
const result = db.get().prepare(`
|
||||
INSERT INTO recipes (title, notes, recipe_url, created_by)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`).run(vTitle.value, vNotes.value, vRecipeUrl.value, req.session.userId);
|
||||
INSERT INTO recipes (title, notes, recipe_url, meal_category, protein, style, tags_json, created_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(vTitle.value, vNotes.value, vRecipeUrl.value, mealCategory, protein, style, tagsJson, req.session.userId);
|
||||
|
||||
const rid = Number(result.lastInsertRowid);
|
||||
const insertIng = db.get().prepare(`
|
||||
@@ -122,12 +143,17 @@ router.put('/:id', (req, res) => {
|
||||
const errors = collectErrors([vTitle, vNotes, vRecipeUrl]);
|
||||
if (errors.length) return res.status(400).json({ error: errors.join(' '), code: 400 });
|
||||
|
||||
const mealCategory = normalizeEnum(req.body.meal_category ?? req.body.mealCategory, VALID_MEAL_CATEGORIES, existing.meal_category || 'other');
|
||||
const protein = normalizeEnum(req.body.protein, VALID_PROTEINS, existing.protein || 'mixed');
|
||||
const style = normalizeEnum(req.body.style, VALID_STYLES, existing.style || 'family');
|
||||
const tagsJson = JSON.stringify(normalizeTags(req.body.tags ?? req.body.tags_json));
|
||||
|
||||
db.transaction(() => {
|
||||
db.get().prepare(`
|
||||
UPDATE recipes
|
||||
SET title = ?, notes = ?, recipe_url = ?
|
||||
SET title = ?, notes = ?, recipe_url = ?, meal_category = ?, protein = ?, style = ?, tags_json = ?
|
||||
WHERE id = ?
|
||||
`).run(vTitle.value, vNotes.value, vRecipeUrl.value, id);
|
||||
`).run(vTitle.value, vNotes.value, vRecipeUrl.value, mealCategory, protein, style, tagsJson, id);
|
||||
|
||||
db.get().prepare('DELETE FROM recipe_ingredients WHERE recipe_id = ?').run(id);
|
||||
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
/**
|
||||
* Modul: Meal Fit Service
|
||||
* Zweck: Deterministic context-aware meal ranking and grocery deltas for family logistics.
|
||||
* Dependencies: none
|
||||
*/
|
||||
|
||||
const STOP_WORDS = new Set([
|
||||
'fresh', 'frozen', 'canned', 'can', 'jar', 'large', 'small', 'medium', 'chopped', 'diced',
|
||||
'sliced', 'grated', 'shredded', 'minced', 'whole', 'organic', 'low', 'fat', 'lean', 'extra',
|
||||
'of', 'with', 'and', 'the', 'a', 'an', 'to', 'for', 'optional', 'some', 'little',
|
||||
]);
|
||||
|
||||
const INGREDIENT_ALIASES = new Map([
|
||||
['cheddar cheese', 'cheddar'],
|
||||
['grated cheddar', 'cheddar'],
|
||||
['shredded cheddar', 'cheddar'],
|
||||
['chicken breast', 'chicken'],
|
||||
['chicken breasts', 'chicken'],
|
||||
['leftover chicken', 'chicken'],
|
||||
['minced beef', 'beef'],
|
||||
['ground beef', 'beef'],
|
||||
['beef mince', 'beef'],
|
||||
['bell pepper', 'pepper'],
|
||||
['bell peppers', 'pepper'],
|
||||
['tortilla wraps', 'tortillas'],
|
||||
['wraps', 'tortillas'],
|
||||
]);
|
||||
|
||||
const EFFORT_SCORE = { survival: 4, easy: 3, normal: 1, project: -4 };
|
||||
const CLEANUP_SCORE = { low: 3, medium: 0, high: -4 };
|
||||
const INTERRUPTION_SCORE = { high: 3, medium: 0, low: -4 };
|
||||
const KID_SCORE = { safe: 3, mixed: 0, risky: -4 };
|
||||
|
||||
function toArray(value) {
|
||||
return Array.isArray(value) ? value : [];
|
||||
}
|
||||
|
||||
export function normalizeIngredientName(value) {
|
||||
const raw = String(value || '').toLowerCase().replace(/[^a-z0-9æøåäöüéèàçñ\s-]/gi, ' ').replace(/\s+/g, ' ').trim();
|
||||
if (!raw) return '';
|
||||
if (INGREDIENT_ALIASES.has(raw)) return INGREDIENT_ALIASES.get(raw);
|
||||
const singularish = raw.replace(/ies$/, 'y').replace(/oes$/, 'o').replace(/s$/, '');
|
||||
if (INGREDIENT_ALIASES.has(singularish)) return INGREDIENT_ALIASES.get(singularish);
|
||||
return singularish.split(' ').filter((part) => part && !STOP_WORDS.has(part)).join(' ') || singularish;
|
||||
}
|
||||
|
||||
function ingredientName(ingredient) {
|
||||
return normalizeIngredientName(typeof ingredient === 'string' ? ingredient : ingredient?.name);
|
||||
}
|
||||
|
||||
function inventoryMap(inventory) {
|
||||
const map = new Map();
|
||||
for (const item of toArray(inventory)) {
|
||||
const normalized = normalizeIngredientName(item.normalizedName || item.normalized_name || item.name);
|
||||
if (!normalized) continue;
|
||||
const existing = map.get(normalized) || { name: item.name || normalized, portions: 0, expiresOn: null, items: [] };
|
||||
existing.portions += Number(item.portions || 0);
|
||||
existing.expiresOn = earlierDate(existing.expiresOn, item.expiresOn || item.expires_on || null);
|
||||
existing.items.push(item);
|
||||
map.set(normalized, existing);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function earlierDate(a, b) {
|
||||
if (!a) return b;
|
||||
if (!b) return a;
|
||||
return String(a) < String(b) ? a : b;
|
||||
}
|
||||
|
||||
function daysUntil(dateStr, today = new Date().toISOString().slice(0, 10)) {
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(String(dateStr || ''))) return null;
|
||||
const ms = Date.parse(`${dateStr}T00:00:00Z`) - Date.parse(`${today}T00:00:00Z`);
|
||||
return Math.round(ms / 86400000);
|
||||
}
|
||||
|
||||
function tagsFor(meal) {
|
||||
const parsed = typeof meal.tags_json === 'string' ? safeJson(meal.tags_json, []) : [];
|
||||
return new Set([...toArray(meal.tags), ...parsed, meal.meal_category, meal.protein, meal.style]
|
||||
.map((tag) => String(tag || '').toLowerCase().trim()).filter(Boolean));
|
||||
}
|
||||
|
||||
function safeJson(value, fallback) {
|
||||
try { return JSON.parse(value); } catch { return fallback; }
|
||||
}
|
||||
|
||||
function mealIngredients(meal) {
|
||||
return toArray(meal.ingredients).map((ing) => ({
|
||||
...((typeof ing === 'string') ? { name: ing } : ing),
|
||||
normalizedName: ingredientName(ing),
|
||||
})).filter((ing) => ing.normalizedName);
|
||||
}
|
||||
|
||||
function groceryDeltaFor(meal, inventory, pantryStaples = []) {
|
||||
const inv = inventoryMap(inventory);
|
||||
const pantry = new Set(toArray(pantryStaples).map(normalizeIngredientName).filter(Boolean));
|
||||
const usesInventory = [];
|
||||
const pantryCovered = [];
|
||||
const missing = [];
|
||||
for (const ingredient of mealIngredients(meal)) {
|
||||
if (inv.has(ingredient.normalizedName)) {
|
||||
usesInventory.push(ingredient.normalizedName);
|
||||
} else if (pantry.has(ingredient.normalizedName)) {
|
||||
pantryCovered.push(ingredient.normalizedName);
|
||||
} else {
|
||||
missing.push(ingredient.normalizedName);
|
||||
}
|
||||
}
|
||||
return {
|
||||
newItems: new Set(missing).size,
|
||||
usesInventory: [...new Set(usesInventory)],
|
||||
pantryCovered: [...new Set(pantryCovered)],
|
||||
missingItems: [...new Set(missing)],
|
||||
};
|
||||
}
|
||||
|
||||
function hardBlocked(meal, preferences) {
|
||||
const allTargets = new Set([
|
||||
String(meal.name || meal.title || '').toLowerCase(),
|
||||
...mealIngredients(meal).map((i) => i.normalizedName),
|
||||
...tagsFor(meal),
|
||||
]);
|
||||
for (const pref of toArray(preferences)) {
|
||||
if ((pref.type || pref.preference) !== 'allergy') continue;
|
||||
const target = normalizeIngredientName(pref.target || pref.name || pref.ingredient || pref.value);
|
||||
if (target && allTargets.has(target)) return `Blocked: allergy/diet conflict with ${target}.`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function preferenceEffect(meal, preferences) {
|
||||
let score = 0;
|
||||
const reasons = [];
|
||||
const warnings = [];
|
||||
const allTargets = new Set([
|
||||
String(meal.name || meal.title || '').toLowerCase(),
|
||||
...mealIngredients(meal).map((i) => i.normalizedName),
|
||||
...tagsFor(meal),
|
||||
]);
|
||||
for (const pref of toArray(preferences)) {
|
||||
const type = pref.type || pref.preference;
|
||||
const rawTarget = pref.target || pref.name || pref.ingredient || pref.value;
|
||||
const target = normalizeIngredientName(rawTarget) || String(rawTarget || '').toLowerCase();
|
||||
if (!target || !allTargets.has(target)) continue;
|
||||
const strength = pref.strength === 'high' ? 3 : pref.strength === 'low' ? 1 : 2;
|
||||
if (type === 'like' || type === 'favorite') { score += strength * 2; reasons.push(`Family signal likes ${target}.`); }
|
||||
if (type === 'dislike') { score -= strength * 3; warnings.push(`Preference risk: ${target} is disliked.`); }
|
||||
}
|
||||
return { score, reasons, warnings };
|
||||
}
|
||||
|
||||
function recencyPenalty(meal, recentMeals) {
|
||||
const title = String(meal.name || meal.title || '').toLowerCase().trim();
|
||||
const category = String(meal.meal_category || '').toLowerCase();
|
||||
let penalty = 0;
|
||||
for (const recent of toArray(recentMeals)) {
|
||||
const recentTitle = String(recent.name || recent.title || '').toLowerCase().trim();
|
||||
if (title && recentTitle === title) penalty -= 8;
|
||||
if (category && category === String(recent.meal_category || '').toLowerCase()) penalty -= 2;
|
||||
}
|
||||
return penalty;
|
||||
}
|
||||
|
||||
function dayLabels(dayContext = {}) {
|
||||
return new Set([
|
||||
...toArray(dayContext.labels),
|
||||
dayContext.busyness === 'high' ? 'busy_evening' : null,
|
||||
dayContext.energy === 'low' ? 'low_energy' : null,
|
||||
dayContext.guests ? 'guests' : null,
|
||||
dayContext.soloParent ? 'solo_parent' : null,
|
||||
].filter(Boolean));
|
||||
}
|
||||
|
||||
export function scoreMealSuggestions({ meals = [], dayContext = {}, preferences = [], inventory = [], recentMeals = [], pantryStaples = [], today } = {}) {
|
||||
const labels = dayLabels(dayContext);
|
||||
const inv = inventoryMap(inventory);
|
||||
return toArray(meals).map((meal) => {
|
||||
const blocked = hardBlocked(meal, preferences);
|
||||
const mealName = meal.name || meal.title || 'Untitled meal';
|
||||
const tags = tagsFor(meal);
|
||||
const ingredients = mealIngredients(meal);
|
||||
const groceryDelta = groceryDeltaFor(meal, inventory, pantryStaples);
|
||||
const reasons = [];
|
||||
const warnings = [];
|
||||
let score = 50;
|
||||
|
||||
if (blocked) {
|
||||
return { mealId: meal.id, mealName, score: -9999, fitLabels: ['blocked'], reasons: [], warnings: [blocked], groceryDelta };
|
||||
}
|
||||
|
||||
const effort = meal.effort || (tags.has('quick') ? 'easy' : 'normal');
|
||||
const cleanup = meal.cleanup || 'medium';
|
||||
const interruptionTolerance = meal.interruptionTolerance || meal.interruption_tolerance || (tags.has('leftovers') ? 'high' : 'medium');
|
||||
const activeMinutes = Number(meal.activeMinutes ?? meal.active_minutes ?? meal.active_time_minutes ?? 30);
|
||||
const totalMinutes = Number(meal.totalMinutes ?? meal.total_minutes ?? 45);
|
||||
|
||||
score += EFFORT_SCORE[effort] ?? 0;
|
||||
score += CLEANUP_SCORE[cleanup] ?? 0;
|
||||
score += INTERRUPTION_SCORE[interruptionTolerance] ?? 0;
|
||||
score += KID_SCORE[meal.kidFit || meal.kid_fit] ?? 0;
|
||||
|
||||
if (labels.has('busy_evening') || labels.has('late_activity') || dayContext.dinnerWindowMinutes <= 30) {
|
||||
if (activeMinutes <= 15) { score += 10; reasons.push(`Fits a tight day: ${activeMinutes} min active time.`); }
|
||||
else if (activeMinutes > 30) { score -= 12; warnings.push(`Busy-day mismatch: ${activeMinutes} min active time.`); }
|
||||
if (cleanup === 'low') { score += 5; reasons.push('Low cleanup helps on a busy evening.'); }
|
||||
if (cleanup === 'high') { score -= 8; warnings.push('High cleanup on a busy evening.'); }
|
||||
if (interruptionTolerance === 'high') { score += 5; reasons.push('Can survive interruptions.'); }
|
||||
if (interruptionTolerance === 'low') { score -= 8; warnings.push('Needs continuous attention on an interruption-heavy day.'); }
|
||||
}
|
||||
|
||||
if (labels.has('low_energy') || labels.has('solo_parent')) {
|
||||
if (effort === 'survival' || effort === 'easy') { score += 8; reasons.push(`Low-energy fit: ${effort} effort.`); }
|
||||
if (effort === 'project') { score -= 18; warnings.push('Project meal is a bad fit for low parent energy.'); }
|
||||
}
|
||||
|
||||
if (labels.has('guests')) {
|
||||
if (meal.guestFit || meal.guest_fit || meal.batchFriendly || meal.batch_friendly) { score += 35; reasons.push('Guest-capable / batch-friendly.'); }
|
||||
else { score -= 15; warnings.push('Not marked as guest-capable.'); }
|
||||
}
|
||||
|
||||
if (dayContext.weather === 'hot' && tags.has('oven')) { score -= 4; warnings.push('Oven-heavy meal on a hot day.'); }
|
||||
if ((dayContext.weather === 'cold' || dayContext.weather === 'rainy') && (tags.has('soup') || tags.has('cozy'))) { score += 3; reasons.push('Season/weather tie-breaker fits.'); }
|
||||
|
||||
for (const ingredient of ingredients) {
|
||||
const available = inv.get(ingredient.normalizedName);
|
||||
if (!available) continue;
|
||||
score += 3;
|
||||
const due = daysUntil(available.expiresOn, today);
|
||||
if (due !== null && due <= 1) {
|
||||
score += 8;
|
||||
reasons.push(`Uses ${ingredient.normalizedName} before it expires.`);
|
||||
} else {
|
||||
reasons.push(`Uses available ${ingredient.normalizedName}.`);
|
||||
}
|
||||
}
|
||||
|
||||
const pref = preferenceEffect(meal, preferences);
|
||||
score += pref.score;
|
||||
reasons.push(...pref.reasons);
|
||||
warnings.push(...pref.warnings);
|
||||
|
||||
const repetition = recencyPenalty(meal, recentMeals);
|
||||
if (repetition < 0) {
|
||||
score += repetition;
|
||||
warnings.push('Recently eaten / similar category, so it is demoted for variety.');
|
||||
}
|
||||
|
||||
if (groceryDelta.newItems <= 2) { score += 7; reasons.push(`Small grocery delta: ${groceryDelta.newItems} new items.`); }
|
||||
else if (groceryDelta.newItems >= 8) { score -= 8; warnings.push(`Heavy grocery burden: ${groceryDelta.newItems} new items.`); }
|
||||
|
||||
const fitLabels = [];
|
||||
if (activeMinutes <= 15) fitLabels.push('quick-active-time');
|
||||
if (cleanup === 'low') fitLabels.push('low-cleanup');
|
||||
if (interruptionTolerance === 'high') fitLabels.push('interruption-friendly');
|
||||
if (groceryDelta.usesInventory.length) fitLabels.push('uses-inventory');
|
||||
if (groceryDelta.newItems <= 2) fitLabels.push('low-grocery-delta');
|
||||
if (meal.guestFit || meal.guest_fit) fitLabels.push('guest-capable');
|
||||
|
||||
return {
|
||||
mealId: meal.id,
|
||||
mealName,
|
||||
score: Math.round(score),
|
||||
fitLabels,
|
||||
reasons: [...new Set(reasons)].slice(0, 5),
|
||||
warnings: [...new Set(warnings)].slice(0, 5),
|
||||
groceryDelta,
|
||||
totalMinutes,
|
||||
activeMinutes,
|
||||
};
|
||||
}).sort((a, b) => b.score - a.score || String(a.mealName).localeCompare(String(b.mealName)));
|
||||
}
|
||||
|
||||
export function generateGroceryList(selectedMeals = [], { inventory = [], pantryStaples = [] } = {}) {
|
||||
const inv = inventoryMap(inventory);
|
||||
const pantry = new Set(toArray(pantryStaples).map(normalizeIngredientName).filter(Boolean));
|
||||
const items = new Map();
|
||||
const coveredByInventory = [];
|
||||
for (const meal of toArray(selectedMeals)) {
|
||||
for (const ingredient of mealIngredients(meal)) {
|
||||
const normalized = ingredient.normalizedName;
|
||||
if (!normalized) continue;
|
||||
if (inv.has(normalized)) {
|
||||
coveredByInventory.push({ name: normalized, sourceMealId: meal.id, sourceMealName: meal.name || meal.title });
|
||||
continue;
|
||||
}
|
||||
if (pantry.has(normalized)) continue;
|
||||
const existing = items.get(normalized) || { name: normalized, quantity: ingredient.quantity || null, category: ingredient.category || null, sourceMeals: [] };
|
||||
existing.sourceMeals.push({ id: meal.id, name: meal.name || meal.title });
|
||||
items.set(normalized, existing);
|
||||
}
|
||||
}
|
||||
return {
|
||||
items: [...items.values()].sort((a, b) => a.name.localeCompare(b.name)),
|
||||
coveredByInventory,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* Modul: Meal-Fit-Test
|
||||
* Zweck: Validiert deterministic context-aware meal suggestions and grocery dedupe.
|
||||
* Ausführen: node test-meal-fit.js
|
||||
*/
|
||||
|
||||
import { generateGroceryList, normalizeIngredientName, scoreMealSuggestions } from './server/services/meal-fit.js';
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function test(name, fn) {
|
||||
try { fn(); console.log(` ✓ ${name}`); passed++; }
|
||||
catch (err) { console.error(` ✗ ${name}: ${err.message}`); failed++; }
|
||||
}
|
||||
function assert(cond, msg) { if (!cond) throw new Error(msg || 'Assertion fehlgeschlagen'); }
|
||||
function topName(result) { return result[0]?.mealName; }
|
||||
|
||||
const meals = [
|
||||
{
|
||||
id: 'wraps',
|
||||
name: 'Chicken wraps',
|
||||
ingredients: ['tortilla wraps', 'leftover chicken', 'cucumber', 'cheddar cheese'],
|
||||
activeMinutes: 12,
|
||||
totalMinutes: 15,
|
||||
effort: 'easy',
|
||||
cleanup: 'low',
|
||||
interruptionTolerance: 'high',
|
||||
kidFit: 'safe',
|
||||
meal_category: 'chicken',
|
||||
style: 'quick',
|
||||
tags: ['portable'],
|
||||
},
|
||||
{
|
||||
id: 'lasagna',
|
||||
name: 'Sunday lasagna',
|
||||
ingredients: ['beef mince', 'tomatoes', 'pasta sheets', 'cheddar cheese'],
|
||||
activeMinutes: 50,
|
||||
totalMinutes: 110,
|
||||
effort: 'project',
|
||||
cleanup: 'high',
|
||||
interruptionTolerance: 'low',
|
||||
kidFit: 'safe',
|
||||
guestFit: true,
|
||||
batchFriendly: true,
|
||||
meal_category: 'pasta',
|
||||
tags: ['oven', 'cozy'],
|
||||
},
|
||||
{
|
||||
id: 'fish',
|
||||
name: 'Fish curry',
|
||||
ingredients: ['fish', 'rice', 'curry paste', 'coconut milk'],
|
||||
activeMinutes: 35,
|
||||
totalMinutes: 40,
|
||||
effort: 'normal',
|
||||
cleanup: 'medium',
|
||||
interruptionTolerance: 'low',
|
||||
kidFit: 'risky',
|
||||
meal_category: 'fish',
|
||||
style: 'family',
|
||||
},
|
||||
{
|
||||
id: 'pancakes',
|
||||
name: 'Pancakes',
|
||||
ingredients: ['flour', 'milk', 'eggs'],
|
||||
activeMinutes: 25,
|
||||
totalMinutes: 30,
|
||||
effort: 'easy',
|
||||
cleanup: 'medium',
|
||||
interruptionTolerance: 'medium',
|
||||
kidFit: 'safe',
|
||||
meal_category: 'breakfast',
|
||||
},
|
||||
];
|
||||
|
||||
console.log('\n[Meal-Fit-Test] Context-aware dinner planner\n');
|
||||
|
||||
test('ingredient normalization dedupes obvious variants', () => {
|
||||
assert(normalizeIngredientName('Grated cheddar') === 'cheddar');
|
||||
assert(normalizeIngredientName('cheddar cheese') === 'cheddar');
|
||||
assert(normalizeIngredientName('Tortilla wraps') === 'tortillas');
|
||||
});
|
||||
|
||||
test('busy low-energy day promotes quick low-cleanup interruption-friendly leftover meal', () => {
|
||||
const suggestions = scoreMealSuggestions({
|
||||
meals,
|
||||
dayContext: { busyness: 'high', energy: 'low', dinnerWindowMinutes: 25, labels: ['late_activity'] },
|
||||
inventory: [{ name: 'leftover chicken', portions: 2, expiresOn: '2026-05-24' }],
|
||||
today: '2026-05-23',
|
||||
});
|
||||
assert(topName(suggestions) === 'Chicken wraps', `Expected Chicken wraps, got ${topName(suggestions)}`);
|
||||
const top = suggestions[0];
|
||||
assert(top.fitLabels.includes('quick-active-time'));
|
||||
assert(top.fitLabels.includes('low-cleanup'));
|
||||
assert(top.fitLabels.includes('uses-inventory'));
|
||||
assert(top.reasons.some((reason) => reason.includes('tight day') || reason.includes('Low-energy')));
|
||||
});
|
||||
|
||||
test('normal guest day promotes guest-capable batch meal over emergency wrap default', () => {
|
||||
const suggestions = scoreMealSuggestions({
|
||||
meals,
|
||||
dayContext: { busyness: 'low', energy: 'high', guests: true, dinnerWindowMinutes: 150 },
|
||||
pantryStaples: ['pasta sheets'],
|
||||
});
|
||||
assert(topName(suggestions) === 'Sunday lasagna', `Expected Sunday lasagna, got ${topName(suggestions)}`);
|
||||
assert(suggestions[0].fitLabels.includes('guest-capable'));
|
||||
});
|
||||
|
||||
test('allergy blocks instead of merely warning', () => {
|
||||
const suggestions = scoreMealSuggestions({
|
||||
meals,
|
||||
preferences: [{ type: 'allergy', target: 'fish', strength: 'high' }],
|
||||
});
|
||||
const fish = suggestions.find((suggestion) => suggestion.mealId === 'fish');
|
||||
assert(fish.score < -1000);
|
||||
assert(fish.warnings.some((warning) => warning.includes('allergy')));
|
||||
});
|
||||
|
||||
test('dislike and recent repetition demote risky meals with grounded warnings', () => {
|
||||
const suggestions = scoreMealSuggestions({
|
||||
meals,
|
||||
preferences: [{ type: 'dislike', target: 'fish', strength: 'high' }],
|
||||
recentMeals: [{ title: 'Fish curry', meal_category: 'fish' }],
|
||||
});
|
||||
const fish = suggestions.find((suggestion) => suggestion.mealId === 'fish');
|
||||
assert(fish.warnings.some((warning) => warning.includes('disliked')));
|
||||
assert(fish.warnings.some((warning) => warning.includes('Recently eaten')));
|
||||
assert(suggestions.indexOf(fish) > 0, 'Fish should not rank first after dislike + repetition');
|
||||
});
|
||||
|
||||
test('inventory matching is concrete, not broad tag magic', () => {
|
||||
const suggestions = scoreMealSuggestions({
|
||||
meals: [{ id: 'taggy', name: 'Mystery pasta', ingredients: ['tomatoes'], meal_category: 'pasta', tags: ['pasta'] }],
|
||||
inventory: [{ name: 'pasta', expiresOn: '2026-05-24' }],
|
||||
today: '2026-05-23',
|
||||
});
|
||||
assert(!suggestions[0].fitLabels.includes('uses-inventory'), 'Broad pasta tag must not count as concrete inventory use');
|
||||
assert(!suggestions[0].reasons.some((reason) => reason.includes('Uses pasta')));
|
||||
});
|
||||
|
||||
test('grocery list dedupes normalized ingredient names and tracks source meals', () => {
|
||||
const grocery = generateGroceryList([
|
||||
meals[0],
|
||||
{ id: 'quesadillas', name: 'Quesadillas', ingredients: ['tortillas', 'grated cheddar', 'pepper'] },
|
||||
], { inventory: [{ name: 'leftover chicken' }], pantryStaples: ['pepper'] });
|
||||
const names = grocery.items.map((item) => item.name);
|
||||
assert(names.filter((name) => name === 'cheddar').length === 1, `Expected one cheddar, got ${names.join(', ')}`);
|
||||
assert(names.filter((name) => name === 'tortillas').length === 1, `Expected one tortillas, got ${names.join(', ')}`);
|
||||
const cheddar = grocery.items.find((item) => item.name === 'cheddar');
|
||||
assert(cheddar.sourceMeals.length === 2, 'Cheddar should link both source meals');
|
||||
assert(grocery.coveredByInventory.some((item) => item.name === 'chicken'), 'Leftover chicken should be inventory-covered');
|
||||
});
|
||||
|
||||
console.log(`\n[Meal-Fit-Test] Ergebnis: ${passed} bestanden, ${failed} fehlgeschlagen\n`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* Modul: Meal-Planning-Test
|
||||
* Zweck: Validiert native Meal-Planning-Signal-Tabellen und Constraints.
|
||||
* Ausführen: node --experimental-sqlite test-meal-planning.js
|
||||
*/
|
||||
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
import fs from 'node:fs';
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function test(name, fn) {
|
||||
try { fn(); console.log(` ✓ ${name}`); passed++; }
|
||||
catch (err) { console.error(` ✗ ${name}: ${err.message}`); failed++; }
|
||||
}
|
||||
function assert(cond, msg) { if (!cond) throw new Error(msg || 'Assertion fehlgeschlagen'); }
|
||||
|
||||
function migrationSql(version) {
|
||||
const source = fs.readFileSync(new URL('./server/db.js', import.meta.url), 'utf8');
|
||||
const re = new RegExp('version:\\s*' + version + ',[\\s\\S]*?up:\\s*`([\\s\\S]*?)`');
|
||||
const match = source.match(re);
|
||||
if (!match) throw new Error(`Migration v${version} nicht gefunden`);
|
||||
return match[1];
|
||||
}
|
||||
|
||||
const db = new DatabaseSync(':memory:');
|
||||
db.exec('PRAGMA foreign_keys = ON;');
|
||||
db.exec(`
|
||||
CREATE TABLE users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
display_name TEXT NOT NULL,
|
||||
avatar_color TEXT
|
||||
);
|
||||
CREATE TABLE recipes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE meals (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
date TEXT NOT NULL,
|
||||
meal_type TEXT NOT NULL CHECK(meal_type IN ('breakfast', 'lunch', 'dinner', 'snack')),
|
||||
title TEXT NOT NULL
|
||||
);
|
||||
`);
|
||||
db.exec(migrationSql(39));
|
||||
|
||||
console.log('\n[Meal-Planning-Test] Native Signaltabellen\n');
|
||||
|
||||
const tables = [
|
||||
'meal_cooking_rules',
|
||||
'recipe_family_preferences',
|
||||
'recipe_variation_meta',
|
||||
'planned_meal_cooks',
|
||||
'meal_plan_feedback',
|
||||
'kids_cookbooks',
|
||||
];
|
||||
|
||||
for (const table of tables) {
|
||||
test(`Tabelle "${table}" existiert`, () => {
|
||||
const row = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name=?").get(table);
|
||||
assert(row, `Tabelle ${table} fehlt`);
|
||||
});
|
||||
}
|
||||
|
||||
let userId, recipeId, mealId;
|
||||
|
||||
test('Fixture-Daten erstellen', () => {
|
||||
userId = db.prepare("INSERT INTO users (display_name, avatar_color) VALUES ('Liv', '#FF6B9D')").run().lastInsertRowid;
|
||||
recipeId = db.prepare("INSERT INTO recipes (title) VALUES ('Pasta med tomat')").run().lastInsertRowid;
|
||||
mealId = db.prepare("INSERT INTO meals (date, meal_type, title) VALUES ('2026-05-12', 'dinner', 'Pasta med tomat')").run().lastInsertRowid;
|
||||
assert(userId > 0 && recipeId > 0 && mealId > 0);
|
||||
});
|
||||
|
||||
test('Recurring cook rule gemmes', () => {
|
||||
db.prepare('INSERT INTO meal_cooking_rules (user_id, weekday, meal_type, priority) VALUES (?, 0, ?, 100)').run(userId, 'dinner');
|
||||
const row = db.prepare('SELECT * FROM meal_cooking_rules WHERE user_id = ?').get(userId);
|
||||
assert(row.weekday === 0 && row.meal_type === 'dinner');
|
||||
});
|
||||
|
||||
test('Recipe preference/capability-signaler gemmes', () => {
|
||||
db.prepare(`
|
||||
INSERT INTO recipe_family_preferences (
|
||||
recipe_id, user_id, preference, can_cook, can_help_cook, will_eat_modified, adult_only, swap_in_count, swap_away_count
|
||||
) VALUES (?, ?, 'favorite', 1, 1, 1, 0, 2, 1)
|
||||
`).run(recipeId, userId);
|
||||
const row = db.prepare('SELECT * FROM recipe_family_preferences WHERE recipe_id = ? AND user_id = ?').get(recipeId, userId);
|
||||
assert(row.preference === 'favorite');
|
||||
assert(row.can_cook === 1 && row.can_help_cook === 1 && row.will_eat_modified === 1 && row.adult_only === 0);
|
||||
assert(row.swap_in_count === 2 && row.swap_away_count === 1);
|
||||
});
|
||||
|
||||
test('Variation metadata gemmes', () => {
|
||||
db.prepare('INSERT INTO recipe_variation_meta (recipe_id, protein, style, kid_suitable_confidence) VALUES (?, ?, ?, 85)')
|
||||
.run(recipeId, 'vegetarian', 'quick');
|
||||
const row = db.prepare('SELECT * FROM recipe_variation_meta WHERE recipe_id = ?').get(recipeId);
|
||||
assert(row.protein === 'vegetarian' && row.style === 'quick' && row.kid_suitable_confidence === 85);
|
||||
});
|
||||
|
||||
test('Planned meal cook assignment gemmes', () => {
|
||||
db.prepare('INSERT INTO planned_meal_cooks (meal_id, user_id, planned_for_date, meal_type, source_plan_id) VALUES (?, ?, ?, ?, ?)')
|
||||
.run(mealId, userId, '2026-05-12', 'dinner', 'plan-test');
|
||||
const row = db.prepare('SELECT * FROM planned_meal_cooks WHERE meal_id = ?').get(mealId);
|
||||
assert(row.user_id === userId && row.source_plan_id === 'plan-test');
|
||||
});
|
||||
|
||||
test('Feedback event gemmes', () => {
|
||||
const id = db.prepare(`
|
||||
INSERT INTO meal_plan_feedback (plan_id, meal_id, recipe_id, slot_date, meal_type, action, original_title, final_title, user_id)
|
||||
VALUES ('plan-test', ?, ?, '2026-05-12', 'dinner', 'accept', 'Pasta', 'Pasta med tomat', ?)
|
||||
`).run(mealId, recipeId, userId).lastInsertRowid;
|
||||
const row = db.prepare('SELECT * FROM meal_plan_feedback WHERE id = ?').get(id);
|
||||
assert(row.action === 'accept' && row.recipe_id === recipeId);
|
||||
});
|
||||
|
||||
test('Kids cookbook gemmes som JSON', () => {
|
||||
const content = JSON.stringify({ title: 'Pasta for børn', steps: ['Vask hænder', 'Rør sovs'] });
|
||||
const id = db.prepare('INSERT INTO kids_cookbooks (recipe_id, title, content_json, created_by) VALUES (?, ?, ?, ?)')
|
||||
.run(recipeId, 'Pasta for børn', content, userId).lastInsertRowid;
|
||||
const row = db.prepare('SELECT * FROM kids_cookbooks WHERE id = ?').get(id);
|
||||
assert(JSON.parse(row.content_json).steps.length === 2);
|
||||
});
|
||||
|
||||
test('Ugyldig preference afvises', () => {
|
||||
let failedConstraint = false;
|
||||
try {
|
||||
db.prepare('INSERT INTO recipe_family_preferences (recipe_id, user_id, preference) VALUES (?, ?, ?)').run(recipeId, userId + 1, 'maybe');
|
||||
} catch { failedConstraint = true; }
|
||||
assert(failedConstraint, 'CHECK constraint skulle afvise ugyldig preference');
|
||||
});
|
||||
|
||||
test('FK cascade sletter recipe-signaler', () => {
|
||||
db.prepare('DELETE FROM recipes WHERE id = ?').run(recipeId);
|
||||
const prefs = db.prepare('SELECT count(*) AS n FROM recipe_family_preferences WHERE recipe_id = ?').get(recipeId).n;
|
||||
const meta = db.prepare('SELECT count(*) AS n FROM recipe_variation_meta WHERE recipe_id = ?').get(recipeId).n;
|
||||
assert(prefs === 0 && meta === 0);
|
||||
});
|
||||
|
||||
console.log(`\n[Meal-Planning-Test] Ergebnis: ${passed} bestanden, ${failed} fehlgeschlagen\n`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -0,0 +1,462 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import fs from 'node:fs';
|
||||
|
||||
const username = process.env.OIKOS_E2E_USERNAME;
|
||||
const password = process.env.OIKOS_E2E_PASSWORD_FILE
|
||||
? fs.readFileSync(process.env.OIKOS_E2E_PASSWORD_FILE, 'utf8').trim()
|
||||
: process.env.OIKOS_E2E_PASSWORD;
|
||||
const mutate = process.env.OIKOS_E2E_MUTATE === '1';
|
||||
|
||||
function seededRandom(seedText) {
|
||||
let seed = 0;
|
||||
for (const char of seedText) seed = (seed * 31 + char.charCodeAt(0)) >>> 0;
|
||||
return () => {
|
||||
seed = (seed * 1664525 + 1013904223) >>> 0;
|
||||
return seed / 0x100000000;
|
||||
};
|
||||
}
|
||||
|
||||
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', {
|
||||
body: JSON.stringify(diagnostics, null, 2),
|
||||
contentType: 'application/json',
|
||||
});
|
||||
}
|
||||
|
||||
function makeDiagnostics(page) {
|
||||
const diagnostics = { consoleErrors: [], pageErrors: [], failedRequests: [], urls: [] };
|
||||
page.on('console', (msg) => { if (['error', 'warning'].includes(msg.type())) diagnostics.consoleErrors.push(`${msg.type()}: ${msg.text()}`); });
|
||||
page.on('pageerror', (err) => diagnostics.pageErrors.push(err.stack || err.message));
|
||||
page.on('requestfailed', (req) => diagnostics.failedRequests.push(`${req.method()} ${req.url()} :: ${req.failure()?.errorText || 'failed'}`));
|
||||
page.on('framenavigated', (frame) => { if (frame === page.mainFrame()) diagnostics.urls.push(page.url()); });
|
||||
return diagnostics;
|
||||
}
|
||||
|
||||
async function gotoApp(page, route) {
|
||||
try {
|
||||
await page.goto(route, { waitUntil: 'domcontentloaded' });
|
||||
} catch (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' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function dismissOnboardingIfPresent(page) {
|
||||
const skip = page.getByRole('button', { name: /^(Skip|Spring over|Senere|Close|Luk)$/i }).first();
|
||||
try { if (await skip.isVisible({ timeout: 1_000 })) await skip.click(); } catch {}
|
||||
}
|
||||
|
||||
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'));
|
||||
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' });
|
||||
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',
|
||||
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 });
|
||||
|
||||
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 run();
|
||||
}
|
||||
}
|
||||
|
||||
async function cleanupTaggedData(page, tag) {
|
||||
const results = {};
|
||||
const safeDelete = async (label, listPath, extract, deletePath) => {
|
||||
try {
|
||||
const listed = await api(page, listPath);
|
||||
const rows = extract(listed.data).filter((item) => JSON.stringify(item).includes(tag));
|
||||
results[label] = [];
|
||||
for (const row of rows) {
|
||||
const del = await api(page, deletePath(row), { method: 'DELETE' });
|
||||
results[label].push({ id: row.id, ok: del.ok, status: del.status });
|
||||
}
|
||||
} catch (err) { results[label] = [{ error: err.message }]; }
|
||||
};
|
||||
|
||||
await safeDelete('tasks', '/tasks?status=open', (d) => d?.data || [], (row) => `/tasks/${row.id}`);
|
||||
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}`);
|
||||
|
||||
const meals = await api(page, `/meals?week=${futureDate(0)}`);
|
||||
results.meals = [];
|
||||
for (const meal of (meals.data?.data || []).filter((item) => JSON.stringify(item).includes(tag))) {
|
||||
const del = await api(page, `/meals/${meal.id}`, { method: 'DELETE' });
|
||||
results.meals.push({ id: meal.id, ok: del.ok, status: del.status });
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
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',
|
||||
'Parent creates, completes, and cleans up a practical chore task',
|
||||
'Family builds a dedicated grocery list with random staples and checks one item',
|
||||
'Meal planned for a week is converted into a shopping list',
|
||||
'Parent pins a sticky note, searches it, and deletes it',
|
||||
'Parent adds a calendar appointment with random time/location and deletes it',
|
||||
'Parent adds a contact/service provider and finds it via search',
|
||||
'Kitchen/Assist generates a meal-plan draft and exposes Studio actions',
|
||||
'Cross-module search finds freshly created household data',
|
||||
];
|
||||
|
||||
test.describe('Oikos everyday randomized use-case matrix', () => {
|
||||
test('documents the 10 everyday use cases under test', async ({}, testInfo) => {
|
||||
await testInfo.attach('everyday-use-cases.json', {
|
||||
body: JSON.stringify(everydayUseCases.map((flow, index) => ({ id: index + 1, flow })), null, 2),
|
||||
contentType: 'application/json',
|
||||
});
|
||||
expect(everydayUseCases).toHaveLength(10);
|
||||
});
|
||||
|
||||
test('01 anonymous protected routes show login cleanly', async ({ page }, testInfo) => {
|
||||
const diagnostics = makeDiagnostics(page);
|
||||
for (const route of ['/', '/tasks', '/shopping', '/meals', '/calendar', '/notes']) {
|
||||
await page.context().clearCookies();
|
||||
await gotoApp(page, route);
|
||||
await expect(page.locator('#login-form')).toBeVisible({ timeout: 10_000 });
|
||||
}
|
||||
await attachDiagnostics(testInfo, diagnostics);
|
||||
expect(diagnostics.pageErrors).toEqual([]);
|
||||
});
|
||||
|
||||
test('02 everyday navigation is usable on desktop and mobile', async ({ page }, testInfo) => {
|
||||
const diagnostics = makeDiagnostics(page);
|
||||
await login(page);
|
||||
for (const size of [{ width: 1366, height: 900 }, { width: 390, height: 844 }]) {
|
||||
await page.setViewportSize(size);
|
||||
for (const route of ['/', '/tasks', '/shopping', '/meals', '/calendar', '/notes', '/contacts']) {
|
||||
await gotoApp(page, route);
|
||||
await dismissOnboardingIfPresent(page);
|
||||
await expect(page.locator('#main-content')).toBeVisible({ timeout: 15_000 });
|
||||
const overflow = await page.evaluate(() => ({ width: window.innerWidth, scrollWidth: document.documentElement.scrollWidth }));
|
||||
expect(overflow.scrollWidth, `${route} overflowed at ${size.width}px`).toBeLessThanOrEqual(overflow.width + 24);
|
||||
}
|
||||
}
|
||||
await attachDiagnostics(testInfo, diagnostics);
|
||||
expect(diagnostics.pageErrors).toEqual([]);
|
||||
});
|
||||
|
||||
test('03 task chore can be created, completed, and cleaned up', async ({ page }, testInfo) => {
|
||||
await requireMutate();
|
||||
const diagnostics = makeDiagnostics(page);
|
||||
const rand = seededRandom(testInfo.title);
|
||||
const tag = stamp(testInfo);
|
||||
const title = `${pick(rand, ['Tøm opvaskemaskine', 'Pak skoletasker', 'Vand planter'])} ${tag}`;
|
||||
await login(page);
|
||||
try {
|
||||
const created = await api(page, '/tasks', { method: 'POST', body: { title, description: `Random household chore ${tag}`, priority: pick(rand, ['low', 'medium', 'high']), category: pick(rand, ['household', 'school', 'shopping']), due_date: futureDate(1), status: 'open' } });
|
||||
expect(created.ok, JSON.stringify(created)).toBeTruthy();
|
||||
await gotoApp(page, '/tasks');
|
||||
await expect(page.locator('.task-card', { hasText: title })).toBeVisible();
|
||||
const done = await api(page, `/tasks/${created.data.data.id}/status`, { method: 'PATCH', body: { status: 'done' } });
|
||||
expect(done.ok, JSON.stringify(done)).toBeTruthy();
|
||||
await gotoApp(page, '/tasks');
|
||||
expect((await api(page, `/tasks/${created.data.data.id}`)).data?.data?.status).toBe('done');
|
||||
} finally { diagnostics.cleanup = await cleanupTaggedData(page, tag); }
|
||||
await attachDiagnostics(testInfo, diagnostics);
|
||||
expect(diagnostics.pageErrors).toEqual([]);
|
||||
});
|
||||
|
||||
test('04 grocery list supports random staples, checking, and cleanup', async ({ page }, testInfo) => {
|
||||
await requireMutate();
|
||||
const diagnostics = makeDiagnostics(page);
|
||||
const rand = seededRandom(testInfo.title + Date.now());
|
||||
const tag = stamp(testInfo);
|
||||
await login(page);
|
||||
try {
|
||||
const categories = (await api(page, '/shopping/categories')).data?.data || [];
|
||||
const category = categories[0]?.name || 'Sonstiges';
|
||||
const listName = `Weekend indkøb ${tag}`;
|
||||
const list = await api(page, '/shopping', { method: 'POST', body: { name: listName } });
|
||||
expect(list.ok, JSON.stringify(list)).toBeTruthy();
|
||||
const items = ['mælk', 'rugbrød', 'bananer', 'havregryn', 'kylling', 'agurk'].sort(() => rand() - 0.5).slice(0, 4);
|
||||
for (const name of items) {
|
||||
const item = await api(page, `/shopping/${list.data.data.id}/items`, { method: 'POST', body: { name: `${name} ${tag}`, quantity: `${1 + Math.floor(rand() * 4)} stk`, category } });
|
||||
expect(item.ok, JSON.stringify(item)).toBeTruthy();
|
||||
}
|
||||
const listItems = await api(page, `/shopping/${list.data.data.id}/items`);
|
||||
expect(listItems.data.data).toHaveLength(items.length);
|
||||
const first = listItems.data.data[0];
|
||||
const checked = await api(page, `/shopping/items/${first.id}`, { method: 'PATCH', body: { is_checked: 1 } });
|
||||
expect(checked.ok, JSON.stringify(checked)).toBeTruthy();
|
||||
await gotoApp(page, '/shopping');
|
||||
await expect(page.locator('body')).toContainText(listName);
|
||||
} finally { diagnostics.cleanup = await cleanupTaggedData(page, tag); }
|
||||
await attachDiagnostics(testInfo, diagnostics);
|
||||
expect(diagnostics.pageErrors).toEqual([]);
|
||||
});
|
||||
|
||||
test('05 week meal ingredients convert to a shopping list', async ({ page }, testInfo) => {
|
||||
await requireMutate();
|
||||
const diagnostics = makeDiagnostics(page);
|
||||
const tag = stamp(testInfo);
|
||||
await login(page);
|
||||
try {
|
||||
const categories = (await api(page, '/shopping/categories')).data?.data || [];
|
||||
const category = categories[0]?.name || 'Sonstiges';
|
||||
const list = await api(page, '/shopping', { method: 'POST', body: { name: `Madplan indkøb ${tag}` } });
|
||||
expect(list.ok, JSON.stringify(list)).toBeTruthy();
|
||||
const meal = await api(page, '/meals', { method: 'POST', body: { title: `Pasta testmiddag ${tag}`, date: futureDate(2), meal_type: 'dinner', meal_category: 'pasta', ingredients: [{ name: `pasta ${tag}`, quantity: '500 g', category }, { name: `tomatsauce ${tag}`, quantity: '1 glas', category }] } });
|
||||
expect(meal.ok, JSON.stringify(meal)).toBeTruthy();
|
||||
const converted = await api(page, '/meals/week-to-shopping-list', { method: 'POST', body: { week: futureDate(2), listId: list.data.data.id } });
|
||||
expect(converted.ok, JSON.stringify(converted)).toBeTruthy();
|
||||
expect(converted.data.data.transferred).toBeGreaterThanOrEqual(2);
|
||||
await gotoApp(page, '/shopping');
|
||||
await expect(page.locator('body')).toContainText(tag);
|
||||
} finally { diagnostics.cleanup = await cleanupTaggedData(page, tag); }
|
||||
await attachDiagnostics(testInfo, diagnostics);
|
||||
expect(diagnostics.pageErrors).toEqual([]);
|
||||
});
|
||||
|
||||
test('06 sticky note can be pinned, searched, and cleaned up', async ({ page }, testInfo) => {
|
||||
await requireMutate();
|
||||
const diagnostics = makeDiagnostics(page);
|
||||
const rand = seededRandom(testInfo.title);
|
||||
const tag = stamp(testInfo);
|
||||
const title = `Husk ${pick(rand, ['bibliotek', 'idrætstøj', 'madpakker'])} ${tag}`;
|
||||
await login(page);
|
||||
try {
|
||||
const note = await api(page, '/notes', { method: 'POST', body: { title, content: `Random note content ${tag}`, color: '#FFEB3B', pinned: 1 } });
|
||||
expect(note.ok, JSON.stringify(note)).toBeTruthy();
|
||||
await gotoApp(page, '/notes');
|
||||
await page.locator('#notes-search').fill(tag);
|
||||
await expect(page.locator('.note-card', { hasText: tag })).toBeVisible();
|
||||
} finally { diagnostics.cleanup = await cleanupTaggedData(page, tag); }
|
||||
await attachDiagnostics(testInfo, diagnostics);
|
||||
expect(diagnostics.pageErrors).toEqual([]);
|
||||
});
|
||||
|
||||
test('07 calendar appointment can be added and found', async ({ page }, testInfo) => {
|
||||
await requireMutate();
|
||||
const diagnostics = makeDiagnostics(page);
|
||||
const rand = seededRandom(testInfo.title);
|
||||
const tag = stamp(testInfo);
|
||||
const title = `${pick(rand, ['Tandlæge', 'Skole-hjem', 'Fodbold'])} ${tag}`;
|
||||
await login(page);
|
||||
try {
|
||||
const event = await api(page, '/calendar', { method: 'POST', body: { title, start_datetime: `${futureDate(3)}T15:00:00`, end_datetime: `${futureDate(3)}T16:00:00`, all_day: 0, location: `Lokation ${tag}`, description: `Random appointment ${tag}`, color: '#007AFF' } });
|
||||
expect(event.ok, JSON.stringify(event)).toBeTruthy();
|
||||
const upcoming = await api(page, '/calendar/upcoming');
|
||||
expect(JSON.stringify(upcoming.data)).toContain(tag);
|
||||
await gotoApp(page, '/calendar');
|
||||
await expect(page.locator('body')).toContainText(tag);
|
||||
} finally { diagnostics.cleanup = await cleanupTaggedData(page, tag); }
|
||||
await attachDiagnostics(testInfo, diagnostics);
|
||||
expect(diagnostics.pageErrors).toEqual([]);
|
||||
});
|
||||
|
||||
test('08 contact/service provider can be added and searched', async ({ page }, testInfo) => {
|
||||
await requireMutate();
|
||||
const diagnostics = makeDiagnostics(page);
|
||||
const tag = stamp(testInfo);
|
||||
await login(page);
|
||||
try {
|
||||
const contact = await api(page, '/contacts', { method: 'POST', body: { name: `VVS Kontakt ${tag}`, category: 'Handwerker', phone: '+4512345678', email: `e2e-${tag.toLowerCase()}@example.invalid`, address: 'Testvej 1', notes: `Random provider ${tag}` } });
|
||||
expect(contact.ok, JSON.stringify(contact)).toBeTruthy();
|
||||
await gotoApp(page, '/contacts');
|
||||
await page.locator('#contacts-search').fill(tag);
|
||||
await expect(page.locator('.contact-item', { hasText: tag })).toBeVisible();
|
||||
} finally { diagnostics.cleanup = await cleanupTaggedData(page, tag); }
|
||||
await attachDiagnostics(testInfo, diagnostics);
|
||||
expect(diagnostics.pageErrors).toEqual([]);
|
||||
});
|
||||
|
||||
test('09 Assist meal-plan draft opens Studio with actionable controls', async ({ page }, testInfo) => {
|
||||
const diagnostics = makeDiagnostics(page);
|
||||
await login(page);
|
||||
await page.waitForFunction(() => window.oikos?.openAssist, { timeout: 15_000 });
|
||||
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]');
|
||||
await expect(action).toHaveCount(1, { timeout: 10_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);
|
||||
expect(diagnostics.pageErrors).toEqual([]);
|
||||
});
|
||||
|
||||
test('10 global search finds freshly created household data', async ({ page }, testInfo) => {
|
||||
await requireMutate();
|
||||
const diagnostics = makeDiagnostics(page);
|
||||
const tag = stamp(testInfo);
|
||||
await login(page);
|
||||
try {
|
||||
const task = await api(page, '/tasks', { method: 'POST', body: { title: `Findbar opgave ${tag}`, category: 'household', priority: 'medium', status: 'open' } });
|
||||
const note = await api(page, '/notes', { method: 'POST', body: { title: `Findbar note ${tag}`, content: `Søgbar note ${tag}`, color: '#A5D6A7', pinned: 0 } });
|
||||
expect(task.ok && note.ok, JSON.stringify({ task, note })).toBeTruthy();
|
||||
const search = await api(page, `/search?q=${encodeURIComponent(tag)}`);
|
||||
expect(search.ok, JSON.stringify(search)).toBeTruthy();
|
||||
expect(JSON.stringify(search.data)).toContain(tag);
|
||||
} finally { diagnostics.cleanup = await cleanupTaggedData(page, tag); }
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,346 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import fs from 'node:fs';
|
||||
|
||||
const username = process.env.OIKOS_E2E_USERNAME;
|
||||
const password = process.env.OIKOS_E2E_PASSWORD_FILE
|
||||
? fs.readFileSync(process.env.OIKOS_E2E_PASSWORD_FILE, 'utf8').trim()
|
||||
: process.env.OIKOS_E2E_PASSWORD;
|
||||
|
||||
async function attachDiagnostics(testInfo, diagnostics) {
|
||||
await testInfo.attach('oikos-flow-diagnostics.json', {
|
||||
body: JSON.stringify(diagnostics, null, 2),
|
||||
contentType: 'application/json',
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
async function dismissOnboardingIfPresent(page) {
|
||||
const skip = page.getByRole('button', { name: /^(Skip|Spring over|Senere|Close|Luk)$/i }).first();
|
||||
try {
|
||||
if (await skip.isVisible({ timeout: 2_000 })) await skip.click();
|
||||
} catch {
|
||||
// Onboarding is optional; ignore when it is not present.
|
||||
}
|
||||
}
|
||||
|
||||
async function login(page) {
|
||||
if (!username || !password) {
|
||||
throw new Error('Set OIKOS_E2E_USERNAME and OIKOS_E2E_PASSWORD_FILE (preferred) or OIKOS_E2E_PASSWORD for the live Oikos E2E flow.');
|
||||
}
|
||||
|
||||
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 });
|
||||
await expect(page.locator('#main-content')).toBeVisible();
|
||||
await dismissOnboardingIfPresent(page);
|
||||
}
|
||||
|
||||
async function openMealPlanStudioFromAssist(page, diagnostics, prompt = 'Lav en praktisk madplan for næste uge ud fra vores opskrifter.') {
|
||||
const assistStart = Date.now();
|
||||
await page.waitForFunction(() => window.oikos?.openAssist, { timeout: 15_000 });
|
||||
await page.evaluate((message) => window.oikos?.openAssist?.({ prompt: message, expanded: true }), prompt);
|
||||
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 });
|
||||
diagnostics.timings.assistMealPlanMs = Date.now() - assistStart;
|
||||
|
||||
const action = page.locator('[data-assist-kitchen-studio]').last();
|
||||
await expect(action, 'Assist meal-plan response should include “Åbn forslag i Køkken”, not only text').toBeVisible({ timeout: 10_000 });
|
||||
|
||||
await action.click();
|
||||
await page.waitForURL(/\/meals(?:$|[?#])/, { timeout: 15_000 });
|
||||
const studioHost = page.locator('[data-oikos-meal-plan-studio]');
|
||||
await expect(studioHost.locator('[data-assist-wizard="day"]'), 'Studio should open into the guided day-by-day wizard, not the old all-days form').toBeVisible({ timeout: 15_000 });
|
||||
await expect(studioHost.locator('.assist-wizard-steps [data-assist-wizard-step]'), 'wizard should expose the five-step planning contract').toHaveCount(5, { timeout: 15_000 });
|
||||
await expect(studioHost.locator('[data-assist-wizard-day]'), 'wizard should summarize every generated day').toHaveCount(7, { timeout: 15_000 });
|
||||
await expect(studioHost).toContainText(/Dagstype|Én beslutning ad gangen|Avanceret/i);
|
||||
return studioHost;
|
||||
}
|
||||
|
||||
async function readStudioPlan(page) {
|
||||
return page.evaluate(() => JSON.parse(localStorage.getItem('oikos-meal-plan-studio-v1') || 'null'));
|
||||
}
|
||||
|
||||
function assertStructuredStudioPlan(plan) {
|
||||
expect(plan?.slots, 'Studio plan should exist in localStorage for structural assertions').toHaveLength(7);
|
||||
const categories = plan.slots.map((slot) => slot.meal_category || slot.variation?.category || 'other');
|
||||
expect(categories.every((category) => category && category !== 'other'), 'all Studio slots should carry structured categories').toBeTruthy();
|
||||
for (let i = 1; i < categories.length; i += 1) {
|
||||
expect(categories[i], `planner should not repeat category on consecutive days (${i - 1}/${i})`).not.toBe(categories[i - 1]);
|
||||
}
|
||||
const counts = categories.reduce((acc, category) => ({ ...acc, [category]: (acc[category] || 0) + 1 }), {});
|
||||
expect(Math.max(...Object.values(counts)), `planner should not overuse one category: ${JSON.stringify(counts)}`).toBeLessThanOrEqual(2);
|
||||
const leftovers = plan.slots.filter((slot) => (slot.meal_category || slot.variation?.category) === 'leftovers' || slot.leftover_from_meal_id);
|
||||
expect(leftovers.length, 'leftovers should be represented as a dedicated structured option when meal history exists').toBeGreaterThanOrEqual(1);
|
||||
expect(leftovers.every((slot) => slot.leftover_from_meal_id || slot.context?.leftoverSource?.id), 'leftover slots should link to a source dish id').toBeTruthy();
|
||||
expect(plan.modulators?.activeSignalTypes || [], 'planner should declare the family-signal modulators it scores against').toEqual(expect.arrayContaining(['favorites', 'canCook', 'adultOnly']));
|
||||
expect(plan.slots.every((slot) => Array.isArray(slot.context?.modulators?.labels) && slot.context.modulators.labels.length >= 3), 'each Studio slot should expose generation modulators, not just a chosen recipe').toBeTruthy();
|
||||
expect(plan.slots.some((slot) => slot.context?.modulators?.easyDay || slot.context?.modulators?.guests || slot.context?.modulators?.noKids || typeof slot.context?.modulators?.cookAge === 'number'), 'generated week should carry actionable day/person modulators').toBeTruthy();
|
||||
}
|
||||
|
||||
async function chooseWizardIntent(studioHost, dayIndex, intent = 'quick') {
|
||||
await expect(studioHost.locator('[data-assist-wizard="day"]')).toBeVisible({ timeout: 15_000 });
|
||||
await studioHost.locator(`[data-assist-wizard-intent="${dayIndex}:${intent}"]`).click();
|
||||
await expect(studioHost.locator('[data-assist-wizard="followup"]')).toBeVisible({ timeout: 15_000 });
|
||||
}
|
||||
|
||||
async function chooseWizardFollowupAndMeal(studioHost, dayIndex, { followup = 'time_20', refresh = false, manualTitle = '', chooseSuggestion = true } = {}) {
|
||||
const followupOption = studioHost.locator(`[data-assist-wizard-followup="${dayIndex}:${followup}"]`);
|
||||
if (await followupOption.count()) await followupOption.click();
|
||||
if (refresh) {
|
||||
await studioHost.locator(`[data-assist-wizard-refresh="${dayIndex}"]`).click();
|
||||
} else {
|
||||
await studioHost.locator('.assist-wizard-steps [data-assist-wizard-step="meal"]').click();
|
||||
}
|
||||
await expect(studioHost.locator('[data-assist-wizard="meal"]')).toBeVisible({ timeout: 30_000 });
|
||||
await expect(studioHost.locator(`[data-assist-suggestion-card^="${dayIndex}:"]`), 'meal step should expose suggestion cards before save').toHaveCount(3, { timeout: 15_000 });
|
||||
if (manualTitle) {
|
||||
const manualForm = studioHost.locator(`[data-assist-wizard-manual="${dayIndex}"]`);
|
||||
await manualForm.locator('input[name="title"]').fill(manualTitle);
|
||||
await manualForm.locator('button[type="submit"]').click();
|
||||
} else if (chooseSuggestion) {
|
||||
await studioHost.locator(`[data-assist-suggestion-card="${dayIndex}:0"]`).click();
|
||||
}
|
||||
}
|
||||
|
||||
async function completeWizardDay(studioHost, dayIndex) {
|
||||
await studioHost.locator(`[data-assist-wizard-complete-day="${dayIndex}"]`).click();
|
||||
}
|
||||
|
||||
async function completeWizardToReview(studioHost, { firstManualTitle = '', refreshFirstDay = false } = {}) {
|
||||
for (let dayIndex = 0; dayIndex < 7; dayIndex += 1) {
|
||||
await chooseWizardIntent(studioHost, dayIndex, dayIndex === 1 ? 'healthy' : 'quick');
|
||||
await chooseWizardFollowupAndMeal(studioHost, dayIndex, {
|
||||
followup: dayIndex === 1 ? 'protein' : 'time_20',
|
||||
refresh: refreshFirstDay && dayIndex === 0,
|
||||
manualTitle: dayIndex === 0 ? firstManualTitle : '',
|
||||
chooseSuggestion: dayIndex !== 0 || !firstManualTitle,
|
||||
});
|
||||
await completeWizardDay(studioHost, dayIndex);
|
||||
}
|
||||
await expect(studioHost.locator('[data-assist-wizard="review"]')).toBeVisible({ timeout: 15_000 });
|
||||
await expect(studioHost.locator('[data-assist-studio-confirm]'), 'save action should appear only at the review step').toBeVisible({ timeout: 15_000 });
|
||||
}
|
||||
|
||||
async function deleteMeals(page, mealIds) {
|
||||
if (!mealIds.length) return [];
|
||||
return page.evaluate(async (ids) => {
|
||||
const csrf = document.cookie.split(';')
|
||||
.map((c) => c.trim())
|
||||
.find((c) => c.startsWith('csrf-token='))
|
||||
?.slice('csrf-token='.length) || '';
|
||||
const results = [];
|
||||
for (const id of ids) {
|
||||
const res = await fetch(`/api/v1/meals/${id}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': decodeURIComponent(csrf) },
|
||||
});
|
||||
results.push({ id, ok: res.ok, status: res.status });
|
||||
}
|
||||
return results;
|
||||
}, mealIds);
|
||||
}
|
||||
|
||||
async function listMealsById(page, mealIds, weeks = []) {
|
||||
return page.evaluate(async ({ ids, weeksToFetch }) => {
|
||||
const wanted = new Set(ids.map(String));
|
||||
const results = [];
|
||||
const mondayFor = (value) => {
|
||||
const date = new Date(`${value}T12:00:00Z`);
|
||||
const day = date.getUTCDay() || 7;
|
||||
date.setUTCDate(date.getUTCDate() - day + 1);
|
||||
return date.toISOString().slice(0, 10);
|
||||
};
|
||||
for (const week of [...new Set(weeksToFetch.filter(Boolean).map(mondayFor))]) {
|
||||
const res = await fetch(`/api/v1/meals?week=${encodeURIComponent(week)}`, { credentials: 'same-origin', cache: 'no-store' });
|
||||
const payload = await res.json();
|
||||
results.push(...(payload.data || []).filter((meal) => wanted.has(String(meal.id))));
|
||||
}
|
||||
return results;
|
||||
}, { ids: mealIds, weeksToFetch: weeks.length ? weeks : [new Date().toISOString().slice(0, 10)] });
|
||||
}
|
||||
|
||||
function makeDiagnostics(page) {
|
||||
const diagnostics = {
|
||||
consoleErrors: [],
|
||||
pageErrors: [],
|
||||
failedRequests: [],
|
||||
timings: {},
|
||||
urls: [],
|
||||
};
|
||||
|
||||
page.on('console', (msg) => {
|
||||
if (['error', 'warning'].includes(msg.type())) diagnostics.consoleErrors.push(`${msg.type()}: ${msg.text()}`);
|
||||
});
|
||||
page.on('pageerror', (err) => diagnostics.pageErrors.push(err.stack || err.message));
|
||||
page.on('requestfailed', (req) => diagnostics.failedRequests.push(`${req.method()} ${req.url()} :: ${req.failure()?.errorText || 'failed'}`));
|
||||
page.on('framenavigated', (frame) => {
|
||||
if (frame === page.mainFrame()) diagnostics.urls.push(page.url());
|
||||
});
|
||||
return diagnostics;
|
||||
}
|
||||
|
||||
test.describe('Oikos route guards and responsive edge coverage', () => {
|
||||
test('Protected kitchen routes send anonymous users to login instead of leaking a blank app', async ({ page }) => {
|
||||
for (const route of ['/', '/meals', '/shopping']) {
|
||||
await page.context().clearCookies();
|
||||
await page.goto(route, { waitUntil: 'domcontentloaded' });
|
||||
await expect(page.locator('#login-form')).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.locator('#username')).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test('Kitchen/Studio works at mobile edge width without horizontal overflow', async ({ page }, testInfo) => {
|
||||
const diagnostics = makeDiagnostics(page);
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.addInitScript(() => localStorage.setItem('oikos-onboarded', '1'));
|
||||
await login(page);
|
||||
await page.goto('/meals', { waitUntil: 'domcontentloaded' });
|
||||
await dismissOnboardingIfPresent(page);
|
||||
|
||||
const studioHost = page.locator('[data-oikos-meal-plan-studio]');
|
||||
await expect(studioHost).toBeVisible({ timeout: 15_000 });
|
||||
await expect(studioHost).toContainText(/Meal Plan Studio|Børnenes madplan/i);
|
||||
await expect(page.getByRole('button', { name: /Generér ugeplan|Regenerér|Meal Plan Studio/i }).first()).toBeVisible();
|
||||
|
||||
const overflow = await page.evaluate(() => ({
|
||||
innerWidth: window.innerWidth,
|
||||
scrollWidth: document.documentElement.scrollWidth,
|
||||
bodyScrollWidth: document.body.scrollWidth,
|
||||
}));
|
||||
diagnostics.mobileOverflow = overflow;
|
||||
await attachDiagnostics(testInfo, diagnostics);
|
||||
|
||||
expect(overflow.scrollWidth, `mobile page should not cause sideways scrolling: ${JSON.stringify(overflow)}`).toBeLessThanOrEqual(overflow.innerWidth + 12);
|
||||
expect(diagnostics.pageErrors, 'No browser page errors during mobile Kitchen smoke').toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Oikos Kitchen + Assist meal-planning flow', () => {
|
||||
test('Kitchen opens quickly, Studio is present, and Assist routes meal plans into Studio', async ({ page }, testInfo) => {
|
||||
const diagnostics = makeDiagnostics(page);
|
||||
|
||||
await page.addInitScript(() => localStorage.setItem('oikos-onboarded', '1'));
|
||||
await login(page);
|
||||
await page.evaluate(() => localStorage.setItem('oikos-onboarded', '1'));
|
||||
|
||||
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
||||
await dismissOnboardingIfPresent(page);
|
||||
const kitchenNav = page.locator('#sidebar-kitchen-nav, #kitchen-btn').filter({ visible: true }).first();
|
||||
await expect(kitchenNav).toBeVisible();
|
||||
|
||||
const kitchenStart = Date.now();
|
||||
await kitchenNav.click();
|
||||
await page.waitForURL(/\/meals(?:$|[?#])/, { timeout: 15_000 });
|
||||
await expect(page.locator('[data-oikos-meal-plan-studio]')).toBeVisible({ timeout: 15_000 });
|
||||
await expect(page.getByRole('button', { name: /Meal Plan Studio|Generér ugeplan|Regenerér/i }).first()).toBeVisible();
|
||||
diagnostics.timings.kitchenOpenMs = Date.now() - kitchenStart;
|
||||
|
||||
expect(diagnostics.timings.kitchenOpenMs, 'Kitchen menu should open fast enough for real use').toBeLessThan(6_000);
|
||||
|
||||
const studioHost = page.locator('[data-oikos-meal-plan-studio]');
|
||||
await expect(studioHost).toContainText(/Meal Plan Studio/i);
|
||||
|
||||
const routedStudioHost = await openMealPlanStudioFromAssist(page, diagnostics);
|
||||
await chooseWizardIntent(routedStudioHost, 0, 'quick');
|
||||
await chooseWizardFollowupAndMeal(routedStudioHost, 0, { followup: 'time_20', refresh: true });
|
||||
await completeWizardDay(routedStudioHost, 0);
|
||||
await expect(routedStudioHost.locator('[data-assist-wizard="day"]')).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
const routedPlan = await readStudioPlan(page);
|
||||
assertStructuredStudioPlan(routedPlan);
|
||||
expect(routedPlan.daySetup?.[0]?.intent, 'wizard day intent should persist into the draft plan').toBe('quick');
|
||||
expect(routedPlan.daySetup?.[0]?.followups || [], 'wizard follow-up choices should persist after suggestion refresh').toContain('time_20');
|
||||
|
||||
expect(diagnostics.pageErrors, 'No browser page errors during Kitchen/Assist flow').toEqual([]);
|
||||
await attachDiagnostics(testInfo, diagnostics);
|
||||
});
|
||||
|
||||
test('Studio supports swap/edit before confirming, then writes and cleans up meals', async ({ page }, testInfo) => {
|
||||
test.skip(process.env.OIKOS_E2E_MUTATE !== '1', 'Set OIKOS_E2E_MUTATE=1 to run the live write-and-cleanup confirmation test.');
|
||||
|
||||
const diagnostics = makeDiagnostics(page);
|
||||
const createdMealIds = [];
|
||||
await page.addInitScript(() => localStorage.setItem('oikos-onboarded', '1'));
|
||||
|
||||
try {
|
||||
await login(page);
|
||||
await page.goto('/meals', { waitUntil: 'domcontentloaded' });
|
||||
await dismissOnboardingIfPresent(page);
|
||||
const studioHost = await openMealPlanStudioFromAssist(page, diagnostics, 'Lav en testbar madplan for næste uge. Brug vores opskrifter og åbn den i Meal Plan Studio.');
|
||||
await completeWizardToReview(studioHost, { firstManualTitle: '[E2E cleanup] Guided wizard dinner', refreshFirstDay: true });
|
||||
|
||||
assertStructuredStudioPlan(await readStudioPlan(page));
|
||||
|
||||
const actionResponsePromise = page.waitForResponse((res) => res.url().includes('/ai/api/action') && res.request().method() === 'POST');
|
||||
await studioHost.locator('[data-assist-studio-confirm]').click();
|
||||
const actionResponse = await actionResponsePromise;
|
||||
expect(actionResponse.ok(), 'Studio confirmation should call the Assist action endpoint successfully').toBeTruthy();
|
||||
const actionPayload = await actionResponse.json();
|
||||
expect(actionPayload.success).toBeTruthy();
|
||||
const createdMeals = actionPayload.result?.data?.meals || [];
|
||||
const cookAssignments = actionPayload.result?.data?.cookAssignments || [];
|
||||
expect(cookAssignments.every((item) => item.source === 'oikos-native-api'), 'cook assignments should use the native meal-planning API when present').toBeTruthy();
|
||||
createdMealIds.push(...createdMeals.map((meal) => meal?.id).filter(Boolean));
|
||||
expect(createdMealIds, 'Studio confirmation should create seven Oikos meals').toHaveLength(7);
|
||||
|
||||
const mealsInOikos = await listMealsById(page, createdMealIds, createdMeals.map((meal) => meal?.date));
|
||||
expect(mealsInOikos, 'created meals should be readable from the native Oikos meals API').toHaveLength(7);
|
||||
expect(mealsInOikos.some((meal) => String(meal.title || '').includes('[E2E cleanup] Guided wizard dinner'))).toBeTruthy();
|
||||
expect(mealsInOikos.every((meal) => meal.meal_category), 'confirmed meals should preserve structured meal categories').toBeTruthy();
|
||||
const leftoverMeal = mealsInOikos.find((meal) => meal.meal_category === 'leftovers');
|
||||
expect(leftoverMeal?.leftover_from_meal_id, 'confirmed leftover meal should link to a source dish').toBeTruthy();
|
||||
|
||||
const favoriteCandidate = mealsInOikos.find((meal) => meal.recipe_id);
|
||||
if (favoriteCandidate) {
|
||||
await page.goto(`/meals?week=${favoriteCandidate.date}`, { waitUntil: 'domcontentloaded' });
|
||||
await dismissOnboardingIfPresent(page);
|
||||
const candidateCard = page.locator('.meal-card').filter({ hasText: favoriteCandidate.title }).first();
|
||||
if (await candidateCard.count()) {
|
||||
await candidateCard.click();
|
||||
} else {
|
||||
await page.locator('.meal-card').first().click();
|
||||
}
|
||||
await expect(page.locator('#modal-recipe-pref-member')).toBeVisible();
|
||||
let modalRecipeId = Number(await page.locator('#modal-recipe-id').inputValue());
|
||||
if (!modalRecipeId) {
|
||||
await page.locator('#modal-recipe-id').selectOption(String(favoriteCandidate.recipe_id));
|
||||
modalRecipeId = Number(await page.locator('#modal-recipe-id').inputValue());
|
||||
}
|
||||
expect(modalRecipeId, 'favorite flow should originate from a meal modal with a saved recipe').toBeTruthy();
|
||||
const memberValue = await page.locator('#modal-recipe-pref-member option').nth(1).getAttribute('value');
|
||||
if (memberValue) {
|
||||
await page.locator('#modal-recipe-pref-member').selectOption(memberValue);
|
||||
const prefResponsePromise = page.waitForResponse((res) => res.url().includes(`/api/v1/meal-planning/recipe-signals/${modalRecipeId}`) && res.request().method() === 'PUT');
|
||||
await page.locator('[data-meal-recipe-pref="favorite"]').click();
|
||||
const prefResponse = await prefResponsePromise;
|
||||
expect(prefResponse.ok(), 'meal modal favorite action should write native recipe signals').toBeTruthy();
|
||||
const members = await page.evaluate(async () => (await (await fetch('/api/v1/family/members', { credentials: 'same-origin' })).json()).data || []);
|
||||
const member = members.find((item) => String(item.id) === String(memberValue));
|
||||
expect(Number(member?.favorite_meal_count || 0), 'family profile card data should reflect favorite meal count').toBeGreaterThanOrEqual(1);
|
||||
diagnostics.favoriteSignalCleanup = await page.evaluate(async ({ recipeId, userId }) => {
|
||||
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/meal-planning/recipe-signals/${recipeId}`, {
|
||||
method: 'PUT', credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': decodeURIComponent(csrf) },
|
||||
body: JSON.stringify({ user_id: userId, preference: 'neutral', can_cook: false, can_help_cook: false, will_eat_modified: false, adult_only: false }),
|
||||
});
|
||||
return { ok: res.ok, status: res.status };
|
||||
}, { recipeId: modalRecipeId, userId: memberValue });
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
const cleanup = await deleteMeals(page, createdMealIds).catch((err) => [{ error: err.message }]);
|
||||
diagnostics.cleanup = cleanup;
|
||||
await attachDiagnostics(testInfo, diagnostics);
|
||||
}
|
||||
|
||||
expect(diagnostics.pageErrors, 'No browser page errors during confirm/write/cleanup flow').toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,296 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import fs from 'node:fs';
|
||||
|
||||
const username = process.env.OIKOS_E2E_USERNAME;
|
||||
const password = process.env.OIKOS_E2E_PASSWORD_FILE
|
||||
? fs.readFileSync(process.env.OIKOS_E2E_PASSWORD_FILE, 'utf8').trim()
|
||||
: process.env.OIKOS_E2E_PASSWORD;
|
||||
|
||||
const MODE_LABELS = {
|
||||
low_effort: 'Nemt',
|
||||
regular: 'Hverdag',
|
||||
flexible: 'Plads til mere',
|
||||
};
|
||||
|
||||
const MODIFIER_LABELS = {
|
||||
freezer: 'Fryser',
|
||||
leftovers: 'Rester',
|
||||
eating_out: 'Spiser ude',
|
||||
guests: 'Gæster',
|
||||
no_kids: 'Børnefri',
|
||||
cook_extra: 'Lav ekstra',
|
||||
rugbrod: 'Rugbrød/koldt',
|
||||
very_quick: 'Meget hurtigt',
|
||||
};
|
||||
|
||||
function isoAdd(start, offset) {
|
||||
const date = new Date(`${start}T12:00:00Z`);
|
||||
date.setUTCDate(date.getUTCDate() + offset);
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function nextMonday() {
|
||||
const date = new Date();
|
||||
date.setUTCHours(12, 0, 0, 0);
|
||||
const day = date.getUTCDay() || 7;
|
||||
date.setUTCDate(date.getUTCDate() + (8 - day));
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
async function dismissOnboardingIfPresent(page) {
|
||||
const skip = page.getByRole('button', { name: /^(Skip|Spring over|Senere|Close|Luk)$/i }).first();
|
||||
try { if (await skip.isVisible({ timeout: 1500 })) await skip.click(); } catch {}
|
||||
}
|
||||
|
||||
async function login(page) {
|
||||
if (!username || !password) throw new Error('Missing OIKOS_E2E_USERNAME and password env.');
|
||||
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 HTTP ${loginResponse.status()}`);
|
||||
await page.waitForURL((url) => !url.pathname.includes('/login'), { timeout: 20_000 });
|
||||
await expect(page.locator('#main-content')).toBeVisible();
|
||||
await dismissOnboardingIfPresent(page);
|
||||
}
|
||||
|
||||
function buildUseCases(start) {
|
||||
const base = [
|
||||
['low_effort freezer quick', 'low_effort', ['freezer', 'very_quick'], 'freezer'],
|
||||
['low_effort leftovers quick', 'low_effort', ['leftovers', 'very_quick'], 'leftovers'],
|
||||
['low_effort rugbrod', 'low_effort', ['rugbrod'], 'quick'],
|
||||
['low_effort kids normal', 'low_effort', [], 'easy'],
|
||||
['regular no modifiers', 'regular', [], 'regular'],
|
||||
['regular cook extra', 'regular', ['cook_extra'], 'batch'],
|
||||
['regular leftovers', 'regular', ['leftovers'], 'leftovers'],
|
||||
['regular freezer', 'regular', ['freezer'], 'freezer'],
|
||||
['regular very quick', 'regular', ['very_quick'], 'quick'],
|
||||
['flexible guests', 'flexible', ['guests'], 'guests'],
|
||||
['flexible guests cook extra', 'flexible', ['guests', 'cook_extra'], 'guests_batch'],
|
||||
['flexible no kids', 'flexible', ['no_kids'], 'adult'],
|
||||
['flexible no kids eating out', 'flexible', ['no_kids', 'eating_out'], 'eating_out'],
|
||||
['flexible eating out', 'flexible', ['eating_out'], 'eating_out'],
|
||||
['flexible freezer guests', 'flexible', ['freezer', 'guests'], 'freezer_guests'],
|
||||
['low effort guests conflict', 'low_effort', ['guests'], 'conflict'],
|
||||
['low effort no kids', 'low_effort', ['no_kids'], 'adult_quick'],
|
||||
['regular rugbrod kids', 'regular', ['rugbrod'], 'cold'],
|
||||
['regular no kids cook extra', 'regular', ['no_kids', 'cook_extra'], 'adult_batch'],
|
||||
['flexible all special', 'flexible', ['guests', 'cook_extra', 'freezer'], 'complex'],
|
||||
['freezer only', 'regular', ['freezer'], 'inventory'],
|
||||
['leftovers only', 'regular', ['leftovers'], 'inventory'],
|
||||
['eating out only', 'regular', ['eating_out'], 'non_cooking'],
|
||||
['cook extra only', 'regular', ['cook_extra'], 'batch'],
|
||||
['very quick only', 'regular', ['very_quick'], 'quick'],
|
||||
['rugbrod very quick', 'low_effort', ['rugbrod', 'very_quick'], 'cold_quick'],
|
||||
['guests no kids', 'flexible', ['guests', 'no_kids'], 'adult_guests'],
|
||||
['guests leftovers', 'flexible', ['guests', 'leftovers'], 'leftover_guests'],
|
||||
['no kids freezer', 'flexible', ['no_kids', 'freezer'], 'adult_inventory'],
|
||||
['kids easy freezer', 'low_effort', ['freezer'], 'kid_inventory'],
|
||||
['busy monday', 'low_effort', ['very_quick'], 'calendar_pressure'],
|
||||
['normal tuesday', 'regular', [], 'baseline'],
|
||||
['ella cooks thursday', 'regular', ['very_quick'], 'kid_cook'],
|
||||
['friday guests', 'flexible', ['guests'], 'weekend_guest'],
|
||||
['saturday big cook', 'flexible', ['cook_extra'], 'weekend_batch'],
|
||||
['sunday family regular', 'regular', [], 'sunday'],
|
||||
['rainy comfort fallback', 'low_effort', [], 'weather'],
|
||||
['warm grill flexible', 'flexible', [], 'weather'],
|
||||
['partial plan one day', 'regular', [], 'partial'],
|
||||
['two-day low effort streak', 'low_effort', ['very_quick'], 'variety'],
|
||||
['avoid duplicate category', 'regular', [], 'variety'],
|
||||
['manual freezer unknown', 'low_effort', ['freezer'], 'manual_inventory'],
|
||||
['planned leftovers style', 'regular', ['leftovers'], 'planned_leftovers'],
|
||||
['eating out still has cards', 'flexible', ['eating_out'], 'cards'],
|
||||
['new suggestion distinct', 'regular', [], 'cards'],
|
||||
['rare suggestion present', 'regular', [], 'cards'],
|
||||
['known suggestion present', 'regular', [], 'cards'],
|
||||
['modulator labels visible', 'flexible', ['guests', 'cook_extra'], 'ui'],
|
||||
['inventory options on freezer', 'low_effort', ['freezer'], 'inventory'],
|
||||
['reservation-compatible day', 'low_effort', ['freezer', 'very_quick'], 'inventory_reserve'],
|
||||
];
|
||||
return base.map(([name, mode, modifiers, intent], idx) => ({
|
||||
id: idx + 1,
|
||||
name,
|
||||
date: isoAdd(start, idx % 14),
|
||||
mode,
|
||||
modifiers,
|
||||
intent,
|
||||
}));
|
||||
}
|
||||
|
||||
function analyzeSlot(useCase, slot) {
|
||||
const issues = [];
|
||||
const mods = slot?.context?.modulators || {};
|
||||
const cards = slot?.suggestionCards || [];
|
||||
if (!slot) issues.push('No slot returned for use case date.');
|
||||
if (mods.dayMode !== useCase.mode) issues.push(`Expected dayMode ${useCase.mode}, got ${mods.dayMode}.`);
|
||||
for (const modifier of useCase.modifiers) {
|
||||
if (!(mods.manualModifiers || []).includes(modifier)) issues.push(`Missing manual modifier ${modifier}.`);
|
||||
}
|
||||
if (cards.length !== 3) issues.push(`Expected exactly 3 suggestion cards, got ${cards.length}.`);
|
||||
const titles = cards.map((card) => card.title).filter(Boolean);
|
||||
if (new Set(titles).size !== titles.length) issues.push(`Suggestion card titles are not distinct: ${titles.join(' | ')}`);
|
||||
if (useCase.modifiers.includes('eating_out') && !/spiser ude|takeaway|café/i.test(`${slot.title} ${titles.join(' ')}`)) issues.push('Eating-out use case did not produce an intentional eating-out/takeaway option.');
|
||||
if (useCase.modifiers.includes('freezer') && !(slot.inventoryOptions || []).length) issues.push('Freezer use case did not expose inventoryOptions.');
|
||||
if (useCase.modifiers.includes('freezer') && !/fryser|freezer/i.test(`${slot.title} ${titles.join(' ')}`)) issues.push('Freezer use case did not show freezer-oriented wording.');
|
||||
if (useCase.modifiers.includes('leftovers') && !/rester|leftover|fryser/i.test(`${slot.title} ${titles.join(' ')}`)) issues.push('Leftovers use case did not show leftover-oriented wording.');
|
||||
if (useCase.modifiers.includes('guests') && !/gæst|guest|stor|salat|ovnret/i.test(`${slot.reason} ${titles.join(' ')}`)) issues.push('Guests use case did not explain/scaffold guest fit.');
|
||||
if (useCase.modifiers.includes('no_kids') && !/børnefri|voksen|adult|chili/i.test(`${slot.reason} ${titles.join(' ')}`)) issues.push('No-kids use case did not open adult/no-kids suggestions.');
|
||||
if (useCase.mode === 'low_effort' && !mods.easyDay) issues.push('Low-effort use case was not marked as easyDay.');
|
||||
if (!Array.isArray(mods.labels) || mods.labels.length < 2) issues.push('Modulator labels are too sparse for user inspection.');
|
||||
return issues;
|
||||
}
|
||||
|
||||
async function attachReport(testInfo, report) {
|
||||
await testInfo.attach('oikos-meal-studio-50-use-cases.json', {
|
||||
body: JSON.stringify(report, null, 2),
|
||||
contentType: 'application/json',
|
||||
});
|
||||
const markdown = [
|
||||
'# Oikos Meal Plan Studio — 50-use-case E2E verification',
|
||||
'',
|
||||
`Run: ${report.startedAt}`,
|
||||
`Base URL: ${report.baseURL}`,
|
||||
`Passed: ${report.summary.passed}/${report.summary.total}`,
|
||||
`Issues: ${report.summary.issueCount}`,
|
||||
'',
|
||||
'## Issues / defects / shortcomings',
|
||||
...(report.issues.length ? report.issues.map((issue) => `- UC${String(issue.id).padStart(2, '0')} ${issue.name}: ${issue.issue}`) : ['- None recorded.']),
|
||||
'',
|
||||
'## Use-case results',
|
||||
...report.results.map((row) => `- ${row.pass ? '✅' : '⚠️'} UC${String(row.id).padStart(2, '0')} ${row.name} — ${row.mode} [${row.modifiers.join(', ') || 'none'}] → ${row.slotTitle || 'no slot'}`),
|
||||
].join('\n');
|
||||
await testInfo.attach('oikos-meal-studio-50-use-cases.md', { body: markdown, contentType: 'text/markdown' });
|
||||
}
|
||||
|
||||
test.describe('Oikos Meal Plan Studio comprehensive 50-use-case verification', () => {
|
||||
test('50 realistic meal-planning use cases across API, UI, inventory, and mobile flow', async ({ page }, testInfo) => {
|
||||
test.setTimeout(240_000);
|
||||
const diagnostics = { consoleErrors: [], pageErrors: [], failedRequests: [] };
|
||||
page.on('console', (msg) => { if (['error', 'warning'].includes(msg.type())) diagnostics.consoleErrors.push(`${msg.type()}: ${msg.text()}`); });
|
||||
page.on('pageerror', (err) => diagnostics.pageErrors.push(err.stack || err.message));
|
||||
page.on('requestfailed', (req) => diagnostics.failedRequests.push(`${req.method()} ${req.url()} :: ${req.failure()?.errorText || 'failed'}`));
|
||||
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.addInitScript(() => localStorage.setItem('oikos-onboarded', '1'));
|
||||
await login(page);
|
||||
|
||||
const startedAt = new Date().toISOString();
|
||||
const baseURL = testInfo.project.use.baseURL || process.env.OIKOS_E2E_BASE_URL || 'https://home.friborg.uk';
|
||||
const start = nextMonday();
|
||||
const useCases = buildUseCases(start);
|
||||
const results = [];
|
||||
const issues = [];
|
||||
|
||||
await test.step('health and served asset markers', async () => {
|
||||
const health = await page.request.get('/ai/health');
|
||||
expect(health.ok()).toBeTruthy();
|
||||
const widget = await page.request.get('/ai/widget.js');
|
||||
expect(widget.ok()).toBeTruthy();
|
||||
const widgetText = await widget.text();
|
||||
for (const marker of ['assist-day-setup', 'assist-meal-options', 'assist-leftover-inventory', 'applySuggestionCard', 'reserveInventoryForDate']) {
|
||||
if (!widgetText.includes(marker)) issues.push({ id: 0, name: 'served widget marker', issue: `Missing widget marker ${marker}` });
|
||||
}
|
||||
const css = await page.request.get('/ai/assist.css');
|
||||
const cssText = await css.text();
|
||||
for (const marker of ['Three-card meal picker slice', 'Leftover/freezer inventory picker slice']) {
|
||||
if (!cssText.includes(marker)) issues.push({ id: 0, name: 'served CSS marker', issue: `Missing CSS marker ${marker}` });
|
||||
}
|
||||
});
|
||||
|
||||
await test.step('inventory API add/reserve/discard smoke', async () => {
|
||||
const uniqueTitle = `E2E fryser portion ${Date.now()}`;
|
||||
const create = await page.request.post('/ai/api/meal-plan/leftovers', { data: { title: uniqueTitle, servings: 3, location: 'freezer', expiresAt: isoAdd(start, 30), notes: 'E2E verification item' } });
|
||||
const created = await create.json();
|
||||
if (!create.ok() || !created.item?.id) issues.push({ id: 0, name: 'inventory create', issue: `Could not create inventory item: HTTP ${create.status()}` });
|
||||
else {
|
||||
const reserve = await page.request.patch('/ai/api/meal-plan/leftovers', { data: { id: created.item.id, patch: { status: 'reserved', reservedForDate: start } } });
|
||||
if (!reserve.ok()) issues.push({ id: 0, name: 'inventory reserve', issue: `Could not reserve inventory item: HTTP ${reserve.status()}` });
|
||||
const discard = await page.request.patch('/ai/api/meal-plan/leftovers', { data: { id: created.item.id, patch: { status: 'discarded' } } });
|
||||
if (!discard.ok()) issues.push({ id: 0, name: 'inventory cleanup', issue: `Could not discard E2E inventory item: HTTP ${discard.status()}` });
|
||||
}
|
||||
});
|
||||
|
||||
for (const useCase of useCases) {
|
||||
await test.step(`UC${String(useCase.id).padStart(2, '0')} ${useCase.name}`, async () => {
|
||||
const response = await page.request.post('/ai/api/meal-plan/generate', {
|
||||
data: {
|
||||
startDate: useCase.date,
|
||||
endDate: useCase.date,
|
||||
dayConfigs: [{ date: useCase.date, mode: useCase.mode, modifiers: useCase.modifiers }],
|
||||
},
|
||||
});
|
||||
let payload = null;
|
||||
if (!response.ok()) {
|
||||
const issue = `Generation failed HTTP ${response.status()}`;
|
||||
issues.push({ id: useCase.id, name: useCase.name, issue });
|
||||
results.push({ ...useCase, pass: false, issues: [issue] });
|
||||
return;
|
||||
}
|
||||
payload = await response.json();
|
||||
const slot = payload.slots?.[0];
|
||||
const rowIssues = analyzeSlot(useCase, slot);
|
||||
for (const issue of rowIssues) issues.push({ id: useCase.id, name: useCase.name, issue });
|
||||
results.push({
|
||||
...useCase,
|
||||
pass: rowIssues.length === 0,
|
||||
issues: rowIssues,
|
||||
slotTitle: slot?.title,
|
||||
cardTitles: (slot?.suggestionCards || []).map((card) => card.title),
|
||||
inventoryOptionCount: slot?.inventoryOptions?.length || 0,
|
||||
modulatorLabels: slot?.context?.modulators?.labels || [],
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
await test.step('mobile UI smoke for day chips, 3 cards, and inventory panel', async () => {
|
||||
await page.goto('/meals', { waitUntil: 'domcontentloaded' });
|
||||
await dismissOnboardingIfPresent(page);
|
||||
const host = page.locator('[data-oikos-meal-plan-studio]');
|
||||
await expect(host).toBeVisible({ timeout: 20_000 });
|
||||
await host.locator('[data-assist-studio-generate]').first().click();
|
||||
await expect(host.locator('.assist-day-setup')).toBeVisible({ timeout: 30_000 });
|
||||
await expect(host.locator('.assist-meal-options').first()).toBeVisible({ timeout: 20_000 });
|
||||
await expect(host.locator('.assist-leftover-inventory')).toBeVisible({ timeout: 20_000 });
|
||||
const dayModeButtons = await host.locator('[data-assist-day-mode]').count();
|
||||
const modifierButtons = await host.locator('[data-assist-day-modifier]').count();
|
||||
const optionCards = await host.locator('[data-assist-suggestion-card]').count();
|
||||
const overflow = await page.evaluate(() => ({ innerWidth: window.innerWidth, scrollWidth: document.documentElement.scrollWidth, bodyScrollWidth: document.body.scrollWidth }));
|
||||
if (dayModeButtons < 21) issues.push({ id: 0, name: 'mobile day mode UI', issue: `Expected day mode buttons for 7 days, got ${dayModeButtons}` });
|
||||
if (modifierButtons < 56) issues.push({ id: 0, name: 'mobile modifier UI', issue: `Expected modifier buttons for 7 days, got ${modifierButtons}` });
|
||||
if (optionCards < 21) issues.push({ id: 0, name: 'mobile suggestion cards', issue: `Expected 3 option cards for 7 days, got ${optionCards}` });
|
||||
if (overflow.scrollWidth > overflow.innerWidth + 12) issues.push({ id: 0, name: 'mobile overflow', issue: `Horizontal overflow: ${JSON.stringify(overflow)}` });
|
||||
|
||||
const firstTitleBefore = await host.locator('[data-assist-studio-title="0"]').inputValue();
|
||||
const secondCard = host.locator('[data-assist-suggestion-card]').nth(1);
|
||||
if (await secondCard.count()) {
|
||||
await secondCard.click();
|
||||
const firstTitleAfter = await host.locator('[data-assist-studio-title="0"]').inputValue();
|
||||
if (firstTitleBefore === firstTitleAfter) issues.push({ id: 0, name: 'suggestion card selection', issue: 'Clicking second suggestion card did not update first slot title.' });
|
||||
}
|
||||
});
|
||||
|
||||
const report = {
|
||||
startedAt,
|
||||
baseURL,
|
||||
summary: {
|
||||
total: useCases.length,
|
||||
passed: results.filter((row) => row.pass).length,
|
||||
failed: results.filter((row) => !row.pass).length,
|
||||
issueCount: issues.length,
|
||||
},
|
||||
issues,
|
||||
diagnostics,
|
||||
results,
|
||||
};
|
||||
|
||||
await attachReport(testInfo, report);
|
||||
fs.mkdirSync('artifacts', { recursive: true });
|
||||
fs.writeFileSync('artifacts/oikos-meal-studio-50-use-cases.json', JSON.stringify(report, null, 2));
|
||||
|
||||
expect(results).toHaveLength(50);
|
||||
expect(diagnostics.pageErrors, 'No browser page errors').toEqual([]);
|
||||
expect(issues, `Recorded issues:\n${issues.map((i) => `UC${i.id} ${i.name}: ${i.issue}`).join('\n')}`).toEqual([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user