Compare commits
10 Commits
4aa2db7c63
...
203456f3e0
| Author | SHA1 | Date | |
|---|---|---|---|
| 203456f3e0 | |||
| 37ad018cbc | |||
| 044c85563f | |||
| 3e4cc78d3e | |||
| 1828bef8f1 | |||
| ba534cb864 | |||
| cf099bb353 | |||
| 5099155c61 | |||
| 0b6603b092 | |||
| 7c118068c0 |
@@ -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,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",
|
"name": "oikos",
|
||||||
"version": "0.48.3",
|
"version": "0.50.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "oikos",
|
"name": "oikos",
|
||||||
"version": "0.48.3",
|
"version": "0.50.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
@@ -20,6 +20,7 @@
|
|||||||
"node-fetch": "^3.3.2"
|
"node-fetch": "^3.3.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.60.0",
|
||||||
"puppeteer": "^24.42.0",
|
"puppeteer": "^24.42.0",
|
||||||
"sharp": "^0.34.5"
|
"sharp": "^0.34.5"
|
||||||
},
|
},
|
||||||
@@ -557,6 +558,22 @@
|
|||||||
"url": "https://opencollective.com/libvips"
|
"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": {
|
"node_modules/@puppeteer/browsers": {
|
||||||
"version": "2.13.0",
|
"version": "2.13.0",
|
||||||
"resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.13.0.tgz",
|
"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==",
|
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/function-bind": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||||
@@ -2639,6 +2671,38 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/prebuild-install": {
|
||||||
"version": "7.1.3",
|
"version": "7.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
|
||||||
|
|||||||
+4
-1
@@ -30,7 +30,9 @@
|
|||||||
"test:backup-scheduler": "node --experimental-sqlite test-backup-scheduler.js",
|
"test:backup-scheduler": "node --experimental-sqlite test-backup-scheduler.js",
|
||||||
"test:caldav": "node --experimental-sqlite test-caldav-sync.js",
|
"test:caldav": "node --experimental-sqlite test-caldav-sync.js",
|
||||||
"test:carddav": "node --experimental-sqlite test-carddav.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:e2e:oikos-flow": "playwright test tests/e2e/oikos-kitchen-assist-flow.spec.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
@@ -49,6 +51,7 @@
|
|||||||
},
|
},
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.60.0",
|
||||||
"puppeteer": "^24.42.0",
|
"puppeteer": "^24.42.0",
|
||||||
"sharp": "^0.34.5"
|
"sharp": "^0.34.5"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { defineConfig, devices } from '@playwright/test';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
|
||||||
|
const baseURL = process.env.OIKOS_E2E_BASE_URL || 'https://home.friborg.uk';
|
||||||
|
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: 'retain-on-failure',
|
||||||
|
screenshot: 'only-on-failure',
|
||||||
|
video: 'retain-on-failure',
|
||||||
|
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",
|
"savedRecipePlaceholder": "Rezept auswählen",
|
||||||
"saveAsRecipe": "Als Rezept speichern",
|
"saveAsRecipe": "Als Rezept speichern",
|
||||||
"recipeScaleLabel": "Zutaten skalieren",
|
"recipeScaleLabel": "Zutaten skalieren",
|
||||||
"deletedToast": "Mahlzeit gelöscht"
|
"deletedToast": "Mahlzeit gelöscht",
|
||||||
|
"cookLabel": "Koch/Köchin",
|
||||||
|
"cookNone": "Keine Koch-Zuweisung"
|
||||||
},
|
},
|
||||||
"calendar": {
|
"calendar": {
|
||||||
"title": "Kalender",
|
"title": "Kalender",
|
||||||
|
|||||||
@@ -313,7 +313,9 @@
|
|||||||
"savedRecipePlaceholder": "Select recipe",
|
"savedRecipePlaceholder": "Select recipe",
|
||||||
"saveAsRecipe": "Save as recipe",
|
"saveAsRecipe": "Save as recipe",
|
||||||
"recipeScaleLabel": "Scale ingredients",
|
"recipeScaleLabel": "Scale ingredients",
|
||||||
"deletedToast": "Meal deleted"
|
"deletedToast": "Meal deleted",
|
||||||
|
"cookLabel": "Cook",
|
||||||
|
"cookNone": "No assigned cook"
|
||||||
},
|
},
|
||||||
"calendar": {
|
"calendar": {
|
||||||
"title": "Calendar",
|
"title": "Calendar",
|
||||||
|
|||||||
+32
-5
@@ -38,6 +38,7 @@ let state = {
|
|||||||
currentWeek: null, // YYYY-MM-DD (Montag)
|
currentWeek: null, // YYYY-MM-DD (Montag)
|
||||||
meals: [],
|
meals: [],
|
||||||
recipes: [],
|
recipes: [],
|
||||||
|
familyMembers: [], // Familienmitglieder für Koch-Zuweisung
|
||||||
lists: [], // Einkaufslisten für Transfer-Dropdown
|
lists: [], // Einkaufslisten für Transfer-Dropdown
|
||||||
categories: [], // Einkaufskategorien für Zutaten
|
categories: [], // Einkaufskategorien für Zutaten
|
||||||
modal: null,
|
modal: null,
|
||||||
@@ -126,6 +127,15 @@ async function loadRecipes() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadFamilyMembers() {
|
||||||
|
try {
|
||||||
|
const res = await api.get('/family/members');
|
||||||
|
state.familyMembers = res.data;
|
||||||
|
} catch {
|
||||||
|
state.familyMembers = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function loadPreferences() {
|
async function loadPreferences() {
|
||||||
try {
|
try {
|
||||||
const res = await api.get('/preferences');
|
const res = await api.get('/preferences');
|
||||||
@@ -169,7 +179,7 @@ export async function render(container, { user }) {
|
|||||||
const today = new Date().toISOString().slice(0, 10);
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
const monday = getMondayOf(today);
|
const monday = getMondayOf(today);
|
||||||
|
|
||||||
await Promise.all([loadWeek(monday), loadLists(), loadPreferences(), loadCategories(), loadRecipes()]);
|
await Promise.all([loadWeek(monday), loadLists(), loadPreferences(), loadCategories(), loadRecipes(), loadFamilyMembers()]);
|
||||||
renderWeekGrid();
|
renderWeekGrid();
|
||||||
wireNav();
|
wireNav();
|
||||||
|
|
||||||
@@ -252,6 +262,7 @@ function renderSlot(date, type, mealsForDay) {
|
|||||||
const ingLabel = ingCount > 0 ? (ingCount !== 1 ? t('meals.ingredientCountPlural', { count: ingCount }) : t('meals.ingredientCount', { count: ingCount })) : '';
|
const ingLabel = ingCount > 0 ? (ingCount !== 1 ? t('meals.ingredientCountPlural', { count: ingCount }) : t('meals.ingredientCount', { count: ingCount })) : '';
|
||||||
const ingDoneLabel = ingCount > 0 && ingDone === ingCount ? ' ✓' : '';
|
const ingDoneLabel = ingCount > 0 && ingDone === ingCount ? ' ✓' : '';
|
||||||
const canTransfer = ingCount > 0 && ingDone < ingCount;
|
const canTransfer = ingCount > 0 && ingDone < ingCount;
|
||||||
|
const cookName = meal.cook_assignment?.cook_name;
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="meal-slot meal-slot--has-meal" data-meal-id="${meal.id}" data-date="${meal.date}" data-type="${type.key}">
|
<div class="meal-slot meal-slot--has-meal" data-meal-id="${meal.id}" data-date="${meal.date}" data-type="${type.key}">
|
||||||
@@ -261,8 +272,9 @@ function renderSlot(date, type, mealsForDay) {
|
|||||||
data-meal-id="${meal.id}"
|
data-meal-id="${meal.id}"
|
||||||
role="button" tabindex="0">
|
role="button" tabindex="0">
|
||||||
<div class="meal-card__title">${esc(meal.title)}</div>
|
<div class="meal-card__title">${esc(meal.title)}</div>
|
||||||
${ingLabel ? `<div class="meal-card__meta">
|
${(ingLabel || cookName) ? `<div class="meal-card__meta">
|
||||||
<span class="meal-card__ingredients-count">${ingLabel}${esc(ingDoneLabel)}</span>
|
${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>` : ''}
|
||||||
<div class="meal-card__actions">
|
<div class="meal-card__actions">
|
||||||
${meal.recipe_url ? `<a class="meal-card__action-btn meal-card__action-btn--recipe"
|
${meal.recipe_url ? `<a class="meal-card__action-btn meal-card__action-btn--recipe"
|
||||||
@@ -754,6 +766,12 @@ 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>`),
|
...state.recipes.map((r) => `<option value="${r.id}" ${isEdit && meal.recipe_id === r.id ? 'selected' : ''}>${esc(r.title)}</option>`),
|
||||||
].join('');
|
].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('');
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="modal-grid modal-grid--2">
|
<div class="modal-grid modal-grid--2">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@@ -766,6 +784,11 @@ function buildModalContent({ mode, date, mealType, meal }) {
|
|||||||
</div>
|
</div>
|
||||||
</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;">
|
<div class="form-group" style="position:relative;">
|
||||||
<label class="form-label" for="modal-title">${t('meals.titleLabel')}</label>
|
<label class="form-label" for="modal-title">${t('meals.titleLabel')}</label>
|
||||||
<input type="text" class="form-input" id="modal-title"
|
<input type="text" class="form-input" id="modal-title"
|
||||||
@@ -865,6 +888,8 @@ async function saveModal(overlay) {
|
|||||||
const notes = overlay.querySelector('#modal-notes').value.trim() || null;
|
const notes = overlay.querySelector('#modal-notes').value.trim() || null;
|
||||||
const recipe_url = overlay.querySelector('#modal-recipe-url').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 recipe_id = overlay.querySelector('#modal-recipe-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)) {
|
if (!date || !isDateInputValid(dateRaw)) {
|
||||||
window.oikos?.showToast(t('calendar.invalidDate'), 'error');
|
window.oikos?.showToast(t('calendar.invalidDate'), 'error');
|
||||||
@@ -884,12 +909,14 @@ async function saveModal(overlay) {
|
|||||||
try {
|
try {
|
||||||
const { mode, meal } = state.modal;
|
const { mode, meal } = state.modal;
|
||||||
|
|
||||||
|
const mealPayload = { date, meal_type, title, notes, recipe_url, recipe_id, cook_user_id };
|
||||||
|
|
||||||
if (mode === 'create') {
|
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);
|
state.meals.push(res.data);
|
||||||
} else {
|
} else {
|
||||||
// Update meal meta
|
// 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
|
// Sync ingredients
|
||||||
const existingIds = new Set((meal.ingredients ?? []).map((i) => i.id));
|
const existingIds = new Set((meal.ingredients ?? []).map((i) => i.id));
|
||||||
|
|||||||
@@ -461,3 +461,11 @@
|
|||||||
transform: none;
|
transform: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.meal-card__cook {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|||||||
+131
@@ -1351,6 +1351,137 @@ const MIGRATIONS = [
|
|||||||
CREATE INDEX IF NOT EXISTS idx_calendar_attachment_document ON calendar_events(attachment_document_id);
|
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);
|
||||||
|
`);
|
||||||
|
},
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import dashboardRouter from './routes/dashboard.js';
|
|||||||
import tasksRouter from './routes/tasks.js';
|
import tasksRouter from './routes/tasks.js';
|
||||||
import shoppingRouter from './routes/shopping.js';
|
import shoppingRouter from './routes/shopping.js';
|
||||||
import mealsRouter from './routes/meals.js';
|
import mealsRouter from './routes/meals.js';
|
||||||
|
import mealPlanningRouter from './routes/meal-planning.js';
|
||||||
import recipesRouter from './routes/recipes.js';
|
import recipesRouter from './routes/recipes.js';
|
||||||
import calendarRouter from './routes/calendar.js';
|
import calendarRouter from './routes/calendar.js';
|
||||||
import notesRouter from './routes/notes.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/tasks', tasksRouter);
|
||||||
app.use('/api/v1/shopping', shoppingRouter);
|
app.use('/api/v1/shopping', shoppingRouter);
|
||||||
app.use('/api/v1/meals', mealsRouter);
|
app.use('/api/v1/meals', mealsRouter);
|
||||||
|
app.use('/api/v1/meal-planning', mealPlanningRouter);
|
||||||
app.use('/api/v1/recipes', recipesRouter);
|
app.use('/api/v1/recipes', recipesRouter);
|
||||||
app.use('/api/v1/calendar', calendarRouter);
|
app.use('/api/v1/calendar', calendarRouter);
|
||||||
app.use('/api/v1/notes', notesRouter);
|
app.use('/api/v1/notes', notesRouter);
|
||||||
|
|||||||
@@ -390,6 +390,36 @@ function buildPaths() {
|
|||||||
'/api/v1/meals/week-to-shopping-list': {
|
'/api/v1/meals/week-to-shopping-list': {
|
||||||
post: op({ summary: 'Transfer weekly meal ingredients to shopping list', tag: 'Meals', stateChanging: true, requestBody: jsonBody(null) }),
|
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': {
|
'/api/v1/recipes': {
|
||||||
get: op({ summary: 'List recipes', tag: 'Recipes' }),
|
get: op({ summary: 'List recipes', tag: 'Recipes' }),
|
||||||
post: op({ summary: 'Create recipe', tag: 'Recipes', stateChanging: true, requestBody: jsonBody(null) }),
|
post: op({ summary: 'Create recipe', tag: 'Recipes', stateChanging: true, requestBody: jsonBody(null) }),
|
||||||
@@ -663,6 +693,7 @@ function buildOpenApiSpec(req, appVersion) {
|
|||||||
{ name: 'Tasks' },
|
{ name: 'Tasks' },
|
||||||
{ name: 'Shopping' },
|
{ name: 'Shopping' },
|
||||||
{ name: 'Meals' },
|
{ name: 'Meals' },
|
||||||
|
{ name: 'Meal Planning' },
|
||||||
{ name: 'Recipes' },
|
{ name: 'Recipes' },
|
||||||
{ name: 'Calendar' },
|
{ name: 'Calendar' },
|
||||||
{ name: 'Notes' },
|
{ name: 'Notes' },
|
||||||
|
|||||||
@@ -0,0 +1,330 @@
|
|||||||
|
/**
|
||||||
|
* 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';
|
||||||
|
|
||||||
|
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.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;
|
||||||
+110
-9
@@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
import { createLogger } from '../logger.js';
|
import { createLogger } from '../logger.js';
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
|
import crypto from 'node:crypto';
|
||||||
import * as db from '../db.js';
|
import * as db from '../db.js';
|
||||||
import { str, oneOf, date, num, collectErrors, MAX_TITLE, MAX_TEXT, MAX_SHORT, DATE_RE } from '../middleware/validate.js';
|
import { str, oneOf, date, num, collectErrors, MAX_TITLE, MAX_TEXT, MAX_SHORT, DATE_RE } from '../middleware/validate.js';
|
||||||
|
|
||||||
@@ -41,6 +42,83 @@ function weekEnd(dateStr) {
|
|||||||
return d.toISOString().slice(0, 10);
|
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 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!)
|
// Routen - Mahlzeiten-Vorschläge (vor dynamischen Routen!)
|
||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
@@ -126,10 +204,11 @@ router.get('/', (req, res) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = meals.map((m) => ({
|
const cookMap = loadCookAssignments(mealIds);
|
||||||
|
const result = meals.map((m) => attachCookAssignment({
|
||||||
...m,
|
...m,
|
||||||
ingredients: ingredientMap[m.id] || [],
|
ingredients: ingredientMap[m.id] || [],
|
||||||
}));
|
}, cookMap));
|
||||||
|
|
||||||
res.json({ data: result, weekStart: from, weekEnd: to });
|
res.json({ data: result, weekStart: from, weekEnd: to });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -145,7 +224,7 @@ router.get('/', (req, res) => {
|
|||||||
/**
|
/**
|
||||||
* POST /api/v1/meals
|
* POST /api/v1/meals
|
||||||
* Neue Mahlzeit anlegen.
|
* Neue Mahlzeit anlegen.
|
||||||
* Body: { date, meal_type, title, notes?, ingredients?: [{ name, quantity? }] }
|
* Body: { date, meal_type, title, notes?, cook_user_id?, source_plan_id?, ingredients?: [{ name, quantity? }] }
|
||||||
* Response: { data: Meal }
|
* Response: { data: Meal }
|
||||||
*/
|
*/
|
||||||
router.post('/', (req, res) => {
|
router.post('/', (req, res) => {
|
||||||
@@ -157,7 +236,11 @@ router.post('/', (req, res) => {
|
|||||||
const vNotes = str(req.body.notes, 'Notizen', { max: MAX_TEXT, required: false });
|
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 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 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 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 (!req.body.meal_type) errors.push('Mahlzeit-Typ ist erforderlich.');
|
if (!req.body.meal_type) errors.push('Mahlzeit-Typ ist erforderlich.');
|
||||||
if (errors.length) return res.status(400).json({ error: errors.join(' '), code: 400 });
|
if (errors.length) return res.status(400).json({ error: errors.join(' '), code: 400 });
|
||||||
|
|
||||||
@@ -170,7 +253,7 @@ router.post('/', (req, res) => {
|
|||||||
const result = db.get().prepare(`
|
const result = db.get().prepare(`
|
||||||
INSERT INTO meals (date, meal_type, title, notes, recipe_url, recipe_id, created_by)
|
INSERT INTO meals (date, meal_type, title, notes, recipe_url, recipe_id, created_by)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
`).run(vDate.value, vType.value, vTitle.value, vNotes.value, vRecipeUrl.value, vRecipeId.value, req.session.userId);
|
`).run(vDate.value, vType.value, vTitle.value, vNotes.value, vRecipeUrl.value, vRecipeId.value, currentUserId(req));
|
||||||
|
|
||||||
const mealId = result.lastInsertRowid;
|
const mealId = result.lastInsertRowid;
|
||||||
|
|
||||||
@@ -185,12 +268,18 @@ router.post('/', (req, res) => {
|
|||||||
if (name) insertIng.run(mealId, name, qty, category);
|
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
|
SELECT m.*, u.display_name AS creator_name, u.avatar_color AS creator_color
|
||||||
FROM meals m
|
FROM meals m
|
||||||
LEFT JOIN users u ON u.id = m.created_by
|
LEFT JOIN users u ON u.id = m.created_by
|
||||||
WHERE m.id = ?
|
WHERE m.id = ?
|
||||||
`).get(mealId);
|
`).get(mealId);
|
||||||
|
|
||||||
|
if (vCookUserId.present && vCookUserId.value !== null) {
|
||||||
|
saveCookAssignment(createdMeal, vCookUserId.value, vSourcePlanId.value, currentUserId(req));
|
||||||
|
}
|
||||||
|
|
||||||
|
return createdMeal;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Zutaten anhängen
|
// Zutaten anhängen
|
||||||
@@ -198,7 +287,8 @@ router.post('/', (req, res) => {
|
|||||||
'SELECT * FROM meal_ingredients WHERE meal_id = ? ORDER BY id ASC'
|
'SELECT * FROM meal_ingredients WHERE meal_id = ? ORDER BY id ASC'
|
||||||
).all(meal.id);
|
).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) {
|
} catch (err) {
|
||||||
log.error('', err);
|
log.error('', err);
|
||||||
res.status(500).json({ error: 'Interner Fehler', code: 500 });
|
res.status(500).json({ error: 'Interner Fehler', code: 500 });
|
||||||
@@ -224,7 +314,11 @@ 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.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_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 }));
|
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 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 (errors.length) return res.status(400).json({ error: errors.join(' '), code: 400 });
|
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 !== '') {
|
if (req.body.recipe_id !== undefined && req.body.recipe_id !== null && req.body.recipe_id !== '') {
|
||||||
@@ -257,11 +351,18 @@ router.put('/:id', (req, res) => {
|
|||||||
WHERE m.id = ?
|
WHERE m.id = ?
|
||||||
`).get(id);
|
`).get(id);
|
||||||
|
|
||||||
|
if (vCookUserId.present) {
|
||||||
|
saveCookAssignment(updated, vCookUserId.value, vSourcePlanId.value, currentUserId(req));
|
||||||
|
} else {
|
||||||
|
syncCookAssignmentSlot(updated);
|
||||||
|
}
|
||||||
|
|
||||||
const ings = db.get().prepare(
|
const ings = db.get().prepare(
|
||||||
'SELECT * FROM meal_ingredients WHERE meal_id = ? ORDER BY id ASC'
|
'SELECT * FROM meal_ingredients WHERE meal_id = ? ORDER BY id ASC'
|
||||||
).all(id);
|
).all(id);
|
||||||
|
const cookMap = loadCookAssignments([id]);
|
||||||
|
|
||||||
res.json({ data: { ...updated, ingredients: ings } });
|
res.json({ data: attachCookAssignment({ ...updated, ingredients: ings }, cookMap) });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.error('', err);
|
log.error('', err);
|
||||||
res.status(500).json({ error: 'Interner Fehler', code: 500 });
|
res.status(500).json({ error: 'Interner Fehler', code: 500 });
|
||||||
|
|||||||
@@ -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,103 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = {
|
||||||
|
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());
|
||||||
|
});
|
||||||
|
|
||||||
|
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 assistPrompt = 'Lav en praktisk madplan for næste uge ud fra vores opskrifter.';
|
||||||
|
const assistStart = Date.now();
|
||||||
|
await page.evaluate((prompt) => window.oikos?.openAssist?.({ prompt, expanded: true }), assistPrompt);
|
||||||
|
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 });
|
||||||
|
await expect(studioHost.locator('[data-assist-studio-confirm]')).toBeVisible({ timeout: 15_000 });
|
||||||
|
await expect(studioHost.locator('[data-assist-studio-title]')).toHaveCount(7, { timeout: 15_000 });
|
||||||
|
await expect(studioHost).toContainText(/Madlavere & familiepræferencer|Børnenes madplan|Måltidshistorik/i);
|
||||||
|
|
||||||
|
expect(diagnostics.pageErrors, 'No browser page errors during Kitchen/Assist flow').toEqual([]);
|
||||||
|
await attachDiagnostics(testInfo, diagnostics);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user