diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 4def30b0c..4835d74d7 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -97,15 +97,6 @@ updates: - "docker" - "automated" - - package-ecosystem: "docker" - directory: "/apps/food/apps/backend" - schedule: - interval: "weekly" - open-pull-requests-limit: 5 - labels: - - "docker" - - "automated" - - package-ecosystem: "docker" directory: "/apps/news/apps/api" schedule: diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 1960c2726..58943836a 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -31,13 +31,10 @@ import { chatRoutes } from './modules/chat/routes'; import { notesRoutes } from './modules/notes/routes'; import { pictureRoutes } from './modules/picture/routes'; import { profileRoutes } from './modules/profile/routes'; -import { wardrobeRoutes } from './modules/wardrobe/routes'; import { storageRoutes } from './modules/storage/routes'; import { todoRoutes } from './modules/todo/routes'; import { plantsRoutes } from './modules/plants/routes'; -import { foodRoutes } from './modules/food/routes'; import { guidesRoutes } from './modules/guides/routes'; -import { moodlitRoutes } from './modules/moodlit/routes'; import { newsRoutes } from './modules/news/routes'; import { newsResearchRoutes } from './modules/news-research/routes'; import { articlesRoutes } from './modules/articles/routes'; @@ -100,11 +97,10 @@ app.use('/api/*', authMiddleware()); // to `beta`+ so that unauthenticated guest fallbacks (tier='public' // from a missing claim) can't hit paid infrastructure. // Pure CRUD modules (calendar, contacts, music, storage, todo, news, -// presi, moodlit) rely on authMiddleware alone — users access only +// presi) rely on authMiddleware alone — users access only // their own records. const RESOURCE_MODULES = [ 'chat', - 'food', 'guides', 'kontext', 'news-research', @@ -135,13 +131,10 @@ app.route('/api/v1/chat', chatRoutes); app.route('/api/v1/notes', notesRoutes); app.route('/api/v1/picture', pictureRoutes); app.route('/api/v1/profile', profileRoutes); -app.route('/api/v1/wardrobe', wardrobeRoutes); app.route('/api/v1/storage', storageRoutes); app.route('/api/v1/todo', todoRoutes); app.route('/api/v1/plants', plantsRoutes); -app.route('/api/v1/food', foodRoutes); app.route('/api/v1/guides', guidesRoutes); -app.route('/api/v1/moodlit', moodlitRoutes); app.route('/api/v1/news', newsRoutes); app.route('/api/v1/news-research', newsResearchRoutes); app.route('/api/v1/articles', articlesRoutes); diff --git a/apps/api/src/lib/media.ts b/apps/api/src/lib/media.ts index f1d99f112..fa68f0701 100644 --- a/apps/api/src/lib/media.ts +++ b/apps/api/src/lib/media.ts @@ -91,11 +91,10 @@ export async function getMediaBufferAsPng( * doesn't land in the owned set — the caller turns that into an HTTP * response. * - * Accepts a single app string or an array. The Wardrobe try-on flow - * (plan docs/plans/wardrobe-module.md M4) passes `['me', 'wardrobe']` - * in one call — face-ref and body-ref live under `me`, garments live - * under `wardrobe`, both legitimate inputs for the same `/v1/images/ - * edits` POST. + * Accepts a single app string or an array. Comic character-ref flows + * pass `['me', 'comic']` in one call when both face/body portraits and + * comic-specific anchors are legitimate inputs for the same + * `/v1/images/edits` POST. * * One `list()` round-trip per app. For N apps this is N calls, each * capped at 500 rows — far beyond the product's intended per-app shape diff --git a/apps/api/src/mcp/executor.ts b/apps/api/src/mcp/executor.ts index 616a5b1f5..321ebf277 100644 --- a/apps/api/src/mcp/executor.ts +++ b/apps/api/src/mcp/executor.ts @@ -529,44 +529,6 @@ register('undo_drink', async (_args, userId) => { return ok(`Letzter Drink-Eintrag (${last.name}) rückgängig gemacht.`); }); -// ── Food tools ──────────────────────────────────────────────── - -register('log_meal', async (args, userId) => { - const mealId = crypto.randomUUID(); - const now = nowIso(); - const today = now.split('T')[0]; - const data = { - id: mealId, - userId, - mealType: args.mealType as string, - description: args.description as string, - calories: (args.calories as number) ?? null, - protein: (args.protein as number) ?? null, - date: today, - createdAt: now, - updatedAt: now, - }; - await writeRecord(userId, 'food', 'meals', mealId, 'insert', data, fieldTs(Object.keys(data))); - return ok(`${args.mealType}: "${args.description}" geloggt.`, { id: mealId }); -}); - -register('nutrition_summary', async (_args, userId) => { - const records = await readLatestRecords(userId, 'food', 'meals'); - const today = new Date().toISOString().split('T')[0]; - const todayMeals = records.filter((r) => r.date === today); - let totalCal = 0, - totalProtein = 0; - for (const m of todayMeals) { - totalCal += (m.calories as number) ?? 0; - totalProtein += (m.protein as number) ?? 0; - } - return ok(`Heute: ${todayMeals.length} Mahlzeiten, ${totalCal} kcal, ${totalProtein}g Protein`, { - meals: todayMeals.length, - calories: totalCal, - protein: totalProtein, - }); -}); - // ── Journal tools ───────────────────────────────────────────── register('create_journal_entry', async (args, userId) => { diff --git a/apps/api/src/modules/food/routes.ts b/apps/api/src/modules/food/routes.ts deleted file mode 100644 index f23583d0f..000000000 --- a/apps/api/src/modules/food/routes.ts +++ /dev/null @@ -1,220 +0,0 @@ -/** - * Food module — Meal analysis (Gemini Vision via mana-llm) + recommendations. - * - * CRUD for meals, goals, favorites is handled by mana-sync. This module - * owns the server-only operations: photo upload to mana-media, structured - * AI analysis using the Vercel AI SDK (`generateObject`) against the - * shared Zod schema in @mana/shared-types, and a small rule-based - * recommendation engine. - * - * Why generateObject + Zod instead of raw fetch? - * - Runtime validation of the AI response — if Gemini drifts on a - * field, we throw at the boundary instead of corrupting downstream - * state. The frontend never sees malformed data. - * - Provider-portable structured outputs: the AI SDK translates one - * Zod schema into OpenAI strict json_schema / Anthropic tool-use / - * Gemini response_schema depending on which backend mana-llm routes - * to. We don't have to know which. - * - Single source of truth: the same MealAnalysisSchema is consumed - * by the unified web app via `z.infer`, - * so changes here propagate end-to-end without manual sync. - */ - -import { Hono } from 'hono'; -import { generateObject } from 'ai'; -import { createOpenAICompatible } from '@ai-sdk/openai-compatible'; -import { - AI_SCHEMA_VERSION, - MealAnalysisSchema, - type AiResponseEnvelope, - type MealAnalysis, -} from '@mana/shared-types'; -import { logger, type AuthVariables } from '@mana/shared-hono'; -import { MANA_LLM } from '@mana/shared-ai'; - -const LLM_URL = process.env.MANA_LLM_URL || 'http://localhost:3025'; -// mana-llm resolves this alias to a healthy vision model (chain in -// services/mana-llm/aliases.yaml). To swap the chain, edit the YAML -// and SIGHUP — no service redeploy here. -const VISION_MODEL = MANA_LLM.VISION; - -const llm = createOpenAICompatible({ - name: 'mana-llm', - // mana-llm exposes /v1/chat/completions (see services/mana-llm/CLAUDE.md + - // src/main.py:125). The AI SDK's openai-compatible adapter appends - // /chat/completions to baseURL, so baseURL ends in /v1. - baseURL: `${LLM_URL}/v1`, - // Tell the AI SDK that mana-llm honours OpenAI-style strict - // json_schema response_format. Without this, generateObject() falls - // back to a tool-call mode that Ollama-backed models don't support - // reliably and the response fails to validate against the Zod schema. - // mana-llm's Ollama provider translates response_format → Ollama's - // native `format` field (services/mana-llm/src/providers/ollama.py) - // so this is honoured end-to-end. - supportsStructuredOutputs: true, -}); - -const ANALYSIS_PROMPT = `Du bist ein Ernährungsexperte. Analysiere die Mahlzeit und gib strukturierte Nährwertdaten zurück. Schätze realistische Portionsgrößen und Kalorien. Antworte auf Deutsch.`; - -/** - * Provider hints attached to the system message. Forward-compat: - * - * - anthropic.cacheControl: ephemeral system-prompt caching. NO-OP today - * because (a) we route to Gemini via mana-llm and (b) the prompt is - * ~50 tokens — well under Anthropic's 1024-token cache minimum. Becomes - * active automatically when mana-llm routes to Claude AND the prompt - * grows (e.g. once we attach per-user dietary preferences as system - * context, which would push us past the threshold). - * - * Kept here so the day we flip the backend, we don't have to revisit - * every route to enable caching — it just starts working. - */ -const SYSTEM_CACHE_HINT = { - anthropic: { cacheControl: { type: 'ephemeral' as const } }, -}; - -/** Wrap a validated AI object in the standard wire-format envelope. */ -function envelope(data: MealAnalysis): AiResponseEnvelope { - return { schemaVersion: AI_SCHEMA_VERSION, data }; -} - -const routes = new Hono<{ Variables: AuthVariables }>(); - -// ─── Photo Upload (server-only: S3 storage via mana-media) ─── - -routes.post('/photos/upload', async (c) => { - const userId = c.get('userId'); - const formData = await c.req.formData(); - const file = formData.get('file') as File | null; - - if (!file) return c.json({ error: 'No file provided' }, 400); - if (file.size > 10 * 1024 * 1024) return c.json({ error: 'File too large (max 10MB)' }, 400); - - try { - const { uploadImageToMedia } = await import('../../lib/media'); - const buffer = await file.arrayBuffer(); - const result = await uploadImageToMedia(buffer, file.name, { app: 'food', userId }); - - return c.json( - { - mediaId: result.id, - publicUrl: result.urls.original, - thumbnailUrl: result.urls.thumbnail || result.urls.original, - storagePath: result.id, - }, - 201 - ); - } catch (err) { - logger.error('food.upload_failed', { - error: err instanceof Error ? err.message : String(err), - }); - return c.json({ error: 'Upload failed' }, 500); - } -}); - -// ─── Photo Analysis (Gemini Vision on uploaded URL) ────────── - -routes.post('/analysis/photo', async (c) => { - const { photoUrl } = await c.req.json(); - if (!photoUrl) return c.json({ error: 'photoUrl required' }, 400); - - try { - const { object } = await generateObject({ - model: llm(VISION_MODEL), - schema: MealAnalysisSchema, - messages: [ - { - role: 'system', - content: ANALYSIS_PROMPT, - providerOptions: SYSTEM_CACHE_HINT, - }, - { - role: 'user', - content: [ - { type: 'text', text: 'Analysiere diese Mahlzeit.' }, - { type: 'image', image: new URL(photoUrl) }, - ], - }, - ], - temperature: 0.3, - }); - return c.json(envelope(object)); - } catch (err) { - logger.error('food.photo_analysis_failed', { - error: err instanceof Error ? err.message : String(err), - }); - return c.json({ error: 'Analysis failed' }, 500); - } -}); - -// ─── Text Analysis (Gemini on a free-text meal description) ── - -routes.post('/analysis/text', async (c) => { - const { description } = await c.req.json(); - if (!description) return c.json({ error: 'description required' }, 400); - - try { - const { object } = await generateObject({ - model: llm(VISION_MODEL), - schema: MealAnalysisSchema, - messages: [ - { - role: 'system', - content: ANALYSIS_PROMPT, - providerOptions: SYSTEM_CACHE_HINT, - }, - { - role: 'user', - content: `Analysiere diese Mahlzeit: ${description}`, - }, - ], - temperature: 0.3, - }); - return c.json(envelope(object)); - } catch (err) { - logger.error('food.text_analysis_failed', { - error: err instanceof Error ? err.message : String(err), - }); - return c.json({ error: 'Analysis failed' }, 500); - } -}); - -// ─── Recommendations (server-only: rule engine) ────────────── - -routes.post('/recommendations/generate', async (c) => { - const { dailyNutrition } = await c.req.json(); - const hints: Array<{ type: string; priority: string; message: string; nutrient?: string }> = []; - - if (dailyNutrition) { - if (dailyNutrition.protein < 25) { - hints.push({ - type: 'hint', - priority: 'medium', - message: - 'Deine Proteinzufuhr ist niedrig. Versuche Hülsenfrüchte, Eier oder Joghurt einzubauen.', - nutrient: 'protein', - }); - } - if (dailyNutrition.fiber < 10) { - hints.push({ - type: 'hint', - priority: 'medium', - message: 'Mehr Ballaststoffe! Vollkornprodukte, Gemüse und Obst helfen.', - nutrient: 'fiber', - }); - } - if (dailyNutrition.sugar > 50) { - hints.push({ - type: 'hint', - priority: 'high', - message: - 'Dein Zuckerkonsum ist hoch. Achte auf versteckten Zucker in Getränken und Fertigprodukten.', - nutrient: 'sugar', - }); - } - } - - return c.json({ recommendations: hints }); -}); - -export { routes as foodRoutes }; diff --git a/apps/api/src/modules/moodlit/routes.ts b/apps/api/src/modules/moodlit/routes.ts deleted file mode 100644 index 8e2e12e58..000000000 --- a/apps/api/src/modules/moodlit/routes.ts +++ /dev/null @@ -1,41 +0,0 @@ -/** - * Moodlit module — Preset moods library - * Ported from apps/moodlit/apps/server - * - * Local-first for user moods/sequences. - * This module serves the default preset library. - */ - -import { Hono } from 'hono'; - -const DEFAULT_MOODS = [ - { id: 'fire', name: 'Fire', colors: ['#ff6b35', '#f72585', '#ff006e'], animation: 'flicker' }, - { id: 'breath', name: 'Breath', colors: ['#4361ee', '#3a0ca3', '#7209b7'], animation: 'pulse' }, - { - id: 'northern-lights', - name: 'Northern Lights', - colors: ['#06d6a0', '#118ab2', '#073b4c'], - animation: 'aurora', - }, - { id: 'thunder', name: 'Thunder', colors: ['#14213d', '#fca311', '#e5e5e5'], animation: 'flash' }, - { - id: 'sunset', - name: 'Sunset', - colors: ['#ff6b6b', '#feca57', '#ff9ff3'], - animation: 'gradient', - }, - { id: 'ocean', name: 'Ocean', colors: ['#0077b6', '#00b4d8', '#90e0ef'], animation: 'wave' }, - { id: 'forest', name: 'Forest', colors: ['#2d6a4f', '#40916c', '#52b788'], animation: 'sway' }, - { - id: 'lavender', - name: 'Lavender', - colors: ['#7b2cbf', '#9d4edd', '#c77dff'], - animation: 'pulse', - }, -]; - -const routes = new Hono(); - -routes.get('/presets', (c) => c.json(DEFAULT_MOODS)); - -export { routes as moodlitRoutes }; diff --git a/apps/api/src/modules/picture/routes.ts b/apps/api/src/modules/picture/routes.ts index a55a42143..5ca66ddd5 100644 --- a/apps/api/src/modules/picture/routes.ts +++ b/apps/api/src/modules/picture/routes.ts @@ -254,11 +254,8 @@ routes.post('/generate', async (c) => { // image input natively. Replicate/local fallback is a later milestone. // OpenAI gpt-image-1 / gpt-image-2 accept up to 16 reference images per -// edit call. We clamp at 8 to cover the Wardrobe try-on workflow — one -// face-ref + one body-ref + up to six garment photos (top/bottom/shoes/ -// outerwear + two accessories) — while keeping credit exposure and -// upload payload size predictable. Pre-wardrobe the cap was 4; bumped -// in docs/plans/wardrobe-module.md M1. +// edit call. We clamp at 8 to keep credit exposure and upload payload +// size predictable. const MAX_REFERENCE_IMAGES = 8; routes.post('/generate-with-reference', async (c) => { @@ -318,18 +315,14 @@ routes.post('/generate-with-reference', async (c) => { } // Ownership check before we spend credits or burn OpenAI quota. - // References span three upload tags today: - // - `me` — face/body portraits from the profile module - // - `wardrobe` — garment photos (M4 try-on flow) - // - `comic` — comic-specific anchor / backdrop uploads - // (slot reserved for M6+; no writer lands in - // this app today, M1 character refs come from - // me + wardrobe only). + // References span two upload tags today: + // - `me` — face/body portraits from the profile module + // - `comic` — comic-specific anchor / backdrop uploads // Anything outside these apps is treated as not-owned regardless of // mana-media's own view. try { const { verifyMediaOwnership } = await import('../../lib/media'); - await verifyMediaOwnership(userId, refIds, ['me', 'wardrobe', 'comic']); + await verifyMediaOwnership(userId, refIds, ['me', 'comic']); } catch (err) { const e = err as Error & { status?: number; missing?: string[] }; if (e.status === 404) { diff --git a/apps/api/src/modules/wardrobe/routes.ts b/apps/api/src/modules/wardrobe/routes.ts deleted file mode 100644 index 970a9fce1..000000000 --- a/apps/api/src/modules/wardrobe/routes.ts +++ /dev/null @@ -1,55 +0,0 @@ -/** - * Wardrobe module — server endpoints. - * - * Thin wrapper around mana-media for garment photo uploads. Plan: - * docs/plans/wardrobe-module.md M1. No logic beyond tagging uploads - * as `app='wardrobe'` so a later `GET /api/v1/media?app=wardrobe&...` - * query can enumerate a user's garment pool without scanning every - * media reference. - * - * Try-on generation does NOT live here — it reuses the Picture - * module's POST /api/v1/picture/generate-with-reference endpoint - * with MAX_REFERENCE_IMAGES bumped to 8 so face + body + garments - * fit into one call. - */ - -import { Hono } from 'hono'; -import type { AuthVariables } from '@mana/shared-hono'; - -const routes = new Hono<{ Variables: AuthVariables }>(); - -// Same 10MB cap as the other photo-upload endpoints (profile me-images, -// picture uploads). Phone-camera PNG/HEIC routinely comes in under 6MB. -const MAX_UPLOAD_BYTES = 10 * 1024 * 1024; - -routes.post('/garments/upload', async (c) => { - const userId = c.get('userId'); - const formData = await c.req.formData(); - const file = formData.get('file') as File | null; - - if (!file) return c.json({ error: 'No file' }, 400); - if (file.size > MAX_UPLOAD_BYTES) return c.json({ error: 'Max 10MB' }, 400); - - try { - const { uploadImageToMedia } = await import('../../lib/media'); - const buffer = await file.arrayBuffer(); - const result = await uploadImageToMedia(buffer, file.name, { - app: 'wardrobe', - userId, - }); - - return c.json( - { - mediaId: result.id, - storagePath: result.id, - publicUrl: result.urls.original, - thumbnailUrl: result.urls.thumbnail, - }, - 201 - ); - } catch (_err) { - return c.json({ error: 'Upload failed' }, 500); - } -}); - -export { routes as wardrobeRoutes }; diff --git a/apps/citycorners/CLAUDE.md b/apps/citycorners/CLAUDE.md deleted file mode 100644 index ee5fa17e5..000000000 --- a/apps/citycorners/CLAUDE.md +++ /dev/null @@ -1,126 +0,0 @@ -# CityCorners - -Open platform for city guides worldwide — users create cities and add locations, growing the platform organically from the community. - -## Live URLs - -| Service | URL | -|---------|-----| -| **Web App** | https://citycorners.mana.how | -| **Landing** | https://citycorners-landing.pages.dev | - -## Architecture - -``` -apps/citycorners/ -├── apps/ -│ ├── landing/ # Astro static site (Tailwind, Cloudflare Pages) -│ └── web/ # SvelteKit web app (port 5196 dev, 5022 prod) -└── CLAUDE.md -``` - -### Tech Stack -- **Data Layer:** Local-first via @mana/local-store (Dexie.js/IndexedDB) -- **Sync:** mana-sync (Go, WebSocket) for server synchronization -- **Web:** SvelteKit 2, Svelte 5 runes, Tailwind 4, OpenStreetMap embeds, svelte-i18n (DE/EN), PWA -- **Landing:** Astro 5, Tailwind 3, static site generation -- **Auth:** mana-auth (JWT, guest mode supported) - -## Development - -```bash -# Full stack (auth + web) -pnpm dev:citycorners:full - -# Individual apps -pnpm dev:citycorners:landing -pnpm dev:citycorners:web -``` - -## Data Model (Local-First) - -Three IndexedDB collections managed by `@mana/local-store`: - -### Cities -- **id** (string, PK) -- **name** (string) — city/village/town name -- **slug** (string, indexed) — URL-friendly name -- **country** (string, indexed) -- **state** (string, optional) — state/region -- **description** (string, optional) -- **latitude** (number) — center coordinates -- **longitude** (number) -- **imageUrl** (string, optional) -- **createdBy** (string, optional) — user ID - -### Locations -- **id** (string, PK) -- **cityId** (string, indexed, FK → cities) -- **name** (string, indexed) -- **category** (enum, indexed: sight/restaurant/shop/museum/cafe/bar/park/beach/hotel/event_venue/viewpoint) -- **description** (string, optional) -- **address** (string, optional) -- **latitude/longitude** (number, optional) -- **imageUrl** (string, optional) -- **timeline** (JSON array of {year, event}, optional) - -### Favorites -- **id** (string, PK) -- **locationId** (string, indexed, FK → locations) - -## Web App Routes - -| Route | Description | -|-------|-------------| -| `/` | City discovery — search & browse cities | -| `/add-city` | Create a new city (auth required) | -| `/cities/:slug` | City home — location grid with category filters | -| `/cities/:slug/map` | OpenStreetMap with location list | -| `/cities/:slug/add` | Add a location to city (auth required) | -| `/cities/:slug/locations/:id` | Location detail with map, timeline, nearby | -| `/cities/:slug/locations/:id/edit` | Edit location (creator only) | -| `/favorites` | User's saved locations | -| `/settings` | Theme mode/variant, account, about | -| `/login`, `/register` | Auth via shared-auth-ui | -| `/offline` | PWA offline fallback | - -## Features - -- **Multi-City Platform:** Users create cities/villages and add locations within them -- **Local-First:** All CRUD via IndexedDB, works offline, syncs to server -- **Guest Mode:** Browse with seed data (Konstanz, Zürich, Berlin) -- **PWA:** Installable, offline fallback, service worker caching -- **i18n:** German + English, language switcher -- **Context-Aware Navigation:** Nav items change based on city context -- **Categories:** 11 location types with color-coded markers -- **Favorites:** Heart button on cards, auth-gated -- **Geocoding:** Auto-coordinates from city/address names (Nominatim) -- **Slug Generation:** Auto-generated URL-safe slugs with umlaut handling - -## Categories - -| DB Value | Label (DE) | Label (EN) | Marker Color | -|----------|------------|------------|------------| -| `sight` | Sehenswürdigkeit | Sight | Blue | -| `restaurant` | Restaurant | Restaurant | Red | -| `shop` | Laden | Shop | Green | -| `museum` | Museum | Museum | Purple | -| `cafe` | Café | Café | Amber | -| `bar` | Bar | Bar | Orange | -| `park` | Park | Park | Emerald | -| `beach` | Strandbad | Beach | Cyan | -| `hotel` | Hotel | Hotel | Indigo | -| `event_venue` | Veranstaltungsort | Event Venue | Pink | -| `viewpoint` | Aussichtspunkt | Viewpoint | Sky | - -## Docker - -- **Web:** `apps/citycorners/apps/web/Dockerfile` (multi-stage, port 5022 prod) -- **docker-compose.macmini.yml:** Web service with health check - -## Environment Variables - -| Variable | Used by | Description | -|----------|---------|-------------| -| `PUBLIC_MANA_AUTH_URL` | Web | Auth service URL (client) | -| `PUBLIC_SYNC_SERVER_URL` | Web | mana-sync WebSocket URL | diff --git a/apps/citycorners/apps/landing/.gitignore b/apps/citycorners/apps/landing/.gitignore deleted file mode 100644 index 92b60c5f6..000000000 --- a/apps/citycorners/apps/landing/.gitignore +++ /dev/null @@ -1,26 +0,0 @@ -# build output -dist/ - -# generated types -.astro/ - -# dependencies -node_modules/ - -# logs -npm-debug.log* -yarn-debug.log* -yarn-error.log* -pnpm-debug.log* -astro_dev.log -server.log - -# environment variables -.env -.env.production - -# macOS-specific files -.DS_Store - -# jetbrains setting folder -.idea/ diff --git a/apps/citycorners/apps/landing/README.md b/apps/citycorners/apps/landing/README.md deleted file mode 100644 index 414a13a66..000000000 --- a/apps/citycorners/apps/landing/README.md +++ /dev/null @@ -1,46 +0,0 @@ -# Astro Starter Kit: Basics - -```sh -npm create astro@latest -- --template basics -``` - -> 🧑‍🚀 **Seasoned astronaut?** Delete this file. Have fun! - -## 🚀 Project Structure - -Inside of your Astro project, you'll see the following folders and files: - -```text -/ -├── public/ -│ └── favicon.svg -├── src -│   ├── assets -│   │   └── astro.svg -│   ├── components -│   │   └── Welcome.astro -│   ├── layouts -│   │   └── Layout.astro -│   └── pages -│   └── index.astro -└── package.json -``` - -To learn more about the folder structure of an Astro project, refer to [our guide on project structure](https://docs.astro.build/en/basics/project-structure/). - -## 🧞 Commands - -All commands are run from the root of the project, from a terminal: - -| Command | Action | -| :------------------------ | :----------------------------------------------- | -| `npm install` | Installs dependencies | -| `npm run dev` | Starts local dev server at `localhost:4321` | -| `npm run build` | Build your production site to `./dist/` | -| `npm run preview` | Preview your build locally, before deploying | -| `npm run astro ...` | Run CLI commands like `astro add`, `astro check` | -| `npm run astro -- --help` | Get help using the Astro CLI | - -## 👀 Want to learn more? - -Feel free to check [our documentation](https://docs.astro.build) or jump into our [Discord server](https://astro.build/chat). diff --git a/apps/citycorners/apps/landing/astro.config.mjs b/apps/citycorners/apps/landing/astro.config.mjs deleted file mode 100644 index fa499952b..000000000 --- a/apps/citycorners/apps/landing/astro.config.mjs +++ /dev/null @@ -1,8 +0,0 @@ -import { defineConfig } from 'astro/config'; -import tailwind from '@astrojs/tailwind'; -import sitemap from '@astrojs/sitemap'; - -export default defineConfig({ - site: 'https://citycorners.mana.how', - integrations: [tailwind(), sitemap()], -}); diff --git a/apps/citycorners/apps/landing/package.json b/apps/citycorners/apps/landing/package.json deleted file mode 100644 index a9b7cd895..000000000 --- a/apps/citycorners/apps/landing/package.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "name": "@citycorners/landing", - "version": "0.0.1", - "private": true, - "type": "module", - "scripts": { - "dev": "astro dev", - "start": "astro dev", - "build": "astro check && astro build", - "preview": "astro preview", - "astro": "astro", - "type-check": "astro check" - }, - "dependencies": { - "@astrojs/check": "^0.9.0", - "@astrojs/sitemap": "^3.2.1", - "@astrojs/tailwind": "^6.0.0", - "astro": "^5.16.11", - "tailwindcss": "^3.4.17", - "typescript": "^5.0.0" - } -} diff --git a/apps/citycorners/apps/landing/public/favicon.svg b/apps/citycorners/apps/landing/public/favicon.svg deleted file mode 100644 index f157bd1c5..000000000 --- a/apps/citycorners/apps/landing/public/favicon.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - diff --git a/apps/citycorners/apps/landing/public/images/alm.jpg b/apps/citycorners/apps/landing/public/images/alm.jpg deleted file mode 100644 index d573e3fb2..000000000 Binary files a/apps/citycorners/apps/landing/public/images/alm.jpg and /dev/null differ diff --git a/apps/citycorners/apps/landing/public/images/imperia.jpg b/apps/citycorners/apps/landing/public/images/imperia.jpg deleted file mode 100644 index e9115853a..000000000 Binary files a/apps/citycorners/apps/landing/public/images/imperia.jpg and /dev/null differ diff --git a/apps/citycorners/apps/landing/public/images/lago.jpg b/apps/citycorners/apps/landing/public/images/lago.jpg deleted file mode 100644 index e2368783f..000000000 Binary files a/apps/citycorners/apps/landing/public/images/lago.jpg and /dev/null differ diff --git a/apps/citycorners/apps/landing/public/images/muenster.jpg b/apps/citycorners/apps/landing/public/images/muenster.jpg deleted file mode 100644 index 803258af0..000000000 Binary files a/apps/citycorners/apps/landing/public/images/muenster.jpg and /dev/null differ diff --git a/apps/citycorners/apps/landing/public/images/ophelia.jpg b/apps/citycorners/apps/landing/public/images/ophelia.jpg deleted file mode 100644 index 79190ba20..000000000 Binary files a/apps/citycorners/apps/landing/public/images/ophelia.jpg and /dev/null differ diff --git a/apps/citycorners/apps/landing/public/images/rosgartenmuseum.jpg b/apps/citycorners/apps/landing/public/images/rosgartenmuseum.jpg deleted file mode 100644 index 0113dfa81..000000000 Binary files a/apps/citycorners/apps/landing/public/images/rosgartenmuseum.jpg and /dev/null differ diff --git a/apps/citycorners/apps/landing/src/layouts/Layout.astro b/apps/citycorners/apps/landing/src/layouts/Layout.astro deleted file mode 100644 index 1748d91a9..000000000 --- a/apps/citycorners/apps/landing/src/layouts/Layout.astro +++ /dev/null @@ -1,25 +0,0 @@ ---- -interface Props { - title?: string; -} - -const { title = 'CityCorners – Entdecke Städte weltweit' } = Astro.props; ---- - - - - - - - - - - {title} - - - - - diff --git a/apps/citycorners/apps/landing/src/pages/index.astro b/apps/citycorners/apps/landing/src/pages/index.astro deleted file mode 100644 index 5da5737b3..000000000 --- a/apps/citycorners/apps/landing/src/pages/index.astro +++ /dev/null @@ -1,157 +0,0 @@ ---- -import Layout from '../layouts/Layout.astro'; - -const APP_URL = 'https://citycorners.mana.how'; - -const exampleCities = [ - { - name: 'Konstanz', - country: 'Deutschland', - description: 'Universitätsstadt am Bodensee mit mittelalterlicher Altstadt', - slug: 'konstanz', - }, - { - name: 'Zürich', - country: 'Schweiz', - description: 'Größte Stadt der Schweiz am Zürichsee', - slug: 'zuerich', - }, - { - name: 'Berlin', - country: 'Deutschland', - description: 'Hauptstadt mit vielfältiger Kultur und Geschichte', - slug: 'berlin', - }, -]; ---- - - - -
-
-

CityCorners

-

Entdecke Städte weltweit

-

- Von der Community für die Community — teile deine Lieblingsorte -

- -
-
- - -
-

So funktioniert's

-
-
-
- 🏙️ -
-

Stadt anlegen

-

- Lege deine Stadt, dein Dorf oder deinen Lieblingsort an — egal wo auf der Welt. -

-
-
-
- 📍 -
-

Orte hinzufügen

-

- Teile Restaurants, Sehenswürdigkeiten, Cafés, Parks und mehr mit der Community. -

-
-
-
- 🗺️ -
-

Entdecken

-

- Erkunde Städte auf der Karte, filtere nach Kategorien und speichere Favoriten. -

-
-
-
- - -
-
-

Beispielstädte

-
- { - exampleCities.map((city) => ( - -

- {city.name} -

-

{city.country}

-

{city.description}

-
- )) - } -
-
-
- - -
-

Features

-
-
-

Offline verfügbar

-

- Funktioniert auch ohne Internet — Daten werden lokal gespeichert und automatisch - synchronisiert. -

-
-
-

11 Kategorien

-

- Restaurants, Cafés, Museen, Parks, Hotels, Bars, Sehenswürdigkeiten und mehr. -

-
-
-

Interaktive Karte

-

- Farbcodierte Marker auf OpenStreetMap mit Standortbestimmung. -

-
-
-

Mehrsprachig

-

Deutsch und Englisch mit einfachem Sprachwechsel.

-
-
-
- - -
-
-

CityCorners – Die offene Plattform für Stadtführer

-

- Teil des Mana{' '} - Ökosystems -

-
-
-
diff --git a/apps/citycorners/apps/landing/tailwind.config.mjs b/apps/citycorners/apps/landing/tailwind.config.mjs deleted file mode 100644 index e2be8b3f7..000000000 --- a/apps/citycorners/apps/landing/tailwind.config.mjs +++ /dev/null @@ -1,13 +0,0 @@ -/** @type {import('tailwindcss').Config} */ -export default { - content: ['./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}'], - theme: { - extend: { - colors: { - primary: '#2563eb', - 'primary-dark': '#1d4ed8', - }, - }, - }, - plugins: [], -}; diff --git a/apps/citycorners/apps/landing/tsconfig.json b/apps/citycorners/apps/landing/tsconfig.json deleted file mode 100644 index a9210e68f..000000000 --- a/apps/citycorners/apps/landing/tsconfig.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "extends": "astro/tsconfigs/strict", - "include": [".astro/types.d.ts", "**/*"], - "exclude": ["dist"] -} diff --git a/apps/citycorners/apps/landing/wrangler.toml b/apps/citycorners/apps/landing/wrangler.toml deleted file mode 100644 index 2459c8869..000000000 --- a/apps/citycorners/apps/landing/wrangler.toml +++ /dev/null @@ -1,6 +0,0 @@ -# Cloudflare Pages configuration for CityCorners Landing -# Deployed via GitHub Actions (Direct Upload) - -name = "citycorners-landing" -compatibility_date = "2024-12-01" -pages_build_output_dir = "dist" diff --git a/apps/food/CLAUDE.md b/apps/food/CLAUDE.md deleted file mode 100644 index b0a889582..000000000 --- a/apps/food/CLAUDE.md +++ /dev/null @@ -1,17 +0,0 @@ -# Food — consolidated into the unified Mana app - -This product was migrated into the unified Mana monorepo. The legacy -per-product `apps/food/apps/backend/` and `apps/food/apps/web/` -directories have been removed. Active code now lives in: - -- **Backend compute routes**: [`apps/api/src/modules/food/routes.ts`](../api/src/modules/food/routes.ts) (Gemini meal-photo analysis + text analysis + recommendations) -- **Frontend module** (local-first): [`apps/mana/apps/web/src/lib/modules/food/`](../mana/apps/web/src/lib/modules/food/) -- **Web route**: [`apps/mana/apps/web/src/routes/(app)/food/`](../mana/apps/web/src/routes/(app)/food/) -- **Landing page** (still standalone): [`apps/food/apps/landing/`](apps/landing/) - -For monorepo-wide patterns (auth, sync, encryption, services), see the -[root `CLAUDE.md`](../../CLAUDE.md) and [`apps/mana/CLAUDE.md`](../mana/CLAUDE.md). - -The previous standalone "Food Project Guide" was deleted in the -audit cleanup of 2026-04-09 — it had been inaccurate since the -consolidation. Pre-consolidation reference is in git history. diff --git a/apps/food/apps/landing/astro.config.mjs b/apps/food/apps/landing/astro.config.mjs deleted file mode 100644 index 26db04be5..000000000 --- a/apps/food/apps/landing/astro.config.mjs +++ /dev/null @@ -1,7 +0,0 @@ -import { defineConfig } from 'astro/config'; -import tailwind from '@astrojs/tailwind'; - -export default defineConfig({ - integrations: [tailwind()], - site: 'https://food.mana.how', -}); diff --git a/apps/food/apps/landing/package.json b/apps/food/apps/landing/package.json deleted file mode 100644 index 7df05768c..000000000 --- a/apps/food/apps/landing/package.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "name": "@food/landing", - "version": "0.2.0", - "private": true, - "type": "module", - "scripts": { - "dev": "astro dev --port 4323", - "start": "astro dev", - "build": "astro check && astro build", - "preview": "astro preview", - "astro": "astro", - "type-check": "astro check", - "format": "prettier --write .", - "clean": "rm -rf dist .astro node_modules" - }, - "dependencies": { - "@astrojs/check": "^0.9.0", - "@mana/shared-landing-ui": "workspace:*", - "astro": "^5.16.0", - "typescript": "^5.9.2" - }, - "devDependencies": { - "@astrojs/tailwind": "^6.0.2", - "@tailwindcss/typography": "^0.5.18", - "@types/node": "^20.0.0", - "eslint": "^9.0.0", - "eslint-config-prettier": "^9.1.0", - "eslint-plugin-astro": "^1.0.0", - "prettier": "^3.6.2", - "prettier-plugin-astro": "^0.14.1", - "prettier-plugin-tailwindcss": "^0.6.14", - "tailwindcss": "^3.4.0" - } -} diff --git a/apps/food/apps/landing/src/layouts/Layout.astro b/apps/food/apps/landing/src/layouts/Layout.astro deleted file mode 100644 index ca1a00847..000000000 --- a/apps/food/apps/landing/src/layouts/Layout.astro +++ /dev/null @@ -1,58 +0,0 @@ ---- -import Analytics from '@mana/shared-landing-ui/atoms/Analytics.astro'; - -interface Props { - title: string; - description?: string; -} - -const { title, description = 'Food - KI-gestützte Ernährungsanalyse per Foto' } = Astro.props; ---- - - - - - - - - - - {title} - - - - - - - - - - - { - import.meta.env.PUBLIC_UMAMI_WEBSITE_ID && ( - - -
- {#if calPoints === 0 && weightPoints === 0} -

Noch keine überlappenden Daten — logge Mahlzeiten und Gewicht parallel.

- {:else} -
-
- -
-
Ø Kalorien / Tag
-
- {avg ?? '—'} - {#if calDelta !== null} - 0} class:down={calDelta < 0}> - {calDelta > 0 ? '+' : ''}{Math.round(calDelta)} - - {/if} -
-
-
-
- -
-
Gewicht
-
- {weightSeries.filter((p) => p.value !== null).slice(-1)[0]?.value ?? '—'} - kg - {#if weightDelta !== null} - 0} class:down={weightDelta < 0}> - {weightDelta > 0 ? '+' : ''}{weightDelta.toFixed(1)} - - {/if} -
-
-
-
- - - {#if targetY !== null} - - {/if} - - - - - - {/if} -
- - diff --git a/apps/mana/apps/web/src/lib/modules/body/context.ts b/apps/mana/apps/web/src/lib/modules/body/context.ts index 54bd618eb..0b4569281 100644 --- a/apps/mana/apps/web/src/lib/modules/body/context.ts +++ b/apps/mana/apps/web/src/lib/modules/body/context.ts @@ -17,7 +17,6 @@ import type { BodyCheck, BodyPhase, } from './types'; -import type { MealWithNutrition } from '$lib/modules/food/types'; export const bodyExercisesCtx = createModuleContext('bodyExercises'); export const bodyRoutinesCtx = createModuleContext('bodyRoutines'); @@ -26,4 +25,3 @@ export const bodySetsCtx = createModuleContext('bodySets'); export const bodyMeasurementsCtx = createModuleContext('bodyMeasurements'); export const bodyChecksCtx = createModuleContext('bodyChecks'); export const bodyPhasesCtx = createModuleContext('bodyPhases'); -export const bodyFoodMealsCtx = createModuleContext('bodyFoodMeals'); diff --git a/apps/mana/apps/web/src/lib/modules/body/queries.ts b/apps/mana/apps/web/src/lib/modules/body/queries.ts index fdc617151..04cbf5f3a 100644 --- a/apps/mana/apps/web/src/lib/modules/body/queries.ts +++ b/apps/mana/apps/web/src/lib/modules/body/queries.ts @@ -9,7 +9,6 @@ import { deriveUpdatedAt } from '$lib/data/sync'; import { decryptRecords } from '$lib/data/crypto'; import { db } from '$lib/data/database'; import { scopedForModule } from '$lib/data/scope'; -import type { LocalMeal, MealWithNutrition } from '$lib/modules/food/types'; import type { LocalBodyExercise, LocalBodyRoutine, @@ -218,48 +217,6 @@ export function useAllBodyPhases() { }, [] as BodyPhase[]); } -/** - * Cross-module read into the food `meals` table for the calorie / - * weight correlation chart. Lives here (instead of consumers calling - * food/queries directly) because the body module owns the Body × - * Food integration boundary, and putting the cross-table read in - * one place keeps the import graph from getting circular if food - * ever wants to reach back the other way. - * - * Returns a thinned MealWithNutrition shape — only the fields the - * correlation chart actually consumes (date + nutrition.calories). - * `since` is a YYYY-MM-DD lower bound; the chart pulls 8 weeks but - * the helper is permissive so a future "year view" can extend it. - */ -export function useFoodMealsSince(since: string) { - return useScopedLiveQuery(async () => { - const locals = await db.table('meals').where('date').aboveOrEqual(since).toArray(); - const visible = locals.filter((m) => !m.deletedAt); - // Encrypted fields (description / portionSize / foods) get unwrapped - // before we project. We don't strictly need them for the chart, but - // future tooltips with "what did you eat that day" will, and the - // decrypt cost on a few hundred rows is negligible. - const decrypted = await decryptRecords('meals', visible); - return decrypted.map( - (m): MealWithNutrition => ({ - id: m.id, - date: m.date, - mealType: m.mealType, - inputType: m.inputType, - description: m.description, - portionSize: m.portionSize ?? null, - confidence: m.confidence, - nutrition: m.nutrition ?? null, - photoMediaId: m.photoMediaId ?? null, - photoUrl: m.photoUrl ?? null, - photoThumbnailUrl: m.photoThumbnailUrl ?? null, - foods: m.foods ?? null, - createdAt: m.createdAt ?? new Date().toISOString(), - }) - ); - }, [] as MealWithNutrition[]); -} - /** Helper: YYYY-MM-DD `n` days ago. */ export function dateNDaysAgo(n: number): string { const d = new Date(); diff --git a/apps/mana/apps/web/src/lib/modules/citycorners/ListView.svelte b/apps/mana/apps/web/src/lib/modules/citycorners/ListView.svelte deleted file mode 100644 index 1a3222872..000000000 --- a/apps/mana/apps/web/src/lib/modules/citycorners/ListView.svelte +++ /dev/null @@ -1,79 +0,0 @@ - - - - l.id} emptyTitle="Keine Orte"> - {#snippet header()} - {locations.length} Orte - {favorites.length} Favoriten - {/snippet} - - {#snippet item(location)} - - {/snippet} - diff --git a/apps/mana/apps/web/src/lib/modules/citycorners/collections.ts b/apps/mana/apps/web/src/lib/modules/citycorners/collections.ts deleted file mode 100644 index e639e93ca..000000000 --- a/apps/mana/apps/web/src/lib/modules/citycorners/collections.ts +++ /dev/null @@ -1,120 +0,0 @@ -/** - * CityCorners module — collection accessors and guest seed data. - * - * Uses prefixed table names in the unified DB: cities, ccLocations, ccFavorites. - */ - -import { db } from '$lib/data/database'; -import type { LocalCity, LocalLocation, LocalFavorite } from './types'; - -// ─── Collection Accessors ────────────────────────────────── - -export const cityTable = db.table('cities'); -export const ccLocationTable = db.table('ccLocations'); -export const ccFavoriteTable = db.table('ccFavorites'); - -// ─── Guest Seed ──────────────────────────────────────────── - -export const CITYCORNERS_GUEST_SEED = { - cities: [ - { - id: 'city-konstanz', - name: 'Konstanz', - slug: 'konstanz', - country: 'Deutschland', - state: 'Baden-Württemberg', - description: - 'Universitätsstadt am Bodensee mit mittelalterlicher Altstadt, direkt an der Schweizer Grenze.', - latitude: 47.6603, - longitude: 9.1757, - }, - { - id: 'city-zuerich', - name: 'Zürich', - slug: 'zuerich', - country: 'Schweiz', - state: 'Zürich', - description: - 'Größte Stadt der Schweiz am Zürichsee, bekannt für Kultur, Finanzen und hohe Lebensqualität.', - latitude: 47.3769, - longitude: 8.5417, - }, - { - id: 'city-berlin', - name: 'Berlin', - slug: 'berlin', - country: 'Deutschland', - state: 'Berlin', - description: 'Hauptstadt Deutschlands mit vielfältiger Kultur, Geschichte und Nachtleben.', - latitude: 52.52, - longitude: 13.405, - }, - ], - ccLocations: [ - { - id: 'loc-muenster', - cityId: 'city-konstanz', - name: 'Konstanzer Münster', - category: 'sight' as const, - description: - 'Das Münster Unserer Lieben Frau ist die ehemalige Bischofskirche des Bistums Konstanz und Wahrzeichen der Stadt.', - address: 'Münsterplatz 1, 78462 Konstanz', - latitude: 47.6603, - longitude: 9.1752, - }, - { - id: 'loc-imperia', - cityId: 'city-konstanz', - name: 'Imperia', - category: 'sight' as const, - description: - 'Die 9 Meter hohe Statue im Hafen von Konstanz dreht sich einmal in 4 Minuten um ihre Achse.', - address: 'Hafen, 78462 Konstanz', - latitude: 47.6596, - longitude: 9.1789, - }, - { - id: 'loc-insel', - cityId: 'city-konstanz', - name: 'Mainau – Blumeninsel', - category: 'park' as const, - description: - 'Die Blumeninsel Mainau im Bodensee ist bekannt für ihre Gärten, das Schmetterlingshaus und das Barockschloss.', - address: 'Mainau 1, 78465 Konstanz', - latitude: 47.7051, - longitude: 9.1919, - }, - { - id: 'loc-strandbad', - cityId: 'city-konstanz', - name: 'Strandbad Horn', - category: 'beach' as const, - description: 'Beliebtes Freibad am Bodensee mit Sandstrand und Blick auf die Alpen.', - address: 'Eichhornstraße 100, 78464 Konstanz', - latitude: 47.6753, - longitude: 9.2001, - }, - { - id: 'loc-grossmuenster', - cityId: 'city-zuerich', - name: 'Grossmünster', - category: 'sight' as const, - description: - 'Romanische Kirche aus dem 12. Jahrhundert, Wahrzeichen Zürichs mit Aussichtsturm über die Altstadt.', - address: 'Grossmünsterplatz, 8001 Zürich', - latitude: 47.3701, - longitude: 8.5441, - }, - { - id: 'loc-brandenburger-tor', - cityId: 'city-berlin', - name: 'Brandenburger Tor', - category: 'sight' as const, - description: - 'Das bekannteste Wahrzeichen Berlins und Symbol der deutschen Wiedervereinigung.', - address: 'Pariser Platz, 10117 Berlin', - latitude: 52.5163, - longitude: 13.3777, - }, - ], -}; diff --git a/apps/mana/apps/web/src/lib/modules/citycorners/index.ts b/apps/mana/apps/web/src/lib/modules/citycorners/index.ts deleted file mode 100644 index 8abc1ec83..000000000 --- a/apps/mana/apps/web/src/lib/modules/citycorners/index.ts +++ /dev/null @@ -1,25 +0,0 @@ -/** - * CityCorners module — barrel exports. - */ - -export { favoritesStore } from './stores/favorites.svelte'; -export { - useAllCities, - useAllLocations, - useAllFavorites, - getFavoriteIds, - isFavorite, - filterByCity, - filterByCategory, - searchLocations, - searchCities, - findCityBySlug, - getLocationCountByCity, - getCityStats, - getPlatformStats, -} from './queries'; -export type { CityStats, PlatformStats } from './queries'; -export { cityTable, ccLocationTable, ccFavoriteTable, CITYCORNERS_GUEST_SEED } from './collections'; -export type { LocalCity, LocalLocation, LocalFavorite } from './types'; -export { CATEGORY_KEYS, CATEGORY_COLORS } from './types'; -export { isOpenNow, haversine, formatDistance } from './utils/opening-hours'; diff --git a/apps/mana/apps/web/src/lib/modules/citycorners/module.config.ts b/apps/mana/apps/web/src/lib/modules/citycorners/module.config.ts deleted file mode 100644 index a5323c53f..000000000 --- a/apps/mana/apps/web/src/lib/modules/citycorners/module.config.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { ModuleConfig } from '$lib/data/module-registry'; - -export const citycornersModuleConfig: ModuleConfig = { - appId: 'citycorners', - tables: [ - { name: 'cities' }, - { name: 'ccLocations', syncName: 'locations' }, - { name: 'ccFavorites', syncName: 'favorites' }, - { name: 'ccLocationTags' }, - ], -}; diff --git a/apps/mana/apps/web/src/lib/modules/citycorners/queries.ts b/apps/mana/apps/web/src/lib/modules/citycorners/queries.ts deleted file mode 100644 index 4543ce0a2..000000000 --- a/apps/mana/apps/web/src/lib/modules/citycorners/queries.ts +++ /dev/null @@ -1,181 +0,0 @@ -/** - * Reactive Queries & Pure Filter Helpers for CityCorners - * - * Uses Dexie liveQuery on the unified DB. Components call these hooks - * at init time; no manual fetch/refresh needed. - */ - -import { useScopedLiveQuery } from '$lib/data/scope/use-scoped-live-query.svelte'; -import { db } from '$lib/data/database'; -import { scopedForModule } from '$lib/data/scope'; -import type { LocalCity, LocalLocation, LocalFavorite } from './types'; - -// ─── Live Query Hooks ───────────────────────────────────── -// -// Each hook returns `{ value, loading, error }` — call sites read -// `.value` reactively (the wrapper internally manages a `$state` -// snapshot synced to the underlying Dexie liveQuery). MUST be called -// from inside a component setup, never from a module-level constant. - -/** All cities, sorted by name. Auto-updates on any change. */ -export function useAllCities() { - return useScopedLiveQuery(async () => { - const all = await scopedForModule('citycorners', 'cities').toArray(); - return all.filter((c) => !c.deletedAt).sort((a, b) => a.name.localeCompare(b.name)); - }, [] as LocalCity[]); -} - -/** All locations, sorted by name. Auto-updates on any change. */ -export function useAllLocations() { - return useScopedLiveQuery(async () => { - const all = await scopedForModule( - 'citycorners', - 'ccLocations' - ).toArray(); - return all.filter((l) => !l.deletedAt).sort((a, b) => a.name.localeCompare(b.name)); - }, [] as LocalLocation[]); -} - -/** All favorites. Auto-updates on any change. */ -export function useAllFavorites() { - return useScopedLiveQuery(async () => { - const all = await scopedForModule( - 'citycorners', - 'ccFavorites' - ).toArray(); - return all.filter((f) => !f.deletedAt); - }, [] as LocalFavorite[]); -} - -// ─── Pure Filter Functions (for $derived) ─────────────────── - -/** Get a Set of favorite location IDs for quick lookup. */ -export function getFavoriteIds(favorites: LocalFavorite[]): Set { - return new Set(favorites.map((f) => f.locationId)); -} - -/** Check if a location is favorited. */ -export function isFavorite(favorites: LocalFavorite[], locationId: string): boolean { - return favorites.some((f) => f.locationId === locationId); -} - -/** Filter locations by city. */ -export function filterByCity(locations: LocalLocation[], cityId: string): LocalLocation[] { - return locations.filter((l) => l.cityId === cityId); -} - -/** Filter locations by category. */ -export function filterByCategory( - locations: LocalLocation[], - category: string | null -): LocalLocation[] { - if (!category) return locations; - return locations.filter((l) => l.category === category); -} - -/** Filter locations by search query across name, description, address. */ -export function searchLocations(locations: LocalLocation[], query: string): LocalLocation[] { - if (!query.trim()) return locations; - const search = query.toLowerCase().trim(); - return locations.filter( - (l) => - l.name.toLowerCase().includes(search) || - l.description?.toLowerCase().includes(search) || - l.address?.toLowerCase().includes(search) - ); -} - -/** Filter cities by search query across name, country, state, description. */ -export function searchCities(cities: LocalCity[], query: string): LocalCity[] { - if (!query.trim()) return cities; - const search = query.toLowerCase().trim(); - return cities.filter( - (c) => - c.name.toLowerCase().includes(search) || - c.country.toLowerCase().includes(search) || - c.state?.toLowerCase().includes(search) || - c.description?.toLowerCase().includes(search) - ); -} - -/** Find a city by slug. */ -export function findCityBySlug(cities: LocalCity[], slug: string): LocalCity | undefined { - return cities.find((c) => c.slug === slug); -} - -/** Count locations per city. */ -export function getLocationCountByCity(locations: LocalLocation[]): Map { - const counts = new Map(); - for (const loc of locations) { - counts.set(loc.cityId, (counts.get(loc.cityId) || 0) + 1); - } - return counts; -} - -/** Stats for a single city. */ -export interface CityStats { - locationCount: number; - categoryCounts: Record; - topCategories: { category: string; count: number }[]; - contributorCount: number; - hasCoordinates: number; - recentLocations: LocalLocation[]; -} - -/** Compute stats for a city's locations. */ -export function getCityStats(locations: LocalLocation[]): CityStats { - const categoryCounts: Record = {}; - const contributors = new Set(); - let hasCoordinates = 0; - - for (const loc of locations) { - categoryCounts[loc.category] = (categoryCounts[loc.category] || 0) + 1; - if (loc.createdBy) contributors.add(loc.createdBy); - if (loc.latitude && loc.longitude) hasCoordinates++; - } - - const topCategories = Object.entries(categoryCounts) - .map(([category, count]) => ({ category, count })) - .sort((a, b) => b.count - a.count) - .slice(0, 5); - - const recentLocations = [...locations] - .sort((a, b) => { - const aTime = a.createdAt ? new Date(a.createdAt).getTime() : 0; - const bTime = b.createdAt ? new Date(b.createdAt).getTime() : 0; - return bTime - aTime; - }) - .slice(0, 3); - - return { - locationCount: locations.length, - categoryCounts, - topCategories, - contributorCount: contributors.size, - hasCoordinates, - recentLocations, - }; -} - -/** Stats summary for the city discovery page. */ -export interface PlatformStats { - totalCities: number; - totalLocations: number; - totalContributors: number; -} - -/** Compute platform-wide stats. */ -export function getPlatformStats(cities: LocalCity[], locations: LocalLocation[]): PlatformStats { - const contributors = new Set(); - for (const loc of locations) { - if (loc.createdBy) contributors.add(loc.createdBy); - } - for (const city of cities) { - if (city.createdBy) contributors.add(city.createdBy); - } - return { - totalCities: cities.length, - totalLocations: locations.length, - totalContributors: contributors.size, - }; -} diff --git a/apps/mana/apps/web/src/lib/modules/citycorners/stores/favorites.svelte.ts b/apps/mana/apps/web/src/lib/modules/citycorners/stores/favorites.svelte.ts deleted file mode 100644 index d85344c8b..000000000 --- a/apps/mana/apps/web/src/lib/modules/citycorners/stores/favorites.svelte.ts +++ /dev/null @@ -1,49 +0,0 @@ -/** - * Favorites Store — Mutation-Only - * - * All reads are handled by liveQuery (see queries.ts). - * This store only exposes mutations that write to IndexedDB. - */ - -import { db } from '$lib/data/database'; -import { CityCornersEvents } from '@mana/shared-utils/analytics'; -import type { LocalFavorite } from '../types'; - -let loading = $state(false); - -export const favoritesStore = { - get loading() { - return loading; - }, - - /** - * Toggle a favorite — writes to / removes from IndexedDB instantly. - */ - async toggle(locationId: string) { - loading = true; - - try { - const all = await db.table('ccFavorites').toArray(); - const existing = all.find((f) => f.locationId === locationId && !f.deletedAt); - - if (existing) { - await db.table('ccFavorites').update(existing.id, { - deletedAt: new Date().toISOString(), - }); - CityCornersEvents.favoriteToggled(false); - } else { - const newFav: LocalFavorite = { - id: crypto.randomUUID(), - locationId, - createdAt: new Date().toISOString(), - }; - await db.table('ccFavorites').add(newFav); - CityCornersEvents.favoriteToggled(true); - } - } catch (err) { - console.error('Failed to toggle favorite:', err); - } finally { - loading = false; - } - }, -}; diff --git a/apps/mana/apps/web/src/lib/modules/citycorners/stores/tags.svelte.ts b/apps/mana/apps/web/src/lib/modules/citycorners/stores/tags.svelte.ts deleted file mode 100644 index ec4209c58..000000000 --- a/apps/mana/apps/web/src/lib/modules/citycorners/stores/tags.svelte.ts +++ /dev/null @@ -1,19 +0,0 @@ -/** - * Ucitycorners Tags — Uses shared global tags + module-specific junction table. - */ - -import { db } from '$lib/data/database'; -import { createTagLinkOps } from '@mana/shared-stores'; - -export { - tagMutations, - useAllTags, - getTagById, - getTagsByIds, - getTagColor, -} from '@mana/shared-stores'; - -export const locationTagOps = createTagLinkOps({ - table: () => db.table('ccLocationTags'), - entityIdField: 'locationId', -}); diff --git a/apps/mana/apps/web/src/lib/modules/citycorners/types.ts b/apps/mana/apps/web/src/lib/modules/citycorners/types.ts deleted file mode 100644 index 3687d0637..000000000 --- a/apps/mana/apps/web/src/lib/modules/citycorners/types.ts +++ /dev/null @@ -1,73 +0,0 @@ -/** - * CityCorners module types for the unified app. - */ - -import type { BaseRecord } from '@mana/local-store'; - -export interface LocalCity extends BaseRecord { - name: string; - slug: string; - country: string; - state?: string | null; - description?: string | null; - latitude: number; - longitude: number; - imageUrl?: string | null; - createdBy?: string | null; -} - -export interface LocalLocation extends BaseRecord { - cityId: string; - name: string; - category: - | 'sight' - | 'restaurant' - | 'shop' - | 'museum' - | 'cafe' - | 'bar' - | 'park' - | 'beach' - | 'hotel' - | 'event_venue' - | 'viewpoint'; - description?: string | null; - address?: string | null; - latitude?: number | null; - longitude?: number | null; - imageUrl?: string | null; - timeline?: Array<{ year: number; event: string }> | null; - createdBy?: string | null; -} - -export interface LocalFavorite extends BaseRecord { - locationId: string; -} - -export const CATEGORY_KEYS = [ - 'sight', - 'restaurant', - 'shop', - 'museum', - 'cafe', - 'bar', - 'park', - 'beach', - 'hotel', - 'event_venue', - 'viewpoint', -] as const; - -export const CATEGORY_COLORS: Record = { - sight: '#2563eb', - restaurant: '#dc2626', - shop: '#16a34a', - museum: '#9333ea', - cafe: '#b45309', - bar: '#ea580c', - park: '#15803d', - beach: '#0891b2', - hotel: '#4f46e5', - event_venue: '#db2777', - viewpoint: '#0ea5e9', -}; diff --git a/apps/mana/apps/web/src/lib/modules/citycorners/utils/opening-hours.ts b/apps/mana/apps/web/src/lib/modules/citycorners/utils/opening-hours.ts deleted file mode 100644 index 53af03483..000000000 --- a/apps/mana/apps/web/src/lib/modules/citycorners/utils/opening-hours.ts +++ /dev/null @@ -1,52 +0,0 @@ -const DAY_KEYS = ['su', 'mo', 'tu', 'we', 'th', 'fr', 'sa'] as const; - -/** - * Check if a location is currently open based on its opening hours. - * Returns null if no opening hours are provided. - */ -export function isOpenNow(openingHours?: Record | null): boolean | null { - if (!openingHours || Object.keys(openingHours).length === 0) return null; - - const now = new Date(); - const dayKey = DAY_KEYS[now.getDay()]; - const hours = openingHours[dayKey]; - - if (!hours || hours === 'closed') return false; - - // Parse "HH:MM - HH:MM" format - const match = hours.match(/(\d{1,2}):(\d{2})\s*-\s*(\d{1,2}):(\d{2})/); - if (!match) return null; - - const [, openH, openM, closeH, closeM] = match; - const currentMinutes = now.getHours() * 60 + now.getMinutes(); - const openMinutes = parseInt(openH) * 60 + parseInt(openM); - const closeMinutes = parseInt(closeH) * 60 + parseInt(closeM); - - // Handle overnight hours (e.g., 22:00 - 03:00) - if (closeMinutes < openMinutes) { - return currentMinutes >= openMinutes || currentMinutes < closeMinutes; - } - - return currentMinutes >= openMinutes && currentMinutes < closeMinutes; -} - -/** - * Haversine formula — distance between two lat/lng points in meters. - */ -export function haversine(lat1: number, lon1: number, lat2: number, lon2: number): number { - const R = 6371000; - const dLat = ((lat2 - lat1) * Math.PI) / 180; - const dLon = ((lon2 - lon1) * Math.PI) / 180; - const a = - Math.sin(dLat / 2) ** 2 + - Math.cos((lat1 * Math.PI) / 180) * Math.cos((lat2 * Math.PI) / 180) * Math.sin(dLon / 2) ** 2; - return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); -} - -/** - * Format distance in meters to human-readable string. - */ -export function formatDistance(meters: number): string { - if (meters < 1000) return `${meters} m`; - return `${(meters / 1000).toFixed(1)} km`; -} diff --git a/apps/mana/apps/web/src/lib/modules/citycorners/views/DetailView.svelte b/apps/mana/apps/web/src/lib/modules/citycorners/views/DetailView.svelte deleted file mode 100644 index 560c56c8a..000000000 --- a/apps/mana/apps/web/src/lib/modules/citycorners/views/DetailView.svelte +++ /dev/null @@ -1,172 +0,0 @@ - - - - - detail.deleteWithUndo({ - label: 'Ort gelöscht', - delete: deleteLocation, - goBack, - })} -> - {#snippet body(location)} -
- - -
- -
-
- -
- -
-
- Adresse - -
-
- -
- - -
- -
- Erstellt: {formatDate(new Date(location.createdAt ?? ''))} - {#if location.updatedAt} - Bearbeitet: {formatDate(new Date(location.updatedAt))} - {/if} -
- {/snippet} -
- - diff --git a/apps/mana/apps/web/src/lib/modules/comic/components/CharacterPicker.svelte b/apps/mana/apps/web/src/lib/modules/comic/components/CharacterPicker.svelte index 9049db14f..9c322adbd 100644 --- a/apps/mana/apps/web/src/lib/modules/comic/components/CharacterPicker.svelte +++ b/apps/mana/apps/web/src/lib/modules/comic/components/CharacterPicker.svelte @@ -1,13 +1,7 @@ +
{#if body?.publicUrl}
- - - {#each garmentPicks as g (g.id)} - {@const mediaId = g.mediaIds[0]} -
- - - {g.name} - -
- {/each} - - - {#if canAddGarment} -
- - - {garmentIdsInValue.length}/{MAX_GARMENTS} - -
- {/if} - - {#if showGarmentPicker} -
-
-

- {$_('comic.picker.garment_picker_title')} -

- -
- {#if availableGarments.length === 0} -

- - {@html $_('comic.picker.garment_picker_empty_html')} -

- {:else} -
- {#each availableGarments as g (g.id)} - {@const mediaId = g.mediaIds[0]} - - {/each} -
- {/if} -
- {/if} - {#if !hasFace}
Gesamt: {day.value.drinks.total.count} Getraenke
- {:else if currentStep.config.source === 'nutrition_progress'} -
- Kalorien: {day.value.nutrition.calories.actual} / {day.value.nutrition.calories - .goal} kcal -
-
Mahlzeiten: {day.value.nutrition.meals}
{:else if currentStep.config.source === 'streaks'} {#each streaks.value as s}
diff --git a/apps/mana/apps/web/src/lib/modules/core/widgets/NutritionProgressWidget.svelte b/apps/mana/apps/web/src/lib/modules/core/widgets/NutritionProgressWidget.svelte deleted file mode 100644 index b0f7a884b..000000000 --- a/apps/mana/apps/web/src/lib/modules/core/widgets/NutritionProgressWidget.svelte +++ /dev/null @@ -1,176 +0,0 @@ - - - diff --git a/apps/mana/apps/web/src/lib/modules/core/widgets/WidgetGrid.svelte b/apps/mana/apps/web/src/lib/modules/core/widgets/WidgetGrid.svelte index af89246c2..04f32ee9d 100644 --- a/apps/mana/apps/web/src/lib/modules/core/widgets/WidgetGrid.svelte +++ b/apps/mana/apps/web/src/lib/modules/core/widgets/WidgetGrid.svelte @@ -12,7 +12,6 @@ import QuoteOfTheDayWidget from './QuoteOfTheDayWidget.svelte'; import ActiveTimerWidget from './ActiveTimerWidget.svelte'; import RecentChatsWidget from './RecentChatsWidget.svelte'; - import NutritionProgressWidget from './NutritionProgressWidget.svelte'; import PlantWateringWidget from './PlantWateringWidget.svelte'; import QuickActionsWidget from './QuickActionsWidget.svelte'; @@ -23,7 +22,6 @@ { id: 'quick-actions', component: QuickActionsWidget }, { id: 'recent-chats', component: RecentChatsWidget }, { id: 'recent-contacts', component: RecentContactsWidget }, - { id: 'nutrition-progress', component: NutritionProgressWidget }, { id: 'plant-watering', component: PlantWateringWidget }, { id: 'quote-of-the-day', component: QuoteOfTheDayWidget }, ]; diff --git a/apps/mana/apps/web/src/lib/modules/core/widgets/index.ts b/apps/mana/apps/web/src/lib/modules/core/widgets/index.ts index 5537153fa..431984e2e 100644 --- a/apps/mana/apps/web/src/lib/modules/core/widgets/index.ts +++ b/apps/mana/apps/web/src/lib/modules/core/widgets/index.ts @@ -12,7 +12,6 @@ export { default as RecentContactsWidget } from './RecentContactsWidget.svelte'; export { default as QuoteOfTheDayWidget } from './QuoteOfTheDayWidget.svelte'; export { default as ActiveTimerWidget } from './ActiveTimerWidget.svelte'; export { default as RecentChatsWidget } from './RecentChatsWidget.svelte'; -export { default as NutritionProgressWidget } from './NutritionProgressWidget.svelte'; export { default as PlantWateringWidget } from './PlantWateringWidget.svelte'; export { default as QuickActionsWidget } from './QuickActionsWidget.svelte'; export { default as WidgetGrid } from './WidgetGrid.svelte'; diff --git a/apps/mana/apps/web/src/lib/modules/food/ListView.svelte b/apps/mana/apps/web/src/lib/modules/food/ListView.svelte deleted file mode 100644 index 6d612d6db..000000000 --- a/apps/mana/apps/web/src/lib/modules/food/ListView.svelte +++ /dev/null @@ -1,282 +0,0 @@ - - - - - -
- m.id} emptyTitle="Noch keine Mahlzeiten heute"> - {#snippet toolbar()} - -
-

{Math.round(totalCalories)}

-

- {#if goal} - von {goal.dailyCalories} kcal - {:else} - kcal heute - {/if} -

- {#if goal} -
-
-
- {/if} -
- - -
- - -
- - {#if showPhotoMenu} -
- - -
- {/if} -
- - - - -
- {/snippet} - - {#snippet header()} - {Math.round(totalProtein)}g Protein · {todayMeals.length} Mahlzeiten - {/snippet} - - {#snippet item(meal)} - -
-
-
- {mealTypeLabels[meal.mealType] ?? meal.mealType} - {#if meal.inputType === 'photo'} - 📷 - {/if} -
-

{meal.description}

-
- {#if meal.photoThumbnailUrl || meal.photoUrl} - {meal.description} - {/if} - {#if meal.nutrition} - {Math.round(meal.nutrition.calories)} kcal - {/if} -
-
- {/snippet} -
-
diff --git a/apps/mana/apps/web/src/lib/modules/food/ai-schemas.test.ts b/apps/mana/apps/web/src/lib/modules/food/ai-schemas.test.ts deleted file mode 100644 index eceb72e59..000000000 --- a/apps/mana/apps/web/src/lib/modules/food/ai-schemas.test.ts +++ /dev/null @@ -1,131 +0,0 @@ -/** - * Sanity tests for the shared AI wire-format contract. - * - * The schemas themselves are mostly self-validating (Zod parses them at - * import time), so the focus here is the envelope contract: every - * frontend api.ts wraps its fetch result with the same shape, and a - * version drift between client and server should produce a clear, - * actionable error rather than silent corruption. - * - * Lives in the food module folder because food was the first - * consumer; the shared-types package itself has no test runner set up. - */ - -import { describe, expect, it } from 'vitest'; -import { - AI_SCHEMA_VERSION, - AiSchemaVersionMismatchError, - MealAnalysisSchema, - PlantIdentificationSchema, -} from '@mana/shared-types'; - -describe('AI_SCHEMA_VERSION', () => { - it('is a non-empty string constant', () => { - expect(typeof AI_SCHEMA_VERSION).toBe('string'); - expect(AI_SCHEMA_VERSION.length).toBeGreaterThan(0); - }); -}); - -describe('AiSchemaVersionMismatchError', () => { - it('captures both versions in the message', () => { - const err = new AiSchemaVersionMismatchError('99', '1' as typeof AI_SCHEMA_VERSION); - expect(err.message).toContain('99'); - expect(err.message).toContain('1'); - expect(err.received).toBe('99'); - expect(err.expected).toBe('1'); - }); - - it('defaults the expected version to AI_SCHEMA_VERSION', () => { - const err = new AiSchemaVersionMismatchError('42'); - expect(err.expected).toBe(AI_SCHEMA_VERSION); - }); - - it('is named so it can be discriminated in catch blocks', () => { - const err = new AiSchemaVersionMismatchError('99'); - expect(err.name).toBe('AiSchemaVersionMismatchError'); - expect(err).toBeInstanceOf(Error); - }); -}); - -describe('MealAnalysisSchema', () => { - const valid = { - foods: [{ name: 'Apfel', quantity: '1 Stück', calories: 95 }], - totalNutrition: { - calories: 95, - protein: 0.5, - carbohydrates: 25, - fat: 0.3, - fiber: 4.4, - sugar: 19, - }, - description: 'Ein mittelgroßer Apfel', - confidence: 0.92, - }; - - it('accepts a complete payload', () => { - const parsed = MealAnalysisSchema.parse(valid); - expect(parsed.foods).toHaveLength(1); - expect(parsed.totalNutrition.calories).toBe(95); - }); - - it('fills in default empty arrays for warnings/suggestions', () => { - const parsed = MealAnalysisSchema.parse(valid); - expect(parsed.warnings).toEqual([]); - expect(parsed.suggestions).toEqual([]); - }); - - it('rejects invalid confidence (out of [0,1])', () => { - expect(() => MealAnalysisSchema.parse({ ...valid, confidence: 1.5 })).toThrow(); - expect(() => MealAnalysisSchema.parse({ ...valid, confidence: -0.1 })).toThrow(); - }); - - it('rejects missing required nutrition fields', () => { - const broken = { - ...valid, - totalNutrition: { calories: 95 }, // missing protein, carbs, etc. - }; - expect(() => MealAnalysisSchema.parse(broken)).toThrow(); - }); - - it('allows foods without quantity/calories (model may not always estimate)', () => { - const minimal = { - ...valid, - foods: [{ name: 'Käse' }], - }; - const parsed = MealAnalysisSchema.parse(minimal); - expect(parsed.foods[0].name).toBe('Käse'); - expect(parsed.foods[0].quantity).toBeUndefined(); - }); -}); - -describe('PlantIdentificationSchema', () => { - it('accepts a complete payload', () => { - const parsed = PlantIdentificationSchema.parse({ - scientificName: 'Monstera deliciosa', - commonNames: ['Fensterblatt', 'Köstliches Fensterblatt'], - confidence: 0.88, - healthAssessment: 'Gesund', - wateringAdvice: 'Alle 7 Tage', - lightAdvice: 'Hell, indirektes Licht', - generalTips: ['Hohe Luftfeuchtigkeit bevorzugt'], - }); - expect(parsed.scientificName).toBe('Monstera deliciosa'); - expect(parsed.commonNames).toHaveLength(2); - }); - - it('fills in default empty arrays for commonNames/generalTips', () => { - const parsed = PlantIdentificationSchema.parse({}); - expect(parsed.commonNames).toEqual([]); - expect(parsed.generalTips).toEqual([]); - }); - - it('accepts an empty object — every field is optional by design', () => { - const parsed = PlantIdentificationSchema.parse({}); - expect(parsed.scientificName).toBeUndefined(); - expect(parsed.confidence).toBeUndefined(); - }); - - it('rejects out-of-range confidence', () => { - expect(() => PlantIdentificationSchema.parse({ confidence: 2 })).toThrow(); - }); -}); diff --git a/apps/mana/apps/web/src/lib/modules/food/api.ts b/apps/mana/apps/web/src/lib/modules/food/api.ts deleted file mode 100644 index 56f43b5d4..000000000 --- a/apps/mana/apps/web/src/lib/modules/food/api.ts +++ /dev/null @@ -1,111 +0,0 @@ -/** - * Food — server-only API client - * - * CRUD lives in IndexedDB + sync. This module talks to mana-api for the - * three server-only operations: photo upload (S3 via mana-media), AI - * meal analysis from a photo URL (Gemini Vision via mana-llm), and - * AI meal analysis from a text description. - */ - -import { authStore } from '$lib/stores/auth.svelte'; -import { getManaApiUrl } from '$lib/api/config'; -// Wire format is the single source of truth in @mana/shared-types — -// the backend validates AI responses with these same Zod schemas and -// wraps them in an AiResponseEnvelope { schemaVersion, data }. -import { - AI_SCHEMA_VERSION, - AiSchemaVersionMismatchError, - type AiResponseEnvelope, - type MealAnalysis, -} from '@mana/shared-types'; - -export type MealAnalysisResult = MealAnalysis; - -/** - * Decode an AI response envelope, asserting the schema version matches - * the one this client was compiled against. Throws if the server is on - * a different version (clears confusing "field is undefined" bugs in - * the wild — instead you get an actionable error in the network panel). - */ -function unwrapEnvelope(raw: unknown): T { - const env = raw as Partial> | null; - if (!env || typeof env !== 'object' || !('schemaVersion' in env)) { - throw new Error('AI response is not a versioned envelope'); - } - if (env.schemaVersion !== AI_SCHEMA_VERSION) { - throw new AiSchemaVersionMismatchError(String(env.schemaVersion)); - } - if (env.data === undefined) { - throw new Error('AI response envelope missing data field'); - } - return env.data as T; -} - -export interface UploadMealPhotoResult { - mediaId: string; - publicUrl: string; - thumbnailUrl: string; - storagePath: string; -} - -async function authHeader(): Promise> { - const token = await authStore.getValidToken(); - return token ? { Authorization: `Bearer ${token}` } : {}; -} - -/** Upload a meal photo to mana-api → S3 (mana-media). */ -export async function uploadMealPhoto(file: File): Promise { - const formData = new FormData(); - formData.append('file', file); - - const res = await fetch(`${getManaApiUrl()}/api/v1/food/photos/upload`, { - method: 'POST', - headers: await authHeader(), - body: formData, - }); - - if (!res.ok) { - const body = await res.text().catch(() => ''); - throw new Error(`Upload failed (${res.status}): ${body || res.statusText}`); - } - - return res.json() as Promise; -} - -/** Run Gemini Vision analysis on a previously uploaded photo URL. */ -export async function analyzeMealPhoto(photoUrl: string): Promise { - const res = await fetch(`${getManaApiUrl()}/api/v1/food/analysis/photo`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - ...(await authHeader()), - }, - body: JSON.stringify({ photoUrl }), - }); - - if (!res.ok) { - const body = await res.text().catch(() => ''); - throw new Error(`Analysis failed (${res.status}): ${body || res.statusText}`); - } - - return unwrapEnvelope(await res.json()); -} - -/** Run Gemini analysis on a free-text meal description. */ -export async function analyzeMealText(description: string): Promise { - const res = await fetch(`${getManaApiUrl()}/api/v1/food/analysis/text`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - ...(await authHeader()), - }, - body: JSON.stringify({ description }), - }); - - if (!res.ok) { - const body = await res.text().catch(() => ''); - throw new Error(`Analysis failed (${res.status}): ${body || res.statusText}`); - } - - return unwrapEnvelope(await res.json()); -} diff --git a/apps/mana/apps/web/src/lib/modules/food/collections.ts b/apps/mana/apps/web/src/lib/modules/food/collections.ts deleted file mode 100644 index 80507dd87..000000000 --- a/apps/mana/apps/web/src/lib/modules/food/collections.ts +++ /dev/null @@ -1,65 +0,0 @@ -/** - * Food module — collection accessors and guest seed data. - * - * Uses table names in the unified DB: meals, goals, foodFavorites. - */ - -import { db } from '$lib/data/database'; -import type { LocalMeal, LocalGoal, LocalFavorite } from './types'; - -// ─── Collection Accessors ────────────────────────────────── - -export const mealTable = db.table('meals'); -export const goalTable = db.table('goals'); -export const foodFavoriteTable = db.table('foodFavorites'); - -// ─── Guest Seed ──────────────────────────────────────────── - -const today = new Date().toISOString().split('T')[0]; - -export const FOOD_GUEST_SEED = { - meals: [ - { - id: 'meal-breakfast', - date: today, - mealType: 'breakfast' as const, - inputType: 'text' as const, - description: 'Haferflocken mit Banane und Honig', - confidence: 0.9, - nutrition: { - calories: 380, - protein: 10, - carbohydrates: 68, - fat: 8, - fiber: 6, - sugar: 24, - }, - }, - { - id: 'meal-lunch', - date: today, - mealType: 'lunch' as const, - inputType: 'text' as const, - description: 'Vollkorn-Sandwich mit Avocado und Ei', - confidence: 0.85, - nutrition: { - calories: 520, - protein: 22, - carbohydrates: 45, - fat: 28, - fiber: 8, - sugar: 4, - }, - }, - ], - goals: [ - { - id: 'default-goals', - dailyCalories: 2000, - dailyProtein: 60, - dailyCarbs: 250, - dailyFat: 65, - dailyFiber: 30, - }, - ], -}; diff --git a/apps/mana/apps/web/src/lib/modules/food/constants.ts b/apps/mana/apps/web/src/lib/modules/food/constants.ts deleted file mode 100644 index da2a9e574..000000000 --- a/apps/mana/apps/web/src/lib/modules/food/constants.ts +++ /dev/null @@ -1,44 +0,0 @@ -/** - * Food constants — meal labels, nutrient info, default values. - * - * Inlined from @food/shared to avoid the cross-app dependency. - */ - -// Default daily recommended values (based on 2000 kcal diet) -export const DEFAULT_DAILY_VALUES = { - calories: 2000, - protein: 50, - carbohydrates: 275, - fat: 78, - fiber: 28, - sugar: 50, -} as const; - -// Meal type labels -export const MEAL_TYPE_LABELS = { - breakfast: { de: 'Fruhstuck', en: 'Breakfast' }, - lunch: { de: 'Mittagessen', en: 'Lunch' }, - dinner: { de: 'Abendessen', en: 'Dinner' }, - snack: { de: 'Snack', en: 'Snack' }, -} as const; - -// Nutrient display info -export const NUTRIENT_INFO = { - calories: { label: 'Kalorien', unit: 'kcal', color: '#F59E0B' }, - protein: { label: 'Protein', unit: 'g', color: '#EF4444' }, - carbohydrates: { label: 'Kohlenhydrate', unit: 'g', color: '#3B82F6' }, - fat: { label: 'Fett', unit: 'g', color: '#8B5CF6' }, - fiber: { label: 'Ballaststoffe', unit: 'g', color: '#10B981' }, - sugar: { label: 'Zucker', unit: 'g', color: '#EC4899' }, -} as const; - -/** - * Suggest meal type based on current time of day. - */ -export function suggestMealType(): 'breakfast' | 'lunch' | 'dinner' | 'snack' { - const hour = new Date().getHours(); - if (hour >= 5 && hour < 11) return 'breakfast'; - if (hour >= 11 && hour < 14) return 'lunch'; - if (hour >= 17 && hour < 21) return 'dinner'; - return 'snack'; -} diff --git a/apps/mana/apps/web/src/lib/modules/food/index.ts b/apps/mana/apps/web/src/lib/modules/food/index.ts deleted file mode 100644 index 10eb0c7ab..000000000 --- a/apps/mana/apps/web/src/lib/modules/food/index.ts +++ /dev/null @@ -1,26 +0,0 @@ -/** - * Food module — barrel exports. - */ - -export { mealTable, goalTable, foodFavoriteTable, FOOD_GUEST_SEED } from './collections'; -export * from './queries'; -export { mealMutations, photoMutations, textAnalysisMutations } from './mutations'; -export type { - CreateMealDto, - CreateMealFromPhotoDto, - UpdateMealDto, - PhotoAnalysisOutcome, -} from './mutations'; -export type { UploadMealPhotoResult, MealAnalysisResult } from './api'; -export type { - LocalMeal, - LocalGoal, - LocalFavorite, - MealType, - InputType, - NutritionData, - AnalyzedFood, - NutritionProgress, - DailySummary, - MealWithNutrition, -} from './types'; diff --git a/apps/mana/apps/web/src/lib/modules/food/module.config.ts b/apps/mana/apps/web/src/lib/modules/food/module.config.ts deleted file mode 100644 index f0cfcafa7..000000000 --- a/apps/mana/apps/web/src/lib/modules/food/module.config.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { ModuleConfig } from '$lib/data/module-registry'; - -export const foodModuleConfig: ModuleConfig = { - appId: 'food', - tables: [ - { name: 'meals' }, - { name: 'goals' }, - { name: 'foodFavorites', syncName: 'favorites' }, - { name: 'mealTags' }, - ], -}; diff --git a/apps/mana/apps/web/src/lib/modules/food/mutations.test.ts b/apps/mana/apps/web/src/lib/modules/food/mutations.test.ts deleted file mode 100644 index fa60fb893..000000000 --- a/apps/mana/apps/web/src/lib/modules/food/mutations.test.ts +++ /dev/null @@ -1,293 +0,0 @@ -/** - * Integration tests for food mutations against a real (fake) IndexedDB. - * - * Focus areas: - * - mealMutations.create persists a text-only meal AND encrypts only the - * description + portionSize fields (registry allowlist). - * - mealMutations.createFromPhoto persists a photo-mode meal with - * photoMediaId / photoUrl plaintext, description encrypted. - * - mealMutations.delete soft-deletes via deletedAt. - * - The decrypted read-path round-trips back to the original plaintext. - */ - -import 'fake-indexeddb/auto'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -// Database hooks call into funnel-tracking + trigger registry on every write. -// They reach for browser-only globals (localStorage), so stub them the same -// way the plants tests do. -vi.mock('$lib/stores/funnel-tracking', () => ({ trackFirstContent: vi.fn() })); -vi.mock('$lib/triggers/registry', () => ({ fire: vi.fn() })); -vi.mock('$lib/triggers/inline-suggest', () => ({ - checkInlineSuggestion: vi.fn().mockResolvedValue(null), -})); - -import { db } from '$lib/data/database'; -import { setCurrentUserId } from '$lib/data/current-user'; -import { - generateMasterKey, - MemoryKeyProvider, - setKeyProvider, - decryptRecord, -} from '$lib/data/crypto'; -import { ENC_PREFIX } from '$lib/data/crypto/aes'; -import { mealMutations } from './mutations'; -import type { LocalMeal, NutritionData } from './types'; - -const meals = () => db.table('meals'); - -const sampleNutrition: NutritionData = { - calories: 520, - protein: 28, - carbohydrates: 60, - fat: 18, - fiber: 6, - sugar: 9, -}; - -beforeEach(async () => { - setCurrentUserId('test-user'); - const key = await generateMasterKey(); - const provider = new MemoryKeyProvider(); - provider.setKey(key); - setKeyProvider(provider); - - await meals().clear(); - await db.table('_pendingChanges').clear(); - await db.table('_activity').clear(); -}); - -describe('mealMutations.create (text-mode)', () => { - it('persists a meal row with the supplied fields', async () => { - await mealMutations.create({ - mealType: 'lunch', - description: 'Linseneintopf mit Brot', - nutrition: sampleNutrition, - }); - - const all = await meals().toArray(); - expect(all).toHaveLength(1); - expect(all[0].mealType).toBe('lunch'); - expect(all[0].inputType).toBe('text'); - expect(all[0].photoMediaId).toBeNull(); - expect(all[0].photoUrl).toBeNull(); - expect(all[0].confidence).toBe(0.8); - }); - - it('encrypts description but leaves nutrition + structural fields plaintext', async () => { - await mealMutations.create({ - mealType: 'breakfast', - description: 'Haferflocken mit Beeren', - nutrition: sampleNutrition, - }); - - const raw = (await meals().toArray())[0]; - // description should be a wrapped enc: blob, NOT the original string. - expect(typeof raw.description).toBe('string'); - expect(raw.description.startsWith(ENC_PREFIX)).toBe(true); - expect(raw.description).not.toContain('Haferflocken'); - // Plaintext fields stay readable. - expect(raw.mealType).toBe('breakfast'); - expect(raw.nutrition).toEqual(sampleNutrition); - expect(raw.confidence).toBe(0.8); - }); - - it('round-trips back to plaintext via decryptRecord', async () => { - await mealMutations.create({ - mealType: 'dinner', - description: 'Pasta mit Tomatensoße', - nutrition: sampleNutrition, - }); - - const raw = (await meals().toArray())[0]; - const decrypted = await decryptRecord('meals', { ...raw }); - expect(decrypted.description).toBe('Pasta mit Tomatensoße'); - expect(decrypted.nutrition).toEqual(sampleNutrition); - }); - - it('returns the plaintext snapshot, not the encrypted row', async () => { - const result = await mealMutations.create({ - mealType: 'snack', - description: 'Apfel', - nutrition: null, - }); - - expect(result.description).toBe('Apfel'); - expect(result.confidence).toBe(0); // no nutrition → 0 - expect(result.nutrition).toBeNull(); - }); - - it('defaults date to today when not provided', async () => { - const today = new Date().toISOString().split('T')[0]; - await mealMutations.create({ - mealType: 'lunch', - description: 'Salat', - }); - const stored = (await meals().toArray())[0]; - expect(stored.date).toBe(today); - }); - - it('respects an explicit date override', async () => { - await mealMutations.create({ - mealType: 'lunch', - description: 'Salat', - date: '2026-04-01', - }); - const stored = (await meals().toArray())[0]; - expect(stored.date).toBe('2026-04-01'); - }); -}); - -describe('mealMutations.createFromPhoto', () => { - it('persists with inputType=photo and the supplied media pointers', async () => { - await mealMutations.createFromPhoto({ - mealType: 'lunch', - description: 'KI: Pizza Margherita', - nutrition: sampleNutrition, - photoMediaId: 'media-abc', - photoUrl: 'https://media.example/abc.jpg', - confidence: 0.74, - }); - - const stored = (await meals().toArray())[0]; - expect(stored.inputType).toBe('photo'); - expect(stored.photoMediaId).toBe('media-abc'); - expect(stored.photoUrl).toBe('https://media.example/abc.jpg'); - expect(stored.confidence).toBe(0.74); - }); - - it('keeps photoMediaId and photoUrl plaintext (registry allowlist)', async () => { - await mealMutations.createFromPhoto({ - mealType: 'dinner', - description: 'KI: Sushi', - nutrition: sampleNutrition, - photoMediaId: 'media-xyz', - photoUrl: 'https://media.example/xyz.jpg', - confidence: 0.91, - }); - - const raw = (await meals().toArray())[0]; - // description encrypted, photo metadata is not. - expect(raw.description.startsWith(ENC_PREFIX)).toBe(true); - expect(raw.photoMediaId).toBe('media-xyz'); - expect(raw.photoUrl).toBe('https://media.example/xyz.jpg'); - expect(typeof raw.confidence).toBe('number'); - }); - - it('returns the plaintext snapshot with photo fields populated', async () => { - const result = await mealMutations.createFromPhoto({ - mealType: 'breakfast', - description: 'KI: Müsli mit Joghurt', - nutrition: sampleNutrition, - photoMediaId: 'media-1', - photoUrl: 'https://media.example/1.jpg', - confidence: 0.85, - }); - - expect(result.description).toBe('KI: Müsli mit Joghurt'); - expect(result.photoMediaId).toBe('media-1'); - expect(result.photoUrl).toBe('https://media.example/1.jpg'); - expect(result.inputType).toBe('photo'); - }); -}); - -describe('mealMutations.update', () => { - it('patches the description and re-encrypts it', async () => { - const created = await mealMutations.create({ - mealType: 'lunch', - description: 'Originaltext', - nutrition: sampleNutrition, - }); - - const result = await mealMutations.update(created.id, { - description: 'Geänderter Text', - }); - - expect(result.description).toBe('Geänderter Text'); - // And the row in Dexie has the new description, encrypted. - const raw = await meals().get(created.id); - expect(raw?.description.startsWith(ENC_PREFIX)).toBe(true); - expect(raw?.description).not.toContain('Geänderter'); - expect(raw?.description).not.toContain('Originaltext'); - }); - - it('patches numeric nutrition fields', async () => { - const created = await mealMutations.create({ - mealType: 'lunch', - description: 'Suppe', - nutrition: sampleNutrition, - }); - - const result = await mealMutations.update(created.id, { - nutrition: { ...sampleNutrition, calories: 999 }, - }); - - expect(result.nutrition?.calories).toBe(999); - // Plaintext on disk for fast aggregation. - const raw = await meals().get(created.id); - expect(raw?.nutrition?.calories).toBe(999); - }); - - it('only touches the fields supplied in the dto', async () => { - const created = await mealMutations.create({ - mealType: 'breakfast', - description: 'Müsli', - nutrition: sampleNutrition, - }); - - await mealMutations.update(created.id, { mealType: 'snack' }); - - const raw = await meals().get(created.id); - expect(raw?.mealType).toBe('snack'); - expect(raw?.nutrition).toEqual(sampleNutrition); - }); - - it('bumps updatedAt', async () => { - const created = await mealMutations.create({ - mealType: 'lunch', - description: 'Pasta', - }); - const before = (await meals().get(created.id))!.updatedAt; - await new Promise((r) => setTimeout(r, 5)); - await mealMutations.update(created.id, { description: 'Pasta mit Pesto' }); - const after = (await meals().get(created.id))!.updatedAt; - expect(after).not.toBe(before); - }); - - it('throws if the meal does not exist', async () => { - await expect(mealMutations.update('nonexistent-id', { description: 'foo' })).rejects.toThrow( - /disappeared/ - ); - }); -}); - -describe('mealMutations.delete', () => { - it('soft-deletes by stamping deletedAt + updatedAt', async () => { - const created = await mealMutations.create({ - mealType: 'lunch', - description: 'Reis mit Gemüse', - }); - - const beforeUpdate = (await meals().get(created.id))!.updatedAt; - // Make sure the updatedAt timestamp would actually change. - await new Promise((r) => setTimeout(r, 5)); - await mealMutations.delete(created.id); - - const stored = await meals().get(created.id); - expect(stored).toBeDefined(); - expect(stored?.deletedAt).toBeTruthy(); - expect(stored?.updatedAt).not.toBe(beforeUpdate); - }); - - it('does not physically remove the row (sync needs the tombstone)', async () => { - const created = await mealMutations.create({ - mealType: 'lunch', - description: 'Bowl', - }); - await mealMutations.delete(created.id); - - const all = await meals().toArray(); - expect(all).toHaveLength(1); - expect(all[0].deletedAt).toBeTruthy(); - }); -}); diff --git a/apps/mana/apps/web/src/lib/modules/food/mutations.ts b/apps/mana/apps/web/src/lib/modules/food/mutations.ts deleted file mode 100644 index 448edcf4f..000000000 --- a/apps/mana/apps/web/src/lib/modules/food/mutations.ts +++ /dev/null @@ -1,190 +0,0 @@ -/** - * Food — Mutation Helpers (Local-First) - * - * All writes go to IndexedDB first, sync handles the rest. Mutations throw - * on failure so UI callers can surface errors via toasts. Server-only - * operations (photo upload, AI analysis) live in ./api. - * - * Encryption pattern: build the LocalMeal as plaintext, shallow-clone it, - * run encryptRecord on the clone (mutates only the allow-listed fields — - * see crypto/registry.ts), then write the clone to Dexie. The original - * plaintext object is returned to the caller. nutrition / photoMediaId / - * photoUrl / confidence are NOT encrypted by design (see registry comment). - */ - -import { db } from '$lib/data/database'; -import { encryptRecord, decryptRecord } from '$lib/data/crypto'; -import { emitDomainEvent } from '$lib/data/events'; -import { - uploadMealPhoto, - analyzeMealPhoto, - analyzeMealText, - type MealAnalysisResult, - type UploadMealPhotoResult, -} from './api'; -import type { LocalMeal, MealType, NutritionData, AnalyzedFood } from './types'; - -export interface CreateMealDto { - mealType: MealType; - description: string; - nutrition?: NutritionData | null; - portionSize?: string | null; - date?: string; // YYYY-MM-DD, defaults to today -} - -export interface CreateMealFromPhotoDto extends CreateMealDto { - photoMediaId: string; - photoUrl: string; - photoThumbnailUrl?: string | null; - confidence: number; - foods?: AnalyzedFood[] | null; -} - -export interface UpdateMealDto { - mealType?: MealType; - description?: string; - nutrition?: NutritionData | null; - portionSize?: string | null; - date?: string; -} - -function todayStr(): string { - return new Date().toISOString().split('T')[0]; -} - -export const mealMutations = { - /** Persist a text-only meal entry. */ - async create(dto: CreateMealDto): Promise { - const now = new Date().toISOString(); - const row: LocalMeal = { - id: crypto.randomUUID(), - date: dto.date ?? todayStr(), - mealType: dto.mealType, - inputType: 'text', - description: dto.description.trim(), - portionSize: dto.portionSize ?? null, - confidence: dto.nutrition ? 0.8 : 0, - nutrition: dto.nutrition ?? null, - photoMediaId: null, - photoUrl: null, - photoThumbnailUrl: null, - foods: null, - createdAt: now, - }; - const encrypted: Record = { ...row }; - await encryptRecord('meals', encrypted); - await db.table('meals').add(encrypted); - emitDomainEvent('MealLogged', 'food', 'meals', row.id, { - mealId: row.id, - mealType: dto.mealType, - inputType: 'text', - description: dto.description, - calories: dto.nutrition?.calories, - protein: dto.nutrition?.protein, - date: row.date, - }); - return row; - }, - - /** Persist a meal entry that originated from a photo + AI analysis. */ - async createFromPhoto(dto: CreateMealFromPhotoDto): Promise { - const now = new Date().toISOString(); - const row: LocalMeal = { - id: crypto.randomUUID(), - date: dto.date ?? todayStr(), - mealType: dto.mealType, - inputType: 'photo', - description: dto.description.trim(), - portionSize: dto.portionSize ?? null, - confidence: dto.confidence, - nutrition: dto.nutrition ?? null, - photoMediaId: dto.photoMediaId, - photoUrl: dto.photoUrl, - photoThumbnailUrl: dto.photoThumbnailUrl ?? null, - foods: dto.foods ?? null, - createdAt: now, - }; - const encrypted: Record = { ...row }; - await encryptRecord('meals', encrypted); - await db.table('meals').add(encrypted); - emitDomainEvent('MealFromPhotoLogged', 'food', 'meals', row.id, { - mealId: row.id, - mealType: dto.mealType, - photoMediaId: dto.photoMediaId, - confidence: dto.confidence, - calories: dto.nutrition?.calories, - }); - return row; - }, - - /** - * Patch an existing meal. Only the provided fields are updated. - * Returns the decrypted snapshot after the write. - * - * Encryption note: we build a partial update object containing only - * the changed fields, run encryptRecord on it (mutates the encrypted - * fields in place), then Dexie .update() merges it into the row. The - * decryptRecord at the end reads back the full merged row from Dexie - * and decrypts it for the caller. - */ - async update(id: string, dto: UpdateMealDto): Promise { - const updateData: Record = {}; - if (dto.mealType !== undefined) updateData.mealType = dto.mealType; - if (dto.description !== undefined) updateData.description = dto.description.trim(); - if (dto.nutrition !== undefined) updateData.nutrition = dto.nutrition; - if (dto.portionSize !== undefined) updateData.portionSize = dto.portionSize; - if (dto.date !== undefined) updateData.date = dto.date; - - await encryptRecord('meals', updateData); - await db.table('meals').update(id, updateData); - - const updated = await db.table('meals').get(id); - if (!updated) throw new Error('Meal disappeared after update'); - return decryptRecord('meals', { ...updated }); - }, - - async delete(id: string): Promise { - const existing = await db.table('meals').get(id); - const now = new Date().toISOString(); - await db.table('meals').update(id, { deletedAt: now }); - emitDomainEvent('MealDeleted', 'food', 'meals', id, { - mealId: id, - mealType: existing?.mealType ?? '', - }); - }, -}; - -export interface PhotoAnalysisOutcome { - upload: UploadMealPhotoResult; - analysis: MealAnalysisResult; -} - -export const photoMutations = { - /** - * Upload a meal photo to mana-media and immediately run AI analysis on it. - * Does NOT persist a meal — the caller (usually the add page) shows the - * result to the user for review and then calls mealMutations.createFromPhoto. - */ - async uploadAndAnalyze(file: File): Promise { - const upload = await uploadMealPhoto(file); - const analysis = await analyzeMealPhoto(upload.publicUrl); - return { upload, analysis }; - }, - - /** Just upload a photo, no analysis. Useful when re-running analysis later. */ - async upload(file: File): Promise { - return uploadMealPhoto(file); - }, - - /** Re-run analysis on an already-uploaded photo URL. */ - async analyze(photoUrl: string): Promise { - return analyzeMealPhoto(photoUrl); - }, -}; - -export const textAnalysisMutations = { - /** Run Gemini analysis on a free-text meal description (no persistence). */ - async analyze(description: string): Promise { - return analyzeMealText(description); - }, -}; diff --git a/apps/mana/apps/web/src/lib/modules/food/queries.ts b/apps/mana/apps/web/src/lib/modules/food/queries.ts deleted file mode 100644 index 8e46178d9..000000000 --- a/apps/mana/apps/web/src/lib/modules/food/queries.ts +++ /dev/null @@ -1,170 +0,0 @@ -/** - * Reactive queries & pure helpers for Food — uses Dexie liveQuery on the unified DB. - * - * Uses table names: meals, goals, foodFavorites. - */ - -import { useScopedLiveQuery } from '$lib/data/scope/use-scoped-live-query.svelte'; -import { db } from '$lib/data/database'; -import { scopedForModule } from '$lib/data/scope'; -import { decryptRecords } from '$lib/data/crypto'; -import type { - LocalMeal, - LocalGoal, - LocalFavorite, - MealWithNutrition, - NutritionData, - NutritionProgress, - DailySummary, -} from './types'; -import { DEFAULT_DAILY_VALUES } from './constants'; - -// ─── Type Converters ─────────────────────────────────────── - -export function toMealWithNutrition(local: LocalMeal): MealWithNutrition { - return { - id: local.id, - date: local.date, - mealType: local.mealType, - inputType: local.inputType, - description: local.description, - portionSize: local.portionSize, - confidence: local.confidence, - nutrition: local.nutrition ?? null, - photoMediaId: local.photoMediaId ?? null, - photoUrl: local.photoUrl ?? null, - photoThumbnailUrl: local.photoThumbnailUrl ?? null, - foods: local.foods ?? null, - createdAt: local.createdAt ?? new Date().toISOString(), - }; -} - -// ─── Live Queries ────────────────────────────────────────── - -/** All meals, auto-updates on any change. */ -export function useAllMeals() { - return useScopedLiveQuery(async () => { - const locals = await scopedForModule('food', 'meals').toArray(); - const visible = locals.filter((m) => !m.deletedAt); - const decrypted = await decryptRecords('meals', visible); - return decrypted.map(toMealWithNutrition); - }, [] as MealWithNutrition[]); -} - -/** - * Look up a single meal by id and decrypt it. Used by the detail page, - * which inlines its own useScopedLiveQuery wrapper so the querier - * can capture the route param directly (matches plants DetailView pattern). - */ -export async function loadMealById(id: string): Promise { - const local = await db.table('meals').get(id); - if (!local || local.deletedAt) return null; - const [decrypted] = await decryptRecords('meals', [local]); - return decrypted ? toMealWithNutrition(decrypted) : null; -} - -/** All goals, auto-updates on any change. */ -export function useAllGoals() { - return useScopedLiveQuery(async () => { - const locals = await scopedForModule('food', 'goals').toArray(); - return locals.filter((g) => !g.deletedAt); - }, [] as LocalGoal[]); -} - -/** All favorites, auto-updates on any change. */ -export function useAllFavorites() { - return useScopedLiveQuery(async () => { - const locals = await scopedForModule('food', 'foodFavorites').toArray(); - return locals.filter((f) => !f.deletedAt); - }, [] as LocalFavorite[]); -} - -// ─── Pure Filter/Helper Functions (for $derived) ────────── - -/** Get today's date as YYYY-MM-DD string. */ -export function getTodayStr(): string { - return new Date().toISOString().split('T')[0]; -} - -/** Filter meals for a specific date string (YYYY-MM-DD). */ -export function filterByDate(meals: MealWithNutrition[], dateStr: string): MealWithNutrition[] { - return meals.filter((m) => { - const mealDate = String(m.date).split('T')[0]; - return mealDate === dateStr; - }); -} - -/** Filter meals for today, sorted by creation time. */ -export function getTodaysMeals(meals: MealWithNutrition[]): MealWithNutrition[] { - const today = getTodayStr(); - return filterByDate(meals, today).sort( - (a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() - ); -} - -/** Sum nutrition values across a set of meals. */ -export function sumNutrition(meals: MealWithNutrition[]): NutritionData { - return meals.reduce( - (acc, m) => ({ - calories: acc.calories + (m.nutrition?.calories || 0), - protein: acc.protein + (m.nutrition?.protein || 0), - carbohydrates: acc.carbohydrates + (m.nutrition?.carbohydrates || 0), - fat: acc.fat + (m.nutrition?.fat || 0), - fiber: acc.fiber + (m.nutrition?.fiber || 0), - sugar: acc.sugar + (m.nutrition?.sugar || 0), - }), - { calories: 0, protein: 0, carbohydrates: 0, fat: 0, fiber: 0, sugar: 0 } - ); -} - -/** Build a DailySummary from meals for a given date. */ -export function getDailySummary( - meals: MealWithNutrition[], - date?: Date, - goals?: LocalGoal | null -): DailySummary { - const dateStr = (date || new Date()).toISOString().split('T')[0]; - const dayMeals = filterByDate(meals, dateStr); - const totalNutrition = sumNutrition(dayMeals); - - const calorieTarget = goals?.dailyCalories ?? DEFAULT_DAILY_VALUES.calories; - const proteinTarget = goals?.dailyProtein ?? DEFAULT_DAILY_VALUES.protein; - const carbsTarget = goals?.dailyCarbs ?? DEFAULT_DAILY_VALUES.carbohydrates; - const fatTarget = goals?.dailyFat ?? DEFAULT_DAILY_VALUES.fat; - - const progress: NutritionProgress = { - calories: { - current: Math.round(totalNutrition.calories), - target: calorieTarget, - percentage: Math.min(Math.round((totalNutrition.calories / calorieTarget) * 100), 100), - }, - protein: { - current: Math.round(totalNutrition.protein), - target: proteinTarget, - percentage: Math.min(Math.round((totalNutrition.protein / proteinTarget) * 100), 100), - }, - carbs: { - current: Math.round(totalNutrition.carbohydrates), - target: carbsTarget, - percentage: Math.min(Math.round((totalNutrition.carbohydrates / carbsTarget) * 100), 100), - }, - fat: { - current: Math.round(totalNutrition.fat), - target: fatTarget, - percentage: Math.min(Math.round((totalNutrition.fat / fatTarget) * 100), 100), - }, - }; - - return { - date: new Date(dateStr), - meals: dayMeals, - totalNutrition, - progress, - }; -} - -/** Search meals by description. */ -export function searchMeals(meals: MealWithNutrition[], query: string): MealWithNutrition[] { - const q = query.toLowerCase(); - return meals.filter((m) => m.description?.toLowerCase().includes(q)); -} diff --git a/apps/mana/apps/web/src/lib/modules/food/quick-input-adapter.test.ts b/apps/mana/apps/web/src/lib/modules/food/quick-input-adapter.test.ts deleted file mode 100644 index c5c05fef3..000000000 --- a/apps/mana/apps/web/src/lib/modules/food/quick-input-adapter.test.ts +++ /dev/null @@ -1,109 +0,0 @@ -/** - * Unit tests for the meal-input parser used by the global quick-input - * bar. The parser is the only non-trivial logic in the adapter — the - * surrounding onSearch / onCreate hooks are thin wrappers over the - * already-tested mealMutations layer. - */ - -import { describe, expect, it, vi, afterEach } from 'vitest'; -import { parseMealInput } from './quick-input-adapter'; - -afterEach(() => { - vi.useRealTimers(); -}); - -function pinTime(hour: number) { - const date = new Date(2026, 3, 9, hour, 0, 0); // 2026-04-09 - vi.useFakeTimers(); - vi.setSystemTime(date); -} - -describe('parseMealInput', () => { - describe('explicit prefix', () => { - it('recognises German Frühstück prefix', () => { - const r = parseMealInput('Frühstück: Müsli mit Beeren'); - expect(r.mealType).toBe('breakfast'); - expect(r.description).toBe('Müsli mit Beeren'); - expect(r.hadExplicitPrefix).toBe(true); - }); - - it('recognises ASCII fruehstueck variant', () => { - const r = parseMealInput('fruehstueck: Toast'); - expect(r.mealType).toBe('breakfast'); - expect(r.description).toBe('Toast'); - }); - - it('recognises lunch and Mittagessen', () => { - expect(parseMealInput('lunch: Salat').mealType).toBe('lunch'); - expect(parseMealInput('Mittagessen: Suppe').mealType).toBe('lunch'); - expect(parseMealInput('mittag: Pasta').mealType).toBe('lunch'); - }); - - it('recognises dinner and Abendessen', () => { - expect(parseMealInput('dinner: Pizza').mealType).toBe('dinner'); - expect(parseMealInput('Abendessen: Reis').mealType).toBe('dinner'); - expect(parseMealInput('abend: Bowl').mealType).toBe('dinner'); - }); - - it('recognises snack and zwischendurch', () => { - expect(parseMealInput('snack: Apfel').mealType).toBe('snack'); - expect(parseMealInput('zwischendurch: Nüsse').mealType).toBe('snack'); - }); - - it('is case insensitive on the prefix', () => { - expect(parseMealInput('LUNCH: Burger').mealType).toBe('lunch'); - expect(parseMealInput('Snack: Banane').mealType).toBe('snack'); - }); - - it('trims whitespace around the prefix and description', () => { - const r = parseMealInput(' lunch : Pasta mit Pesto '); - expect(r.mealType).toBe('lunch'); - expect(r.description).toBe('Pasta mit Pesto'); - }); - }); - - describe('no prefix → time-of-day fallback', () => { - it('falls back to breakfast in the morning', () => { - pinTime(8); - const r = parseMealInput('Müsli'); - expect(r.mealType).toBe('breakfast'); - expect(r.description).toBe('Müsli'); - expect(r.hadExplicitPrefix).toBe(false); - }); - - it('falls back to lunch around noon', () => { - pinTime(13); - expect(parseMealInput('Salat').mealType).toBe('lunch'); - }); - - it('falls back to dinner in the evening', () => { - pinTime(19); - expect(parseMealInput('Pasta').mealType).toBe('dinner'); - }); - }); - - describe('edge cases', () => { - it('treats unknown prefix-like text as plain description', () => { - pinTime(13); - const r = parseMealInput('Hähnchen: gegrillt'); - expect(r.hadExplicitPrefix).toBe(false); - expect(r.description).toBe('Hähnchen: gegrillt'); - expect(r.mealType).toBe('lunch'); // from time-of-day - }); - - it('does not treat far-away colons as prefixes', () => { - pinTime(13); - const longPrefix = 'das ist eine sehr lange beschreibung: foo'; - const r = parseMealInput(longPrefix); - expect(r.hadExplicitPrefix).toBe(false); - expect(r.description).toBe(longPrefix); - }); - - it('rejects empty description after prefix', () => { - pinTime(13); - const r = parseMealInput('lunch: '); - expect(r.hadExplicitPrefix).toBe(false); - expect(r.description).toBe('lunch:'); - }); - }); -}); diff --git a/apps/mana/apps/web/src/lib/modules/food/quick-input-adapter.ts b/apps/mana/apps/web/src/lib/modules/food/quick-input-adapter.ts deleted file mode 100644 index 81e71198b..000000000 --- a/apps/mana/apps/web/src/lib/modules/food/quick-input-adapter.ts +++ /dev/null @@ -1,111 +0,0 @@ -/** - * Food QuickInputBar Adapter - * - * Parses meal-type prefixes from the query so power users can type - * "frühstück: müsli mit beeren" or "snack: apfel" without picking the - * type from the UI. Falls back to time-of-day suggestion when no - * prefix is given. - */ - -import type { InputBarAdapter } from '$lib/quick-input/types'; -import { db } from '$lib/data/database'; -import { decryptRecords } from '$lib/data/crypto'; -import { mealMutations } from './mutations'; -import { suggestMealType, MEAL_TYPE_LABELS } from './constants'; -import type { LocalMeal, MealType } from './types'; - -interface ParsedMealInput { - mealType: MealType; - description: string; - hadExplicitPrefix: boolean; -} - -// Map of recognised lowercase prefixes → MealType. Both German and -// English forms are accepted so the bar works regardless of UI locale. -const PREFIX_TO_MEALTYPE: Record = { - breakfast: 'breakfast', - frühstück: 'breakfast', - fruehstueck: 'breakfast', - lunch: 'lunch', - mittag: 'lunch', - mittagessen: 'lunch', - dinner: 'dinner', - abend: 'dinner', - abendessen: 'dinner', - snack: 'snack', - zwischendurch: 'snack', -}; - -export function parseMealInput(raw: string): ParsedMealInput { - const trimmed = raw.trim(); - const colonIdx = trimmed.indexOf(':'); - if (colonIdx > 0 && colonIdx < 20) { - const prefix = trimmed.slice(0, colonIdx).trim().toLowerCase(); - const rest = trimmed.slice(colonIdx + 1).trim(); - const mealType = PREFIX_TO_MEALTYPE[prefix]; - if (mealType && rest.length > 0) { - return { mealType, description: rest, hadExplicitPrefix: true }; - } - } - return { - mealType: suggestMealType(), - description: trimmed, - hadExplicitPrefix: false, - }; -} - -export function createAdapter(): InputBarAdapter { - return { - placeholder: 'Mahlzeit hinzufügen oder suchen…', - appIcon: 'food', - deferSearch: true, - createText: 'Hinzufügen', - emptyText: 'Keine Mahlzeiten gefunden', - - async onSearch(query) { - const q = query.toLowerCase(); - // `description` is encrypted on disk — decrypt before substring matching. - const raw = await db.table('meals').toArray(); - const visible = raw.filter((m) => !m.deletedAt); - const decrypted = await decryptRecords('meals', visible); - return decrypted - .filter((m) => m.description?.toLowerCase().includes(q)) - .sort((a, b) => (b.createdAt ?? '').localeCompare(a.createdAt ?? '')) - .slice(0, 10) - .map((m) => ({ - id: m.id, - title: m.description || '(ohne Beschreibung)', - subtitle: `${MEAL_TYPE_LABELS[m.mealType]?.de ?? m.mealType}${ - m.nutrition ? ` · ${Math.round(m.nutrition.calories)} kcal` : '' - }`, - })); - }, - - onSelect() { - // Selecting an existing meal is informational only — there's no - // edit-in-place from the global bar. The user can navigate to - // /food/[id] from the workbench card row instead. - }, - - onParseCreate(query) { - if (!query.trim()) return null; - const parsed = parseMealInput(query); - const typeLabel = MEAL_TYPE_LABELS[parsed.mealType]?.de ?? parsed.mealType; - return { - title: `"${parsed.description}" als ${typeLabel} hinzufügen`, - subtitle: parsed.hadExplicitPrefix - ? 'Mahlzeittyp aus Eingabe erkannt' - : 'Mahlzeittyp aus Tageszeit', - }; - }, - - async onCreate(query) { - if (!query.trim()) return; - const parsed = parseMealInput(query); - await mealMutations.create({ - mealType: parsed.mealType, - description: parsed.description, - }); - }, - }; -} diff --git a/apps/mana/apps/web/src/lib/modules/food/stores/tags.svelte.ts b/apps/mana/apps/web/src/lib/modules/food/stores/tags.svelte.ts deleted file mode 100644 index 0dd904a73..000000000 --- a/apps/mana/apps/web/src/lib/modules/food/stores/tags.svelte.ts +++ /dev/null @@ -1,19 +0,0 @@ -/** - * Ufood Tags — Uses shared global tags + module-specific junction table. - */ - -import { db } from '$lib/data/database'; -import { createTagLinkOps } from '@mana/shared-stores'; - -export { - tagMutations, - useAllTags, - getTagById, - getTagsByIds, - getTagColor, -} from '@mana/shared-stores'; - -export const mealTagOps = createTagLinkOps({ - table: () => db.table('mealTags'), - entityIdField: 'mealId', -}); diff --git a/apps/mana/apps/web/src/lib/modules/food/tools.ts b/apps/mana/apps/web/src/lib/modules/food/tools.ts deleted file mode 100644 index 6266014e1..000000000 --- a/apps/mana/apps/web/src/lib/modules/food/tools.ts +++ /dev/null @@ -1,80 +0,0 @@ -/** - * Food Tools — LLM-accessible operations for nutrition tracking. - */ - -import type { ModuleTool } from '$lib/data/tools/types'; -import { mealMutations } from './mutations'; -import { db } from '$lib/data/database'; -import { decryptRecords } from '$lib/data/crypto'; -import { getDailySummary, toMealWithNutrition } from './queries'; -import type { LocalMeal, MealType, LocalGoal } from './types'; - -export const foodTools: ModuleTool[] = [ - { - name: 'log_meal', - module: 'food', - description: 'Loggt eine Mahlzeit mit optionalen Naehrwerten', - parameters: [ - { - name: 'mealType', - type: 'string', - description: 'Art der Mahlzeit', - required: true, - enum: ['breakfast', 'lunch', 'dinner', 'snack'], - }, - { - name: 'description', - type: 'string', - description: 'Beschreibung der Mahlzeit', - required: true, - }, - { name: 'calories', type: 'number', description: 'Kalorien (kcal)', required: false }, - { name: 'protein', type: 'number', description: 'Protein (g)', required: false }, - ], - async execute(params) { - const nutrition = - params.calories || params.protein - ? { - calories: (params.calories as number) ?? 0, - protein: (params.protein as number) ?? 0, - carbohydrates: 0, - fat: 0, - fiber: 0, - sugar: 0, - } - : undefined; - - const meal = await mealMutations.create({ - mealType: params.mealType as MealType, - description: params.description as string, - nutrition, - }); - return { - success: true, - data: meal, - message: `${params.mealType} geloggt: "${params.description}"${nutrition ? ` (${nutrition.calories} kcal)` : ''}`, - }; - }, - }, - { - name: 'get_nutrition_summary', - module: 'food', - description: - 'Gibt die heutige Ernaehrungs-Zusammenfassung zurueck (Mahlzeiten, Kalorien, Protein)', - parameters: [], - async execute() { - const allMeals = await db.table('meals').toArray(); - const active = allMeals.filter((m) => !m.deletedAt); - const decrypted = await decryptRecords('meals', active); - const meals = decrypted.map(toMealWithNutrition); - const goals = await db.table('goals').toArray(); - const activeGoal = goals.find((g) => !g.deletedAt) ?? null; - const summary = getDailySummary(meals, new Date(), activeGoal); - return { - success: true, - data: summary, - message: `${summary.meals.length} Mahlzeiten, ${summary.progress.calories.current}/${summary.progress.calories.target} kcal`, - }; - }, - }, -]; diff --git a/apps/mana/apps/web/src/lib/modules/food/types.ts b/apps/mana/apps/web/src/lib/modules/food/types.ts deleted file mode 100644 index aa9fc4612..000000000 --- a/apps/mana/apps/web/src/lib/modules/food/types.ts +++ /dev/null @@ -1,80 +0,0 @@ -/** - * Food module types for the unified app. - * - * NutritionData and AnalyzedFood are re-exported from @mana/shared-types - * because they double as the AI wire format (same Zod schema lives in - * packages/shared-types/src/ai-schemas.ts and is used by the backend - * generateObject() validator). Module-local types like LocalMeal compose - * those shared shapes with storage-specific BaseRecord fields. - */ - -import type { BaseRecord } from '@mana/local-store'; -import type { NutritionData, AnalyzedFood } from '@mana/shared-types'; - -export type { NutritionData, AnalyzedFood }; - -export type MealType = 'breakfast' | 'lunch' | 'dinner' | 'snack'; -export type InputType = 'photo' | 'text'; - -export interface LocalMeal extends BaseRecord { - date: string; - mealType: MealType; - inputType: InputType; - description: string; - portionSize?: string | null; - confidence: number; - nutrition?: NutritionData | null; - photoMediaId?: string | null; - /** Full-resolution media URL — used in the detail view + lightbox. */ - photoUrl?: string | null; - /** Pre-generated thumbnail URL — used in list views to save bandwidth. */ - photoThumbnailUrl?: string | null; - /** AI-identified individual food items. Encrypted (food names = user content). */ - foods?: AnalyzedFood[] | null; -} - -export interface LocalGoal extends BaseRecord { - dailyCalories: number; - dailyProtein?: number | null; - dailyCarbs?: number | null; - dailyFat?: number | null; - dailyFiber?: number | null; -} - -export interface LocalFavorite extends BaseRecord { - name: string; - description: string; - mealType: MealType; - nutrition: NutritionData; - usageCount: number; -} - -export interface NutritionProgress { - calories: { current: number; target: number; percentage: number }; - protein: { current: number; target: number; percentage: number }; - carbs: { current: number; target: number; percentage: number }; - fat: { current: number; target: number; percentage: number }; -} - -export interface DailySummary { - date: Date; - meals: MealWithNutrition[]; - totalNutrition: NutritionData; - progress: NutritionProgress; -} - -export interface MealWithNutrition { - id: string; - date: string; - mealType: MealType; - inputType: InputType; - description: string; - portionSize?: string | null; - confidence: number; - nutrition: NutritionData | null; - photoMediaId?: string | null; - photoUrl?: string | null; - photoThumbnailUrl?: string | null; - foods?: AnalyzedFood[] | null; - createdAt: string; -} diff --git a/apps/mana/apps/web/src/lib/modules/goals/GoalEditor.svelte b/apps/mana/apps/web/src/lib/modules/goals/GoalEditor.svelte index bcc707c35..bedac672c 100644 --- a/apps/mana/apps/web/src/lib/modules/goals/GoalEditor.svelte +++ b/apps/mana/apps/web/src/lib/modules/goals/GoalEditor.svelte @@ -29,7 +29,6 @@ { value: 'TaskCompleted', label: $_('goals.editor.event_task_completed'), module: 'todo' }, { value: 'TaskCreated', label: $_('goals.editor.event_task_created'), module: 'todo' }, { value: 'DrinkLogged', label: $_('goals.editor.event_drink_logged'), module: 'drink' }, - { value: 'MealLogged', label: $_('goals.editor.event_meal_logged'), module: 'food' }, { value: 'HabitLogged', label: $_('goals.editor.event_habit_logged'), module: 'habits' }, { value: 'JournalEntryCreated', diff --git a/apps/mana/apps/web/src/lib/modules/goals/tools.ts b/apps/mana/apps/web/src/lib/modules/goals/tools.ts index aab04eca1..41ba8e1f3 100644 --- a/apps/mana/apps/web/src/lib/modules/goals/tools.ts +++ b/apps/mana/apps/web/src/lib/modules/goals/tools.ts @@ -152,13 +152,13 @@ export const goalsTools: ModuleTool[] = [ name: 'eventType', type: 'string', description: - 'Domain-Event zum Zaehlen (z.B. "DrinkLogged", "TaskCompleted", "MealLogged", "WorkoutFinished")', + 'Domain-Event zum Zaehlen (z.B. "DrinkLogged", "TaskCompleted", "WorkoutFinished")', required: false, }, { name: 'moduleId', type: 'string', - description: 'Zugehoeriges Modul (z.B. "drink", "todo", "food", "body")', + description: 'Zugehoeriges Modul (z.B. "drink", "todo", "body")', required: false, }, ], diff --git a/apps/mana/apps/web/src/lib/modules/lasts/inference/sources/places.ts b/apps/mana/apps/web/src/lib/modules/lasts/inference/sources/places.ts index 358e543cb..398a1ef12 100644 --- a/apps/mana/apps/web/src/lib/modules/lasts/inference/sources/places.ts +++ b/apps/mana/apps/web/src/lib/modules/lasts/inference/sources/places.ts @@ -21,7 +21,6 @@ import type { LastCategory } from '../../types'; const PLACE_CATEGORY_MAP: Record = { home: 'other', work: 'career', - food: 'culinary', shopping: 'other', transit: 'travel', leisure: 'culture', diff --git a/apps/mana/apps/web/src/lib/modules/lasts/types.ts b/apps/mana/apps/web/src/lib/modules/lasts/types.ts index 5077ab7b2..cbad41deb 100644 --- a/apps/mana/apps/web/src/lib/modules/lasts/types.ts +++ b/apps/mana/apps/web/src/lib/modules/lasts/types.ts @@ -30,7 +30,7 @@ export type WouldReclaim = 'no' | 'maybe' | 'yes'; */ export interface InferredFrom { tool: string; // e.g. 'suggest_lasts' - refTable: string; // 'places' | 'contacts' | 'food' | 'habits' | … + refTable: string; // 'places' | 'contacts' | 'habits' | … refId: string; frequencyHint?: string; // human-readable: '3x/week → 0 in 18mo' scannedAt: string; // ISO diff --git a/apps/mana/apps/web/src/lib/modules/moodlit/ListView.svelte b/apps/mana/apps/web/src/lib/modules/moodlit/ListView.svelte deleted file mode 100644 index 97e478a8b..000000000 --- a/apps/mana/apps/web/src/lib/modules/moodlit/ListView.svelte +++ /dev/null @@ -1,335 +0,0 @@ - - - - m.id} - emptyTitle="Keine Moods" - class="gap-4" - listClass="grid grid-cols-2 sm:grid-cols-3 gap-2 content-start" -> - {#snippet toolbar()} - -
- {moods.length} Moods - -
- - {#if creating} -
- -
-
- {newName || 'Vorschau'} -
- - - - - -
- {#each newColors as color, i} -
- { - newColors = newColors.map((c, j) => (j === i ? e.currentTarget.value : c)); - }} - class="h-8 w-8 cursor-pointer rounded-md border border-[hsl(var(--color-border))]" - /> - {#if newColors.length > 1} - - {/if} -
- {/each} - {#if newColors.length < 8} - - {/if} -
- - - - - - -
- {/if} - {/snippet} - - {#snippet item(mood)} - - {/snippet} -
- -{#if fullscreenMood} - (fullscreenMood = null)} /> -{/if} - - - - diff --git a/apps/mana/apps/web/src/lib/modules/moodlit/collections.ts b/apps/mana/apps/web/src/lib/modules/moodlit/collections.ts deleted file mode 100644 index d9fc48b0f..000000000 --- a/apps/mana/apps/web/src/lib/modules/moodlit/collections.ts +++ /dev/null @@ -1,88 +0,0 @@ -/** - * Moodlit module — collection accessors and guest seed data. - */ - -import { db } from '$lib/data/database'; -import type { LocalMood, LocalSequence } from './types'; - -// ─── Collection Accessors ────────────────────────────────── - -export const moodTable = db.table('moods'); -export const sequenceTable = db.table('sequences'); - -// ─── Guest Seed ──────────────────────────────────────────── - -export const MOODLIT_GUEST_SEED = { - moods: [ - { - id: 'fire', - name: 'Fire', - colors: ['#ff6b35', '#f72585', '#ff006e'], - animation: 'flicker', - isDefault: true, - }, - { - id: 'breath', - name: 'Breath', - colors: ['#4361ee', '#3a0ca3', '#7209b7'], - animation: 'pulse', - isDefault: true, - }, - { - id: 'northern-lights', - name: 'Northern Lights', - colors: ['#06d6a0', '#118ab2', '#073b4c'], - animation: 'aurora', - isDefault: true, - }, - { - id: 'sunset', - name: 'Sunset', - colors: ['#ff6b6b', '#feca57', '#ff9ff3'], - animation: 'gradient', - isDefault: true, - }, - { - id: 'ocean', - name: 'Ocean', - colors: ['#0077b6', '#00b4d8', '#90e0ef'], - animation: 'wave', - isDefault: true, - }, - { - id: 'forest', - name: 'Forest', - colors: ['#2d6a4f', '#40916c', '#52b788'], - animation: 'sway', - isDefault: true, - }, - { - id: 'lavender', - name: 'Lavender', - colors: ['#7b2cbf', '#9d4edd', '#c77dff'], - animation: 'pulse', - isDefault: true, - }, - { - id: 'thunder', - name: 'Thunder', - colors: ['#14213d', '#fca311', '#e5e5e5'], - animation: 'flash', - isDefault: true, - }, - ], - sequences: [ - { - id: 'evening-flow', - name: 'Evening Flow', - moodIds: ['sunset', 'lavender', 'breath'], - duration: 30, - }, - { - id: 'nature', - name: 'Nature', - moodIds: ['forest', 'ocean', 'northern-lights'], - duration: 45, - }, - ], -}; diff --git a/apps/mana/apps/web/src/lib/modules/moodlit/components/mood/CreateMoodDialog.svelte b/apps/mana/apps/web/src/lib/modules/moodlit/components/mood/CreateMoodDialog.svelte deleted file mode 100644 index 4aec56669..000000000 --- a/apps/mana/apps/web/src/lib/modules/moodlit/components/mood/CreateMoodDialog.svelte +++ /dev/null @@ -1,215 +0,0 @@ - - - - -{#if isOpen} - - - - -
- -
-{/if} diff --git a/apps/mana/apps/web/src/lib/modules/moodlit/components/mood/MoodCard.svelte b/apps/mana/apps/web/src/lib/modules/moodlit/components/mood/MoodCard.svelte deleted file mode 100644 index f0df8f5f2..000000000 --- a/apps/mana/apps/web/src/lib/modules/moodlit/components/mood/MoodCard.svelte +++ /dev/null @@ -1,189 +0,0 @@ - - - - {/if} -
- - - - {#if mood.isCustom} -
- - Custom - -
- {/if} - - - diff --git a/apps/mana/apps/web/src/lib/modules/moodlit/components/mood/MoodFullscreen.svelte b/apps/mana/apps/web/src/lib/modules/moodlit/components/mood/MoodFullscreen.svelte deleted file mode 100644 index aac2faf3f..000000000 --- a/apps/mana/apps/web/src/lib/modules/moodlit/components/mood/MoodFullscreen.svelte +++ /dev/null @@ -1,612 +0,0 @@ - - - - - - - diff --git a/apps/mana/apps/web/src/lib/modules/moodlit/default-moods.ts b/apps/mana/apps/web/src/lib/modules/moodlit/default-moods.ts deleted file mode 100644 index 6b34f5c40..000000000 --- a/apps/mana/apps/web/src/lib/modules/moodlit/default-moods.ts +++ /dev/null @@ -1,198 +0,0 @@ -/** - * 24 preset moods matching the mobile app. - */ - -import type { Mood } from './types'; - -export const DEFAULT_MOODS: Mood[] = [ - { - id: 'fire', - name: 'Fire', - colors: ['#ff6b35', '#ff4500', '#dc143c', '#8b0000'], - animationType: 'candle', - order: 0, - }, - { - id: 'breath', - name: 'Breath', - colors: ['#667eea', '#764ba2', '#f093fb'], - animationType: 'breath', - order: 1, - }, - { - id: 'northern-lights', - name: 'Northern Lights', - colors: ['#5f27cd', '#341f97', '#8854d0', '#a29bfe'], - animationType: 'wave', - order: 2, - }, - { - id: 'thunder', - name: 'Thunder', - colors: ['#2c3e50', '#34495e', '#ffffff', '#95a5a6'], - animationType: 'thunder', - order: 3, - }, - { - id: 'light', - name: 'Light', - colors: ['#ffffff', '#f8f9fa', '#e9ecef'], - animationType: 'gradient', - order: 4, - }, - { - id: 'flash', - name: 'Flash', - colors: ['#ffffff'], - animationType: 'flash', - order: 5, - }, - { - id: 'sos', - name: 'SOS', - colors: ['#ffffff'], - animationType: 'sos', - order: 6, - }, - { - id: 'ocean', - name: 'Ocean', - colors: ['#48dbfb', '#0abde3', '#10ac84', '#1dd1a1'], - animationType: 'wave', - order: 7, - }, - { - id: 'candle', - name: 'Candle', - colors: ['#ff9f43', '#ee5a24', '#ffeaa7'], - animationType: 'candle', - order: 8, - }, - { - id: 'police', - name: 'Police', - colors: ['#e74c3c', '#3498db'], - animationType: 'police', - order: 9, - }, - { - id: 'warning', - name: 'Warning', - colors: ['#f39c12', '#e67e22'], - animationType: 'warning', - order: 10, - }, - { - id: 'disco', - name: 'Disco', - colors: ['#e74c3c', '#9b59b6', '#3498db', '#1abc9c', '#f1c40f', '#e67e22'], - animationType: 'disco', - order: 11, - }, - { - id: 'sunrise', - name: 'Sunrise', - colors: ['#1a1a2e', '#16213e', '#e94560', '#ff6b6b', '#feca57', '#fffacd'], - animationType: 'sunrise', - order: 12, - }, - { - id: 'sunset', - name: 'Sunset', - colors: ['#ff6b6b', '#feca57', '#ff9ff3', '#a29bfe', '#341f97', '#1a1a2e'], - animationType: 'sunset', - order: 13, - }, - { - id: 'forest', - name: 'Forest', - colors: ['#27ae60', '#2ecc71', '#1abc9c', '#16a085'], - animationType: 'pulse', - order: 14, - }, - { - id: 'rave', - name: 'Rave', - colors: [ - '#ff0000', - '#ff00ff', - '#00ffff', - '#00ff00', - '#ffff00', - '#ff6600', - '#0066ff', - '#ff0066', - ], - animationType: 'rave', - order: 15, - }, - { - id: 'scanner', - name: 'Scanner', - colors: ['#e74c3c'], - animationType: 'scanner', - order: 16, - }, - { - id: 'matrix', - name: 'Matrix', - colors: ['#00ff00'], - animationType: 'matrix', - order: 17, - }, - { - id: 'lavender', - name: 'Lavender', - colors: ['#e6e6fa', '#dda0dd', '#da70d6', '#ba55d3'], - animationType: 'pulse', - order: 18, - }, - { - id: 'cherry-blossom', - name: 'Cherry Blossom', - colors: ['#ffb7c5', '#ff69b4', '#ff1493', '#db7093'], - animationType: 'wave', - order: 19, - }, - { - id: 'autumn', - name: 'Autumn', - colors: ['#d35400', '#e67e22', '#f39c12', '#c0392b'], - animationType: 'gradient', - order: 20, - }, - { - id: 'ice', - name: 'Ice', - colors: ['#74b9ff', '#0984e3', '#81ecec', '#00cec9'], - animationType: 'wave', - order: 21, - }, - { - id: 'romance', - name: 'Romance', - colors: ['#fd79a8', '#e84393', '#d63031', '#ff7675'], - animationType: 'pulse', - order: 22, - }, - { - id: 'midnight', - name: 'Midnight', - colors: ['#0c0c0c', '#1a1a2e', '#16213e', '#0f3460'], - animationType: 'breath', - order: 23, - }, -]; - -/** Get mood by ID. */ -export function getDefaultMoodById(id: string): Mood | undefined { - return DEFAULT_MOODS.find((m) => m.id === id); -} - -/** Get gradient CSS for a mood. */ -export function getMoodGradient(mood: Mood): string { - if (mood.colors.length === 1) { - return mood.colors[0]; - } - return `linear-gradient(135deg, ${mood.colors.join(', ')})`; -} diff --git a/apps/mana/apps/web/src/lib/modules/moodlit/index.ts b/apps/mana/apps/web/src/lib/modules/moodlit/index.ts deleted file mode 100644 index d9092eb67..000000000 --- a/apps/mana/apps/web/src/lib/modules/moodlit/index.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** - * Moodlit module — barrel exports. - */ - -export { moodsStore } from './stores/moods.svelte'; -export { sequencesStore } from './stores/sequences.svelte'; -export { useAllMoods, useAllSequences, getMoodGradient, getMoodById } from './queries'; -export { moodTable, sequenceTable, MOODLIT_GUEST_SEED } from './collections'; -export { DEFAULT_MOODS, getDefaultMoodById } from './default-moods'; -export type { - LocalMood, - LocalSequence, - Mood, - MoodSequence, - MoodSequenceItem, - MoodSettings, - AnimationType, - AnimationInfo, -} from './types'; -export { ANIMATIONS } from './types'; diff --git a/apps/mana/apps/web/src/lib/modules/moodlit/module.config.ts b/apps/mana/apps/web/src/lib/modules/moodlit/module.config.ts deleted file mode 100644 index b3c8ee4d0..000000000 --- a/apps/mana/apps/web/src/lib/modules/moodlit/module.config.ts +++ /dev/null @@ -1,6 +0,0 @@ -import type { ModuleConfig } from '$lib/data/module-registry'; - -export const moodlitModuleConfig: ModuleConfig = { - appId: 'moodlit', - tables: [{ name: 'moods' }, { name: 'sequences' }, { name: 'moodTags' }], -}; diff --git a/apps/mana/apps/web/src/lib/modules/moodlit/queries.ts b/apps/mana/apps/web/src/lib/modules/moodlit/queries.ts deleted file mode 100644 index e98ff9c46..000000000 --- a/apps/mana/apps/web/src/lib/modules/moodlit/queries.ts +++ /dev/null @@ -1,41 +0,0 @@ -/** - * Reactive queries for Moodlit — uses Dexie liveQuery on the unified DB. - */ - -import { useScopedLiveQuery } from '$lib/data/scope/use-scoped-live-query.svelte'; -import { db } from '$lib/data/database'; -import { scopedForModule } from '$lib/data/scope'; -import type { LocalMood, LocalSequence, Mood } from './types'; - -// ─── Helpers ────────────────────────────────────────────── - -/** Get gradient CSS for a mood. */ -export function getMoodGradient(mood: Mood): string { - if (mood.colors.length === 1) { - return mood.colors[0]; - } - return `linear-gradient(135deg, ${mood.colors.join(', ')})`; -} - -/** Get mood by ID from a list. */ -export function getMoodById(moods: Mood[], id: string): Mood | undefined { - return moods.find((m) => m.id === id); -} - -// ─── Live Queries ────────────────────────────────────────── - -/** All moods, sorted by name. */ -export function useAllMoods() { - return useScopedLiveQuery(async () => { - const locals = await scopedForModule('moodlit', 'moods').toArray(); - return locals.filter((m) => !m.deletedAt); - }, []); -} - -/** All sequences, sorted by name. */ -export function useAllSequences() { - return useScopedLiveQuery(async () => { - const locals = await scopedForModule('moodlit', 'sequences').toArray(); - return locals.filter((s) => !s.deletedAt); - }, []); -} diff --git a/apps/mana/apps/web/src/lib/modules/moodlit/stores/moods.svelte.ts b/apps/mana/apps/web/src/lib/modules/moodlit/stores/moods.svelte.ts deleted file mode 100644 index 9f05945df..000000000 --- a/apps/mana/apps/web/src/lib/modules/moodlit/stores/moods.svelte.ts +++ /dev/null @@ -1,151 +0,0 @@ -/** - * Moods mutation store — write operations for the unified DB. - */ - -import { db } from '$lib/data/database'; -import { MoodlitEvents } from '@mana/shared-utils/analytics'; -import { createBlock, updateBlock } from '$lib/data/time-blocks/service'; -import type { LocalMood } from '../types'; -import type { Mood, MoodSettings } from '../types'; - -// Default settings -const DEFAULT_SETTINGS: MoodSettings = { - animationSpeed: 'normal', - brightness: 100, - autoTimer: 0, - autoMoodSwitch: false, - autoMoodSwitchInterval: 5, -}; - -function createMoodsStore() { - let customMoods = $state([]); - let favoriteIds = $state([]); - let settings = $state({ ...DEFAULT_SETTINGS }); - let activeMood = $state(null); - let activeSessionBlockId = $state(null); - - // Load from localStorage on init - if (typeof window !== 'undefined') { - const saved = localStorage.getItem('moodlit-store'); - if (saved) { - try { - const parsed = JSON.parse(saved); - if (parsed.customMoods) customMoods = parsed.customMoods; - if (parsed.favoriteIds) favoriteIds = parsed.favoriteIds; - if (parsed.settings) settings = { ...DEFAULT_SETTINGS, ...parsed.settings }; - } catch (e) { - console.error('Failed to load moods from localStorage', e); - } - } - } - - function persist() { - if (typeof window !== 'undefined') { - localStorage.setItem('moodlit-store', JSON.stringify({ customMoods, favoriteIds, settings })); - } - } - - return { - get customMoods() { - return customMoods; - }, - get favoriteIds() { - return favoriteIds; - }, - get settings() { - return settings; - }, - get activeMood() { - return activeMood; - }, - - isFavorite(moodId: string): boolean { - return favoriteIds.includes(moodId); - }, - - setActiveMood(mood: Mood | null) { - activeMood = mood; - }, - - async startMoodSession(mood: Mood): Promise { - if (activeSessionBlockId) { - await updateBlock(activeSessionBlockId, { - endDate: new Date().toISOString(), - }); - } - activeSessionBlockId = await createBlock({ - startDate: new Date().toISOString(), - endDate: null, - isLive: true, - kind: 'logged', - type: 'mood', - sourceModule: 'moodlit', - sourceId: mood.id, - title: mood.name, - color: mood.colors?.[0] ?? '#fb923c', - }); - }, - - async endMoodSession(): Promise { - if (!activeSessionBlockId) return; - await updateBlock(activeSessionBlockId, { - endDate: new Date().toISOString(), - isLive: false, - }); - activeSessionBlockId = null; - }, - - addMood(mood: Mood) { - customMoods = [...customMoods, mood]; - persist(); - }, - - updateMood(id: string, updates: Partial) { - customMoods = customMoods.map((m) => (m.id === id ? { ...m, ...updates } : m)); - persist(); - }, - - removeMood(id: string) { - customMoods = customMoods.filter((m) => m.id !== id); - favoriteIds = favoriteIds.filter((fid) => fid !== id); - persist(); - }, - - toggleFavorite(moodId: string) { - if (favoriteIds.includes(moodId)) { - favoriteIds = favoriteIds.filter((id) => id !== moodId); - } else { - favoriteIds = [...favoriteIds, moodId]; - } - persist(); - MoodlitEvents.moodFavorited(); - }, - - updateSettings(updates: Partial) { - settings = { ...settings, ...updates }; - persist(); - }, - - // IndexedDB mutation methods - async createMood(data: { name: string; colors: string[]; animation: string }) { - await db.table('moods').add({ - id: crypto.randomUUID(), - name: data.name, - colors: data.colors, - animation: data.animation, - isDefault: false, - createdAt: new Date().toISOString(), - }); - MoodlitEvents.moodCreated(); - }, - - async deleteMood(id: string) { - await db.table('moods').update(id, { - deletedAt: new Date().toISOString(), - }); - MoodlitEvents.moodDeleted(); - }, - }; -} - -export const moodsStore = createMoodsStore(); diff --git a/apps/mana/apps/web/src/lib/modules/moodlit/stores/sequences.svelte.ts b/apps/mana/apps/web/src/lib/modules/moodlit/stores/sequences.svelte.ts deleted file mode 100644 index d03c569c4..000000000 --- a/apps/mana/apps/web/src/lib/modules/moodlit/stores/sequences.svelte.ts +++ /dev/null @@ -1,152 +0,0 @@ -/** - * Sequences mutation store — write operations for the unified DB. - */ - -import { db } from '$lib/data/database'; -import { MoodlitEvents } from '@mana/shared-utils/analytics'; -import type { LocalSequence } from '../types'; -import type { MoodSequence } from '../types'; - -// Default sequences for demo purposes -const DEFAULT_SEQUENCES: MoodSequence[] = [ - { - id: 'relaxation', - name: 'Relaxation', - items: [ - { moodId: 'breath', duration: 60 }, - { moodId: 'ocean', duration: 60 }, - { moodId: 'lavender', duration: 60 }, - ], - transitionDuration: 5, - }, - { - id: 'focus', - name: 'Focus Flow', - items: [ - { moodId: 'forest', duration: 120 }, - { moodId: 'northern-lights', duration: 120 }, - ], - transitionDuration: 10, - }, - { - id: 'party', - name: 'Party Mode', - items: [ - { moodId: 'disco', duration: 30 }, - { moodId: 'rave', duration: 30 }, - { moodId: 'police', duration: 15 }, - ], - transitionDuration: 2, - }, -]; - -function createSequencesStore() { - let sequences = $state([...DEFAULT_SEQUENCES]); - let customSequences = $state([]); - let activeSequence = $state(null); - let currentItemIndex = $state(0); - let isPlaying = $state(false); - - // Load from localStorage on init - if (typeof window !== 'undefined') { - const saved = localStorage.getItem('moodlit-sequences'); - if (saved) { - try { - const parsed = JSON.parse(saved); - if (parsed.customSequences) customSequences = parsed.customSequences; - } catch (e) { - console.error('Failed to load sequences from localStorage', e); - } - } - } - - function persist() { - if (typeof window !== 'undefined') { - localStorage.setItem('moodlit-sequences', JSON.stringify({ customSequences })); - } - } - - return { - get sequences() { - return [...sequences, ...customSequences]; - }, - get customSequences() { - return customSequences; - }, - get activeSequence() { - return activeSequence; - }, - get currentItemIndex() { - return currentItemIndex; - }, - get isPlaying() { - return isPlaying; - }, - - addSequence(sequence: MoodSequence) { - customSequences = [...customSequences, { ...sequence, isCustom: true }]; - persist(); - }, - - updateSequence(id: string, updates: Partial) { - customSequences = customSequences.map((s) => (s.id === id ? { ...s, ...updates } : s)); - persist(); - }, - - removeSequence(id: string) { - customSequences = customSequences.filter((s) => s.id !== id); - persist(); - }, - - playSequence(sequence: MoodSequence) { - activeSequence = sequence; - currentItemIndex = 0; - isPlaying = true; - }, - - stopSequence() { - activeSequence = null; - currentItemIndex = 0; - isPlaying = false; - }, - - nextItem() { - if (activeSequence && currentItemIndex < activeSequence.items.length - 1) { - currentItemIndex++; - } else { - currentItemIndex = 0; - } - }, - - previousItem() { - if (currentItemIndex > 0) { - currentItemIndex--; - } - }, - - togglePlay() { - isPlaying = !isPlaying; - }, - - // IndexedDB mutation methods - async createSequence(data: { name: string; moodIds: string[]; duration: number }) { - await db.table('sequences').add({ - id: crypto.randomUUID(), - name: data.name, - moodIds: data.moodIds, - duration: data.duration, - createdAt: new Date().toISOString(), - }); - MoodlitEvents.sequenceCreated(); - }, - - async deleteSequence(id: string) { - await db.table('sequences').update(id, { - deletedAt: new Date().toISOString(), - }); - MoodlitEvents.sequenceDeleted(); - }, - }; -} - -export const sequencesStore = createSequencesStore(); diff --git a/apps/mana/apps/web/src/lib/modules/moodlit/stores/tags.svelte.ts b/apps/mana/apps/web/src/lib/modules/moodlit/stores/tags.svelte.ts deleted file mode 100644 index bdba5d181..000000000 --- a/apps/mana/apps/web/src/lib/modules/moodlit/stores/tags.svelte.ts +++ /dev/null @@ -1,19 +0,0 @@ -/** - * Umoodlit Tags — Uses shared global tags + module-specific junction table. - */ - -import { db } from '$lib/data/database'; -import { createTagLinkOps } from '@mana/shared-stores'; - -export { - tagMutations, - useAllTags, - getTagById, - getTagsByIds, - getTagColor, -} from '@mana/shared-stores'; - -export const moodTagOps = createTagLinkOps({ - table: () => db.table('moodTags'), - entityIdField: 'moodId', -}); diff --git a/apps/mana/apps/web/src/lib/modules/moodlit/types.ts b/apps/mana/apps/web/src/lib/modules/moodlit/types.ts deleted file mode 100644 index 6a24a697e..000000000 --- a/apps/mana/apps/web/src/lib/modules/moodlit/types.ts +++ /dev/null @@ -1,109 +0,0 @@ -/** - * Moodlit module types for the unified app. - */ - -import type { BaseRecord } from '@mana/local-store'; - -// Animation types available for moods -export type AnimationType = - | 'gradient' - | 'pulse' - | 'wave' - | 'flash' - | 'sos' - | 'candle' - | 'police' - | 'warning' - | 'disco' - | 'thunder' - | 'breath' - | 'rave' - | 'scanner' - | 'matrix' - | 'sunrise' - | 'sunset' - | 'aurora' - | 'fire' - | 'ocean' - | 'forest' - | 'sparkle'; - -export interface LocalMood extends BaseRecord { - name: string; - colors: string[]; - animation: string; - isDefault: boolean; -} - -export interface LocalSequence extends BaseRecord { - name: string; - moodIds: string[]; - duration: number; -} - -// Mood interface (UI-facing) -export interface Mood { - id: string; - name: string; - colors: string[]; - animationType: AnimationType; - isCustom?: boolean; - order?: number; - createdAt?: string; -} - -// Sequence item (mood with duration) -export interface MoodSequenceItem { - moodId: string; - duration: number; // seconds -} - -// Mood sequence -export interface MoodSequence { - id: string; - name: string; - items: MoodSequenceItem[]; - transitionDuration: number; // 2, 5, or 10 seconds - isCustom?: boolean; -} - -// Settings -export interface MoodSettings { - animationSpeed: 'slow' | 'normal' | 'fast'; - brightness: number; // 0-100 - autoTimer: number; // 0 = off, else minutes - autoMoodSwitch: boolean; - autoMoodSwitchInterval: number; // minutes -} - -// Animation metadata for UI -export interface AnimationInfo { - id: AnimationType; - name: string; - description: string; -} - -// Available animations with descriptions -export const ANIMATIONS: AnimationInfo[] = [ - { id: 'gradient', name: 'Gradient', description: 'Smooth color gradient' }, - { id: 'pulse', name: 'Pulse', description: 'Breathing opacity effect' }, - { id: 'wave', name: 'Wave', description: 'Smooth wave oscillation' }, - { id: 'breath', name: 'Breath', description: '4-second breathing cycle' }, - { id: 'aurora', name: 'Aurora', description: 'Northern lights effect' }, - { id: 'fire', name: 'Fire', description: 'Warm flickering flames' }, - { id: 'candle', name: 'Candle', description: 'Soft candlelight flicker' }, - { id: 'ocean', name: 'Ocean', description: 'Calm ocean waves' }, - { id: 'forest', name: 'Forest', description: 'Peaceful forest ambience' }, - { id: 'thunder', name: 'Thunder', description: 'Random lightning flashes' }, - { id: 'sparkle', name: 'Sparkle', description: 'Twinkling star effect' }, - { id: 'sunrise', name: 'Sunrise', description: 'Slow warming colors' }, - { id: 'sunset', name: 'Sunset', description: 'Evening color transition' }, - { id: 'disco', name: 'Disco', description: 'Fast color cycling' }, - { id: 'rave', name: 'Rave', description: 'Very fast chaotic colors' }, - { id: 'scanner', name: 'Scanner', description: 'Light wave sweep' }, - { id: 'matrix', name: 'Matrix', description: 'Digital green blinking' }, - { id: 'flash', name: 'Flash', description: 'Quick white flashes' }, - { id: 'sos', name: 'SOS', description: 'Morse code pattern' }, - { id: 'police', name: 'Police', description: 'Red/blue alternating' }, - { id: 'warning', name: 'Warning', description: 'Blinking orange/yellow' }, -]; diff --git a/apps/mana/apps/web/src/lib/modules/myday/ListView.svelte b/apps/mana/apps/web/src/lib/modules/myday/ListView.svelte index 81abc2620..1b0765734 100644 --- a/apps/mana/apps/web/src/lib/modules/myday/ListView.svelte +++ b/apps/mana/apps/web/src/lib/modules/myday/ListView.svelte @@ -5,7 +5,7 @@ - -
- - - {#if showBanner} -
- {#if uploadPhase === 'success'} -
- {#if uploadedPreviewUrl} - - {:else} - - - - {/if} -
-

- - {$_('wardrobe.list_view.face_saved_title')} -

-

- {$_('wardrobe.list_view.face_saved_hint')} -

-
- -
- {:else} -
- -
-

- {$_('wardrobe.list_view.face_prompt_title')} -

-

- {$_('wardrobe.list_view.face_prompt_desc')} -

-
-
-
- - {#if uploadPhase === 'uploading'} - - - {$_('wardrobe.list_view.face_uploading_chip')} - - {/if} -
- {#if faceUploadError} - - {/if} - {/if} -
- {/if} - -
- {#if activeTab === 'garments'} - - {:else} - - {/if} -
-
- - diff --git a/apps/mana/apps/web/src/lib/modules/wardrobe/api/media-url.ts b/apps/mana/apps/web/src/lib/modules/wardrobe/api/media-url.ts deleted file mode 100644 index a84019fab..000000000 --- a/apps/mana/apps/web/src/lib/modules/wardrobe/api/media-url.ts +++ /dev/null @@ -1,26 +0,0 @@ -/** - * Tiny resolver mediaId → mana-media URL. Each module that renders - * mana-media files keeps its own — consolidating into a shared helper - * is a future-optimization, not an M2 task. Mirrors the inline pattern - * in wallpaper and invoices/pdf/logo. - */ - -import { browser } from '$app/environment'; - -function mediaBaseUrl(): string { - if (browser) { - const injected = (window as unknown as { __PUBLIC_MANA_MEDIA_URL__?: string }) - .__PUBLIC_MANA_MEDIA_URL__; - if (injected) return injected; - } - return import.meta.env.PUBLIC_MANA_MEDIA_URL ?? 'http://localhost:3015'; -} - -export function garmentPhotoUrl( - mediaId: string, - variant: 'original' | 'large' | 'medium' | 'thumb' = 'medium' -): string { - const base = mediaBaseUrl(); - if (variant === 'original') return `${base}/api/v1/media/${mediaId}/file`; - return `${base}/api/v1/media/${mediaId}/file/${variant}`; -} diff --git a/apps/mana/apps/web/src/lib/modules/wardrobe/api/try-on.ts b/apps/mana/apps/web/src/lib/modules/wardrobe/api/try-on.ts deleted file mode 100644 index 8eafa1e07..000000000 --- a/apps/mana/apps/web/src/lib/modules/wardrobe/api/try-on.ts +++ /dev/null @@ -1,343 +0,0 @@ -/** - * Try-On client. Composes a reference-based image-edit call against - * the M3 endpoint `/api/v1/picture/generate-with-reference` using the - * active space's face-ref + body-ref meImages plus every garment in - * the outfit, then persists the result into `picture.images` with the - * outfit's `wardrobeOutfitId` back-reference and updates the outfit's - * `lastTryOn` snapshot. - * - * The caller resolves the primaries reactively (via useImageByPrimary) - * and hands us the raw mediaIds — keeps this pure and testable. - * - * Plan: docs/plans/wardrobe-module.md M4. - */ - -import { getManaApiUrl } from '$lib/api/config'; -import { authStore } from '$lib/stores/auth.svelte'; -import { imagesStore } from '$lib/modules/picture/stores/images.svelte'; -import { wardrobeOutfitsStore } from '../stores/outfits.svelte'; -import { FACE_ONLY_CATEGORIES } from '../types'; -import type { Garment, GarmentCategory, Outfit } from '../types'; - -/** - * Models the Try-On flow can target. Each card on the shared picker - * maps to one of these. `openai/gpt-image-2` is the existing default - * (falls back to gpt-image-1 server-side when the user's org isn't - * verified yet — see picture/routes.ts). - */ -export type TryOnModel = - | 'openai/gpt-image-2' - | 'google/gemini-3-pro-image-preview' - | 'google/gemini-3.1-flash-image-preview'; - -export const DEFAULT_TRY_ON_MODEL: TryOnModel = 'openai/gpt-image-2'; - -/** Shared low-level POST to /generate-with-reference. Returns the first - * generated image's URL + mediaId + prompt + model — outfit and solo - * variants both go through here to keep the HTTP error matrix identical. - */ -async function callGenerateWithReference(opts: { - prompt: string; - referenceMediaIds: string[]; - quality: 'low' | 'medium' | 'high'; - size: TryOnSize; - model: TryOnModel; -}): Promise<{ imageUrl: string; mediaId: string; prompt: string; model: string }> { - const token = await authStore.getValidToken(); - const res = await fetch(`${getManaApiUrl()}/api/v1/picture/generate-with-reference`, { - method: 'POST', - headers: { - 'content-type': 'application/json', - ...(token ? { Authorization: `Bearer ${token}` } : {}), - }, - body: JSON.stringify({ - prompt: opts.prompt, - referenceMediaIds: opts.referenceMediaIds, - model: opts.model, - quality: opts.quality, - size: opts.size, - n: 1, - }), - }); - - if (!res.ok) { - const body = (await res.json().catch(() => ({}))) as { - error?: string; - detail?: string; - required?: number; - missing?: string[]; - }; - if (res.status === 402) { - throw new Error(`Nicht genug Credits (${body.required ?? '?'} erforderlich)`); - } - if (res.status === 404) { - throw new Error( - 'Ein oder mehrere Referenzbilder sind im Server-Ownership-Check durchgefallen — vermutlich sind Face/Body noch nicht in diesem Space hochgeladen.' - ); - } - // Surface the server's `detail` so the user sees *why* it failed - // (OpenAI policy rejection, media-download timeout, etc.) instead - // of a generic "Try-On fehlgeschlagen". Server always includes - // detail on 502 branches — see routes.ts generate-with-reference. - const label = body.error ?? `Try-On fehlgeschlagen (${res.status})`; - throw new Error(body.detail ? `${label}: ${body.detail}` : label); - } - - const data = (await res.json()) as { - images?: Array<{ imageUrl: string; mediaId?: string }>; - imageUrl?: string; - mediaId?: string; - prompt: string; - model: string; - }; - const first = - (data.images && data.images[0]) ?? - (data.imageUrl ? { imageUrl: data.imageUrl, mediaId: data.mediaId } : null); - if (!first?.imageUrl || !first.mediaId) { - throw new Error('Keine Bilder zurückgegeben'); - } - return { - imageUrl: first.imageUrl, - mediaId: first.mediaId, - prompt: data.prompt, - model: data.model, - }; -} - -function dimsForSize(size: TryOnSize): { width: number; height: number } { - if (size === '1024x1536') return { width: 1024, height: 1536 }; - if (size === '1536x1024') return { width: 1536, height: 1024 }; - return { width: 1024, height: 1024 }; -} - -export type TryOnSize = '1024x1024' | '1536x1024' | '1024x1536'; - -export interface RunOutfitTryOnParams { - outfit: Outfit; - garments: Garment[]; // resolved LocalWardrobeGarment rows, primary photos must exist - faceRefMediaId: string; - /** Optional — omit for accessory-only mode (glasses/jewelry/hat/accessory). */ - bodyRefMediaId?: string | null; - /** Image model that should produce the try-on. Defaults to - * DEFAULT_TRY_ON_MODEL if the caller doesn't pass one. */ - model?: TryOnModel; - /** Optional override; default is composed from the outfit meta. */ - prompt?: string; - /** `medium` balances FLUX-ish detail against credit cost (10c). */ - quality?: 'low' | 'medium' | 'high'; - size?: TryOnSize; -} - -export interface RunTryOnResult { - imageId: string; // picture.images.id (local UUID) - imageUrl: string; // mana-media URL - prompt: string; - model: string; -} - -/** - * True iff every garment in the outfit is in a face-only category — - * then the try-on renders just the face without a fullbody reference - * (better for brille/schmuck/hut zoom). - */ -export function isAccessoryOnlyOutfit(garments: Garment[]): boolean { - if (garments.length === 0) return false; - return garments.every((g) => FACE_ONLY_CATEGORIES.has(g.category as GarmentCategory)); -} - -function composeDefaultPrompt(outfit: Outfit, accessoryOnly: boolean): string { - if (accessoryOnly) { - // Portrait is the right framing for accessories — we want a tight - // head-and-shoulders shot so the Brille/Schmuck/Hut is legible. - return `Fotorealistisches Portrait von mir mit ${outfit.name}, frontal, studio-Licht, neutraler Hintergrund, Fokus auf dem Accessoire`; - } - // Explicitly ask for a full-body frame — gpt-image-1/2 otherwise read - // "Portrait" as "headshot" and crop to head-and-shoulders, ignoring - // the body-ref. "Ganzkörperfoto … stehend, von Kopf bis Fuß sichtbar" - // is the German idiom that reliably biases the model to full-length. - const occasionHint = outfit.occasion ? ` (Anlass: ${outfit.occasion})` : ''; - return `Fotorealistisches Ganzkörperfoto von mir im Outfit ${outfit.name}${occasionHint}, stehend, von Kopf bis Fuß sichtbar, natürliches Licht, neutraler Hintergrund`; -} - -export async function runOutfitTryOn(params: RunOutfitTryOnParams): Promise { - const { outfit, garments, faceRefMediaId, bodyRefMediaId, prompt, quality, size, model } = params; - - const garmentMediaIds = garments - .map((g) => g.mediaIds[0]) - .filter((id): id is string => Boolean(id)); - if (garmentMediaIds.length === 0) { - throw new Error('Outfit hat keine Kleidungsstücke mit Foto.'); - } - - const accessoryOnly = isAccessoryOnlyOutfit(garments); - const effectiveSize: TryOnSize = size ?? (accessoryOnly ? '1024x1024' : '1024x1536'); - const effectivePrompt = prompt?.trim() || composeDefaultPrompt(outfit, accessoryOnly); - - // Reference order: face first, then body (if present), then garments. - // gpt-image-2 weights early refs slightly more for identity — keeping - // face at [0] makes the person recognizable before the garments - // negotiate for attention. - const referenceMediaIds: string[] = [faceRefMediaId]; - if (!accessoryOnly && bodyRefMediaId) { - referenceMediaIds.push(bodyRefMediaId); - } - for (const id of garmentMediaIds) { - if (referenceMediaIds.length >= 8) break; // server caps at 8 - referenceMediaIds.push(id); - } - - const result = await callGenerateWithReference({ - prompt: effectivePrompt, - referenceMediaIds, - quality: quality ?? 'medium', - size: effectiveSize, - model: model ?? DEFAULT_TRY_ON_MODEL, - }); - - const now = new Date().toISOString(); - const localImageId = crypto.randomUUID(); - const dims = dimsForSize(effectiveSize); - - // Persist the generated image to the Picture gallery + tag it with - // the outfit's wardrobeOutfitId so the outfit detail's Try-On strip - // picks it up via the useOutfitTryOns liveQuery. - await imagesStore.insert({ - id: localImageId, - prompt: result.prompt, - negativePrompt: null, - model: result.model, - publicUrl: result.imageUrl, - storagePath: result.mediaId, - filename: `wardrobe-tryon-${Date.now()}.png`, - format: 'png', - width: dims.width, - height: dims.height, - visibility: 'private', - isFavorite: false, - downloadCount: 0, - generationMode: 'reference', - referenceImageIds: referenceMediaIds, - wardrobeOutfitId: outfit.id, - createdAt: now, - }); - - // Pin the snapshot on the outfit so OutfitCard + DetailOutfitView - // render the cover instantly without waiting for a full picture.images - // live-query round-trip. - await wardrobeOutfitsStore.setLastTryOn(outfit.id, { - imageId: localImageId, - imageUrl: result.imageUrl, - createdAt: now, - prompt: result.prompt, - model: result.model, - }); - - return { - imageId: localImageId, - imageUrl: result.imageUrl, - prompt: result.prompt, - model: result.model, - }; -} - -// ─── Solo-Garment Try-On ───────────────────────────────────────── - -export interface RunGarmentTryOnParams { - garment: Garment; - faceRefMediaId: string; - /** Null for accessory categories (glasses/jewelry/hat/accessory) — the - * category check happens here, callers don't need to pre-filter. */ - bodyRefMediaId?: string | null; - prompt?: string; - quality?: 'low' | 'medium' | 'high'; - /** Image model that should produce the try-on. Defaults to - * DEFAULT_TRY_ON_MODEL if the caller doesn't pass one. */ - model?: TryOnModel; -} - -/** True iff the garment category implies a face-only render. Exposed so - * the button can decide whether body-ref is required. */ -export function isAccessoryGarment(garment: Garment): boolean { - return FACE_ONLY_CATEGORIES.has(garment.category as GarmentCategory); -} - -function composeGarmentPrompt(garment: Garment, accessoryOnly: boolean): string { - if (accessoryOnly) { - // Headshot framing for face-only categories (Brille/Schmuck/Hut). - return `Fotorealistisches Portrait von mir mit ${garment.name}, frontal, studio-Licht, neutraler Hintergrund, Fokus auf dem Accessoire`; - } - // Explicit full-body framing — see composeDefaultPrompt for the - // rationale. "Portrait" biases to headshot, "Ganzkörperfoto" doesn't. - return `Fotorealistisches Ganzkörperfoto von mir im/in ${garment.name}, stehend, von Kopf bis Fuß sichtbar, natürliches Licht, neutraler Hintergrund`; -} - -/** - * Single-garment try-on — "nur diese Brille auf mein Gesicht" / "wie - * sähe dieses Hemd an mir aus". Writes a picture.images row WITHOUT a - * wardrobeOutfitId back-reference (it's not an outfit) and does NOT - * update any outfit's lastTryOn. The Picture gallery picks it up like - * any other generated image. - * - * Plan follow-up to docs/plans/wardrobe-module.md M4. - */ -export async function runGarmentTryOn(params: RunGarmentTryOnParams): Promise { - const { garment, faceRefMediaId, bodyRefMediaId, prompt, quality, model } = params; - - const garmentMediaId = garment.mediaIds[0]; - if (!garmentMediaId) { - throw new Error('Dieses Kleidungsstück hat kein Foto.'); - } - - const accessoryOnly = isAccessoryGarment(garment); - const effectiveSize: TryOnSize = accessoryOnly ? '1024x1024' : '1024x1536'; - const effectivePrompt = prompt?.trim() || composeGarmentPrompt(garment, accessoryOnly); - - const referenceMediaIds: string[] = [faceRefMediaId]; - if (!accessoryOnly && bodyRefMediaId) referenceMediaIds.push(bodyRefMediaId); - referenceMediaIds.push(garmentMediaId); - - const result = await callGenerateWithReference({ - prompt: effectivePrompt, - referenceMediaIds, - quality: quality ?? 'medium', - size: effectiveSize, - model: model ?? DEFAULT_TRY_ON_MODEL, - }); - - const now = new Date().toISOString(); - const localImageId = crypto.randomUUID(); - const dims = dimsForSize(effectiveSize); - - await imagesStore.insert({ - id: localImageId, - prompt: result.prompt, - negativePrompt: null, - model: result.model, - publicUrl: result.imageUrl, - storagePath: result.mediaId, - filename: `wardrobe-garment-tryon-${Date.now()}.png`, - format: 'png', - width: dims.width, - height: dims.height, - visibility: 'private', - isFavorite: false, - downloadCount: 0, - generationMode: 'reference', - referenceImageIds: referenceMediaIds, - // Symmetric back-ref to wardrobeOutfitId: a solo try-on belongs - // to exactly one garment, so the garment detail page can - // liveQuery `where wardrobeGarmentId === id`. Outfit and - // garment back-refs are mutually exclusive — this row is a - // garment try-on, not an outfit one. - wardrobeOutfitId: null, - wardrobeGarmentId: garment.id, - createdAt: now, - }); - - return { - imageId: localImageId, - imageUrl: result.imageUrl, - prompt: result.prompt, - model: result.model, - }; -} diff --git a/apps/mana/apps/web/src/lib/modules/wardrobe/api/upload.ts b/apps/mana/apps/web/src/lib/modules/wardrobe/api/upload.ts deleted file mode 100644 index b96edf7af..000000000 --- a/apps/mana/apps/web/src/lib/modules/wardrobe/api/upload.ts +++ /dev/null @@ -1,35 +0,0 @@ -/** - * Client for `POST /api/v1/wardrobe/garments/upload` — the M1 endpoint - * that wraps mana-media with `app='wardrobe'` tagging. Mirror of - * `profile/api/me-images.ts` — same shape, different endpoint, so later - * generalization is a rename away. - */ - -import { getManaApiUrl } from '$lib/api/config'; -import { authStore } from '$lib/stores/auth.svelte'; - -export interface UploadGarmentResult { - mediaId: string; - storagePath: string; - publicUrl: string; - thumbnailUrl?: string; -} - -export async function uploadGarmentPhoto(file: File): Promise { - const token = await authStore.getValidToken(); - const formData = new FormData(); - formData.append('file', file); - - const response = await fetch(`${getManaApiUrl()}/api/v1/wardrobe/garments/upload`, { - method: 'POST', - headers: token ? { Authorization: `Bearer ${token}` } : {}, - body: formData, - }); - - if (!response.ok) { - const body = await response.json().catch(() => ({ error: `HTTP ${response.status}` })); - throw new Error(body.error || `Upload failed (${response.status})`); - } - - return response.json() as Promise; -} diff --git a/apps/mana/apps/web/src/lib/modules/wardrobe/collections.ts b/apps/mana/apps/web/src/lib/modules/wardrobe/collections.ts deleted file mode 100644 index cbd4390a2..000000000 --- a/apps/mana/apps/web/src/lib/modules/wardrobe/collections.ts +++ /dev/null @@ -1,9 +0,0 @@ -/** - * Wardrobe module — Dexie table accessors. - */ - -import { db } from '$lib/data/database'; -import type { LocalWardrobeGarment, LocalWardrobeOutfit } from './types'; - -export const wardrobeGarmentsTable = db.table('wardrobeGarments'); -export const wardrobeOutfitsTable = db.table('wardrobeOutfits'); diff --git a/apps/mana/apps/web/src/lib/modules/wardrobe/components/CategoryTabs.svelte b/apps/mana/apps/web/src/lib/modules/wardrobe/components/CategoryTabs.svelte deleted file mode 100644 index 128bc3d8c..000000000 --- a/apps/mana/apps/web/src/lib/modules/wardrobe/components/CategoryTabs.svelte +++ /dev/null @@ -1,49 +0,0 @@ - - - -
- - {#each CATEGORY_ORDER as category} - - {/each} -
diff --git a/apps/mana/apps/web/src/lib/modules/wardrobe/components/GarmentCard.svelte b/apps/mana/apps/web/src/lib/modules/wardrobe/components/GarmentCard.svelte deleted file mode 100644 index 46164e07b..000000000 --- a/apps/mana/apps/web/src/lib/modules/wardrobe/components/GarmentCard.svelte +++ /dev/null @@ -1,56 +0,0 @@ - - - - -
- {#if primaryUrl} - {garment.name} - {/if} - - {$_('wardrobe.categories_singular.' + garment.category)} - - {#if garment.wearCount && garment.wearCount > 0} - - {garment.wearCount}× - - {/if} -
-
-

{garment.name}

- {#if garment.brand} -

{garment.brand}

- {/if} -
-
diff --git a/apps/mana/apps/web/src/lib/modules/wardrobe/components/GarmentForm.svelte b/apps/mana/apps/web/src/lib/modules/wardrobe/components/GarmentForm.svelte deleted file mode 100644 index f935c75a6..000000000 --- a/apps/mana/apps/web/src/lib/modules/wardrobe/components/GarmentForm.svelte +++ /dev/null @@ -1,267 +0,0 @@ - - - -
-
-
- - -
- -
- - -
- -
- - -
- -
- - -
- -
- - -
- -
- - -
- -
- - -
- -
- -
- - -
-
- -
- - -
-
- - {#if error} - - {/if} - -
- - {#if onCancel} - - {/if} -
-
diff --git a/apps/mana/apps/web/src/lib/modules/wardrobe/components/GarmentTryOnButton.svelte b/apps/mana/apps/web/src/lib/modules/wardrobe/components/GarmentTryOnButton.svelte deleted file mode 100644 index 86e55eb9f..000000000 --- a/apps/mana/apps/web/src/lib/modules/wardrobe/components/GarmentTryOnButton.svelte +++ /dev/null @@ -1,248 +0,0 @@ - - - -{#if !hasPhoto} -

- {$_('wardrobe.try_on_garment.no_photo')} -

-{:else if missingFace || missingBody} -
-
- -
-

{$_('wardrobe.try_on_garment.refs_title')}

-

- {accessoryOnly - ? $_('wardrobe.try_on_garment.refs_accessory') - : $_('wardrobe.try_on_garment.refs_full')} -

-
-
- - {#if missingFace} - handleRefUpload(files, 'face', 'face-ref')} - /> - {/if} - {#if missingBody} - handleRefUpload(files, 'fullbody', 'body-ref')} - /> - {/if} - - {#if uploadRefError} - - {/if} - -

- {$_('wardrobe.try_on_garment.refs_more_prefix')} - - {$_('wardrobe.try_on_garment.refs_link')} - . -

-
-{:else} -
- (selectedModel = next)} - disabled={running} - /> - - - - - {#if accessoryOnly} -

- - {$_('wardrobe.try_on_garment.accessory_hint')} -

- {/if} - - {#if activeSpace && activeSpace.type !== 'personal'} -

- - - {$_('wardrobe.try_on_garment.space_hint_prefix')} - ({activeSpace.name}){$_( - 'wardrobe.try_on_garment.space_hint_suffix' - )} - -

- {/if} - - {#if error} - - {/if} - - {#if lastResultUrl} -
-

- {$_('wardrobe.try_on_garment.result_label')} -

- {$_('wardrobe.try_on_garment.try_on_alt')} -

- {$_('wardrobe.try_on_garment.result_hint_prefix')} - {$_('wardrobe.try_on_garment.picture_gallery_link')} - {$_('wardrobe.try_on_garment.result_hint_suffix')} -

-
- {/if} -
-{/if} diff --git a/apps/mana/apps/web/src/lib/modules/wardrobe/components/OutfitCard.svelte b/apps/mana/apps/web/src/lib/modules/wardrobe/components/OutfitCard.svelte deleted file mode 100644 index f580c9d0d..000000000 --- a/apps/mana/apps/web/src/lib/modules/wardrobe/components/OutfitCard.svelte +++ /dev/null @@ -1,110 +0,0 @@ - - - - -
- {#if tryOnUrl} - {outfit.name} - - - {$_('wardrobe.outfit_card.try_on_badge')} - - {:else if resolvedGarments.length > 0} -
- {#each resolvedGarments as g} - {@const mediaId = g.mediaIds[0]} -
- {#if mediaId} - {g.name} - {/if} -
- {/each} - {#if resolvedGarments.length < 4} - {#each Array(4 - resolvedGarments.length) as _, i (i)} -
- {/each} - {/if} -
- {:else} -
- {$_('wardrobe.outfit_card.empty')} -
- {/if} - - {#if outfit.isFavorite} - - - - {/if} -
-
-

{outfit.name}

-
- {outfit.garmentIds.length} - {outfit.garmentIds.length === 1 - ? $_('wardrobe.piece_singular') - : $_('wardrobe.piece_plural')} - {#if outfit.occasion} - · - {$_('wardrobe.occasions.' + outfit.occasion)} - {/if} -
-
-
diff --git a/apps/mana/apps/web/src/lib/modules/wardrobe/components/OutfitComposer.svelte b/apps/mana/apps/web/src/lib/modules/wardrobe/components/OutfitComposer.svelte deleted file mode 100644 index 828c4541d..000000000 --- a/apps/mana/apps/web/src/lib/modules/wardrobe/components/OutfitComposer.svelte +++ /dev/null @@ -1,386 +0,0 @@ - - - -
- -
-
-

- {$_('wardrobe.composer.section_library')} -

- - {garments.length === 1 - ? $_('wardrobe.composer.available_singular', { values: { count: garments.length } }) - : $_('wardrobe.composer.available_plural', { values: { count: garments.length } })} - -
- - {#if garments.length === 0} -
-

{$_('wardrobe.composer.empty_title')}

-

- {$_('wardrobe.composer.empty_hint_prefix')} - {$_('wardrobe.composer.tab_garments_link')} - {$_('wardrobe.composer.empty_hint_suffix')} -

-
- {:else} -
- {#each CATEGORY_ORDER as category} - {@const list = grouped[category]} - {#if list.length > 0} -
-

- {$_('wardrobe.categories.' + category)} - · {list.length} -

-
- {#each list as g (g.id)} - {@const mediaId = g.mediaIds[0]} - {@const selected = selectedIds.includes(g.id)} - - {/each} -
-
- {/if} - {/each} -
- {/if} -
- - -
-
-
- - -
- -
- - -
- -
- - -
- -
- {$_('wardrobe.composer.label_seasons')} -
- {#each SEASON_KEYS as s} - {@const on = selectedSeasons.includes(s)} - - {/each} -
-
- -
- - -
-
- -
-
-

- {$_('wardrobe.composer.section_composition')} - - {selectedGarments.length === 1 - ? $_('wardrobe.composer.composition_count_singular', { - values: { count: selectedGarments.length }, - }) - : $_('wardrobe.composer.composition_count_plural', { - values: { count: selectedGarments.length }, - })} - -

-
- {#if selectedGarments.length === 0} -

- {$_('wardrobe.composer.composition_empty')} -

- {:else} -
- {#each selectedGarments as g (g.id)} - {@const mediaId = g.mediaIds[0]} -
- {#if mediaId} - {g.name} - {/if} - -
- {/each} -
- {/if} -
- - {#if error} - - {/if} - -
- - {#if onCancel} - - {/if} -
-
-
diff --git a/apps/mana/apps/web/src/lib/modules/wardrobe/components/TryOnButton.svelte b/apps/mana/apps/web/src/lib/modules/wardrobe/components/TryOnButton.svelte deleted file mode 100644 index 776958723..000000000 --- a/apps/mana/apps/web/src/lib/modules/wardrobe/components/TryOnButton.svelte +++ /dev/null @@ -1,235 +0,0 @@ - - - -{#if missingFace || missingBody} -
-
- -
-

{$_('wardrobe.try_on_outfit.refs_title')}

-

- {accessoryOnly - ? $_('wardrobe.try_on_outfit.refs_accessory') - : $_('wardrobe.try_on_outfit.refs_full')} -

-
-
- - {#if missingFace} - handleRefUpload(files, 'face', 'face-ref')} - /> - {/if} - {#if missingBody} - handleRefUpload(files, 'fullbody', 'body-ref')} - /> - {/if} - - {#if uploadRefError} - - {/if} - -

- {$_('wardrobe.try_on_outfit.refs_more_prefix')} - - {$_('wardrobe.try_on_outfit.refs_link')} - . -

-
-{:else} -
- (selectedModel = next)} - disabled={running} - /> - - - - - {#if accessoryOnly} -

- - {$_('wardrobe.try_on_outfit.accessory_hint')} -

- {:else if garments.length > 6} -

- - {$_('wardrobe.try_on_outfit.many_garments_hint', { values: { count: garments.length } })} -

- {/if} - - {#if activeSpace && activeSpace.type !== 'personal'} -

- - - {$_('wardrobe.try_on_outfit.space_hint_prefix')} - ({activeSpace.name}){$_( - 'wardrobe.try_on_outfit.space_hint_suffix' - )} - {#if activeSpace.type === 'family'} - {$_('wardrobe.try_on_outfit.family_hint')} - {/if} - -

- {/if} - - {#if error} - - {/if} -
-{/if} - -{#if !missingFace && !missingBody && garments.length === 0} -

- {$_('wardrobe.try_on_outfit.empty_garments', { - values: { category: $_('wardrobe.categories_singular.top') }, - })} -

-{/if} diff --git a/apps/mana/apps/web/src/lib/modules/wardrobe/components/TryOnModelPicker.svelte b/apps/mana/apps/web/src/lib/modules/wardrobe/components/TryOnModelPicker.svelte deleted file mode 100644 index 43bca8994..000000000 --- a/apps/mana/apps/web/src/lib/modules/wardrobe/components/TryOnModelPicker.svelte +++ /dev/null @@ -1,129 +0,0 @@ - - - -
- {$_('wardrobe.model_picker.legend')} -
- {#each OPTIONS as opt (opt.id)} - - {/each} -
-
- - diff --git a/apps/mana/apps/web/src/lib/modules/wardrobe/constants.ts b/apps/mana/apps/web/src/lib/modules/wardrobe/constants.ts deleted file mode 100644 index 612c370a9..000000000 --- a/apps/mana/apps/web/src/lib/modules/wardrobe/constants.ts +++ /dev/null @@ -1,82 +0,0 @@ -/** - * Wardrobe module — display constants. - * - * CATEGORY_ORDER is the order the tabs and pickers render in; CATEGORY_LABELS - * is the DE display label per category id. OCCASION_LABELS + SEASON_LABELS - * are consumed by the M3 outfit composer. - */ - -import type { GarmentCategory, OutfitOccasion, OutfitSeason } from './types'; - -export const CATEGORY_ORDER: readonly GarmentCategory[] = [ - 'top', - 'bottom', - 'dress', - 'outerwear', - 'shoes', - 'bag', - 'accessory', - 'glasses', - 'jewelry', - 'hat', - 'other', -] as const; - -export const CATEGORY_LABELS: Record = { - top: 'Oberteile', - bottom: 'Hosen', - dress: 'Kleider', - outerwear: 'Jacken', - shoes: 'Schuhe', - bag: 'Taschen', - accessory: 'Accessoires', - glasses: 'Brillen', - jewelry: 'Schmuck', - hat: 'Kopfbedeckung', - other: 'Sonstiges', -}; - -export const CATEGORY_LABELS_SINGULAR: Record = { - top: 'Oberteil', - bottom: 'Hose', - dress: 'Kleid', - outerwear: 'Jacke', - shoes: 'Schuh', - bag: 'Tasche', - accessory: 'Accessoire', - glasses: 'Brille', - jewelry: 'Schmuck', - hat: 'Kopfbedeckung', - other: 'Item', -}; - -export const OCCASION_ORDER: readonly OutfitOccasion[] = [ - 'casual', - 'work', - 'formal', - 'workout', - 'date', - 'travel', - 'event', - 'sleep', - 'other', -] as const; - -export const OCCASION_LABELS: Record = { - casual: 'Casual', - work: 'Arbeit', - formal: 'Festlich', - workout: 'Sport', - date: 'Date', - travel: 'Reise', - event: 'Event', - sleep: 'Schlafanzug', - other: 'Sonstiges', -}; - -export const SEASON_LABELS: Record = { - spring: 'Frühling', - summer: 'Sommer', - autumn: 'Herbst', - winter: 'Winter', -}; diff --git a/apps/mana/apps/web/src/lib/modules/wardrobe/module.config.ts b/apps/mana/apps/web/src/lib/modules/wardrobe/module.config.ts deleted file mode 100644 index 2d5b3b5a9..000000000 --- a/apps/mana/apps/web/src/lib/modules/wardrobe/module.config.ts +++ /dev/null @@ -1,6 +0,0 @@ -import type { ModuleConfig } from '$lib/data/module-registry'; - -export const wardrobeModuleConfig: ModuleConfig = { - appId: 'wardrobe', - tables: [{ name: 'wardrobeGarments' }, { name: 'wardrobeOutfits' }], -}; diff --git a/apps/mana/apps/web/src/lib/modules/wardrobe/queries.ts b/apps/mana/apps/web/src/lib/modules/wardrobe/queries.ts deleted file mode 100644 index 9c5bdf496..000000000 --- a/apps/mana/apps/web/src/lib/modules/wardrobe/queries.ts +++ /dev/null @@ -1,184 +0,0 @@ -/** - * Wardrobe module — read-side queries. - * - * All queries go through `scopedForModule` so switching the active - * space swaps the visible pool automatically (Brand-merch vs personal - * wardrobe vs family-wardrobe). Try-on history lives in `picture.images` - * filtered by `wardrobeOutfitId` — see useOutfitTryOns below. - */ - -import { useScopedLiveQuery } from '$lib/data/scope/use-scoped-live-query.svelte'; -import { scopedForModule } from '$lib/data/scope'; -import { decryptRecords } from '$lib/data/crypto'; -import type { LocalImage, Image } from '$lib/modules/picture/types'; -import { toImage } from '$lib/modules/picture/queries'; -import { - toGarment, - toOutfit, - type Garment, - type GarmentCategory, - type LocalWardrobeGarment, - type LocalWardrobeOutfit, - type Outfit, - type OutfitOccasion, -} from './types'; - -// ─── Garments ───────────────────────────────────────────────────── - -/** All non-archived, non-deleted garments in the active space. */ -export function useAllGarments() { - return useScopedLiveQuery(async () => { - const locals = await scopedForModule( - 'wardrobe', - 'wardrobeGarments' - ).toArray(); - const visible = locals - .filter((row) => !row.deletedAt && !row.isArchived) - .sort((a, b) => (b.createdAt ?? '').localeCompare(a.createdAt ?? '')); - const decrypted = await decryptRecords('wardrobeGarments', visible); - return decrypted.map(toGarment); - }, [] as Garment[]); -} - -/** Garments filtered by category — used by the Category-Tabs view. */ -export function useGarmentsByCategory(category: GarmentCategory) { - return useScopedLiveQuery(async () => { - const locals = await scopedForModule( - 'wardrobe', - 'wardrobeGarments' - ) - .and((row) => row.category === category) - .toArray(); - const visible = locals - .filter((row) => !row.deletedAt && !row.isArchived) - .sort((a, b) => (b.createdAt ?? '').localeCompare(a.createdAt ?? '')); - const decrypted = await decryptRecords('wardrobeGarments', visible); - return decrypted.map(toGarment); - }, [] as Garment[]); -} - -/** A single garment by id, live-updating. Null while loading / missing. */ -export function useGarment(id: string | null) { - return useScopedLiveQuery(async () => { - if (!id) return null; - const locals = await scopedForModule( - 'wardrobe', - 'wardrobeGarments' - ) - .and((row) => row.id === id) - .toArray(); - const [local] = locals; - if (!local || local.deletedAt) return null; - const [decrypted] = await decryptRecords('wardrobeGarments', [local]); - return toGarment(decrypted); - }, null); -} - -// ─── Outfits ────────────────────────────────────────────────────── - -/** All non-archived outfits in the active space. */ -export function useAllOutfits() { - return useScopedLiveQuery(async () => { - const locals = await scopedForModule( - 'wardrobe', - 'wardrobeOutfits' - ).toArray(); - const visible = locals - .filter((row) => !row.deletedAt && !row.isArchived) - .sort((a, b) => (b.createdAt ?? '').localeCompare(a.createdAt ?? '')); - const decrypted = await decryptRecords('wardrobeOutfits', visible); - return decrypted.map(toOutfit); - }, [] as Outfit[]); -} - -export function useOutfitsByOccasion(occasion: OutfitOccasion) { - return useScopedLiveQuery(async () => { - const locals = await scopedForModule('wardrobe', 'wardrobeOutfits') - .and((row) => row.occasion === occasion) - .toArray(); - const visible = locals - .filter((row) => !row.deletedAt && !row.isArchived) - .sort((a, b) => (b.createdAt ?? '').localeCompare(a.createdAt ?? '')); - const decrypted = await decryptRecords('wardrobeOutfits', visible); - return decrypted.map(toOutfit); - }, [] as Outfit[]); -} - -export function useOutfit(id: string | null) { - return useScopedLiveQuery(async () => { - if (!id) return null; - const locals = await scopedForModule('wardrobe', 'wardrobeOutfits') - .and((row) => row.id === id) - .toArray(); - const [local] = locals; - if (!local || local.deletedAt) return null; - const [decrypted] = await decryptRecords('wardrobeOutfits', [local]); - return toOutfit(decrypted); - }, null); -} - -/** - * Every try-on ever rendered for an outfit, newest first. Pulls from - * `picture.images` (filtered by `wardrobeOutfitId`) because that's where - * generations physically land — see plan decision #1 (kein drittes Table - * für Try-Ons). The outfit detail view renders these as a horizontal - * strip under the current composition. - */ -export function useOutfitTryOns(outfitId: string | null) { - return useScopedLiveQuery(async () => { - if (!outfitId) return []; - const locals = await scopedForModule('picture', 'images') - .and((row) => row.wardrobeOutfitId === outfitId) - .toArray(); - const visible = locals - .filter((row) => !row.deletedAt && !row.isArchived) - .sort((a, b) => (b.createdAt ?? '').localeCompare(a.createdAt ?? '')); - const decrypted = await decryptRecords('images', visible); - return decrypted.map(toImage); - }, [] as Image[]); -} - -/** - * Every solo try-on rendered for a single garment, newest first. - * Symmetric to `useOutfitTryOns`: filters `picture.images` by the - * `wardrobeGarmentId` FK that `runGarmentTryOn` stamps on write. Does - * NOT include outfit try-ons the garment participates in — see - * `useOutfitsContainingGarment` for the cross-outfit view. - */ -export function useGarmentSoloTryOns(garmentId: string | null) { - return useScopedLiveQuery(async () => { - if (!garmentId) return []; - const locals = await scopedForModule('picture', 'images') - .and((row) => row.wardrobeGarmentId === garmentId) - .toArray(); - const visible = locals - .filter((row) => !row.deletedAt && !row.isArchived) - .sort((a, b) => (b.createdAt ?? '').localeCompare(a.createdAt ?? '')); - const decrypted = await decryptRecords('images', visible); - return decrypted.map(toImage); - }, [] as Image[]); -} - -/** - * All outfits in the active space that include the given garment, - * newest first. Used on the garment detail page to render a "Teil - * dieser Outfits"-Strip so the user can jump back into any outfit - * they've built around the item — each outfit's own `lastTryOn` - * snapshot provides the thumbnail without another image lookup. - */ -export function useOutfitsContainingGarment(garmentId: string | null) { - return useScopedLiveQuery(async () => { - if (!garmentId) return []; - const locals = await scopedForModule( - 'wardrobe', - 'wardrobeOutfits' - ).toArray(); - const visible = locals - .filter( - (row) => !row.deletedAt && !row.isArchived && (row.garmentIds ?? []).includes(garmentId) - ) - .sort((a, b) => (b.createdAt ?? '').localeCompare(a.createdAt ?? '')); - const decrypted = await decryptRecords('wardrobeOutfits', visible); - return decrypted.map(toOutfit); - }, [] as Outfit[]); -} diff --git a/apps/mana/apps/web/src/lib/modules/wardrobe/stores/garments.svelte.ts b/apps/mana/apps/web/src/lib/modules/wardrobe/stores/garments.svelte.ts deleted file mode 100644 index 3ba4a4e89..000000000 --- a/apps/mana/apps/web/src/lib/modules/wardrobe/stores/garments.svelte.ts +++ /dev/null @@ -1,122 +0,0 @@ -/** - * Garments store — mutation-only service. - * - * Reads happen via `queries.ts`; this module owns the write path so - * encryption + domain events stay in one place. The Dexie creating-hook - * stamps `spaceId`, `authorId`, `visibility` automatically — wardrobe - * is NOT in USER_LEVEL_TABLES. - */ - -import { encryptRecord } from '$lib/data/crypto'; -import { emitDomainEvent } from '$lib/data/events'; -import { wardrobeGarmentsTable } from '../collections'; -import { toGarment } from '../types'; -import type { Garment, GarmentCategory, LocalWardrobeGarment } from '../types'; - -export interface CreateGarmentInput { - name: string; - category: GarmentCategory; - mediaIds: string[]; - brand?: string | null; - color?: string | null; - size?: string | null; - material?: string | null; - tags?: string[]; - notes?: string | null; - purchasedAt?: string | null; - priceCents?: number | null; - currency?: string | null; -} - -export const wardrobeGarmentsStore = { - async createGarment(input: CreateGarmentInput): Promise { - if (input.mediaIds.length === 0) { - throw new Error('Garment needs at least one photo'); - } - const newLocal: LocalWardrobeGarment = { - id: crypto.randomUUID(), - name: input.name, - category: input.category, - mediaIds: input.mediaIds, - brand: input.brand ?? null, - color: input.color ?? null, - size: input.size ?? null, - material: input.material ?? null, - tags: input.tags ?? [], - notes: input.notes ?? null, - purchasedAt: input.purchasedAt ?? null, - priceCents: input.priceCents ?? null, - currency: input.currency ?? null, - wearCount: 0, - lastWornAt: null, - }; - const snapshot = toGarment({ ...newLocal }); - await encryptRecord('wardrobeGarments', newLocal); - await wardrobeGarmentsTable.add(newLocal); - emitDomainEvent('WardrobeGarmentAdded', 'wardrobe', 'wardrobeGarments', newLocal.id, { - garmentId: newLocal.id, - category: input.category, - }); - return snapshot; - }, - - async updateGarment( - id: string, - patch: Partial< - Pick< - LocalWardrobeGarment, - | 'name' - | 'category' - | 'mediaIds' - | 'brand' - | 'color' - | 'size' - | 'material' - | 'tags' - | 'notes' - | 'purchasedAt' - | 'priceCents' - | 'currency' - > - > - ): Promise { - const wrapped: Partial = { ...patch }; - await encryptRecord('wardrobeGarments', wrapped); - await wardrobeGarmentsTable.update(id, wrapped); - }, - - /** - * Mark a garment as worn today. Bumps the wear count + stamps - * `lastWornAt`. The UI surfaces this as a one-tap button in the - * detail view; M7 adds it to the card too. - */ - async markWornToday(id: string): Promise { - const existing = await wardrobeGarmentsTable.get(id); - if (!existing) return; - const today = new Date().toISOString().slice(0, 10); - await wardrobeGarmentsTable.update(id, { - wearCount: (existing.wearCount ?? 0) + 1, - lastWornAt: today, - }); - emitDomainEvent('WardrobeGarmentWorn', 'wardrobe', 'wardrobeGarments', id, { - garmentId: id, - wearCount: (existing.wearCount ?? 0) + 1, - }); - }, - - async archiveGarment(id: string, archived: boolean): Promise { - await wardrobeGarmentsTable.update(id, { - isArchived: archived, - }); - }, - - async deleteGarment(id: string): Promise { - const nowIso = new Date().toISOString(); - await wardrobeGarmentsTable.update(id, { - deletedAt: nowIso, - }); - emitDomainEvent('WardrobeGarmentDeleted', 'wardrobe', 'wardrobeGarments', id, { - garmentId: id, - }); - }, -}; diff --git a/apps/mana/apps/web/src/lib/modules/wardrobe/stores/outfits.svelte.ts b/apps/mana/apps/web/src/lib/modules/wardrobe/stores/outfits.svelte.ts deleted file mode 100644 index 005dc8f72..000000000 --- a/apps/mana/apps/web/src/lib/modules/wardrobe/stores/outfits.svelte.ts +++ /dev/null @@ -1,157 +0,0 @@ -/** - * Outfits store — mutation-only service. - * - * Outfits reference garments by id (plaintext array on the row). Try-On - * results are stored in `picture.images` with `wardrobeOutfitId` back- - * reference — the `lastTryOn` snapshot here is just a convenience pointer - * so the outfit card can render the latest preview without a join query. - */ - -import { encryptRecord } from '$lib/data/crypto'; -import { emitDomainEvent } from '$lib/data/events'; -import { getActiveSpace } from '$lib/data/scope'; -import { getEffectiveUserId } from '$lib/data/current-user'; -import { - defaultVisibilityFor, - generateUnlistedToken, - type VisibilityLevel, -} from '@mana/shared-privacy'; -import { wardrobeOutfitsTable } from '../collections'; -import { toOutfit } from '../types'; -import type { - LocalWardrobeOutfit, - Outfit, - OutfitOccasion, - OutfitSeason, - OutfitTryOn, -} from '../types'; - -export interface CreateOutfitInput { - name: string; - garmentIds: string[]; - description?: string | null; - occasion?: OutfitOccasion | null; - season?: OutfitSeason[]; - tags?: string[]; - isFavorite?: boolean; -} - -export const wardrobeOutfitsStore = { - async createOutfit(input: CreateOutfitInput): Promise { - if (input.garmentIds.length === 0) { - throw new Error('Outfit needs at least one garment'); - } - const newLocal: LocalWardrobeOutfit = { - id: crypto.randomUUID(), - name: input.name, - description: input.description ?? null, - garmentIds: input.garmentIds, - occasion: input.occasion ?? null, - season: input.season, - tags: input.tags ?? [], - isFavorite: input.isFavorite ?? false, - visibility: defaultVisibilityFor(getActiveSpace()?.type), - }; - const snapshot = toOutfit({ ...newLocal }); - await encryptRecord('wardrobeOutfits', newLocal); - await wardrobeOutfitsTable.add(newLocal); - emitDomainEvent('WardrobeOutfitCreated', 'wardrobe', 'wardrobeOutfits', newLocal.id, { - outfitId: newLocal.id, - garmentCount: input.garmentIds.length, - }); - return snapshot; - }, - - async updateOutfit( - id: string, - patch: Partial< - Pick< - LocalWardrobeOutfit, - 'name' | 'description' | 'garmentIds' | 'occasion' | 'season' | 'tags' - > - > - ): Promise { - const wrapped: Partial = { ...patch }; - await encryptRecord('wardrobeOutfits', wrapped); - await wardrobeOutfitsTable.update(id, wrapped); - }, - - async toggleFavorite(id: string): Promise { - const existing = await wardrobeOutfitsTable.get(id); - if (!existing) return; - await wardrobeOutfitsTable.update(id, { - isFavorite: !existing.isFavorite, - }); - }, - - async markWornToday(id: string): Promise { - const today = new Date().toISOString().slice(0, 10); - await wardrobeOutfitsTable.update(id, { - lastWornAt: today, - }); - }, - - /** - * Pinning the most recent try-on. The `imageId` points at a - * `picture.images` row written by the M4 runTryOn helper; this - * method is called right after that row lands so the outfit card - * can surface the latest preview. - */ - async setLastTryOn(id: string, tryOn: OutfitTryOn): Promise { - await wardrobeOutfitsTable.update(id, { - lastTryOn: tryOn, - }); - emitDomainEvent('WardrobeOutfitTryOn', 'wardrobe', 'wardrobeOutfits', id, { - outfitId: id, - imageId: tryOn.imageId, - }); - }, - - async archiveOutfit(id: string, archived: boolean): Promise { - await wardrobeOutfitsTable.update(id, { - isArchived: archived, - }); - }, - - async deleteOutfit(id: string): Promise { - const nowIso = new Date().toISOString(); - await wardrobeOutfitsTable.update(id, { - deletedAt: nowIso, - }); - emitDomainEvent('WardrobeOutfitDeleted', 'wardrobe', 'wardrobeOutfits', id, { - outfitId: id, - }); - }, - - /** - * Flip an outfit's visibility. Enables the style-portfolio use - * case — mark curated outfits 'public' so they appear in the - * wardrobe.outfits embed on the owner's website. - */ - async setVisibility(id: string, next: VisibilityLevel): Promise { - const existing = await wardrobeOutfitsTable.get(id); - if (!existing) throw new Error(`Outfit ${id} not found`); - const before: VisibilityLevel = existing.visibility ?? 'space'; - if (before === next) return; - - const now = new Date().toISOString(); - const patch: Partial = { - visibility: next, - visibilityChangedAt: now, - visibilityChangedBy: getEffectiveUserId(), - }; - if (next === 'unlisted' && !existing.unlistedToken) { - patch.unlistedToken = generateUnlistedToken(); - } else if (next !== 'unlisted' && existing.unlistedToken) { - patch.unlistedToken = undefined; - } - await wardrobeOutfitsTable.update(id, patch); - - emitDomainEvent('VisibilityChanged', 'wardrobe', 'wardrobeOutfits', id, { - recordId: id, - collection: 'wardrobeOutfits', - before, - after: next, - }); - }, -}; diff --git a/apps/mana/apps/web/src/lib/modules/wardrobe/types.ts b/apps/mana/apps/web/src/lib/modules/wardrobe/types.ts deleted file mode 100644 index 7f983ff9d..000000000 --- a/apps/mana/apps/web/src/lib/modules/wardrobe/types.ts +++ /dev/null @@ -1,218 +0,0 @@ -/** - * Wardrobe module types — two tables: - * - * - `wardrobeGarments`: individual clothing items / accessories, space- - * scoped via the standard Spaces stamping. Brand spaces hold Merch, - * clubs hold Trikots, families hold kid + parent wardrobes, etc. - * - `wardrobeOutfits`: named compositions of garment refs. A try-on - * snapshot points at a picture.images row (the generated image is - * just another entry in the Picture module's gallery). - * - * Try-on results themselves live in `picture.images` with an additional - * `wardrobeOutfitId` back-reference — see apps/mana/apps/web/src/lib/ - * modules/picture/types.ts. No third table in this module. - * - * Plan: docs/plans/wardrobe-module.md. - */ - -import type { BaseRecord } from '@mana/local-store'; -import { deriveUpdatedAt } from '$lib/data/sync'; -import type { VisibilityLevel } from '@mana/shared-privacy'; - -// ─── Garment ────────────────────────────────────────────────────── - -/** - * Closed enum of clothing/accessory categories. Drives the category - * filter tabs in the UI and the try-on preset (`accessory`, `glasses`, - * `jewelry`, `hat` go face-ref only — the others use face + fullbody). - */ -export type GarmentCategory = - | 'top' // Hemd, T-Shirt, Bluse, Pullover - | 'bottom' // Hose, Rock, Shorts - | 'dress' // Kleid, Anzug-Einteiler - | 'outerwear' // Jacke, Mantel - | 'shoes' - | 'accessory' // Schal, Gürtel, Tuch - | 'glasses' - | 'jewelry' - | 'hat' - | 'bag' - | 'other'; - -/** - * Accessory categories that skip the fullbody reference in try-on. - * `accessoryOnly=true` in the M4 runTryOn helper flips to face-only - * and a square prompt preset. - */ -export const FACE_ONLY_CATEGORIES: ReadonlySet = new Set([ - 'glasses', - 'jewelry', - 'hat', - 'accessory', -]); - -export interface LocalWardrobeGarment extends BaseRecord { - id: string; - name: string; - category: GarmentCategory; - /** - * mana-media ids, at least one. `mediaIds[0]` is the primary photo - * used by try-on and tile thumbnails; additional ids are alternate - * views (back, detail) rendered on the detail page in M7. - */ - mediaIds: string[]; - brand?: string | null; - color?: string | null; // freeform — "navy", "hellgrau", "#2a4d6e" - size?: string | null; // freeform — "M", "42", "US 10" - material?: string | null; - tags: string[]; - notes?: string | null; - purchasedAt?: string | null; // ISO date (YYYY-MM-DD) - priceCents?: number | null; - currency?: string | null; // ISO 4217 - isArchived?: boolean; - /** Incremented by the "heute getragen"-Button; null if never tracked. */ - wearCount?: number; - lastWornAt?: string | null; -} - -export interface Garment { - id: string; - name: string; - category: GarmentCategory; - mediaIds: string[]; - brand?: string; - color?: string; - size?: string; - material?: string; - tags: string[]; - notes?: string; - purchasedAt?: string; - priceCents?: number; - currency?: string; - isArchived?: boolean; - wearCount?: number; - lastWornAt?: string; - createdAt: string; - updatedAt: string; -} - -export function toGarment(local: LocalWardrobeGarment): Garment { - return { - id: local.id, - name: local.name, - category: local.category, - mediaIds: local.mediaIds ?? [], - brand: local.brand ?? undefined, - color: local.color ?? undefined, - size: local.size ?? undefined, - material: local.material ?? undefined, - tags: local.tags ?? [], - notes: local.notes ?? undefined, - purchasedAt: local.purchasedAt ?? undefined, - priceCents: local.priceCents ?? undefined, - currency: local.currency ?? undefined, - isArchived: local.isArchived ?? undefined, - wearCount: local.wearCount ?? undefined, - lastWornAt: local.lastWornAt ?? undefined, - createdAt: local.createdAt ?? '', - updatedAt: deriveUpdatedAt(local), - }; -} - -/** Primary photo of a garment; `null` if the row somehow has no ids. */ -export function garmentPrimaryMediaId(garment: Pick): string | null { - return garment.mediaIds[0] ?? null; -} - -// ─── Outfit ─────────────────────────────────────────────────────── - -/** - * Snapshot of the most recent try-on for an outfit. The full history - * lives in `picture.images` filtered by `wardrobeOutfitId === outfit.id` - * — this pointer exists so the outfit detail view can render the latest - * preview without re-querying. - * - * `imageUrl` is cached here (mana-media URL from the picture.images row) - * so OutfitCard's thumbnail renders without a second Dexie round-trip. - * The source of truth remains picture.images; if the user deletes that - * row the pointer goes stale but the card just falls back to the - * garment-collage render — no error. - */ -export interface OutfitTryOn { - imageId: string; // picture.images.id (UUID) - imageUrl: string; // mana-media URL, cached for cheap card rendering - createdAt: string; // ISO - prompt: string; - model: string; -} - -/** Closed enum of occasions the outfit is appropriate for. Freeform - * remains possible via tags; the enum keeps the primary filter small. */ -export type OutfitOccasion = - | 'casual' - | 'work' - | 'formal' - | 'workout' - | 'date' - | 'travel' - | 'event' - | 'sleep' - | 'other'; - -export type OutfitSeason = 'spring' | 'summer' | 'autumn' | 'winter'; - -export interface LocalWardrobeOutfit extends BaseRecord { - id: string; - name: string; - description?: string | null; - /** References into `wardrobeGarments`. Must be in the same space. */ - garmentIds: string[]; - occasion?: OutfitOccasion | null; - season?: OutfitSeason[]; - tags: string[]; - isFavorite?: boolean; - isArchived?: boolean; - lastTryOn?: OutfitTryOn | null; - lastWornAt?: string | null; - visibility?: VisibilityLevel; - visibilityChangedAt?: string; - visibilityChangedBy?: string; - unlistedToken?: string; -} - -export interface Outfit { - id: string; - name: string; - description?: string; - garmentIds: string[]; - occasion?: OutfitOccasion; - season?: OutfitSeason[]; - tags: string[]; - isFavorite?: boolean; - isArchived?: boolean; - lastTryOn?: OutfitTryOn; - lastWornAt?: string; - visibility: VisibilityLevel; - createdAt: string; - updatedAt: string; -} - -export function toOutfit(local: LocalWardrobeOutfit): Outfit { - return { - id: local.id, - name: local.name, - description: local.description ?? undefined, - garmentIds: local.garmentIds ?? [], - occasion: local.occasion ?? undefined, - season: local.season, - tags: local.tags ?? [], - isFavorite: local.isFavorite, - isArchived: local.isArchived, - lastTryOn: local.lastTryOn ?? undefined, - lastWornAt: local.lastWornAt ?? undefined, - visibility: local.visibility ?? 'space', - createdAt: local.createdAt ?? '', - updatedAt: deriveUpdatedAt(local), - }; -} diff --git a/apps/mana/apps/web/src/lib/modules/wardrobe/utils/name.ts b/apps/mana/apps/web/src/lib/modules/wardrobe/utils/name.ts deleted file mode 100644 index ad3470668..000000000 --- a/apps/mana/apps/web/src/lib/modules/wardrobe/utils/name.ts +++ /dev/null @@ -1,73 +0,0 @@ -/** - * Turn an upload filename into a presentable default garment name. - * - * Filenames from e-commerce sources and phone cameras come in as - * URL-safe slugs with SKU-numbers, duplicate segments, and hyphens — - * e.g. `17390-gestreiftes-herren-t-shirt-aus-baumwolle-17390-2-w.png`. - * A raw strip-extension leaves the user staring at that string as the - * display name and having to manually clean it up. This helper does - * a best-effort pretty-print so the default label is usable as-is. - * - * Rules (order matters): - * 1. Strip the last extension. - * 2. Replace underscores + hyphens with spaces. - * 3. Collapse runs of whitespace. - * 4. Drop "pure-number" tokens that look like SKU / size codes - * (≥ 4 digits AND longer than any letter-only neighbour — matches - * `17390`, `2-w` stays because it's not pure digits). The short - * alpha-numerics like `4xl` / `w38` are kept; stock codes are not. - * 5. Title-case each remaining word. "T-Shirt" style hyphenated terms - * are rebuilt by re-hyphenating two-letter-max fragments so - * `t-shirt` becomes `T-Shirt` and `v-neck` becomes `V-Neck`. - * 6. Trim trailing punctuation + clamp to 80 characters on a word - * boundary so wild inputs don't blow up the UI. - * - * Returns a non-empty string — falls back to the trimmed, extension- - * less original when normalisation would otherwise yield "". - */ -export function prettifyUploadName(filename: string): string { - const extIdx = filename.lastIndexOf('.'); - const withoutExt = extIdx > 0 ? filename.slice(0, extIdx) : filename; - - const raw = withoutExt.replace(/[_-]+/g, ' ').replace(/\s+/g, ' ').trim(); - if (!raw) return filename; - - // Token filter: drop pure-digit tokens of length ≥ 4 (SKU-shaped). - const tokens = raw.split(' ').filter((t) => !(t.length >= 4 && /^\d+$/.test(t))); - - const titled = tokens - .map((t) => { - // "t-shirt" would have been split on hyphens earlier, but if a - // caller pre-tokenised with hyphens we stitch them back here. - if (t.includes('-')) { - return t - .split('-') - .map((seg) => capitalise(seg)) - .join('-'); - } - return capitalise(t); - }) - .join(' ') - .replace(/[\s.,;:\-]+$/, ''); - - const clamped = clampAtWordBoundary(titled, 80); - return clamped || withoutExt; -} - -function capitalise(word: string): string { - if (word.length === 0) return word; - // Keep short tokens that look like codes (`4xl`, `w38`) uppercase - // for readability. Anything ≤ 2 chars or mixed-digit-letter stays - // uppercased so `T-Shirt` works and `w38` reads as `W38`. - if (word.length <= 2 || /[0-9]/.test(word)) { - return word.toUpperCase(); - } - return word[0].toUpperCase() + word.slice(1).toLowerCase(); -} - -function clampAtWordBoundary(s: string, max: number): string { - if (s.length <= max) return s; - const cut = s.slice(0, max); - const lastSpace = cut.lastIndexOf(' '); - return (lastSpace > 0 ? cut.slice(0, lastSpace) : cut).replace(/[\s.,;:\-]+$/, ''); -} diff --git a/apps/mana/apps/web/src/lib/modules/wardrobe/views/DetailGarmentView.svelte b/apps/mana/apps/web/src/lib/modules/wardrobe/views/DetailGarmentView.svelte deleted file mode 100644 index a3e4a3182..000000000 --- a/apps/mana/apps/web/src/lib/modules/wardrobe/views/DetailGarmentView.svelte +++ /dev/null @@ -1,415 +0,0 @@ - - - - -
- {#if !garment} - {#if garment$.loading} -

{$_('wardrobe.detail_garment.loading')}

- {:else} -
-

- {$_('wardrobe.detail_garment.not_found_title')} -

-

- {$_('wardrobe.detail_garment.not_found_desc')} -

-
- {/if} - {:else} -
- - {#if garment.mediaIds[0]} - - {:else} -
- {/if} - - -
- {#if editing} - (editing = false)} /> - {:else} -
-
-
-

{garment.name}

-

- {$_('wardrobe.categories.' + garment.category)} -

-
- - -
- -
- {#if garment.brand} -
-
- {$_('wardrobe.detail_garment.label_brand')} -
-
{garment.brand}
-
- {/if} - {#if garment.color} -
-
- {$_('wardrobe.detail_garment.label_color')} -
-
{garment.color}
-
- {/if} - {#if garment.size} -
-
- {$_('wardrobe.detail_garment.label_size')} -
-
{garment.size}
-
- {/if} - {#if garment.material} -
-
- {$_('wardrobe.detail_garment.label_material')} -
-
{garment.material}
-
- {/if} - {#if garment.priceCents} -
-
- {$_('wardrobe.detail_garment.label_price')} -
-
- {(garment.priceCents / 100).toFixed(2)} - {garment.currency ?? ''} -
-
- {/if} - {#if garment.wearCount && garment.wearCount > 0} -
-
- {$_('wardrobe.detail_garment.label_wear_count')} -
-
- {$_('wardrobe.detail_garment.wear_count_value', { - values: { count: garment.wearCount }, - })}{garment.lastWornAt - ? $_('wardrobe.detail_garment.last_worn_suffix', { - values: { date: garment.lastWornAt }, - }) - : ''} -
-
- {/if} -
- - {#if garment.tags.length > 0} -
- {#each garment.tags as tag} - - {tag} - - {/each} -
- {/if} - - {#if garment.notes} -

{garment.notes}

- {/if} -
- - - - - - {#if garment && !garment.isArchived} - - - {$_('wardrobe.detail_garment.action_comic')} - - {/if} - - -
- - - -
- {/if} -
-
- - - {#if soloTryOns.length > 0} -
-
-

- {$_('wardrobe.detail_garment.section_try_ons', { - values: { count: soloTryOns.length }, - })} -

-
-
- {#each soloTryOns as image (image.id)} - - {/each} -
-
- {/if} - - - {#if outfits.length > 0} -
-
-

- {$_('wardrobe.detail_garment.section_outfits', { - values: { count: outfits.length }, - })} -

-
- -
- {/if} - {/if} -
- - - (lightboxImage = null)}> - {#snippet actions()} - - {$_('wardrobe.detail_garment.action_open_picture')} - - {/snippet} - diff --git a/apps/mana/apps/web/src/lib/modules/wardrobe/views/DetailOutfitView.svelte b/apps/mana/apps/web/src/lib/modules/wardrobe/views/DetailOutfitView.svelte deleted file mode 100644 index b0c97990c..000000000 --- a/apps/mana/apps/web/src/lib/modules/wardrobe/views/DetailOutfitView.svelte +++ /dev/null @@ -1,317 +0,0 @@ - - - -
- - - {#if !outfit} - {#if outfit$.loading} -

{$_('wardrobe.detail_outfit.loading')}

- {:else} -
-

- {$_('wardrobe.detail_outfit.not_found_title')} -

-

- {$_('wardrobe.detail_outfit.not_found_desc')} -

-
- {/if} - {:else} -
- -
-
- {#if outfit.lastTryOn?.imageUrl} - {$_('wardrobe.detail_outfit.try_on_preview_alt')} - {:else if resolvedGarments.length > 0} -
- {#each resolvedGarments.slice(0, 4) as g} - {@const mediaId = g.mediaIds[0]} -
- {#if mediaId} - {g.name} - {/if} -
- {/each} -
- {:else} -
- {$_('wardrobe.detail_outfit.no_garments')} -
- {/if} -
- - - - - - {#if outfit && !outfit.isArchived} - - - {$_('wardrobe.detail_outfit.action_comic')} - - {/if} - - {#if tryOns.length > 0} -
-

- {$_('wardrobe.detail_outfit.try_on_history')} -

-
- {#each tryOns as t (t.id)} - {#if t.publicUrl} - {outfit.name} - {/if} - {/each} -
-
- {/if} -
- - -
-
-
-
-

{outfit.name}

-
- - {outfit.garmentIds.length} - {outfit.garmentIds.length === 1 - ? $_('wardrobe.piece_singular') - : $_('wardrobe.piece_plural')} - - {#if outfit.occasion} - · - {$_('wardrobe.occasions.' + outfit.occasion)} - {/if} - {#if outfit.season && outfit.season.length > 0} - · - {outfit.season.map((s) => $_('wardrobe.seasons.' + s)).join(', ')} - {/if} -
-
-
- - - - -
-
- -
- {$_('wardrobe.detail_outfit.label_visibility')} - -
- - {#if outfit.description} -

{outfit.description}

- {/if} - - {#if outfit.tags.length > 0} -
- {#each outfit.tags as tag} - - {tag} - - {/each} -
- {/if} -
- - -
-

- {$_('wardrobe.detail_outfit.section_composition')} -

- {#if resolvedGarments.length > 0} -
- {#each resolvedGarments as g (g.id)} - {@const mediaId = g.mediaIds[0]} - -
- {#if mediaId} - {g.name} - {/if} -
-
-

{g.name}

-

- {$_('wardrobe.categories_singular.' + g.category)} -

-
-
- {/each} -
- {:else} -

- {$_('wardrobe.detail_outfit.composition_missing')} -

- {/if} -
- - -
- - -
-
-
- {/if} -
diff --git a/apps/mana/apps/web/src/lib/modules/wardrobe/views/GridView.svelte b/apps/mana/apps/web/src/lib/modules/wardrobe/views/GridView.svelte deleted file mode 100644 index 69975fc69..000000000 --- a/apps/mana/apps/web/src/lib/modules/wardrobe/views/GridView.svelte +++ /dev/null @@ -1,147 +0,0 @@ - - - -
- - (activeTab = next)} /> - - - - - {#if uploadError} - - {/if} - - - {#if filtered.length > 0} -
- {#each filtered as g (g.id)} - - {/each} -
- {:else if garments.length === 0} -
-

{$_('wardrobe.grid_view.empty_title')}

-

- {$_('wardrobe.grid_view.empty_hint')} -

-
- {:else} -
-

- {$_('wardrobe.grid_view.no_entries_under', { - values: { - category: - activeTab === 'all' - ? $_('wardrobe.categories.all') - : $_('wardrobe.categories.' + activeTab), - }, - })} -

-
- {/if} - - - {#if activeSpace && activeSpace.type !== 'personal'} -

- {$_('wardrobe.grid_view.space_footer', { values: { name: activeSpace.name } })} -

- {/if} -
diff --git a/apps/mana/apps/web/src/lib/modules/wardrobe/views/OutfitsView.svelte b/apps/mana/apps/web/src/lib/modules/wardrobe/views/OutfitsView.svelte deleted file mode 100644 index 633911a42..000000000 --- a/apps/mana/apps/web/src/lib/modules/wardrobe/views/OutfitsView.svelte +++ /dev/null @@ -1,81 +0,0 @@ - - - -
-
-
-

- {$_('wardrobe.outfits_view.title')} -

- {#if outfits.length > 0} -

- {outfits.length} - {outfits.length === 1 - ? $_('wardrobe.outfits_view.count_singular') - : $_('wardrobe.outfits_view.count_plural')} -

- {/if} -
- - - {$_('wardrobe.outfits_view.action_new')} - -
- - {#if outfits.length > 0} -
- {#each outfits as outfit (outfit.id)} - - {/each} -
- {:else if garments.length === 0} -
- -

{$_('wardrobe.outfits_view.empty_title')}

-

- {$_('wardrobe.outfits_view.empty_no_garments')} -

-
- {:else} -
- -

{$_('wardrobe.outfits_view.empty_title')}

-

- {$_('wardrobe.outfits_view.empty_with_garments')} -

- - - {$_('wardrobe.outfits_view.action_compose_first')} - -
- {/if} -
diff --git a/apps/mana/apps/web/src/lib/modules/website/embeds.ts b/apps/mana/apps/web/src/lib/modules/website/embeds.ts index c13677eb4..2cf17092d 100644 --- a/apps/mana/apps/web/src/lib/modules/website/embeds.ts +++ b/apps/mana/apps/web/src/lib/modules/website/embeds.ts @@ -29,7 +29,6 @@ import type { LocalTaskTag } from '$lib/modules/todo/types'; import type { LocalGoal } from '$lib/companion/goals/types'; import type { LocalPlace } from '$lib/modules/places/types'; import type { LocalRecipe } from '$lib/modules/recipes/types'; -import type { LocalWardrobeOutfit } from '$lib/modules/wardrobe/types'; import type { LocalComicStory } from '$lib/modules/comic/types'; import type { LocalHabit, LocalHabitLog } from '$lib/modules/habits/types'; import type { LocalQuiz } from '$lib/modules/quiz/types'; @@ -72,9 +71,6 @@ export async function resolveEmbed(props: ModuleEmbedProps): Promise { }); } -/** - * Wardrobe-outfits: style-portfolio use case. Returns outfits flipped - * to 'public' with their most recent try-on preview as the card image. - * Hard-gated on canEmbedOnWebsite. - * - * Whitelist: name + occasion/season line + the `lastTryOn.imageUrl` - * (which is just a mana-media URL pointing at an AI-generated wearing - * shot — no facial identifier unless the user chose to share one). - * Individual garments, tags, and description stay out of the snapshot. - */ -async function resolveWardrobeOutfits(props: ModuleEmbedProps): Promise { - let outfits = await db.table('wardrobeOutfits').toArray(); - outfits = outfits.filter( - (o) => !o.deletedAt && !o.isArchived && canEmbedOnWebsite(o.visibility ?? 'private') - ); - - if (props.filter?.isFavorite === true) { - outfits = outfits.filter((o) => o.isFavorite === true); - } - if (props.filter?.tagIds?.length) { - const wanted = new Set(props.filter.tagIds); - outfits = outfits.filter((o) => (o.tags ?? []).some((t) => wanted.has(t))); - } - - const decrypted = (await decryptRecords('wardrobeOutfits', outfits)) as LocalWardrobeOutfit[]; - - // Favourites first, then newest. - decrypted.sort((a, b) => { - const favA = a.isFavorite ? 0 : 1; - const favB = b.isFavorite ? 0 : 1; - if (favA !== favB) return favA - favB; - return (b.updatedAt ?? '').localeCompare(a.updatedAt ?? ''); - }); - - return decrypted.map((o) => { - const meta: string[] = []; - if (o.occasion) meta.push(o.occasion); - if (o.season?.length) meta.push(o.season.join(', ')); - return { - title: o.name, - subtitle: meta.length > 0 ? meta.join(' · ') : undefined, - imageUrl: o.lastTryOn?.imageUrl ?? undefined, - }; - }); -} - /** * Comic-stories: public-comic-portfolio use case. Returns stories * flipped to 'public' with their cover panel as the card image diff --git a/apps/mana/apps/web/src/lib/quick-input/registry.ts b/apps/mana/apps/web/src/lib/quick-input/registry.ts index 25f736669..c9e37c160 100644 --- a/apps/mana/apps/web/src/lib/quick-input/registry.ts +++ b/apps/mana/apps/web/src/lib/quick-input/registry.ts @@ -13,7 +13,6 @@ const registry = new Map Promise>([ ['/contacts', () => import('$lib/modules/contacts/quick-input-adapter')], ['/times', () => import('$lib/modules/times/quick-input-adapter')], ['/plants', () => import('$lib/modules/plants/quick-input-adapter')], - ['/food', () => import('$lib/modules/food/quick-input-adapter')], ]); /** diff --git a/apps/mana/apps/web/src/lib/splitscreen/registry.ts b/apps/mana/apps/web/src/lib/splitscreen/registry.ts index 07db3e537..c910ed73c 100644 --- a/apps/mana/apps/web/src/lib/splitscreen/registry.ts +++ b/apps/mana/apps/web/src/lib/splitscreen/registry.ts @@ -20,14 +20,11 @@ const SPLIT_APP_ID_LIST = [ 'inventory', 'photos', 'skilltree', - 'citycorners', 'times', 'questions', - 'food', 'plants', 'uload', 'calc', - 'moodlit', 'memoro', 'places', 'automations', diff --git a/apps/mana/apps/web/src/lib/triggers/event-bridge.ts b/apps/mana/apps/web/src/lib/triggers/event-bridge.ts index 75356f632..6cccb95b0 100644 --- a/apps/mana/apps/web/src/lib/triggers/event-bridge.ts +++ b/apps/mana/apps/web/src/lib/triggers/event-bridge.ts @@ -28,7 +28,6 @@ const EVENT_MAP: Record void) | null = null; diff --git a/apps/mana/apps/web/src/lib/types/dashboard.test.ts b/apps/mana/apps/web/src/lib/types/dashboard.test.ts index fe31962ed..5f61afb48 100644 --- a/apps/mana/apps/web/src/lib/types/dashboard.test.ts +++ b/apps/mana/apps/web/src/lib/types/dashboard.test.ts @@ -52,7 +52,6 @@ describe('WIDGET_REGISTRY', () => { 'music', 'presi', 'mana-auth', - 'food', 'plants', 'period', 'body', diff --git a/apps/mana/apps/web/src/lib/types/dashboard.ts b/apps/mana/apps/web/src/lib/types/dashboard.ts index 820abc01a..f2a127883 100644 --- a/apps/mana/apps/web/src/lib/types/dashboard.ts +++ b/apps/mana/apps/web/src/lib/types/dashboard.ts @@ -24,7 +24,6 @@ export type WidgetType = | 'music-library' // Music: music library stats | 'presi-decks' // Presi: recent presentations | 'active-timer' // Times: running timer - | 'nutrition-progress' // Food: today's calorie progress | 'plant-watering' // Plants: plants due for watering | 'day-timeline' // TimeBlocks: chronological day timeline | 'activity-feed' // TimeBlocks: recent activity across modules @@ -131,7 +130,6 @@ export interface WidgetMeta { | 'music' | 'presi' | 'times' - | 'food' | 'plants' | 'period' | 'body' @@ -285,15 +283,6 @@ export const WIDGET_REGISTRY: WidgetMeta[] = [ allowMultiple: false, requiredBackend: 'times', }, - { - type: 'nutrition-progress', - nameKey: 'dashboard.widgets.nutrition.title', - descriptionKey: 'dashboard.widgets.nutrition.description', - icon: '🍽️', - defaultSize: 'small', - allowMultiple: false, - requiredBackend: 'food', - }, { type: 'plant-watering', nameKey: 'dashboard.widgets.plant_watering.title', diff --git a/apps/mana/apps/web/src/routes/(app)/body/+layout.svelte b/apps/mana/apps/web/src/routes/(app)/body/+layout.svelte index 101eee49d..3358a0b92 100644 --- a/apps/mana/apps/web/src/routes/(app)/body/+layout.svelte +++ b/apps/mana/apps/web/src/routes/(app)/body/+layout.svelte @@ -9,8 +9,6 @@ useAllBodyMeasurements, useAllBodyChecks, useAllBodyPhases, - useFoodMealsSince, - dateNDaysAgo, } from '$lib/modules/body/queries'; let { children }: { children: Snippet } = $props(); @@ -22,9 +20,6 @@ setContext('bodyMeasurements', useAllBodyMeasurements()); setContext('bodyChecks', useAllBodyChecks()); setContext('bodyPhases', useAllBodyPhases()); - // Cross-module read for the Body × Food correlation chart. - // 8 weeks back covers a typical cut/bulk cycle and matches the chart default. - setContext('bodyFoodMeals', useFoodMealsSince(dateNDaysAgo(56))); {@render children()} diff --git a/apps/mana/apps/web/src/routes/(app)/citycorners/+page.svelte b/apps/mana/apps/web/src/routes/(app)/citycorners/+page.svelte deleted file mode 100644 index ba72e1839..000000000 --- a/apps/mana/apps/web/src/routes/(app)/citycorners/+page.svelte +++ /dev/null @@ -1,172 +0,0 @@ - - - - {$_('app.name')} - {$_('app.tagline')} - - - -
-
-

{$_('cities.title')}

-

{$_('cities.subtitle')}

-
- {#if authStore.isAuthenticated} - - - - {/if} -
- - - {#if platformStats.totalCities > 0} -
-
- 🏙️ -
-

{platformStats.totalCities}

-

{$_('nav.cities')}

-
-
-
- 📍 -
-

{platformStats.totalLocations}

-

{$_('home.title')}

-
-
- {#if platformStats.totalContributors > 0} -
- 👥 -
-

{platformStats.totalContributors}

-

- {$_('cities.totalContributors', { - values: { count: platformStats.totalContributors }, - })} -

-
-
- {/if} -
- {/if} - - -
- -
- - {#if filtered.length === 0} -
- 🏙️ -

{$_('cities.empty')}

- {#if authStore.isAuthenticated} - - {$_('cities.add')} - - {/if} -
- {:else} - - {/if} -
diff --git a/apps/mana/apps/web/src/routes/(app)/citycorners/add-city/+page.svelte b/apps/mana/apps/web/src/routes/(app)/citycorners/add-city/+page.svelte deleted file mode 100644 index b15e14ba0..000000000 --- a/apps/mana/apps/web/src/routes/(app)/citycorners/add-city/+page.svelte +++ /dev/null @@ -1,254 +0,0 @@ - - - - {$_('cityAdd.title')} - CityCorners - - - -
-
- - - -

{$_('cityAdd.title')}

-
-

{$_('cityAdd.subtitle')}

-
- - {#if !authStore.isAuthenticated} -
- 🏙️ -

{$_('cityAdd.loginRequired')}

- - {$_('settings.login')} - -
- {:else} -
{ - e.preventDefault(); - handleSubmit(); - }} - class="space-y-5" - > - {#if error} -
{error}
- {/if} - -
- - - {#if slug && slugExists} -

{$_('cityAdd.slugExists')}

- {:else if slug} -

/{slug}

- {/if} -
- -
- - -
- -
- - -
- -
- - -
- -
- - (imageError = false)} - placeholder={$_('cityAdd.imageUrlPlaceholder')} - class="w-full rounded-lg border border-border bg-background px-4 py-2.5 text-foreground placeholder:text-foreground-secondary/50 focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary" - /> - {#if imageUrl.trim() && !imageError} -
- Preview (imageError = true)} - /> -
- {/if} -
- - {#if geocoding} -

{$_('cityAdd.geocoding')}

- {:else if latitude !== undefined && longitude !== undefined} -

{$_('cityAdd.coordinatesFound')}

- {/if} - -
- - {$_('edit.cancel')} - - -
-
- {/if} -
diff --git a/apps/mana/apps/web/src/routes/(app)/citycorners/add/+page.svelte b/apps/mana/apps/web/src/routes/(app)/citycorners/add/+page.svelte deleted file mode 100644 index d7dafe339..000000000 --- a/apps/mana/apps/web/src/routes/(app)/citycorners/add/+page.svelte +++ /dev/null @@ -1,9 +0,0 @@ - diff --git a/apps/mana/apps/web/src/routes/(app)/citycorners/cities/[slug]/+layout.svelte b/apps/mana/apps/web/src/routes/(app)/citycorners/cities/[slug]/+layout.svelte deleted file mode 100644 index 10f9e72ba..000000000 --- a/apps/mana/apps/web/src/routes/(app)/citycorners/cities/[slug]/+layout.svelte +++ /dev/null @@ -1,37 +0,0 @@ - - -{#if currentCity} - {@render children()} -{:else if allCities.value.length > 0} -
- 🔍 -

Stadt nicht gefunden.

- - Zurück zu allen Städten - -
-{:else} - -
-
-
-{/if} diff --git a/apps/mana/apps/web/src/routes/(app)/citycorners/cities/[slug]/+page.svelte b/apps/mana/apps/web/src/routes/(app)/citycorners/cities/[slug]/+page.svelte deleted file mode 100644 index d665de63f..000000000 --- a/apps/mana/apps/web/src/routes/(app)/citycorners/cities/[slug]/+page.svelte +++ /dev/null @@ -1,260 +0,0 @@ - - - - {city?.name || ''} - CityCorners - - - -
-
-
- - - -

{city?.name}

-
-

- {#if city?.state} - {city.state}, {city.country} - {:else} - {city?.country} - {/if} -

- {#if city?.description} -

{city.description}

- {/if} -
- - - -
- - - {#if stats.locationCount > 0} -
-
- -
-
- -
-
-

{stats.locationCount}

-

- {$_('cities.locationsCount', { values: { count: stats.locationCount } })} -

-
-
- - - {#if stats.hasCoordinates > 0} -
-
- -
-
-

{stats.hasCoordinates}

-

- {$_('cities.onMap', { values: { count: stats.hasCoordinates } })} -

-
-
- {/if} - - - {#if stats.contributorCount > 0} -
-
- -
-
-

{stats.contributorCount}

-

- {$_('cities.contributors', { values: { count: stats.contributorCount } })} -

-
-
- {/if} -
- - - {#if stats.topCategories.length > 1} -
- {#each stats.topCategories as { category, count }} - - {$_(`categories.${category}`)} - {count} - - {/each} -
- {/if} -
- {/if} - - -
- - {#each CATEGORY_KEYS as cat} - {@const count = categoryCounts[cat] || 0} - {#if count > 0} - - {/if} - {/each} -
- - {#if filtered.length === 0} -
- 📍 -

- {#if selectedCategory} - {$_('home.noResultsCategory', { - values: { category: $_(`categories.${selectedCategory}`) }, - })} - {:else} - {$_('home.noResults')} - {/if} -

- - {$_('home.addFirst')} - -
- {:else} - - {/if} -
diff --git a/apps/mana/apps/web/src/routes/(app)/citycorners/cities/[slug]/add/+page.svelte b/apps/mana/apps/web/src/routes/(app)/citycorners/cities/[slug]/add/+page.svelte deleted file mode 100644 index 1d55981d1..000000000 --- a/apps/mana/apps/web/src/routes/(app)/citycorners/cities/[slug]/add/+page.svelte +++ /dev/null @@ -1,304 +0,0 @@ - - - - {$_('add.title')} - {city?.name || 'CityCorners'} - - - -
-
- - - -

{$_('add.title')}

-
-

{$_('add.subtitle')} — {city?.name}

-
- - {#if !authStore.isAuthenticated} -
- 📍 -

{$_('add.loginRequired')}

- - {$_('settings.login')} - -
- {:else} -
{ - e.preventDefault(); - handleSubmit(); - }} - class="space-y-5" - > - {#if error} -
{error}
- {/if} - -
- - -
- -
- -
- {#each categories as cat} - - {/each} -
-
- -
- - -

{$_('add.minChars')}

-
- -
- - - {#if geocoding} -

{$_('add.geocoding')}

- {:else if latitude !== undefined && longitude !== undefined} -

- {$_('add.coordinatesFound')} -

- {/if} -
- -
- - (imageError = false)} - placeholder={$_('add.imageUrlPlaceholder')} - class="w-full rounded-lg border border-border bg-background px-4 py-2.5 text-foreground placeholder:text-foreground-secondary/50 focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary" - /> - {#if imageUrl.trim() && !imageError} -
- {$_('add.imagePreview')} (imageError = true)} - /> -
- {:else if imageError} -
-

{$_('add.imageLoadError')}

- -
- {/if} -
- -
- - -
- -
- - -
- -
- - {$_('edit.cancel')} - - -
-
- {/if} -
diff --git a/apps/mana/apps/web/src/routes/(app)/citycorners/cities/[slug]/locations/[id]/+page.svelte b/apps/mana/apps/web/src/routes/(app)/citycorners/cities/[slug]/locations/[id]/+page.svelte deleted file mode 100644 index 042b3e300..000000000 --- a/apps/mana/apps/web/src/routes/(app)/citycorners/cities/[slug]/locations/[id]/+page.svelte +++ /dev/null @@ -1,502 +0,0 @@ - - - - {location?.name || 'Location'} - {city?.name || 'CityCorners'} - - - - {#if loading} -
-
-
- {:else if !location} -
- 🔍 -

{$_('detail.notFound')}

- {$_('detail.back')} -
- {:else} - {@const images = allImages()} - - -
- {#if images.length > 0} - {location.name} - {:else} -
- 📍 -
- {/if} - - -
- - - -
- - -
- - - {#if authStore.isAuthenticated} - - {/if} -
- - {#if images.length > 1} -
- {selectedImageIndex + 1} / {images.length} -
- {/if} - -
- - {$_(`category.${location.category}`)} - - {#if isOpenNow(location.openingHours) === true} - - {$_('detail.openNow')} - - {:else if isOpenNow(location.openingHours) === false} - - {$_('detail.closedNow')} - - {/if} -
-
- - -
-
-

{location.name}

- {#if location.address} -

- - {location.address} -

- {/if} -
- -

{location.description}

- - - {#if location.website || location.phone} -
- {#if location.website} - - {/if} - {#if location.phone} -
- {$_('detail.phone')}: - - {location.phone} - -
- {/if} -
- {/if} - - - {#if location.openingHours && Object.keys(location.openingHours).length > 0} -
-

{$_('detail.openingHours')}

-
- - - {#each ['mo', 'tu', 'we', 'th', 'fr', 'sa', 'su'] as day} - {#if location.openingHours[day]} - - - - - {/if} - {/each} - -
{$_(`days.${day}`)} - {location.openingHours[day] === 'closed' - ? $_('detail.closed') - : location.openingHours[day]} -
-
-
- {/if} - - - {#if isOwner} -
- - - {$_('detail.edit')} - - -
- {/if} - - - {#if showDeleteConfirm} -
-

{$_('detail.deleteConfirm')}

-
- - -
-
- {/if} - - - {#if location.latitude && location.longitude} - - {/if} - - - {#if location.timeline && location.timeline.length > 0} -
-

{$_('detail.history')}

-
- {#each location.timeline as entry, i} -
- {#if i < location.timeline!.length - 1} -
- {/if} -
-
-
-
- {entry.year} -

{entry.event}

-
-
- {/each} -
-
- {/if} - - - {#if nearbyLocations.length > 0} - - {/if} -
- {/if} -
diff --git a/apps/mana/apps/web/src/routes/(app)/citycorners/cities/[slug]/locations/[id]/edit/+page.svelte b/apps/mana/apps/web/src/routes/(app)/citycorners/cities/[slug]/locations/[id]/edit/+page.svelte deleted file mode 100644 index b1ed22c7f..000000000 --- a/apps/mana/apps/web/src/routes/(app)/citycorners/cities/[slug]/locations/[id]/edit/+page.svelte +++ /dev/null @@ -1,273 +0,0 @@ - - - - {$_('edit.title')} - {city?.name || 'CityCorners'} - - - -
-
- - - -

{$_('edit.title')}

-
-

{$_('edit.subtitle')}

-
- - {#if loading} -
-
-
- {:else if forbidden} -
- 🔒 -

{$_('edit.forbidden')}

- - {$_('detail.back')} - -
- {:else} -
{ - e.preventDefault(); - handleSubmit(); - }} - class="space-y-5" - > - {#if error} -
{error}
- {/if} - -
- - -
- -
- -
- {#each categories as cat} - - {/each} -
-
- -
- - -

{$_('add.minChars')}

-
- -
- - -
- -
- - (imageError = false)} - placeholder={$_('add.imageUrlPlaceholder')} - class="w-full rounded-lg border border-border bg-background px-4 py-2.5 text-foreground placeholder:text-foreground-secondary/50 focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary" - /> - {#if imageUrl.trim() && !imageError} -
- {$_('add.imagePreview')} (imageError = true)} - /> -
- {:else if imageError} -
-

{$_('add.imageLoadError')}

- -
- {/if} -
- -
- - -
- -
- - -
- -
- - {$_('edit.cancel')} - - -
-
- {/if} -
diff --git a/apps/mana/apps/web/src/routes/(app)/citycorners/cities/[slug]/map/+page.svelte b/apps/mana/apps/web/src/routes/(app)/citycorners/cities/[slug]/map/+page.svelte deleted file mode 100644 index 1dd7396c9..000000000 --- a/apps/mana/apps/web/src/routes/(app)/citycorners/cities/[slug]/map/+page.svelte +++ /dev/null @@ -1,138 +0,0 @@ - - - - {$_('map.title')} - {city?.name || 'CityCorners'} - - - -
-
-
-
- - - -

{$_('map.title')}

-
-

{city?.name} - {$_('map.subtitle')}

-
- - - -
- -
- - {#each CATEGORY_KEYS as cat} - - {/each} -
- -
- {#if browser} - - {/if} -
- - {#if filtered.length > 0} -
-

- {filtered.length} - {filtered.length === 1 ? $_('map.location') : $_('map.locations')} -

-
- {#each filtered as loc} - {#if loc.latitude && loc.longitude} - -
-
-

{loc.name}

-

{$_(`category.${loc.category}`)}

-
-
- {/if} - {/each} -
-
- {/if} -
-
- - diff --git a/apps/mana/apps/web/src/routes/(app)/citycorners/favorites/+page.svelte b/apps/mana/apps/web/src/routes/(app)/citycorners/favorites/+page.svelte deleted file mode 100644 index a63994607..000000000 --- a/apps/mana/apps/web/src/routes/(app)/citycorners/favorites/+page.svelte +++ /dev/null @@ -1,90 +0,0 @@ - - - - {$_('favorites.title')} - CityCorners - - - -
-

{$_('favorites.title')}

-

{$_('favorites.subtitle')}

-
- - {#if !authStore.isAuthenticated} -
-

{$_('favorites.loginRequired')}

- - {$_('settings.login')} - -
- {:else if favoriteLocations.length === 0} -
- 💙 -

{$_('favorites.empty')}

-
- {:else} - - {/if} -
diff --git a/apps/mana/apps/web/src/routes/(app)/citycorners/locations/[id]/+page.svelte b/apps/mana/apps/web/src/routes/(app)/citycorners/locations/[id]/+page.svelte deleted file mode 100644 index 6731d1b82..000000000 --- a/apps/mana/apps/web/src/routes/(app)/citycorners/locations/[id]/+page.svelte +++ /dev/null @@ -1,10 +0,0 @@ - diff --git a/apps/mana/apps/web/src/routes/(app)/citycorners/locations/[id]/edit/+page.svelte b/apps/mana/apps/web/src/routes/(app)/citycorners/locations/[id]/edit/+page.svelte deleted file mode 100644 index a70fb30b0..000000000 --- a/apps/mana/apps/web/src/routes/(app)/citycorners/locations/[id]/edit/+page.svelte +++ /dev/null @@ -1,8 +0,0 @@ - diff --git a/apps/mana/apps/web/src/routes/(app)/citycorners/map/+page.svelte b/apps/mana/apps/web/src/routes/(app)/citycorners/map/+page.svelte deleted file mode 100644 index d7dafe339..000000000 --- a/apps/mana/apps/web/src/routes/(app)/citycorners/map/+page.svelte +++ /dev/null @@ -1,9 +0,0 @@ - diff --git a/apps/mana/apps/web/src/routes/(app)/food/+page.svelte b/apps/mana/apps/web/src/routes/(app)/food/+page.svelte deleted file mode 100644 index 766279ee2..000000000 --- a/apps/mana/apps/web/src/routes/(app)/food/+page.svelte +++ /dev/null @@ -1,324 +0,0 @@ - - - - {$_('food.home.page_title_html')} - - - -
- -
-
-

- {$_('food.home.heading_today')} -

-

- {new Date().toLocaleDateString(get(locale) ?? 'de', { - weekday: 'long', - day: 'numeric', - month: 'long', - })} -

-
- -
- - -
- -
-
-
- {$_('food.nutrition.calories')} -
-

- {progress.calories.current} -

-

- / {progress.calories.target} kcal -

-
-
-
-
- - -
-
-
- {$_('food.nutrition.protein')} -
-

- {progress.protein.current}g -

-

- / {progress.protein.target}g -

-
-
-
-
- - -
-
-
- {$_('food.nutrition.carbs')} -
-

- {progress.carbs.current}g -

-

- / {progress.carbs.target}g -

-
-
-
-
- - -
-
-
- {$_('food.nutrition.fat')} -
-

- {progress.fat.current}g -

-

- / {progress.fat.target}g -

-
-
-
-
-
- - -
-
-

- {$_('food.home.section_today_meals')} -

- - {$_('food.home.entries_count', { values: { n: todaysMeals.length } })} - -
- - {#if todaysMeals.length === 0} -
- 🍽️ -

- {$_('food.home.empty_no_meals')} -

-

- {$_('food.home.empty_hint')} -

- - {$_('food.home.action_add_meal')} - -
- {:else} - - {/if} -
- - - -
-
diff --git a/apps/mana/apps/web/src/routes/(app)/food/[id]/+page.svelte b/apps/mana/apps/web/src/routes/(app)/food/[id]/+page.svelte deleted file mode 100644 index 5a5f45c38..000000000 --- a/apps/mana/apps/web/src/routes/(app)/food/[id]/+page.svelte +++ /dev/null @@ -1,567 +0,0 @@ - - - - {$_('food.detail.page_title_html', { - values: { description: meal?.description ?? $_('food.detail.untitled_fallback') }, - })} - - - -
- - - {$_('food.detail.back')} - - - {#if !meal} -
-

- {$_('food.detail.not_found')} -

-
- {:else} - {#if error} -
- {error} -
- {/if} - - - {#if meal.photoUrl} - - {/if} - - -
-
- - {getMealTypeLabel(meal.mealType)} - - - {formatDateTime(meal.createdAt)} - - {#if meal.inputType === 'photo'} - 📷 - {/if} - {#if meal.confidence > 0 && meal.confidence < 1} - - KI {Math.round(meal.confidence * 100)}% - - {/if} -
- - {#if !editing} -

- {meal.description} -

- {#if meal.nutrition} -
-
-
-
- {$_('food.nutrition.calories')} -
-

- {meal.nutrition.calories} - kcal -

-
-
-
-
- {$_('food.nutrition.protein')} -
-

- {meal.nutrition.protein}g -

-
-
-
-
- {$_('food.nutrition.carbs')} -
-

- {meal.nutrition.carbohydrates}g -

-
-
-
-
- {$_('food.nutrition.fat')} -
-

- {meal.nutrition.fat}g -

-
-
-
-
- {$_('food.detail.fiber_with_value', { - values: { n: meal.nutrition.fiber }, - })} -
-
- {$_('food.detail.sugar_with_value', { - values: { n: meal.nutrition.sugar }, - })} -
-
- {/if} - -
- - {#if meal.inputType === 'photo' && meal.photoUrl} - - {/if} - {#if !confirmDelete} - - {:else} -
- {$_('food.detail.confirm_sure')} - - -
- {/if} -
- {:else} - -
-
- - {$_('food.detail.label_meal_type')} - -
- {#each mealTypes as type} - - {/each} -
-
- -
- - -
- -
-

- {$_('food.detail.section_nutrients')} -

-
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
-
- -
- - -
-
- {/if} -
- - - {#if !editing && meal.foods && meal.foods.length > 0} -
-

- {$_('food.detail.section_foods')} -

-
    - {#each meal.foods as food} -
  • -
    - {food.name} - {#if food.quantity} - - · {food.quantity} - {/if} -
    - {#if food.calories != null} - - {food.calories} kcal - - {/if} -
  • - {/each} -
-
- {/if} - {/if} -
- - - {#if lightboxOpen && meal?.photoUrl} - - {/if} -
diff --git a/apps/mana/apps/web/src/routes/(app)/food/add/+page.svelte b/apps/mana/apps/web/src/routes/(app)/food/add/+page.svelte deleted file mode 100644 index a82fa1142..000000000 --- a/apps/mana/apps/web/src/routes/(app)/food/add/+page.svelte +++ /dev/null @@ -1,619 +0,0 @@ - - - - Mahlzeit hinzufuegen - Food - Mana - - - -
- -
- - - Zurueck - -

Mahlzeit hinzufuegen

-
- - -
- - -
- - {#if error} -
- {error} -
- {/if} - - - {#if mode === 'photo'} -
- - - {#if !photoPreviewUrl} - - {:else} -
-
- Mahlzeit -
-
- - {#if !analyzed} - - {:else} - - {/if} -
- - {#if analyzed && confidencePct !== null} -
- KI-Analyse - · - {confidencePct}% sicher - {#if lowConfidence} - ⚠ Bitte Werte prüfen - {/if} -
- {/if} - - {#if analyzed && aiFoods && aiFoods.length > 0} -
-

- Erkannte Bestandteile -

-
    - {#each aiFoods as food} -
  • - - {food.name} - {#if food.quantity} - - · {food.quantity} - {/if} - - {#if food.calories != null} - - {food.calories} kcal - - {/if} -
  • - {/each} -
-
- {/if} -
- {/if} -
- {/if} - - - {#if mode === 'text' && favorites.length > 0} -
-

Favoriten

-
- {#each favorites as fav (fav.id)} - - {/each} -
-
- {/if} - -
- -
- - Mahlzeittyp - -
- {#each mealTypes as type} - - {/each} -
-
- - -
-
- - {#if mode === 'text'} - - {/if} -
- - {#if mode === 'text' && textAnalyzed && textConfidencePct !== null} -
- KI-Schätzung - · - {textConfidencePct}% sicher - {#if textLowConfidence} - ⚠ Bitte Werte prüfen - {/if} -
- {/if} -
- - -
-

- Naehrwerte - {#if mode === 'photo' && analyzed} - (KI-Schätzung, editierbar) - {:else if mode === 'text' && textAnalyzed} - (KI-Schätzung, editierbar) - {:else} - (optional) - {/if} -

-
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
-
- - -
- - Abbrechen - - -
-
-
-
diff --git a/apps/mana/apps/web/src/routes/(app)/food/goals/+page.svelte b/apps/mana/apps/web/src/routes/(app)/food/goals/+page.svelte deleted file mode 100644 index 2e018a087..000000000 --- a/apps/mana/apps/web/src/routes/(app)/food/goals/+page.svelte +++ /dev/null @@ -1,241 +0,0 @@ - - - - Ziele - Food - Mana - - - -
- -
- - - Zurueck - -

Tagesziele

-

- Passe deine taeglichen Naehrwertziele an -

-
- - {#if saved} -
- Ziele gespeichert! -
- {/if} - -
- -
- - -
- - -
- - -
- - -
- - -
- - -
- - -
- - -
- - -
- - -
- - -
-
- - -
-

- Die Standardwerte basieren auf einer 2000 kcal Diaet. Passe sie an deine individuellen - Beduerfnisse an. Konsultiere bei Bedarf einen Ernaehrungsberater. -

-
-
-
diff --git a/apps/mana/apps/web/src/routes/(app)/food/history/+page.svelte b/apps/mana/apps/web/src/routes/(app)/food/history/+page.svelte deleted file mode 100644 index 636f0e29f..000000000 --- a/apps/mana/apps/web/src/routes/(app)/food/history/+page.svelte +++ /dev/null @@ -1,223 +0,0 @@ - - - - Verlauf - Food - Mana - - - -
- -
- - - Zurueck - -

Mahlzeiten-Verlauf

-

- {meals.length} Eintraege insgesamt -

-
- - -
-
- - -
- - {#if selectedDate} - - {/if} -
- - - {#if groupedByDate.length === 0} -
- 📋 -

- Keine Eintraege -

-

- {searchQuery || selectedDate - ? 'Keine Ergebnisse fuer diese Filter.' - : 'Noch keine Mahlzeiten erfasst.'} -

-
- {:else} -
- {#each groupedByDate as group (group.date)} -
-
-

- {formatDateHeader(group.date)} -

- - {Math.round(group.totalCalories)} kcal - -
- -
- {#each group.meals as meal (meal.id)} - - {/each} -
-
- {/each} -
- {/if} -
-
diff --git a/apps/mana/apps/web/src/routes/(app)/moodlit/+page.svelte b/apps/mana/apps/web/src/routes/(app)/moodlit/+page.svelte deleted file mode 100644 index 04e742ff1..000000000 --- a/apps/mana/apps/web/src/routes/(app)/moodlit/+page.svelte +++ /dev/null @@ -1,70 +0,0 @@ - - - - Moodlit - Mana - - - -
-
-

Moodlit

-

Ambient Lighting & Mood App

-
- - -
-
-
- -
-
-
Stimmungslicht
-
Wahle ein Mood oder erstelle dein eigenes
-
-
-
- - -
- {#each quickLinks as link} - -
-
- -
-
-
{link.label}
-
{link.description}
-
-
-
- {/each} -
-
-
diff --git a/apps/mana/apps/web/src/routes/(app)/moodlit/moods/+page.svelte b/apps/mana/apps/web/src/routes/(app)/moodlit/moods/+page.svelte deleted file mode 100644 index 20cc2d115..000000000 --- a/apps/mana/apps/web/src/routes/(app)/moodlit/moods/+page.svelte +++ /dev/null @@ -1,296 +0,0 @@ - - - - {$_('moodlit.moodsPage.page_title_html')} - - - -
-
-

{$_('moodlit.moodsPage.title')}

- -
- - {#if showCreate} -
-
-
- - -
-
- - -
-
- {$_('moodlit.moodsPage.label_colors')} -
- {#each newColors as color, i} - - {/each} - -
-
-
-
- -
- {/if} - - {#if moods.loading} -
- {#each Array(6) as _} -
- {/each} -
- {:else} -
- {#each moods.value ?? [] as mood (mood.id)} - {@const gradient = - mood.colors.length === 1 - ? mood.colors[0] - : `linear-gradient(135deg, ${mood.colors.join(', ')})`} - - {/each} -
- {/if} -
- - {#if fullscreenMood} - (fullscreenMood = null)} /> - {/if} -
- - diff --git a/apps/mana/apps/web/src/routes/(app)/moodlit/sequences/+page.svelte b/apps/mana/apps/web/src/routes/(app)/moodlit/sequences/+page.svelte deleted file mode 100644 index f5107ae87..000000000 --- a/apps/mana/apps/web/src/routes/(app)/moodlit/sequences/+page.svelte +++ /dev/null @@ -1,132 +0,0 @@ - - - - Sequences - Moodlit - Mana - - - -
-
-

Sequences

- -
- - {#if showCreate} -
-
- - - Sek. - -
-
- {/if} - - {#if !sequences.value?.length} -
-

Keine Sequences

-

- Verkette mehrere Moods zu einer automatischen Sequenz. -

-
- {:else} -
- {#each sequences.value as seq (seq.id)} -
-
-
-

{seq.name}

-
- {#each seq.moodIds as moodId} - {getMoodName(moodId)} - {/each} - · {seq.duration}s pro Mood -
-
- -
-
- {/each} -
- {/if} -
-
diff --git a/apps/mana/apps/web/src/routes/(app)/wardrobe/+page.svelte b/apps/mana/apps/web/src/routes/(app)/wardrobe/+page.svelte deleted file mode 100644 index 206ba31b3..000000000 --- a/apps/mana/apps/web/src/routes/(app)/wardrobe/+page.svelte +++ /dev/null @@ -1,12 +0,0 @@ - - - - Kleiderschrank · Mana - - - - - diff --git a/apps/mana/apps/web/src/routes/(app)/wardrobe/compose/[[outfitId]]/+page.svelte b/apps/mana/apps/web/src/routes/(app)/wardrobe/compose/[[outfitId]]/+page.svelte deleted file mode 100644 index c6a273910..000000000 --- a/apps/mana/apps/web/src/routes/(app)/wardrobe/compose/[[outfitId]]/+page.svelte +++ /dev/null @@ -1,101 +0,0 @@ - - - - {outfitId ? $_('wardrobe.compose.title_edit') : $_('wardrobe.compose.title_new')} · Mana - - - -
-
- - - -

- {outfitId ? $_('wardrobe.compose.title_edit') : $_('wardrobe.compose.title_new')} -

-
- - {#if outfitId && !outfit && !existingOutfit$.loading} -
-

- {$_('wardrobe.detail_outfit.not_found_title')} -

-

- {$_('wardrobe.detail_outfit.not_found_desc')} -

-
- {:else} - - {#key outfitId ?? 'new'} - - {/key} - {/if} -
-
diff --git a/apps/mana/apps/web/src/routes/(app)/wardrobe/garment/[id]/+page.svelte b/apps/mana/apps/web/src/routes/(app)/wardrobe/garment/[id]/+page.svelte deleted file mode 100644 index da0d53b6f..000000000 --- a/apps/mana/apps/web/src/routes/(app)/wardrobe/garment/[id]/+page.svelte +++ /dev/null @@ -1,19 +0,0 @@ - - - - Kleidungsstück · Mana - - - - - {#key id} - - {/key} - diff --git a/apps/mana/apps/web/src/routes/(app)/wardrobe/outfit/[id]/+page.svelte b/apps/mana/apps/web/src/routes/(app)/wardrobe/outfit/[id]/+page.svelte deleted file mode 100644 index 21654132c..000000000 --- a/apps/mana/apps/web/src/routes/(app)/wardrobe/outfit/[id]/+page.svelte +++ /dev/null @@ -1,19 +0,0 @@ - - - - Outfit · Mana - - - - - {#key id} - - {/key} - diff --git a/apps/moodlit/CLAUDE.md b/apps/moodlit/CLAUDE.md deleted file mode 100644 index 65c1c99f9..000000000 --- a/apps/moodlit/CLAUDE.md +++ /dev/null @@ -1,17 +0,0 @@ -# Moodlit — consolidated into the unified Mana app - -This product was migrated into the unified Mana monorepo. The legacy -per-product `apps/moodlit/apps/server/` and `apps/moodlit/apps/web/` -directories have been removed. Active code now lives in: - -- **Backend compute routes**: [`apps/api/src/modules/moodlit/routes.ts`](../api/src/modules/moodlit/routes.ts) -- **Frontend module** (local-first): [`apps/mana/apps/web/src/lib/modules/moodlit/`](../mana/apps/web/src/lib/modules/moodlit/) -- **Web route**: [`apps/mana/apps/web/src/routes/(app)/moodlit/`](../mana/apps/web/src/routes/(app)/moodlit/) -- **Landing page** (still standalone): [`apps/moodlit/apps/landing/`](apps/landing/) - -For monorepo-wide patterns (auth, sync, encryption, services), see the -[root `CLAUDE.md`](../../CLAUDE.md) and [`apps/mana/CLAUDE.md`](../mana/CLAUDE.md). - -The previous standalone "Moodlit" guide was deleted in the audit cleanup -of 2026-04-09 — it had been inaccurate since the consolidation. -Pre-consolidation reference is in git history. diff --git a/apps/moodlit/apps/landing/astro.config.mjs b/apps/moodlit/apps/landing/astro.config.mjs deleted file mode 100644 index 83c9c822a..000000000 --- a/apps/moodlit/apps/landing/astro.config.mjs +++ /dev/null @@ -1,19 +0,0 @@ -import { defineConfig } from 'astro/config'; -import tailwind from '@astrojs/tailwind'; - -// https://astro.build/config -export default defineConfig({ - integrations: [tailwind()], - output: 'static', - build: { - inlineStylesheets: 'auto', - }, - vite: { - resolve: { - alias: { - '@components': '/src/components', - '@layouts': '/src/layouts', - }, - }, - }, -}); diff --git a/apps/moodlit/apps/landing/package.json b/apps/moodlit/apps/landing/package.json deleted file mode 100644 index f40812fec..000000000 --- a/apps/moodlit/apps/landing/package.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "name": "@moodlit/landing", - "version": "1.0.0", - "private": true, - "type": "module", - "scripts": { - "dev": "astro dev --port 4332", - "start": "astro dev", - "build": "astro check && astro build", - "preview": "astro preview", - "astro": "astro", - "type-check": "astro check", - "lint": "prettier --check . && eslint .", - "format": "prettier --write .", - "clean": "rm -rf dist .astro node_modules" - }, - "dependencies": { - "@astrojs/check": "^0.9.0", - "@mana/shared-landing-ui": "workspace:*", - "astro": "^5.16.0", - "typescript": "^5.9.2" - }, - "devDependencies": { - "@astrojs/tailwind": "^6.0.2", - "@tailwindcss/typography": "^0.5.18", - "@types/node": "^20.0.0", - "eslint": "^9.0.0", - "eslint-config-prettier": "^9.1.0", - "eslint-plugin-astro": "^1.0.0", - "prettier": "^3.6.2", - "prettier-plugin-astro": "^0.14.1", - "prettier-plugin-tailwindcss": "^0.6.14", - "tailwindcss": "^3.4.0" - } -} diff --git a/apps/moodlit/apps/landing/src/layouts/Layout.astro b/apps/moodlit/apps/landing/src/layouts/Layout.astro deleted file mode 100644 index 246f532b9..000000000 --- a/apps/moodlit/apps/landing/src/layouts/Layout.astro +++ /dev/null @@ -1,35 +0,0 @@ ---- -interface Props { - title: string; -} - -const { title } = Astro.props; ---- - - - - - - - - - - {title} - - - - - - - diff --git a/apps/moodlit/apps/landing/src/pages/index.astro b/apps/moodlit/apps/landing/src/pages/index.astro deleted file mode 100644 index 777a06087..000000000 --- a/apps/moodlit/apps/landing/src/pages/index.astro +++ /dev/null @@ -1,117 +0,0 @@ ---- -import Layout from '../layouts/Layout.astro'; ---- - - -
- -
-

- Moodlit -

-

- Transform your space with ambient lighting. Create custom moods, chain sequences, and let - the colors flow. -

- -
- - -
-

Features

-
-
-
- 🎨 -
-

Custom Moods

-

- Create your own lighting effects with custom colors and animations. -

-
-
-
- 🔗 -
-

Sequences

-

- Chain multiple moods together with configurable durations and transitions. -

-
-
-
- 🔦 -
-

Dual Output

-

Toggle between screen-based lighting and device flashlight.

-
-
-
- - -
-

Ready to set the mood?

-

Download Moodlit and transform your environment.

- -
- - -
-
-

© 2024 Moodlit. All rights reserved.

-
-
-
-
diff --git a/apps/moodlit/apps/landing/tailwind.config.mjs b/apps/moodlit/apps/landing/tailwind.config.mjs deleted file mode 100644 index 0146e6eda..000000000 --- a/apps/moodlit/apps/landing/tailwind.config.mjs +++ /dev/null @@ -1,24 +0,0 @@ -/** @type {import('tailwindcss').Config} */ -export default { - content: ['./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}'], - theme: { - extend: { - colors: { - primary: { - 50: '#fdf4ff', - 100: '#fae8ff', - 200: '#f5d0fe', - 300: '#f0abfc', - 400: '#e879f9', - 500: '#d946ef', - 600: '#c026d3', - 700: '#a21caf', - 800: '#86198f', - 900: '#701a75', - 950: '#4a044e', - }, - }, - }, - }, - plugins: [require('@tailwindcss/typography')], -}; diff --git a/apps/moodlit/apps/landing/tsconfig.json b/apps/moodlit/apps/landing/tsconfig.json deleted file mode 100644 index 4b0f22d55..000000000 --- a/apps/moodlit/apps/landing/tsconfig.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "extends": "astro/tsconfigs/strict", - "compilerOptions": { - "baseUrl": ".", - "paths": { - "@components/*": ["src/components/*"], - "@layouts/*": ["src/layouts/*"] - } - } -} diff --git a/apps/moodlit/apps/landing/wrangler.toml b/apps/moodlit/apps/landing/wrangler.toml deleted file mode 100644 index 15c40bab0..000000000 --- a/apps/moodlit/apps/landing/wrangler.toml +++ /dev/null @@ -1,3 +0,0 @@ -name = "moodlit-landing" -compatibility_date = "2024-12-01" -pages_build_output_dir = "dist" diff --git a/apps/moodlit/package.json b/apps/moodlit/package.json deleted file mode 100644 index 287e6a422..000000000 --- a/apps/moodlit/package.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "name": "@mana/moodlit", - "version": "0.0.1", - "private": true -} diff --git a/docker-compose.macmini.yml b/docker-compose.macmini.yml index e98512c2c..c39e6ff70 100644 --- a/docker-compose.macmini.yml +++ b/docker-compose.macmini.yml @@ -149,7 +149,7 @@ services: environment: MINIO_ROOT_USER: ${MINIO_ACCESS_KEY:-minioadmin} MINIO_ROOT_PASSWORD: ${MINIO_SECRET_KEY:-minioadmin} - MINIO_API_CORS_ALLOW_ORIGIN: https://music.mana.how,https://mana.how,https://picture.mana.how,https://storage.mana.how,https://plants.mana.how,https://contacts.mana.how,https://chat.mana.how,https://food.mana.how,https://photos.mana.how + MINIO_API_CORS_ALLOW_ORIGIN: https://music.mana.how,https://mana.how,https://picture.mana.how,https://storage.mana.how,https://plants.mana.how,https://contacts.mana.how,https://chat.mana.how,https://photos.mana.how volumes: - /Volumes/ManaData/minio:/data ports: @@ -250,7 +250,7 @@ services: # Enforced by services/mana-auth/src/auth/sso-config.spec.ts. # All productivity modules now live under mana.how (path-based) — # no per-module subdomain entries required here. - CORS_ORIGINS: https://mana.how,https://auth.mana.how,https://whopxl.mana.how,https://cardecky.mana.how,https://cardecky-api.mana.how,https://memoro-app.mana.how,https://zitare.mana.how,https://zitare-api.mana.how,https://nutriphi.mana.how,https://nutriphi-api.mana.how,https://manawald.mana.how,https://werdrobe.com,https://api.werdrobe.com,https://lesen.mana.how,https://lesen-api.mana.how + CORS_ORIGINS: https://mana.how,https://auth.mana.how,https://whopxl.mana.how,https://cardecky.mana.how,https://cardecky-api.mana.how,https://memoro-app.mana.how,https://zitare.mana.how,https://zitare-api.mana.how,https://nutriphi.mana.how,https://nutriphi-api.mana.how,https://manawald.mana.how,https://werdrobe.com,https://api.werdrobe.com,https://lesen.mana.how,https://lesen-api.mana.how,https://herbatrium.mana.how,https://herbatrium-api.mana.how,https://moodlit.mana.how,https://moodlit-api.mana.how ports: - "3001:3001" healthcheck: @@ -288,7 +288,7 @@ services: STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY:-} STRIPE_WEBHOOK_SECRET: ${STRIPE_CREDITS_WEBHOOK_SECRET:-} BASE_URL: https://credits.mana.how - CORS_ORIGINS: https://mana.how,https://chat.mana.how,https://picture.mana.how,https://todo.mana.how,https://quotes.mana.how,https://calendar.mana.how,https://clock.mana.how,https://contacts.mana.how,https://cardecky.mana.how,https://presi.mana.how,https://storage.mana.how,https://food.mana.how,https://plants.mana.how,https://music.mana.how,https://context.mana.how,https://photos.mana.how,https://questions.mana.how,https://calc.mana.how + CORS_ORIGINS: https://mana.how,https://chat.mana.how,https://picture.mana.how,https://todo.mana.how,https://quotes.mana.how,https://calendar.mana.how,https://clock.mana.how,https://contacts.mana.how,https://cardecky.mana.how,https://presi.mana.how,https://storage.mana.how,https://plants.mana.how,https://music.mana.how,https://context.mana.how,https://photos.mana.how,https://questions.mana.how,https://calc.mana.how ports: - "3002:3002" healthcheck: @@ -497,7 +497,7 @@ services: DATABASE_URL: postgresql://postgres:${POSTGRES_PASSWORD:-mana123}@postgres:5432/mana_platform MANA_AUTH_URL: http://mana-auth:3001 MANA_SERVICE_KEY: ${MANA_SERVICE_KEY} - CORS_ORIGINS: https://mana.how,https://calc.mana.how,https://calendar.mana.how,https://chat.mana.how,https://clock.mana.how,https://contacts.mana.how,https://context.mana.how,https://cardecky.mana.how,https://music.mana.how,https://food.mana.how,https://photos.mana.how,https://picture.mana.how,https://plants.mana.how,https://presi.mana.how,https://questions.mana.how,https://storage.mana.how,https://todo.mana.how,https://quotes.mana.how + CORS_ORIGINS: https://mana.how,https://calc.mana.how,https://calendar.mana.how,https://chat.mana.how,https://clock.mana.how,https://contacts.mana.how,https://context.mana.how,https://cardecky.mana.how,https://music.mana.how,https://photos.mana.how,https://picture.mana.how,https://plants.mana.how,https://presi.mana.how,https://questions.mana.how,https://storage.mana.how,https://todo.mana.how,https://quotes.mana.how ports: - "3062:3062" healthcheck: @@ -816,7 +816,7 @@ services: S3_BUCKET: mana-media S3_PUBLIC_URL: https://media.mana.how PUBLIC_URL: https://media.mana.how/api/v1 - CORS_ORIGINS: https://mana.how,https://food.mana.how,https://contacts.mana.how,https://chat.mana.how,https://storage.mana.how,https://photos.mana.how + CORS_ORIGINS: https://mana.how,https://contacts.mana.how,https://chat.mana.how,https://storage.mana.how,https://photos.mana.how ports: - "3011:3011" healthcheck: @@ -923,7 +923,7 @@ services: PUBLIC_MANA_CREDITS_URL: http://mana-credits:3002 PUBLIC_MANA_CREDITS_URL_CLIENT: https://credits.mana.how # Per-app HTTP backend URLs (todo-api, calendar-api, contacts-api, - # chat-api, storage-api, cards-api, music-api, food-api, + # chat-api, storage-api, cards-api, music-api, # picture-api, presi-api, quotes-api, clock-api, context-api) and # the standalone memoro-server URL were removed in the pre-launch # ghost-API cleanup — every product module talks to mana-sync @@ -987,8 +987,8 @@ services: # REMOVED standalone web containers — now served by unified mana-web container (mana.how): # chat-web, todo-web, quotes-web, calendar-web, clock-web, contacts-web, - # storage-web, presi-web, cards-web, food-web, skilltree-web, photos-web, - # music-web, citycorners-web, picture-web, inventory-web, calc-web, times-web, + # storage-web, presi-web, cards-web, skilltree-web, photos-web, + # music-web, picture-web, inventory-web, calc-web, times-web, # uload-web, memoro-web # picture-backend: REMOVED — replaced by Hono server (apps/picture/apps/server) diff --git a/docker/nginx/landings.conf b/docker/nginx/landings.conf index a81c81f24..767235485 100644 --- a/docker/nginx/landings.conf +++ b/docker/nginx/landings.conf @@ -108,24 +108,6 @@ server { -# food.mana.how — Food Landing -server { - listen 80; - server_name food.mana.how; - root /srv/landings/food; - index index.html; - include /etc/nginx/snippets/landing-common.conf; -} - -# citycorners.mana.how — CityCorners Landing -server { - listen 80; - server_name citycorners.mana.how; - root /srv/landings/citycorners; - index index.html; - include /etc/nginx/snippets/landing-common.conf; -} - # docs.mana.how — Documentation server { listen 80; diff --git a/docker/prometheus/prometheus.yml b/docker/prometheus/prometheus.yml index 4e36df09f..54133fd3f 100644 --- a/docker/prometheus/prometheus.yml +++ b/docker/prometheus/prometheus.yml @@ -240,14 +240,12 @@ scrape_configs: - https://mana.how/picture - https://mana.how/storage - https://mana.how/presi - - https://mana.how/food - https://mana.how/plants - https://mana.how/calc - https://mana.how/quotes - https://mana.how/cards - https://mana.how/skilltree - https://mana.how/music - - https://mana.how/citycorners - https://mana.how/memoro - https://mana.how/moodlit # mana.how/context: Modul wurde 2026-04-29 gedropt (Commit 1815139dc) — Probe entfernt diff --git a/load-tests/web-apps.js b/load-tests/web-apps.js index 143c222d5..656ed80cc 100644 --- a/load-tests/web-apps.js +++ b/load-tests/web-apps.js @@ -20,11 +20,9 @@ const apps = [ { name: 'storage', url: `${BASE}:5178` }, { name: 'presi', url: `${BASE}:5180` }, { name: 'cards', url: `${BASE}:5181` }, - { name: 'food', url: `${BASE}:5182` }, { name: 'skilltree', url: `${BASE}:5183` }, { name: 'photos', url: `${BASE}:5184` }, { name: 'music', url: `${BASE}:5189` }, - { name: 'citycorners', url: `${BASE}:5190` }, { name: 'picture', url: `${BASE}:5174` }, { name: 'inventory', url: `${BASE}:5191` }, ]; diff --git a/package.json b/package.json index 81f52430a..81b02344f 100644 --- a/package.json +++ b/package.json @@ -167,22 +167,10 @@ "dev:figgos:web": "pnpm --filter @figgos/web dev", "dev:figgos:ios": "pnpm --filter @figgos/mobile ios", "dev:figgos:android": "pnpm --filter @figgos/mobile android", - "citycorners:dev": "turbo run dev --filter=citycorners...", - "dev:citycorners:landing": "pnpm --filter @citycorners/landing dev", - "dev:citycorners:web": "pnpm --filter @citycorners/web dev", - "dev:citycorners:app": "pnpm dev:citycorners:web", - "dev:citycorners:full": "concurrently -n auth,sync,web -c blue,magenta,cyan \"pnpm dev:auth\" \"pnpm dev:sync\" \"pnpm dev:citycorners:web\"", - "deploy:landing:citycorners": "pnpm --filter @citycorners/landing build && npx wrangler pages deploy apps/citycorners/apps/landing/dist --project-name=citycorners-landing", "plants:dev": "turbo run dev --filter=plants...", "dev:plants:web": "pnpm --filter @plants/web dev", "dev:plants:app": "concurrently -n api,web -c yellow,cyan \"pnpm dev:api\" \"pnpm dev:plants:web\"", "dev:plants:full": "concurrently -n auth,sync,api -c blue,magenta,yellow \"pnpm dev:auth\" \"pnpm dev:sync\" \"pnpm dev:api\"", - "food:dev": "turbo run dev --filter=food...", - "dev:food:web": "pnpm --filter @food/web dev", - "dev:food:landing": "pnpm --filter @food/landing dev", - "dev:food:app": "concurrently -n api,web -c yellow,cyan \"pnpm dev:api\" \"pnpm dev:food:web\"", - "dev:food:full": "concurrently -n auth,sync,api -c blue,magenta,yellow \"pnpm dev:auth\" \"pnpm dev:sync\" \"pnpm dev:api\"", - "deploy:landing:food": "pnpm --filter @food/landing build && npx wrangler pages deploy apps/food/apps/landing/dist --project-name=food-landing", "presi:dev": "turbo run dev --filter=presi...", "dev:presi:web": "pnpm --filter @presi/web dev", "dev:presi:landing": "pnpm --filter @presi/landing dev", @@ -225,7 +213,7 @@ "deploy:landing:mail": "pnpm --filter @mail/landing build && npx wrangler pages deploy apps/mail/apps/landing/dist --project-name=mail-landing", "deploy:landing:moodlit": "pnpm --filter @moodlit/landing build && npx wrangler pages deploy apps/moodlit/apps/landing/dist --project-name=moodlit-landing", "deploy:landing:it": "pnpm --filter @mana/it-landing build && npx wrangler pages deploy services/it-landing/dist --project-name=it-landing", - "deploy:landing:all": "pnpm deploy:landing:calendar && pnpm deploy:landing:chat && pnpm deploy:landing:picture && pnpm deploy:landing:mana && pnpm deploy:landing:cards && pnpm deploy:landing:quotes && pnpm deploy:landing:presi && pnpm deploy:landing:mail && pnpm deploy:landing:food && pnpm deploy:landing:contacts && pnpm deploy:landing:todo", + "deploy:landing:all": "pnpm deploy:landing:calendar && pnpm deploy:landing:chat && pnpm deploy:landing:picture && pnpm deploy:landing:mana && pnpm deploy:landing:cards && pnpm deploy:landing:quotes && pnpm deploy:landing:presi && pnpm deploy:landing:mail && pnpm deploy:landing:contacts && pnpm deploy:landing:todo", "dev:docs": "pnpm --filter @mana/docs dev", "build:docs": "pnpm --filter @mana/docs build", "deploy:docs": "pnpm --filter @mana/docs build && npx wrangler pages deploy apps/docs/dist --project-name=mana-docs", @@ -252,7 +240,6 @@ "dev:contacts:local": "concurrently -n sync,api -c magenta,yellow \"pnpm dev:sync\" \"pnpm dev:api\"", "dev:cards:local": "concurrently -n sync,api -c magenta,yellow \"pnpm dev:sync\" \"pnpm dev:api\"", "dev:music:local": "concurrently -n sync,api -c magenta,yellow \"pnpm dev:sync\" \"pnpm dev:api\"", - "dev:food:local": "concurrently -n sync,api -c magenta,yellow \"pnpm dev:sync\" \"pnpm dev:api\"", "dev:picture:local": "concurrently -n sync,api -c magenta,yellow \"pnpm dev:sync\" \"pnpm dev:api\"", "dev:plants:local": "concurrently -n sync,api -c magenta,yellow \"pnpm dev:sync\" \"pnpm dev:api\"", "dev:questions:local": "concurrently -n sync,api -c magenta,yellow \"pnpm dev:sync\" \"pnpm dev:api\"", @@ -262,7 +249,6 @@ "dev:quotes:local": "concurrently -n sync,web -c magenta,cyan \"pnpm dev:sync\" \"pnpm dev:quotes:web\"", "dev:skilltree:local": "concurrently -n sync,web -c magenta,cyan \"pnpm dev:sync\" \"pnpm dev:skilltree:web\"", "dev:photos:local": "concurrently -n sync,web -c magenta,cyan \"pnpm dev:sync\" \"pnpm dev:photos:web\"", - "dev:citycorners:local": "concurrently -n sync,web -c magenta,cyan \"pnpm dev:sync\" \"pnpm dev:citycorners:web\"", "dev:inventory:local": "concurrently -n sync,web -c magenta,cyan \"pnpm dev:sync\" \"pnpm dev:inventory:web\"", "dev:times:local": "concurrently -n sync,web -c magenta,cyan \"pnpm dev:sync\" \"pnpm dev:times:web\"", "dev:calc:local": "concurrently -n sync,web -c magenta,cyan \"pnpm dev:sync\" \"pnpm dev:calc:web\"", diff --git a/packages/credits/src/operations.ts b/packages/credits/src/operations.ts index 92dff256b..5c8f24b8f 100644 --- a/packages/credits/src/operations.ts +++ b/packages/credits/src/operations.ts @@ -34,9 +34,6 @@ export enum CreditOperationType { AI_RESEARCH_QUICK = 'ai_research_quick', AI_RESEARCH_DEEP = 'ai_research_deep', - // Food - Food analysis - AI_FOOD_ANALYSIS = 'ai_food_analysis', - // Cards - AI deck generation AI_DECK_GENERATION = 'ai_deck_generation', AI_CARD_GENERATION = 'ai_card_generation', @@ -95,8 +92,6 @@ export const CREDIT_COSTS: Record = { [CreditOperationType.AI_RESEARCH_QUICK]: 5, [CreditOperationType.AI_RESEARCH_DEEP]: 25, - [CreditOperationType.AI_FOOD_ANALYSIS]: 3, - [CreditOperationType.AI_DECK_GENERATION]: 20, [CreditOperationType.AI_CARD_GENERATION]: 2, @@ -213,14 +208,6 @@ export const OPERATION_METADATA: Record app: 'questions', }, - // Food Analysis - [CreditOperationType.AI_FOOD_ANALYSIS]: { - name: 'Analyze Food Photo', - description: 'Analyze nutrition from a food photo', - category: CreditCategory.AI, - app: 'food', - }, - // Deck Generation [CreditOperationType.AI_DECK_GENERATION]: { name: 'Generate AI Deck', diff --git a/packages/mana-tool-registry/src/modules/comic.ts b/packages/mana-tool-registry/src/modules/comic.ts index 737e5c212..fc4f7a2b4 100644 --- a/packages/mana-tool-registry/src/modules/comic.ts +++ b/packages/mana-tool-registry/src/modules/comic.ts @@ -15,18 +15,17 @@ * existing story * * Space scope: stories live in the active space. Character references - * (meImages face-ref / wardrobe garments) likewise space-scoped after - * v40. Every tool filters `row.spaceId === ctx.spaceId` client-side - * mirroring the webapp's scopedForModule behaviour. + * (meImages face-ref) are likewise space-scoped after v40. Every tool + * filters `row.spaceId === ctx.spaceId` client-side mirroring the + * webapp's scopedForModule behaviour. * - * Why generatePanel writes the story update server-side (and - * wardrobe.tryOn doesn't): + * Why generatePanel writes the story update server-side: * A comic panel's value is its position inside a story — leaving - * the panel orphan (preview-only in wardrobe style) loses the - * story linkage and defeats the tool's purpose. So we pull the - * story row, decrypt panelMeta, append, re-encrypt, and push a - * field-level update back. A user with the webapp open will see - * the new panel via liveQuery within one sync tick. + * the panel orphan loses the story linkage and defeats the tool's + * purpose. So we pull the story row, decrypt panelMeta, append, + * re-encrypt, and push a field-level update back. A user with the + * webapp open will see the new panel via liveQuery within one sync + * tick. * * Plan: docs/plans/comic-module.md M5. */ @@ -199,10 +198,10 @@ export const comicListStories: ToolSpec process.env.MANA_SYNC_URL ?? 'http://localhost:3050'; -const PICTURE_API_URL = () => process.env.MANA_API_URL ?? 'http://localhost:3060'; -const CLIENT_ID = () => process.env.MANA_MCP_CLIENT_ID ?? 'mana-mcp'; - -function syncCfg(ctx: ToolContext) { - return { baseUrl: SYNC_URL(), jwt: ctx.jwt, clientId: CLIENT_ID() }; -} - -// ─── Domain shapes (zod) ────────────────────────────────────────── - -const garmentCategory = z.enum([ - 'top', - 'bottom', - 'dress', - 'outerwear', - 'shoes', - 'bag', - 'accessory', - 'glasses', - 'jewelry', - 'hat', - 'other', -]); -type GarmentCategory = z.infer; - -const FACE_ONLY_CATEGORIES: ReadonlySet = new Set([ - 'accessory', - 'glasses', - 'jewelry', - 'hat', -]); - -const outfitOccasion = z.enum([ - 'casual', - 'work', - 'formal', - 'workout', - 'date', - 'travel', - 'event', - 'sleep', - 'other', -]); - -const garmentSchema = z.object({ - id: z.string(), - name: z.string(), - category: garmentCategory, - mediaIds: z.array(z.string()), - brand: z.string().nullable(), - color: z.string().nullable(), - size: z.string().nullable(), - material: z.string().nullable(), - tags: z.array(z.string()), - notes: z.string().nullable(), -}); - -const outfitSchema = z.object({ - id: z.string(), - name: z.string(), - description: z.string().nullable(), - garmentIds: z.array(z.string()), - occasion: outfitOccasion.nullable(), - tags: z.array(z.string()), - isFavorite: z.boolean(), -}); - -// Raw row shapes — fields beyond what we consume are tolerated. -interface RawGarmentRow { - id?: string; - name?: string; - category?: string; - mediaIds?: string[]; - brand?: string | null; - color?: string | null; - size?: string | null; - material?: string | null; - tags?: string[] | null; - notes?: string | null; - isArchived?: boolean; - deletedAt?: string | null; - spaceId?: string | null; -} - -interface RawOutfitRow { - id?: string; - name?: string; - description?: string | null; - garmentIds?: string[]; - occasion?: string | null; - tags?: string[] | null; - isFavorite?: boolean; - isArchived?: boolean; - deletedAt?: string | null; - spaceId?: string | null; -} - -interface RawMeImageRow { - id?: string; - mediaId?: string; - primaryFor?: string | null; - deletedAt?: string | null; - spaceId?: string | null; -} - -// ─── wardrobe.listGarments ──────────────────────────────────────── - -const listGarmentsInput = z.object({ - category: garmentCategory.optional(), - /** Intersection filter: rows must contain EVERY tag listed. Empty = no filter. */ - tags: z.array(z.string()).max(10).default([]), - limit: z.number().int().positive().max(200).default(50), -}); - -const listGarmentsOutput = z.object({ - garments: z.array(garmentSchema), -}); - -export const wardrobeListGarments: ToolSpec = { - name: 'wardrobe.listGarments', - module: 'wardrobe', - scope: 'user-space', - policyHint: 'read', - description: - "List the caller's garments in the active space. Filter by `category` (closed enum) and/or `tags` (intersection — every listed tag must be present). Returns at most `limit` rows, newest first. Archived + soft-deleted items are excluded.", - input: listGarmentsInput, - output: listGarmentsOutput, - encryptedFields: { table: GARMENTS_TABLE, fields: [...GARMENT_ENCRYPTED_FIELDS] }, - async handler(input, ctx) { - const key = await ctx.getMasterKey(); - const res = await pullAll(syncCfg(ctx), GARMENTS_APP_ID, GARMENTS_TABLE); - const alive = res.changes - .filter((c) => c.op !== 'delete' && c.data) - .map((c) => c.data as RawGarmentRow) - .filter((row) => !row.deletedAt && !row.isArchived) - .filter((row) => row.spaceId === ctx.spaceId); - - const decrypted = (await Promise.all( - alive.map((row) => - decryptRecordFields( - row as unknown as Record, - GARMENT_ENCRYPTED_FIELDS, - key - ) - ) - )) as unknown as RawGarmentRow[]; - - const filtered = decrypted - .filter((row): row is RawGarmentRow & { id: string; name: string; category: string } => - Boolean(row.id && row.name && row.category) - ) - .filter((row) => !input.category || row.category === input.category) - .filter((row) => { - if (input.tags.length === 0) return true; - const rowTags = new Set(row.tags ?? []); - return input.tags.every((t) => rowTags.has(t)); - }) - .slice(0, input.limit); - - const garments = filtered.map((row) => ({ - id: row.id, - name: row.name, - category: row.category as GarmentCategory, - mediaIds: row.mediaIds ?? [], - brand: row.brand ?? null, - color: row.color ?? null, - size: row.size ?? null, - material: row.material ?? null, - tags: row.tags ?? [], - notes: row.notes ?? null, - })); - - ctx.logger.info('wardrobe.listGarments', { - count: garments.length, - category: input.category ?? 'all', - }); - - return { garments }; - }, -}; - -// ─── wardrobe.listOutfits ───────────────────────────────────────── - -const listOutfitsInput = z.object({ - occasion: outfitOccasion.optional(), - favoriteOnly: z.boolean().default(false), - limit: z.number().int().positive().max(200).default(50), -}); - -const listOutfitsOutput = z.object({ - outfits: z.array(outfitSchema), -}); - -export const wardrobeListOutfits: ToolSpec = { - name: 'wardrobe.listOutfits', - module: 'wardrobe', - scope: 'user-space', - policyHint: 'read', - description: - "List the caller's outfits in the active space. Filter by `occasion` and/or `favoriteOnly`. The returned rows include garmentIds — use `wardrobe.listGarments` to resolve them to full rows when you need more than ids.", - input: listOutfitsInput, - output: listOutfitsOutput, - encryptedFields: { table: OUTFITS_TABLE, fields: [...OUTFIT_ENCRYPTED_FIELDS] }, - async handler(input, ctx) { - const key = await ctx.getMasterKey(); - const res = await pullAll(syncCfg(ctx), OUTFITS_APP_ID, OUTFITS_TABLE); - const alive = res.changes - .filter((c) => c.op !== 'delete' && c.data) - .map((c) => c.data as RawOutfitRow) - .filter((row) => !row.deletedAt && !row.isArchived) - .filter((row) => row.spaceId === ctx.spaceId); - - const decrypted = (await Promise.all( - alive.map((row) => - decryptRecordFields(row as unknown as Record, OUTFIT_ENCRYPTED_FIELDS, key) - ) - )) as unknown as RawOutfitRow[]; - - const filtered = decrypted - .filter((row): row is RawOutfitRow & { id: string; name: string } => - Boolean(row.id && row.name) - ) - .filter((row) => !input.occasion || row.occasion === input.occasion) - .filter((row) => !input.favoriteOnly || row.isFavorite === true) - .slice(0, input.limit); - - const outfits = filtered.map((row) => ({ - id: row.id, - name: row.name, - description: row.description ?? null, - garmentIds: row.garmentIds ?? [], - occasion: (row.occasion ?? null) as z.infer | null, - tags: row.tags ?? [], - isFavorite: row.isFavorite === true, - })); - - ctx.logger.info('wardrobe.listOutfits', { - count: outfits.length, - occasion: input.occasion ?? 'all', - favoriteOnly: input.favoriteOnly, - }); - - return { outfits }; - }, -}; - -// ─── wardrobe.createOutfit ──────────────────────────────────────── - -const createOutfitInput = z.object({ - name: z.string().min(1).max(200), - garmentIds: z.array(z.string()).min(1).max(16), - description: z.string().max(2000).nullable().default(null), - occasion: outfitOccasion.nullable().default(null), - tags: z.array(z.string()).max(20).default([]), -}); - -const createOutfitOutput = z.object({ - outfit: outfitSchema, -}); - -export const wardrobeCreateOutfit: ToolSpec = { - name: 'wardrobe.createOutfit', - module: 'wardrobe', - scope: 'user-space', - policyHint: 'write', - description: - "Compose a new outfit in the active space. `garmentIds` must reference garments the caller owns in the same space — the server will persist whatever you pass (there's no cross-space validation here), so call `wardrobe.listGarments` first to confirm the ids.", - input: createOutfitInput, - output: createOutfitOutput, - encryptedFields: { table: OUTFITS_TABLE, fields: [...OUTFIT_ENCRYPTED_FIELDS] }, - async handler(input, ctx) { - const key = await ctx.getMasterKey(); - const id = crypto.randomUUID(); - const plaintext = { - id, - name: input.name, - description: input.description, - garmentIds: input.garmentIds, - occasion: input.occasion, - tags: input.tags, - isFavorite: false, - }; - - const encrypted = await encryptRecordFields( - plaintext as unknown as Record, - OUTFIT_ENCRYPTED_FIELDS, - key - ); - - await pushInsert(syncCfg(ctx), OUTFITS_APP_ID, { - table: OUTFITS_TABLE, - id, - spaceId: ctx.spaceId, - data: encrypted, - }); - - ctx.logger.info('wardrobe.createOutfit', { - outfitId: id, - garmentCount: input.garmentIds.length, - occasion: input.occasion ?? 'none', - }); - - return { - outfit: { - id, - name: input.name, - description: input.description, - garmentIds: input.garmentIds, - occasion: input.occasion, - tags: input.tags, - isFavorite: false, - }, - }; - }, -}; - -// ─── wardrobe.tryOn ─────────────────────────────────────────────── - -const tryOnInput = z.object({ - outfitId: z.string(), - /** Optional override; default is composed from the outfit's name + occasion. */ - prompt: z.string().max(2000).optional(), - /** - * Force accessory-only mode (face-only render, square 1024×1024). - * Auto-detected when every garment in the outfit is in the face- - * only category set — pass true explicitly to override on mixed - * outfits (rare). - */ - accessoryOnly: z.boolean().optional(), - quality: z.enum(['low', 'medium', 'high']).default('medium'), -}); - -const tryOnOutput = z.object({ - imageUrl: z.string(), - mediaId: z.string(), - prompt: z.string(), - model: z.string(), - referenceMediaIds: z.array(z.string()), - mode: z.literal('edit'), -}); - -export const wardrobeTryOn: ToolSpec = { - name: 'wardrobe.tryOn', - module: 'wardrobe', - // `write` rather than `destructive`: the result is additive (a new - // image in the Picture gallery) and credits are consumed at the - // standard picture-generation tarif. No existing data is overwritten. - scope: 'user-space', - policyHint: 'write', - description: - "Render the caller wearing the outfit using OpenAI gpt-image-2. Resolves the active space's primary face-ref (and body-ref when the outfit isn't accessory-only) from meImages, combines them with the outfit's garment photos, and calls the picture-generate-with-reference endpoint. Returns the generated image's URL + mana-media id. Consumes credits at the same tarif as text-to-image (medium = 10). Does NOT persist the result into the Picture gallery from here — that's deferred to avoid double-writes when a user is also on the page; treat this tool as a preview.", - input: tryOnInput, - output: tryOnOutput, - async handler(input, ctx) { - // 1. Fetch outfit + garments + meImages, decrypt what's needed. - const key = await ctx.getMasterKey(); - - const outfitsRes = await pullAll(syncCfg(ctx), OUTFITS_APP_ID, OUTFITS_TABLE); - const outfit = outfitsRes.changes - .filter((c) => c.op !== 'delete' && c.data) - .map((c) => c.data as RawOutfitRow) - .find((row) => row.id === input.outfitId && !row.deletedAt && row.spaceId === ctx.spaceId); - if (!outfit) { - throw new Error(`Outfit ${input.outfitId} not found in the active space`); - } - - const decryptedOutfit = (await decryptRecordFields( - outfit as unknown as Record, - OUTFIT_ENCRYPTED_FIELDS, - key - )) as unknown as RawOutfitRow; - - const garmentIds = decryptedOutfit.garmentIds ?? []; - if (garmentIds.length === 0) { - throw new Error('Outfit has no garments'); - } - - const garmentsRes = await pullAll(syncCfg(ctx), GARMENTS_APP_ID, GARMENTS_TABLE); - const garmentSet = new Set(garmentIds); - const relevantGarments = garmentsRes.changes - .filter((c) => c.op !== 'delete' && c.data) - .map((c) => c.data as RawGarmentRow) - .filter( - (row) => row.id && garmentSet.has(row.id) && !row.deletedAt && row.spaceId === ctx.spaceId - ); - if (relevantGarments.length === 0) { - throw new Error('None of the outfit garments exist in the active space (moved or deleted?)'); - } - - // Garment metadata we need (category, mediaIds) is plaintext; no - // decrypt round-trip needed for ref composition. - const garmentMediaIds = relevantGarments - .map((g) => g.mediaIds?.[0]) - .filter((id): id is string => Boolean(id)); - if (garmentMediaIds.length === 0) { - throw new Error('None of the outfit garments have a primary photo'); - } - - const meRes = await pullAll(syncCfg(ctx), ME_APP_ID, ME_TABLE); - const liveMeImages = meRes.changes - .filter((c) => c.op !== 'delete' && c.data) - .map((c) => c.data as RawMeImageRow) - .filter((row) => !row.deletedAt && row.spaceId === ctx.spaceId); - - const faceRef = liveMeImages.find((row) => row.primaryFor === 'face-ref'); - const bodyRef = liveMeImages.find((row) => row.primaryFor === 'body-ref'); - - if (!faceRef?.mediaId) { - throw new Error( - 'No primary face-ref meImage in the active space. Upload one via /profile/me-images.' - ); - } - - // 2. Accessory-only detection. - const allFaceOnly = relevantGarments.every((g) => - FACE_ONLY_CATEGORIES.has((g.category ?? 'other') as GarmentCategory) - ); - const accessoryOnly = input.accessoryOnly ?? allFaceOnly; - - if (!accessoryOnly && !bodyRef?.mediaId) { - throw new Error( - 'No primary body-ref meImage in the active space. Upload a fullbody photo via /profile/me-images, or pass accessoryOnly=true if the outfit is face-only.' - ); - } - - // 3. Compose reference list respecting the 8-slot server cap. - const referenceMediaIds: string[] = [faceRef.mediaId]; - if (!accessoryOnly && bodyRef?.mediaId) referenceMediaIds.push(bodyRef.mediaId); - for (const id of garmentMediaIds) { - if (referenceMediaIds.length >= 8) break; - referenceMediaIds.push(id); - } - - // 4. Compose prompt if none given. - const outfitName = decryptedOutfit.name ?? 'Outfit'; - const effectivePrompt = - input.prompt?.trim() || - (accessoryOnly - ? `Fotorealistisches Portrait von mir mit ${outfitName}, frontal, studio-Licht, neutraler Hintergrund, Fokus auf dem Accessoire` - : `Fotorealistisches Portrait von mir im Outfit ${outfitName}, natürliches Licht, neutraler Hintergrund`); - - const size: '1024x1024' | '1024x1536' = accessoryOnly ? '1024x1024' : '1024x1536'; - - // 5. Call the picture endpoint. - const res = await fetch(`${PICTURE_API_URL()}/api/v1/picture/generate-with-reference`, { - method: 'POST', - headers: { - 'content-type': 'application/json', - authorization: `Bearer ${ctx.jwt}`, - }, - body: JSON.stringify({ - prompt: effectivePrompt, - referenceMediaIds, - model: 'openai/gpt-image-2', - quality: input.quality, - size, - n: 1, - }), - }); - - if (!res.ok) { - const text = await res.text().catch(() => ''); - throw new Error( - `picture.generate-with-reference failed: ${res.status} ${res.statusText} — ${text.slice(0, 500)}` - ); - } - - const data = (await res.json()) as { - images?: Array<{ imageUrl: string; mediaId?: string }>; - imageUrl?: string; - mediaId?: string; - prompt: string; - model: string; - referenceMediaIds?: string[]; - }; - const first = - (data.images && data.images[0]) ?? - (data.imageUrl ? { imageUrl: data.imageUrl, mediaId: data.mediaId } : null); - if (!first?.imageUrl || !first.mediaId) { - throw new Error('picture endpoint returned no image'); - } - - ctx.logger.info('wardrobe.tryOn', { - outfitId: input.outfitId, - accessoryOnly, - refs: referenceMediaIds.length, - }); - - return { - imageUrl: first.imageUrl, - mediaId: first.mediaId, - prompt: data.prompt, - model: data.model, - referenceMediaIds: data.referenceMediaIds ?? referenceMediaIds, - mode: 'edit' as const, - }; - }, -}; - -// ─── Registration barrel ────────────────────────────────────────── - -export function registerWardrobeTools(): void { - registerTool(wardrobeListGarments); - registerTool(wardrobeListOutfits); - registerTool(wardrobeCreateOutfit); - registerTool(wardrobeTryOn); -} diff --git a/packages/mana-tool-registry/src/types.ts b/packages/mana-tool-registry/src/types.ts index 460757ffe..48f29b508 100644 --- a/packages/mana-tool-registry/src/types.ts +++ b/packages/mana-tool-registry/src/types.ts @@ -30,8 +30,6 @@ export type ModuleId = | 'mood' // — M5 (me-images + reference-based image generation) — | 'me' - // — Wardrobe M5 (garments + outfits + try-on) — - | 'wardrobe' // — Comic M5 (stories + panel generation from cross-module text) — | 'comic' // — Augur M5 (signs / fortunes / hunches + Living Oracle + year recap) — diff --git a/packages/shared-ai/src/tools/schemas.ts b/packages/shared-ai/src/tools/schemas.ts index 925ea94d6..840ee8995 100644 --- a/packages/shared-ai/src/tools/schemas.ts +++ b/packages/shared-ai/src/tools/schemas.ts @@ -247,7 +247,6 @@ export const AI_TOOL_CATALOG: readonly ToolSchema[] = [ enum: [ 'home', 'work', - 'food', 'shopping', 'sport', 'culture', @@ -327,39 +326,6 @@ export const AI_TOOL_CATALOG: readonly ToolSchema[] = [ ], }, - // ── Food ────────────────────────────────────────────────── - { - name: 'nutrition_summary', - module: 'food', - description: - 'Gibt die heutige Ernaehrungs-Zusammenfassung zurueck (Mahlzeiten, Kalorien, Protein)', - defaultPolicy: 'auto', - parameters: [], - }, - { - name: 'log_meal', - module: 'food', - description: 'Loggt eine Mahlzeit mit optionalen Naehrwerten', - defaultPolicy: 'auto', - parameters: [ - { - name: 'mealType', - type: 'string', - description: 'Art der Mahlzeit', - required: true, - enum: ['breakfast', 'lunch', 'dinner', 'snack'], - }, - { - name: 'description', - type: 'string', - description: 'Beschreibung der Mahlzeit', - required: true, - }, - { name: 'calories', type: 'number', description: 'Kalorien (kcal)', required: false }, - { name: 'protein', type: 'number', description: 'Protein (g)', required: false }, - ], - }, - // ── News ────────────────────────────────────────────────── { name: 'save_news_article', @@ -710,13 +676,13 @@ export const AI_TOOL_CATALOG: readonly ToolSchema[] = [ name: 'eventType', type: 'string', description: - 'Domain-Event zum Zaehlen (z.B. "DrinkLogged", "TaskCompleted", "MealLogged", "WorkoutFinished")', + 'Domain-Event zum Zaehlen (z.B. "DrinkLogged", "TaskCompleted", "WorkoutFinished")', required: false, }, { name: 'moduleId', type: 'string', - description: 'Zugehoeriges Modul (z.B. "drink", "todo", "food", "body")', + description: 'Zugehoeriges Modul (z.B. "drink", "todo", "body")', required: false, }, ], @@ -1049,7 +1015,6 @@ export const AI_TOOL_CATALOG: readonly ToolSchema[] = [ 'art', 'tech', 'sport', - 'food', 'family', 'nature', 'education', diff --git a/packages/shared-auth-ui/src/components/GuestWelcomeModal.svelte b/packages/shared-auth-ui/src/components/GuestWelcomeModal.svelte index 5328834a4..92890c52e 100644 --- a/packages/shared-auth-ui/src/components/GuestWelcomeModal.svelte +++ b/packages/shared-auth-ui/src/components/GuestWelcomeModal.svelte @@ -52,7 +52,6 @@ moodlit: ['Dein Raum, deine Stimmung', 'Quelloffen & unabhängig', 'Privat by Design'], calc: ['Rechnen ohne Ablenkung', 'Quelloffen & unabhängig', 'Privat by Design'], guides: ['Anleitungen, die funktionieren', 'Quelloffen & unabhängig', 'Privat by Design'], - citycorners: ['Entdecke deine Stadt', 'Quelloffen & unabhängig', 'Privat by Design'], plants: ['Pflanzenpflege leicht gemacht', 'Quelloffen & unabhängig', 'Privat by Design'], photos: ['Deine Fotos, deine Galerie', 'Quelloffen & unabhängig', 'Privat by Design'], questions: ['Recherche mit System', 'Quelloffen & unabhängig', 'Privat by Design'], @@ -65,7 +64,6 @@ uload: ['Links kürzen & verwalten', 'Quelloffen & unabhängig', 'Privat by Design'], news: ['Nachrichten, kuratiert für dich', 'Quelloffen & unabhängig', 'Privat by Design'], skilltree: ['Dein Fortschritt, sichtbar', 'Quelloffen & unabhängig', 'Privat by Design'], - food: ['Ernährung bewusst leben', 'Quelloffen & unabhängig', 'Privat by Design'], wisekeep: ['Wissen bewahren & teilen', 'Quelloffen & unabhängig', 'Privat by Design'], memoro: ['Sprache wird zu Wissen', 'Quelloffen & unabhängig', 'Privat by Design'], }; @@ -84,7 +82,6 @@ moodlit: ['Your space, your mood', 'Open-source & independent', 'Private by design'], calc: ['Calculate without distraction', 'Open-source & independent', 'Private by design'], guides: ['Guides that actually work', 'Open-source & independent', 'Private by design'], - citycorners: ['Discover your city', 'Open-source & independent', 'Private by design'], plants: ['Plant care made simple', 'Open-source & independent', 'Private by design'], photos: ['Your photos, your gallery', 'Open-source & independent', 'Private by design'], questions: ['Research with structure', 'Open-source & independent', 'Private by design'], @@ -97,7 +94,6 @@ uload: ['Shorten & manage links', 'Open-source & independent', 'Private by design'], news: ['News, curated for you', 'Open-source & independent', 'Private by design'], skilltree: ['Your progress, visualized', 'Open-source & independent', 'Private by design'], - food: ['Mindful nutrition tracking', 'Open-source & independent', 'Private by design'], wisekeep: ['Preserve & share knowledge', 'Open-source & independent', 'Private by design'], memoro: ['Voice becomes knowledge', 'Open-source & independent', 'Private by design'], }; diff --git a/packages/shared-branding/src/app-icons.ts b/packages/shared-branding/src/app-icons.ts index 83d35cd57..920b165bf 100644 --- a/packages/shared-branding/src/app-icons.ts +++ b/packages/shared-branding/src/app-icons.ts @@ -36,9 +36,6 @@ const wisekeepSvg = ``; -// Food icon (nutrition/heart with gradient) -const foodSvg = ``; - // Contacts icon (address book/person with gradient) const contactsSvg = ``; @@ -63,9 +60,6 @@ const inventorySvg = ``; -// CityCorners icon (map pin with blue gradient) -const citycornersSvg = ``; - // Taktik icon (clock with play button, amber gradient) const timesSvg = ``; @@ -73,16 +67,9 @@ const timesSvg = ``; // Comic icon — speech bubble with a lightning-bolt panel marker on -// orange→red gradient. Sits warm between Picture (green) and Wardrobe -// (rose) so the Mana launcher reads as a coherent creative family. +// orange→red gradient. Warm creative-family tone for the Mana launcher. const comicSvg = ``; -// Wardrobe icon — T-shirt on hanger with rose-violet gradient. -// Rose/violet to sit between Picture (green) and Calc (pink) without -// clashing; the hanger loop sits on the shoulder line so the silhouette -// reads as "clothing" at any scale. -const wardrobeSvg = ``; - // Augur icon — open eye with a small star in the iris and three drifting // dots ("signs in the air") on indigo→violet gradient. Sits in the cosmic // family next to Dreams (indigo) and Cards (violet) so the launcher reads @@ -109,7 +96,6 @@ export const APP_ICONS = { quotes: svgToDataUrl(quotesSvg), wisekeep: svgToDataUrl(wisekeepSvg), moodlit: svgToDataUrl(moodlitSvg), - food: svgToDataUrl(foodSvg), contacts: svgToDataUrl(contactsSvg), calendar: svgToDataUrl(calendarSvg), storage: svgToDataUrl(storageSvg), @@ -117,11 +103,9 @@ export const APP_ICONS = { todo: svgToDataUrl(todoSvg), mail: svgToDataUrl(mailSvg), inventory: svgToDataUrl(inventorySvg), - wardrobe: svgToDataUrl(wardrobeSvg), comic: svgToDataUrl(comicSvg), augur: svgToDataUrl(augurSvg), questions: svgToDataUrl(questionsSvg), - citycorners: svgToDataUrl(citycornersSvg), times: svgToDataUrl(timesSvg), calc: svgToDataUrl(calcSvg), uload: svgToDataUrl( @@ -178,7 +162,7 @@ export const APP_ICONS = { body: svgToDataUrl( // Dumbbell + heart-pulse hybrid: training (barbell) + body (pulse line). // Red→orange gradient to set it apart from the green health-adjacent - // modules (plants, food) and the pink period icon. + // modules (plants) and the pink period icon. `` ), firsts: svgToDataUrl( diff --git a/packages/shared-branding/src/config.ts b/packages/shared-branding/src/config.ts index 03028a8af..8bffd8d98 100644 --- a/packages/shared-branding/src/config.ts +++ b/packages/shared-branding/src/config.ts @@ -83,19 +83,6 @@ export const APP_BRANDING = { logoStroke: true, logoStrokeWidth: 1.5, }, - food: { - id: 'food', - name: 'Food', - tagline: 'AI Nutrition Tracker', - primaryColor: '#10b981', - secondaryColor: '#34d399', - // Heart with sparkle for healthy nutrition - logoPath: - 'M21 8.25c0-2.485-2.099-4.5-4.688-4.5-1.935 0-3.597 1.126-4.312 2.733-.715-1.607-2.377-2.733-4.313-2.733C5.1 3.75 3 5.765 3 8.25c0 7.22 9 12 9 12s9-4.78 9-12z', - logoViewBox: '0 0 24 24', - logoStroke: true, - logoStrokeWidth: 1.5, - }, quotes: { id: 'quotes', name: 'Quotes', @@ -289,19 +276,6 @@ export const APP_BRANDING = { logoStroke: true, logoStrokeWidth: 1.5, }, - citycorners: { - id: 'citycorners', - name: 'CityCorners', - tagline: 'City Guide', - primaryColor: '#2563eb', - secondaryColor: '#3b82f6', - // Map pin / compass icon - logoPath: - 'M15 10.5a3 3 0 11-6 0 3 3 0 016 0z M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1115 0z', - logoViewBox: '0 0 24 24', - logoStroke: true, - logoStrokeWidth: 1.5, - }, } satisfies Record; /** Derived from `APP_BRANDING` keys — single source of truth. */ diff --git a/packages/shared-branding/src/index.ts b/packages/shared-branding/src/index.ts index 1b9c5919c..cbdb38996 100644 --- a/packages/shared-branding/src/index.ts +++ b/packages/shared-branding/src/index.ts @@ -21,7 +21,6 @@ export { UloadLogo, ChatLogo, PresiLogo, - FoodLogo, QuotesLogo, ContactsLogo, CalendarLogo, @@ -36,7 +35,6 @@ export { PlantsLogo, LightWriteLogo, MusicLogo, - CitycornersLogo, } from './logos'; // Configuration diff --git a/packages/shared-branding/src/logos/CitycornersLogo.svelte b/packages/shared-branding/src/logos/CitycornersLogo.svelte deleted file mode 100644 index 3ecc2b59d..000000000 --- a/packages/shared-branding/src/logos/CitycornersLogo.svelte +++ /dev/null @@ -1,13 +0,0 @@ - - - diff --git a/packages/shared-branding/src/logos/FoodLogo.svelte b/packages/shared-branding/src/logos/FoodLogo.svelte deleted file mode 100644 index c472dc6a2..000000000 --- a/packages/shared-branding/src/logos/FoodLogo.svelte +++ /dev/null @@ -1,13 +0,0 @@ - - - diff --git a/packages/shared-branding/src/logos/index.ts b/packages/shared-branding/src/logos/index.ts index 82bb2945b..ff2f34dae 100644 --- a/packages/shared-branding/src/logos/index.ts +++ b/packages/shared-branding/src/logos/index.ts @@ -8,7 +8,6 @@ export { default as CardsLogo } from './CardsLogo.svelte'; export { default as UloadLogo } from './UloadLogo.svelte'; export { default as ChatLogo } from './ChatLogo.svelte'; export { default as PresiLogo } from './PresiLogo.svelte'; -export { default as FoodLogo } from './FoodLogo.svelte'; export { default as QuotesLogo } from './QuotesLogo.svelte'; export { default as ContactsLogo } from './ContactsLogo.svelte'; export { default as CalendarLogo } from './CalendarLogo.svelte'; @@ -23,4 +22,3 @@ export { default as SkillTreeLogo } from './SkillTreeLogo.svelte'; export { default as PlantsLogo } from './PlantsLogo.svelte'; export { default as LightWriteLogo } from './LightWriteLogo.svelte'; export { default as MusicLogo } from './MusicLogo.svelte'; -export { default as CitycornersLogo } from './CitycornersLogo.svelte'; diff --git a/packages/shared-branding/src/mana-apps.ts b/packages/shared-branding/src/mana-apps.ts index 670690fc6..426804c81 100644 --- a/packages/shared-branding/src/mana-apps.ts +++ b/packages/shared-branding/src/mana-apps.ts @@ -242,23 +242,6 @@ export const MANA_APPS: ManaApp[] = [ requiredTier: 'guest', archived: true, }, - { - id: 'food', - name: 'Food', - description: { - de: 'KI Ernährungstracker', - en: 'AI Nutrition Tracker', - }, - longDescription: { - de: 'Tracke deine Ernährung mit KI-gestützter Foto-Analyse und erhalte detaillierte Nährwertinformationen.', - en: 'Track your nutrition with AI-powered photo analysis and get detailed nutritional information.', - }, - icon: APP_ICONS.food, - color: '#10b981', - comingSoon: false, - status: 'development', - requiredTier: 'guest', - }, { id: 'contacts', name: 'Kontakte', @@ -379,23 +362,6 @@ export const MANA_APPS: ManaApp[] = [ status: 'beta', requiredTier: 'guest', }, - { - id: 'wardrobe', - name: 'Wardrobe', - description: { - de: 'Dein digitaler Kleiderschrank', - en: 'Your digital wardrobe', - }, - longDescription: { - de: 'Fotografiere Kleidungsstücke, komponiere Outfits und probiere sie mit KI an dir selbst an — vom eigenen Schrank bis zu Brillen, Vereinstrikots und Merch.', - en: 'Photograph garments, compose outfits, and try them on yourself with AI — from your own closet to glasses, club jerseys, and brand merch.', - }, - icon: APP_ICONS.wardrobe, - color: '#e11d48', - comingSoon: false, - status: 'beta', - requiredTier: 'guest', // LOCAL TIER PATCH — revert to 'beta' before release - }, { id: 'comic', name: 'Comic', @@ -447,23 +413,6 @@ export const MANA_APPS: ManaApp[] = [ status: 'beta', requiredTier: 'guest', }, - { - id: 'citycorners', - name: 'CityCorners', - description: { - de: 'Stadtführer für Konstanz', - en: 'City Guide for Konstanz', - }, - longDescription: { - de: 'Entdecke Sehenswürdigkeiten, Restaurants, Museen und Läden in Konstanz am Bodensee.', - en: 'Discover sights, restaurants, museums, and shops in Konstanz at Lake Constance.', - }, - icon: APP_ICONS.citycorners, - color: '#2563eb', - comingSoon: false, - status: 'beta', - requiredTier: 'guest', - }, { id: 'uload', name: 'uLoad', diff --git a/packages/shared-branding/src/onboarding-templates.ts b/packages/shared-branding/src/onboarding-templates.ts index 3617d1a49..11555beb2 100644 --- a/packages/shared-branding/src/onboarding-templates.ts +++ b/packages/shared-branding/src/onboarding-templates.ts @@ -59,14 +59,14 @@ export const ONBOARDING_TEMPLATES: readonly OnboardingTemplate[] = [ name: 'Health', shortDescription: 'Gesundheit, Stimmung, Ernährung, Zyklus', iconName: 'Heart', - moduleIds: ['habits', 'body', 'mood', 'food', 'period'], + moduleIds: ['habits', 'body', 'mood', 'period'], }, { id: 'sport', name: 'Sport', shortDescription: 'Training, Ziele, Körper, Ernährung', iconName: 'Barbell', - moduleIds: ['habits', 'body', 'food', 'goals', 'stretch'], + moduleIds: ['habits', 'body', 'goals', 'stretch'], }, { id: 'lernen', @@ -80,7 +80,7 @@ export const ONBOARDING_TEMPLATES: readonly OnboardingTemplate[] = [ name: 'Entdecken', shortDescription: 'Orte, Fotos, Musik, Wetter', iconName: 'Compass', - moduleIds: ['places', 'citycorners', 'photos', 'music', 'wetter'], + moduleIds: ['places', 'photos', 'music', 'wetter'], }, { id: 'erinnern', diff --git a/packages/shared-types/src/spaces.ts b/packages/shared-types/src/spaces.ts index c11e615c0..f230c7a0c 100644 --- a/packages/shared-types/src/spaces.ts +++ b/packages/shared-types/src/spaces.ts @@ -88,7 +88,6 @@ export const SPACE_MODULE_ALLOWLIST: Record - track.food('meal_added', { meal_type: mealType, input_type: inputType }), - mealDeleted: () => track.food('meal_deleted'), - photoAnalyzed: () => track.food('photo_analyzed'), - textAnalyzed: () => track.food('text_analyzed'), - goalsUpdated: () => track.food('goals_updated'), - favoriteSaved: () => track.food('favorite_saved'), - favoriteUsed: () => track.food('favorite_used'), -}; - /** * Plants App Events */ @@ -581,10 +565,3 @@ export const MoodlitEvents = { sequenceCreated: () => track.moodlit('sequence_created'), sequenceDeleted: () => track.moodlit('sequence_deleted'), }; - -/** - * CityCorners App Events - */ -export const CityCornersEvents = { - favoriteToggled: (favorited: boolean) => track.citycorners('favorite_toggled', { favorited }), -}; diff --git a/packages/spiral-db/src/schema.ts b/packages/spiral-db/src/schema.ts index 2f3ea41f0..a5a396353 100644 --- a/packages/spiral-db/src/schema.ts +++ b/packages/spiral-db/src/schema.ts @@ -188,10 +188,8 @@ export const MANA_APP_INDEX: Record = { cards: 11, photos: 12, skilltree: 13, - citycorners: 14, inventory: 15, times: 16, - food: 17, plants: 18, questions: 19, moodlit: 20, diff --git a/packages/website-blocks/src/moduleEmbed/schema.ts b/packages/website-blocks/src/moduleEmbed/schema.ts index 4a964fa06..cb72ec761 100644 --- a/packages/website-blocks/src/moduleEmbed/schema.ts +++ b/packages/website-blocks/src/moduleEmbed/schema.ts @@ -34,7 +34,6 @@ export const EmbedSourceSchema = z.enum([ 'goals.goals', 'places.places', 'recipes.recipes', - 'wardrobe.outfits', 'comic.stories', 'habits.habits', 'quiz.quizzes', diff --git a/scripts/generate-env.mjs b/scripts/generate-env.mjs index 32b3cd6bf..8d5c41608 100644 --- a/scripts/generate-env.mjs +++ b/scripts/generate-env.mjs @@ -326,33 +326,8 @@ const APP_CONFIGS = [ }, // Food Server (Hono/Bun) - { - path: 'apps/food/apps/server/.env', - vars: { - NODE_ENV: () => 'development', - PORT: (env) => env.FOOD_BACKEND_PORT || '3002', - DATABASE_URL: (env) => env.FOOD_DATABASE_URL, - MANA_AUTH_URL: (env) => env.MANA_AUTH_URL, - GEMINI_API_KEY: (env) => env.FOOD_GEMINI_API_KEY, - S3_ENDPOINT: (env) => env.FOOD_S3_ENDPOINT, - S3_ACCESS_KEY_ID: (env) => env.FOOD_S3_ACCESS_KEY_ID, - S3_SECRET_ACCESS_KEY: (env) => env.FOOD_S3_SECRET_ACCESS_KEY, - S3_BUCKET_NAME: (env) => env.FOOD_S3_BUCKET_NAME, - S3_REGION: (env) => env.FOOD_S3_REGION, - S3_PUBLIC_URL: (env) => env.FOOD_S3_PUBLIC_URL, - }, - }, // Food Web (SvelteKit) - { - path: 'apps/food/apps/web/.env', - vars: { - PUBLIC_BACKEND_URL: (env) => `http://localhost:${env.FOOD_BACKEND_PORT || '3002'}`, - PUBLIC_MANA_AUTH_URL: (env) => env.MANA_AUTH_URL, - PUBLIC_MIDDLEWARE_APP_ID: (env) => env.FOOD_APP_ID || 'food', - PUBLIC_GLITCHTIP_DSN: (env) => env.PUBLIC_GLITCHTIP_DSN || '', - }, - }, // Quotes Backend: REMOVED — migrated to local-first @@ -590,17 +565,7 @@ const APP_CONFIGS = [ }, }, - // CityCorners Backend: REMOVED — migrated to local-first - - // CityCorners Web (SvelteKit) - { - path: 'apps/citycorners/apps/web/.env', - vars: { - PUBLIC_BACKEND_URL: (env) => `http://localhost:${env.CITYCORNERS_BACKEND_PORT || '3025'}`, - PUBLIC_MANA_AUTH_URL: (env) => env.MANA_AUTH_URL, - PUBLIC_GLITCHTIP_DSN: (env) => env.PUBLIC_GLITCHTIP_DSN || '', - }, - }, + // CityCorners: REMOVED — module entfernt 2026-05-18 // TechBase: REMOVED @@ -785,12 +750,6 @@ const APP_CONFIGS = [ }, // Food Landing - { - path: 'apps/food/apps/landing/.env', - vars: { - PUBLIC_UMAMI_WEBSITE_ID: (env) => env.UMAMI_WEBSITE_ID_FOOD_LANDING || '', - }, - }, // Presi Landing { diff --git a/scripts/i18n-hardcoded-baseline.json b/scripts/i18n-hardcoded-baseline.json index 639328ca1..1f5aa21a6 100644 --- a/scripts/i18n-hardcoded-baseline.json +++ b/scripts/i18n-hardcoded-baseline.json @@ -54,7 +54,6 @@ "apps/mana/apps/web/src/lib/modules/articles/views/HighlightsView.svelte": 4, "apps/mana/apps/web/src/lib/modules/articles/widgets/ArticlesUnreadWidget.svelte": 1, "apps/mana/apps/web/src/lib/modules/augur/SharedAugurEntryView.svelte": 1, - "apps/mana/apps/web/src/lib/modules/body/components/CalorieWeightChart.svelte": 1, "apps/mana/apps/web/src/lib/modules/body/components/ExerciseProgressionChart.svelte": 1, "apps/mana/apps/web/src/lib/modules/body/components/PhaseManager.svelte": 1, "apps/mana/apps/web/src/lib/modules/body/components/RoutineManager.svelte": 3, diff --git a/scripts/i18n-missing-baseline.json b/scripts/i18n-missing-baseline.json index 9ba66e6ed..729fc08f8 100644 --- a/scripts/i18n-missing-baseline.json +++ b/scripts/i18n-missing-baseline.json @@ -33,14 +33,6 @@ "apps/mana/apps/web/src/lib/modules/times/components/EntryList.svelte": 2, "apps/mana/apps/web/src/lib/modules/times/components/TimerCard.svelte": 6, "apps/mana/apps/web/src/lib/modules/times/components/TimerIndicator.svelte": 2, - "apps/mana/apps/web/src/lib/modules/wardrobe/components/CategoryTabs.svelte": 1, - "apps/mana/apps/web/src/lib/modules/wardrobe/components/GarmentCard.svelte": 1, - "apps/mana/apps/web/src/lib/modules/wardrobe/components/GarmentForm.svelte": 1, - "apps/mana/apps/web/src/lib/modules/wardrobe/components/OutfitCard.svelte": 1, - "apps/mana/apps/web/src/lib/modules/wardrobe/components/OutfitComposer.svelte": 3, - "apps/mana/apps/web/src/lib/modules/wardrobe/views/DetailGarmentView.svelte": 1, - "apps/mana/apps/web/src/lib/modules/wardrobe/views/DetailOutfitView.svelte": 3, - "apps/mana/apps/web/src/lib/modules/wardrobe/views/GridView.svelte": 2, "apps/mana/apps/web/src/lib/modules/writing/components/BriefingForm.svelte": 2, "apps/mana/apps/web/src/lib/modules/writing/components/ReferencePicker.svelte": 1, "apps/mana/apps/web/src/lib/modules/writing/views/DetailView.svelte": 2, diff --git a/scripts/mac-mini/build-landings.sh b/scripts/mac-mini/build-landings.sh index 9718779a3..dd81019ac 100755 --- a/scripts/mac-mini/build-landings.sh +++ b/scripts/mac-mini/build-landings.sh @@ -24,8 +24,6 @@ declare -A LANDINGS=( ["presi"]="apps/presi/apps/landing" ["clock"]="apps/clock/apps/landing" ["cards"]="apps/cards/apps/landing" - ["food"]="apps/food/apps/landing" - ["citycorners"]="apps/citycorners/apps/landing" ) cd "$PROJECT_ROOT" diff --git a/scripts/validate-i18n-keys.mjs b/scripts/validate-i18n-keys.mjs index bbb3f1a45..0852ff883 100644 --- a/scripts/validate-i18n-keys.mjs +++ b/scripts/validate-i18n-keys.mjs @@ -72,7 +72,9 @@ function scanUsages() { const dynamicPrefixes = new Set(); for (const f of files) { - const src = readFileSync(join(REPO_ROOT, f), 'utf8'); + const abs = join(REPO_ROOT, f); + if (!existsSync(abs)) continue; // tracked but deleted-on-disk (rm without commit) + const src = readFileSync(abs, 'utf8'); // $_('a.b.c') or _('a.b.c') for (const m of src.matchAll(/\$?_\(\s*['"]([a-zA-Z][\w.-]*)['"]/g)) { @@ -177,7 +179,9 @@ function main() { } if (violations.length > 0) { - console.error(`\n✗ i18n missing-key check FAILED — ${violations.length} file(s) over baseline:\n`); + console.error( + `\n✗ i18n missing-key check FAILED — ${violations.length} file(s) over baseline:\n` + ); for (const v of violations.slice(0, 20)) { console.error(` ${v.file}: ${v.current} (was ${v.baseline}, +${v.delta})`); for (const k of v.keys.slice(0, 3)) console.error(` - ${k}`); diff --git a/scripts/validate-no-recursive-turbo.mjs b/scripts/validate-no-recursive-turbo.mjs index f64cb7e3e..a9ec90352 100755 --- a/scripts/validate-no-recursive-turbo.mjs +++ b/scripts/validate-no-recursive-turbo.mjs @@ -20,7 +20,7 @@ */ import { execSync } from 'node:child_process'; -import { readFileSync } from 'node:fs'; +import { readFileSync, existsSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; @@ -50,8 +50,9 @@ function validate() { for (const rel of paths) { if (rel === rootRel) continue; // root is ALLOWED to orchestrate turbo - scanned++; const abs = join(REPO_ROOT, rel); + if (!existsSync(abs)) continue; // tracked but deleted-on-disk (rm without commit) + scanned++; let pkg; try { pkg = JSON.parse(readFileSync(abs, 'utf8'));