From 33f730ff946118c532159263f436e66821c974d1 Mon Sep 17 00:00:00 2001 From: Robert Hamilton Date: Thu, 9 Jul 2026 16:14:21 -0500 Subject: [PATCH 1/3] feat(today): Today dashboard, one-tap meal logging & meal verdicts (006) - meal_logs (0009): append-only with verdict/notes annotation window, insert-time type<->reference validation, cross-user source guards, no DELETE; portion_events triggered_by gains a real FK + ownership guard; 26 new pgTAP tests - src/lib/log: types, service, runes store; one-tap orchestration reuses the portion ledger (CONSUMED events attributed to the log) and flips PLANNED->LOGGED (first writer of the transition shipped in 003) - /today home surface per design/screens/*-today.html: coverage chips, gentle nudge with device-local dismissal, meal cards with single primary action, Quick Log sheet (proper dialog a11y: Escape/backdrop/focus-restore/inert), recap, attention list - 3-way verdict (Again / Fine / Not for me) supersedes the 4-value MealRating per the canonical mockups; keepers/recents derived from logs at query time - gates: check 0/0, lint clean, 266 unit (45 new), 99 pgTAP, 25 e2e (8 new), build ok; adversarial review criticals fixed (SET NULL cascade carve-out, triggered_by guard, sheet stays open on failed insert) --- .prettierignore | 3 + .../checklists/requirements.md | 37 ++ .../006-today-logging/contracts/logService.md | 72 +++ .../features/006-today-logging/data-model.md | 167 ++++++ .specswarm/features/006-today-logging/plan.md | 144 +++++ .../features/006-today-logging/quickstart.md | 41 ++ .../features/006-today-logging/research.md | 92 ++++ .specswarm/features/006-today-logging/spec.md | 259 +++++++++ .../features/006-today-logging/tasks.md | 89 ++++ .specswarm/metrics.json | 15 + .../quality-analysis-006-today-logging.md | 70 +++ .../components/shared/VerdictControl.svelte | 56 ++ src/lib/components/today/AttentionList.svelte | 100 ++++ .../components/today/DayCoverageCard.svelte | 122 +++++ src/lib/components/today/NudgeBanner.svelte | 86 +++ src/lib/components/today/QuickLogSheet.svelte | 490 +++++++++++++++++ src/lib/components/today/RecapCard.svelte | 88 ++++ .../components/today/TodayDashboard.svelte | 495 ++++++++++++++++++ src/lib/components/today/TodayMealCard.svelte | 254 +++++++++ src/lib/database.types.ts | 82 ++- src/lib/log/derive.spec.ts | 186 +++++++ src/lib/log/derive.ts | 182 +++++++ src/lib/log/index.ts | 58 ++ src/lib/log/logService.spec.ts | 217 ++++++++ src/lib/log/logService.ts | 150 ++++++ src/lib/log/logStore.spec.ts | 233 +++++++++ src/lib/log/logStore.svelte.ts | 261 +++++++++ src/lib/log/slots.spec.ts | 116 ++++ src/lib/log/slots.ts | 74 +++ src/lib/log/types.ts | 88 ++++ src/lib/pantry/preppedMealService.ts | 9 +- src/lib/pantry/preppedMealStore.svelte.ts | 5 +- src/lib/planning/planStore.svelte.ts | 11 + src/routes/+layout.svelte | 4 +- src/routes/+page.svelte | 7 +- src/routes/today/+page.svelte | 5 + supabase/migrations/0009_meal_logs.sql | 248 +++++++++ supabase/tests/meal_logs_rls.test.sql | 244 +++++++++ tests/today.e2e.ts | 402 ++++++++++++++ 39 files changed, 5251 insertions(+), 11 deletions(-) create mode 100644 .specswarm/features/006-today-logging/checklists/requirements.md create mode 100644 .specswarm/features/006-today-logging/contracts/logService.md create mode 100644 .specswarm/features/006-today-logging/data-model.md create mode 100644 .specswarm/features/006-today-logging/plan.md create mode 100644 .specswarm/features/006-today-logging/quickstart.md create mode 100644 .specswarm/features/006-today-logging/research.md create mode 100644 .specswarm/features/006-today-logging/spec.md create mode 100644 .specswarm/features/006-today-logging/tasks.md create mode 100644 .specswarm/quality-analysis-006-today-logging.md create mode 100644 src/lib/components/shared/VerdictControl.svelte create mode 100644 src/lib/components/today/AttentionList.svelte create mode 100644 src/lib/components/today/DayCoverageCard.svelte create mode 100644 src/lib/components/today/NudgeBanner.svelte create mode 100644 src/lib/components/today/QuickLogSheet.svelte create mode 100644 src/lib/components/today/RecapCard.svelte create mode 100644 src/lib/components/today/TodayDashboard.svelte create mode 100644 src/lib/components/today/TodayMealCard.svelte create mode 100644 src/lib/log/derive.spec.ts create mode 100644 src/lib/log/derive.ts create mode 100644 src/lib/log/index.ts create mode 100644 src/lib/log/logService.spec.ts create mode 100644 src/lib/log/logService.ts create mode 100644 src/lib/log/logStore.spec.ts create mode 100644 src/lib/log/logStore.svelte.ts create mode 100644 src/lib/log/slots.spec.ts create mode 100644 src/lib/log/slots.ts create mode 100644 src/lib/log/types.ts create mode 100644 src/routes/today/+page.svelte create mode 100644 supabase/migrations/0009_meal_logs.sql create mode 100644 supabase/tests/meal_logs_rls.test.sql create mode 100644 tests/today.e2e.ts diff --git a/.prettierignore b/.prettierignore index 95cabd0..09bc019 100644 --- a/.prettierignore +++ b/.prettierignore @@ -19,5 +19,8 @@ skills-lock.json # SpecSwarm planning artifacts (generated by /ss:* commands, not hand-edited source) /.specswarm/ +# Impeccable design-hook cache (generated, gitignored) +/.impeccable/ + # GitHub Actions workflows (must stay byte-for-byte identical to main for claude-code-action validation) /.github/ diff --git a/.specswarm/features/006-today-logging/checklists/requirements.md b/.specswarm/features/006-today-logging/checklists/requirements.md new file mode 100644 index 0000000..8957f57 --- /dev/null +++ b/.specswarm/features/006-today-logging/checklists/requirements.md @@ -0,0 +1,37 @@ +# Specification Quality Checklist: Today dashboard, one-tap meal logging & meal verdicts + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-07-09 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) — seam names cited only as existing contract references +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain — all 5 resolved in clarification session 2026-07-09 +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Notes + +- Clarification session 2026-07-09 resolved: verdict enum shape (mockups supersede the + 4-value MealRating), append-only vs. late verdicts (annotation window), five log types + vs. two-invariant coverage (insert-time validation), undo (deferred, A-004), and the + nudge banner's independence from the reminders backend. Spec is ready for planning. diff --git a/.specswarm/features/006-today-logging/contracts/logService.md b/.specswarm/features/006-today-logging/contracts/logService.md new file mode 100644 index 0000000..04ab736 --- /dev/null +++ b/.specswarm/features/006-today-logging/contracts/logService.md @@ -0,0 +1,72 @@ +# Contract: log module + +PostgREST tables touched: `meal_logs` (new, INSERT/SELECT/UPDATE), `planned_meals` +(UPDATE status safe-flip), `portion_events` (INSERT via existing pantry consume path), +`prepped_meals` (SELECT via existing store). + +```ts +// src/lib/log/logService.ts — stateless; every fn awaits requireSession(); errors +// rethrown as LogError with calm message; no owner filters (RLS authoritative). + +/** Insert one meal log from a draft. Returns the persisted, mapped row. */ +export async function createLog(draft: MealLogDraft, loggedAt?: string): Promise; + +/** Set or clear the verdict on an existing log (annotation window, R2). */ +export async function setVerdict(logId: string, verdict: MealVerdict | null): Promise; + +/** Logs in [fromISO, toISO) for the current user, newest first. */ +export async function fetchLogsBetween(fromISO: string, toISO: string): Promise; + +/** Latest `limit` non-QUICK_LOG logs (for recents/keepers grouping). */ +export async function fetchRecentLogs(limit?: number): Promise; // default 100 + +/** Safe-flip: PLANNED → LOGGED with logged_at; returns false if already flipped. */ +export async function markPlannedMealLogged(plannedMealId: string, loggedAt: string): Promise; +``` + +```ts +// src/lib/log/logStore.svelte.ts — getter-function store (planStore pattern). +export function todayLogs(): MealLog[]; +export function recentSources(): LoggedSource[]; +export function logLoading(): boolean; +export function logError(): string | null; +export function logNotice(): string | null; // calm partial-failure notice (FR-TL-014) + +export async function loadToday(now?: Date): Promise; +export async function loadRecents(): Promise; + +/** + * Orchestrated one-tap log (R5): optimistic append → createLog → + * FROM_PREPPED: optimisticConsumePortions(preppedMealId, servings, { triggeredBy: log.id }) + * FROM_PLAN: markPlannedMealLogged(plannedMealId, log.loggedAt) + * Insert failure → rollback optimistic row + logError. + * Follow-up failure → keep log + logNotice, never throws. + */ +export async function logMeal(draft: MealLogDraft): Promise; + +/** Optimistic verdict set/clear on a log (recap card + sheet footer). */ +export async function rateLog(logId: string, verdict: MealVerdict | null): Promise; +``` + +```ts +// src/lib/pantry — additive, backwards-compatible change: +export async function consumePortions( + preppedMealId: string, + count: number, + opts?: { triggeredBy?: string } // meal_logs.id → portion_events.triggered_by +): Promise; +// optimisticConsumePortions gains the same optional opts pass-through. +``` + +## Behavioral guarantees + +| Guarantee | Backstop | +|-----------|----------| +| A log's occurrence fields never change after insert | DB trigger `meal_logs_annotation_only` (pgTAP both ways) | +| No log can be deleted (v1) | No DELETE policy + no DELETE grant (pgTAP) | +| FROM_PLAN/FROM_PREPPED/FROM_RECIPE always born with their reference; non-quick logs always born named | DB trigger `meal_logs_validate_source` (pgTAP) | +| A log can never reference another user's plan/recipe/prepped meal | SECURITY DEFINER ownership trigger (pgTAP cross-user) | +| Prepped consumption can't go negative and never writes the count directly | existing `sync_portions_remaining` trigger (pgTAP in 0004's suite) | +| Planned meal flips PLANNED→LOGGED at most once | safe-flip UPDATE predicate returns 0 rows on repeat (unit test) | +| One rapid double-tap ⇒ one log | optimistic in-flight guard on the card button (e2e) | +| Users see only their own logs | RLS owner policies (pgTAP both directions) | diff --git a/.specswarm/features/006-today-logging/data-model.md b/.specswarm/features/006-today-logging/data-model.md new file mode 100644 index 0000000..f08170f --- /dev/null +++ b/.specswarm/features/006-today-logging/data-model.md @@ -0,0 +1,167 @@ +# Data model: Today dashboard, one-tap meal logging & meal verdicts + +## Enums + +```sql +CREATE TYPE meal_log_type AS ENUM ('FROM_PLAN', 'FROM_RECIPE', 'FROM_PREPPED', 'QUICK_LOG', 'CUSTOM'); +CREATE TYPE meal_verdict AS ENUM ('KEEP', 'FINE', 'REST'); +``` + +`meal_slot` is reused from migration 0005 (`BREAKFAST | LUNCH | DINNER | SNACK`). + +## Table: meal_logs + +| Column | Type | Constraints | +|--------|------|-------------| +| id | uuid | PK, `gen_random_uuid()` | +| owner_id | uuid | NOT NULL, DEFAULT `auth.uid()`, FK `auth.users` ON DELETE CASCADE | +| household_id | uuid | NULL (no FK until Household feature) | +| log_type | meal_log_type | NOT NULL | +| planned_meal_id | uuid | NULL, FK `planned_meals` ON DELETE SET NULL | +| recipe_id | uuid | NULL, FK `recipes` ON DELETE SET NULL | +| prepped_meal_id | uuid | NULL, FK `prepped_meals` ON DELETE SET NULL | +| name_snapshot | text | NULL; CHECK 1..200 chars when present | +| meal_slot | meal_slot | NULL (unspecified allowed) | +| servings | numeric(6,2) | NOT NULL DEFAULT 1, CHECK `servings > 0` | +| logged_at | timestamptz | NOT NULL DEFAULT `now()` | +| verdict | meal_verdict | NULL | +| notes | text | NULL, CHECK ≤ 1000 chars (domain: 0..1000) | +| created_at | timestamptz | NOT NULL DEFAULT `now()` | +| updated_at | timestamptz | NOT NULL DEFAULT `now()` (shared `set_updated_at()` trigger) | + +**CHECKs (row-local):** + +- `meal_logs_servings_positive` — `servings > 0` (domain: PositiveDecimal). +- `meal_logs_name_snapshot_len` — `name_snapshot IS NULL OR char_length(name_snapshot) BETWEEN 1 AND 200`. +- `meal_logs_notes_len` — `notes IS NULL OR char_length(notes) <= 1000`. + +**Triggers (cross-row / lifecycle — not expressible as CHECKs):** + +- `meal_logs_validate_source` BEFORE INSERT — INV-XD-001/002 + spec R3: + `FROM_PLAN ⇒ planned_meal_id NOT NULL`; `FROM_PREPPED ⇒ prepped_meal_id NOT NULL`; + `FROM_RECIPE ⇒ recipe_id NOT NULL`; `CUSTOM ⇒ name_snapshot NOT NULL`; + additionally `log_type <> 'QUICK_LOG' ⇒ name_snapshot NOT NULL`. + Insert-time only, because source FKs are ON DELETE SET NULL (history durability). +- `meal_logs_check_source_ownership` BEFORE INSERT, SECURITY DEFINER — rejects references + to another user's `planned_meals` (via parent `meal_plans.owner_id`), `recipes`, or + `prepped_meals` (pattern: 0007 `check_source_planned_meal_ownership`). Blocks + cross-user probing that RLS on this table alone cannot. +- `meal_logs_annotation_only` BEFORE UPDATE — INV-CC-004 / REQ-CN-007: compares OLD/NEW + on every occurrence column explicitly (id, owner, type, refs, snapshot, slot, servings, + logged_at, created_at) and raises if any differ; only `verdict`, `notes`, and + `updated_at` may change. Carve-out: the three source references may transition + value → NULL (never to a different row) — that is what the FKs' ON DELETE SET NULL + issues when a source is deleted, and blocking it would make logged sources + undeletable. +- `trg_meal_logs_updated_at` BEFORE UPDATE — shared `set_updated_at()`. + +**Indexes:** + +- `meal_logs_owner_logged_idx` on `(owner_id, logged_at DESC)` — today window + recents. +- `meal_logs_planned_meal_idx` on `(planned_meal_id)` WHERE NOT NULL. +- `meal_logs_recipe_idx` on `(recipe_id)` WHERE NOT NULL. +- `meal_logs_prepped_idx` on `(prepped_meal_id)` WHERE NOT NULL. + +**RLS (P7, default-deny):** enabled in the same migration. +`meal_logs_owner_select` FOR SELECT USING owner; `meal_logs_owner_insert` FOR INSERT +WITH CHECK owner; `meal_logs_owner_update` FOR UPDATE USING/WITH CHECK owner. +**No DELETE policy and no DELETE grant** (append-only, A-004). + +**Grants (post-0008 world, required inline):** + +```sql +GRANT SELECT, INSERT, UPDATE ON meal_logs TO authenticated; +GRANT ALL ON meal_logs TO service_role; +GRANT SELECT ON meal_logs TO anon; +ALTER PUBLICATION supabase_realtime ADD TABLE meal_logs; +``` + +## Existing tables touched (no schema change) + +- `planned_meals` — first writer of `status = 'LOGGED'`, `logged_at` (safe-flip: + `UPDATE … WHERE id = ? AND status = 'PLANNED'`, INV-PL-005 CHECK already shipped). +- `portion_events` — CONSUMED events now populate `triggered_by` with the meal-log id + (column reserved for this in 0004). + +## State machines + +MealLog has none (append + annotate). PlannedMeal `PLANNED → LOGGED` transition is +existing (0005); this feature merely triggers it. + +## Application types (src/lib/log/types.ts) + +```ts +export type MealLogType = Enums<'meal_log_type'>; // 'FROM_PLAN' | 'FROM_RECIPE' | 'FROM_PREPPED' | 'QUICK_LOG' | 'CUSTOM' +export type MealVerdict = Enums<'meal_verdict'>; // 'KEEP' | 'FINE' | 'REST' +export type MealSlot = Enums<'meal_slot'>; + +export interface MealLog { + id: string; + ownerId: string; + logType: MealLogType; + plannedMealId: string | null; + recipeId: string | null; + preppedMealId: string | null; + nameSnapshot: string | null; + mealSlot: MealSlot | null; + servings: number; // Number(row.servings) + loggedAt: string; // ISO + verdict: MealVerdict | null; + notes: string | null; + createdAt: string; + updatedAt: string; +} + +/** Exactly-one-source drafts — invalid combinations unrepresentable. */ +export type MealLogDraft = + | { kind: 'fromPlan'; plannedMeal: PlannedMeal } // snapshot + slot + servings derived + | { kind: 'fromPrepped'; preppedMeal: PreppedMeal; slot: MealSlot | null; servings?: number } + | { kind: 'fromRecipe'; recipeId: string; name: string; slot: MealSlot | null } + | { kind: 'quick'; slot: MealSlot | null } // "I ate something" + | { kind: 'custom'; name: string; slot: MealSlot | null }; +``` + +## Derived/pure interfaces (unit-testable, no I/O) + +```ts +/** R6 — slot inference + nudge derivation (pure). */ +export function slotForTime(now: Date): MealSlot; +export interface NudgeState { slot: MealSlot; plannedMealId: string; name: string } +export function deriveNudge( + now: Date, + todaysMeals: PlannedMeal[], + todaysLogs: MealLog[], + dismissedSlots: MealSlot[] +): NudgeState | null; + +/** R7 — grouping over recent logs (pure). */ +export interface LoggedSource { + key: string; // recipe: | prepped: | name: + name: string; + recipeId: string | null; + latestVerdict: MealVerdict | null; + timesMade: number; + lastLoggedAt: string; + lastSlot: MealSlot | null; +} +export function groupLogSources(logs: MealLog[]): LoggedSource[]; +export function keepers(sources: LoggedSource[]): LoggedSource[]; // latestVerdict === 'KEEP' +export function recents(sources: LoggedSource[], limit: number): LoggedSource[]; + +/** Recap: unrated, recent, not QUICK_LOG (pure). */ +export function unratedRecent(logs: MealLog[], now: Date, limit: number): MealLog[]; + +/** Coverage: per-slot rollup of fulfillment + logged state (pure; consumes feature 004 output). */ +export interface SlotCoverage { slot: MealSlot | null; label: string; state: 'EATEN' | 'HAVE_IT' | 'CAN_MAKE_IT' | 'MUST_ACQUIRE' } +export function deriveDayCoverage(meals: PlannedMealWithFulfillment[], logs: MealLog[]): { + slots: SlotCoverage[]; + headline: string; // calm summary sentence + detail: string; +} +``` + +Invariant placement summary: servings/name/notes bounds → DB CHECKs; type↔ref presence, +ownership, annotation-only mutability → DB triggers (insert/update-time); portion +arithmetic → existing ledger trigger; PLANNED→LOGGED single-flip → safe-flip UPDATE +predicate (+ existing CHECK `status='LOGGED' ⇒ logged_at NOT NULL`); everything +display-time (coverage, keepers, nudges) → pure client functions. diff --git a/.specswarm/features/006-today-logging/plan.md b/.specswarm/features/006-today-logging/plan.md new file mode 100644 index 0000000..d8ee252 --- /dev/null +++ b/.specswarm/features/006-today-logging/plan.md @@ -0,0 +1,144 @@ +# Implementation Plan: Today dashboard, one-tap meal logging & meal verdicts + +**Feature**: 006 | **Branch**: `feat/006-today-logging` +**Spec**: [spec.md](./spec.md) | **Created**: 2026-07-09 + +## Summary + +Add `meal_logs` (migration 0009) with append-only-plus-annotation semantics; a `log` +domain module (types, service, runes store, pure derivations for slots/nudges/ +recents/keepers/coverage); the `/today` route composed per the canonical mockups; a +Quick Log sheet; and wiring into existing seams — feature 004 fulfillment for coverage, +the portion ledger for prepped consumption, and the dormant `PLANNED → LOGGED` +transition from feature 003. + +## Technical Context + +- **Language/Framework**: TypeScript, SvelteKit SPA (adapter-static), Svelte 5 runes. +- **Data access**: supabase-js via `$lib/supabaseClient`; RLS-authoritative; no owner + filters in queries. +- **State**: module-level `$state` stores with getter functions + optimistic writes with + rollback (planStore pattern). +- **Styling**: design tokens + `.nk-*` primitives; `.nk-verdict` already shipped in + `base.css`; Tailwind-with-token utility classes as used by planning/shopping + components (heed the `[font-weight:var(--weight-*)]` gotcha). +- **Testing**: vitest server project for pure logic + service specs (thenable + `makeChain` supabase mock); pgTAP for RLS/invariants; Playwright e2e with the + plan.e2e.ts stateful mock backend extended for `meal_logs`. +- No new dependencies. + +## Tech Stack Compliance Report + +### ✅ Approved Technologies (already in stack) + +SvelteKit, Svelte 5 runes, supabase-js, Tailwind (token-bound), vitest, Playwright, +pgTAP, Phosphor icons. + +### ➕ New Technologies (auto-added) + +None. + +### ⚠️ Conflicting / ❌ Prohibited + +None. + +## Constitution Check + +| Principle | Status | Notes | +|-----------|--------|-------| +| P1/P2 SPA only, no server files | ✅ | `/today/+page.svelte` only; all logic client-side | +| P3 Runes mode | ✅ | `logStore.svelte.ts` uses `$state` + getter functions | +| P4 Design tokens | ✅ | Today components use `var(--token)` / `.nk-*`; verdict UI is the shipped `.nk-verdict` primitive | +| P5 No unsanitized HTML | ✅ | No `{@html}` | +| P6 Service-role key server-only | ✅ | anon key + RLS only; ownership-guard trigger is SECURITY DEFINER in the DB, not a client secret | +| P7 RLS default-deny before exposure | ✅ | 0009 enables RLS + policies in the same migration that creates `meal_logs` | +| P8/P9 UUID PKs, timestamptz UTC | ✅ | Complies; shared `set_updated_at()` reused | +| P13 No clinical labels | ✅ | "Log it", "I ate something", "Not for me" — calm copy straight from mockups | +| P14 Append-only records | ✅ | No DELETE policy/grant; `meal_logs_annotation_only` trigger restricts UPDATE to verdict/notes; portion changes via ledger events only | +| P15 Online-first, server authoritative | ✅ | Optimistic log with rollback on insert failure; follow-up writes sequential with per-step calm failure report (research R5) | +| INV-XD-001/002/003 | ✅ | R3 insert trigger; R4 ledger reuse with `triggered_by` link | +| INV-PL-005 | ✅ | Safe-flip UPDATE predicate; CHECK already in 0005 | +| INV-CC-004 / REQ-CN-007 | ✅ | R2 annotation-only trigger + no DELETE | + +**Gate result: PASS** — no violations, no justifications needed. + +## Design Decisions (rationale in research.md) + +- D1 (R1): three-way `meal_verdict` on the log row; per-source marks derived. +- D2 (R2): append-only + verdict/notes annotation window via BEFORE UPDATE trigger. +- D3 (R3): type↔reference validation and ownership guard as insert triggers; FKs SET NULL. +- D4 (R4): prepped consumption through existing `consumePortions` + new `triggeredBy` arg. +- D5 (R5): log-first orchestration; follow-up failures reported, never rolled back. +- D6 (R6): client-local day bounds, fixed slot windows, pure `deriveNudge`, localStorage dismissals. +- D7 (R7): recents/keepers from client-side grouping of last 100 non-quick logs. +- D8 (R8): coverage card reuses feature 004 fulfillment derivation verbatim. +- D9 (R9): Quick Log sheet implements Escape/backdrop/focus-restore/`inert` properly. + +## Phases & File Map + +### Phase A — Schema & types + +| File | Change | +|------|--------| +| supabase/migrations/0009_meal_logs.sql | enums, table, CHECKs, triggers, RLS, grants, realtime, indexes | +| src/lib/database.types.ts | regenerate (`bun run db:types`) | +| supabase/tests/meal_logs_rls.test.sql | pgTAP: RLS both directions, append-only, source validation, ownership guard, ledger link | + +### Phase B — Domain module + +| File | Change | +|------|--------| +| src/lib/log/types.ts | aliases, `MealLog`, `MealLogDraft`, `toMealLog` | +| src/lib/log/slots.ts | `slotForTime`, `deriveNudge`, day-bounds helpers (pure) | +| src/lib/log/derive.ts | `groupLogSources`, `keepers`, `recents`, `unratedRecent`, `deriveDayCoverage` (pure) | +| src/lib/log/logService.ts | `createLog(draft)`, `setVerdict`, `fetchLogsBetween`, `fetchRecentLogs` | +| src/lib/log/logStore.svelte.ts | today logs + recents state; `logMeal(draft)` orchestration (optimistic, R5), `rateLog` | +| src/lib/log/index.ts | curated exports | +| src/lib/pantry/preppedMealService.ts / preppedMealStore.svelte.ts | optional `triggeredBy` param threaded to CONSUMED event | +| colocated *.spec.ts | server-project unit tests for all of the above | + +### Phase C — Today UI + +| File | Change | +|------|--------| +| src/lib/components/today/TodayDashboard.svelte | shell: greeting, coverage, nudge, meals, quick actions, recap, attention | +| src/lib/components/today/TodayMealCard.svelte | slot card: status/action per FR-TL-003/004 | +| src/lib/components/today/DayCoverageCard.svelte | coverage chips + headline | +| src/lib/components/today/NudgeBanner.svelte | gentle banner, Not now | +| src/lib/components/today/QuickLogSheet.svelte | bottom sheet per mobile-log.html (D9) | +| src/lib/components/today/RecapCard.svelte | "How were these?" | +| src/lib/components/today/AttentionList.svelte | expiring prepped items | +| src/lib/components/shared/VerdictControl.svelte | wraps `.nk-verdict` (aria-pressed, deselect) | +| src/routes/today/+page.svelte | thin shell | +| src/routes/+page.svelte | redirect target `/plan` → `/today` | +| src/routes/+layout.svelte | Today nav item gets `href` | + +### Phase D — e2e & verification + +| File | Change | +|------|--------| +| tests/today.e2e.ts | mock-backend flow: view today, one-tap log, prepped decrement, quick log, verdict | +| .specswarm/quality-analysis-006-today-logging.md | end-of-feature analysis | + +## Risks + +- **Trigger strictness vs. PostgREST updates** — supabase-js UPDATE sends only changed + columns, but PostgREST applies them to the full row; the annotation-only trigger must + compare OLD/NEW values, not "columns present in payload". Mitigated by pgTAP tests + updating verdict alone (pass) and servings (fail). +- **Cross-module store coupling** (log store → pantry store → plan store) — keep + orchestration one-directional in `logMeal`; stores never import each other circularly + (log → pantry/plan only). +- **Browser-project component tests hang in agent sandboxes** — pure logic goes in + `slots.ts`/`derive.ts` (server project); component behaviour covered by e2e + manual + quickstart. +- **Clock-dependent logic** — all time-dependent functions take `now: Date` explicitly; + no `Date.now()` inside derivations. + +## Progress Tracking + +- [x] Phase 0: research.md +- [x] Phase 1: data-model.md + contracts + quickstart +- [x] Phase 2: tasks.md +- [x] Implementation +- [x] Verification (automated gates + adversarial reviews; manual quickstart T026 pending) diff --git a/.specswarm/features/006-today-logging/quickstart.md b/.specswarm/features/006-today-logging/quickstart.md new file mode 100644 index 0000000..869d6b9 --- /dev/null +++ b/.specswarm/features/006-today-logging/quickstart.md @@ -0,0 +1,41 @@ +# Quickstart: Today dashboard & meal logging + +## Migrate + types + +```bash +bunx supabase start -x edge-runtime # local stack (563xx ports) +bunx supabase migration up # applies 0009_meal_logs.sql +bun run db:types # regen src/lib/database.types.ts (CI checks drift) +``` + +## Automated tests + +```bash +bun run check +bun run lint +bun run test:unit -- --run +bunx supabase test db # pgTAP incl. meal_logs_rls.test.sql +bunx playwright test tests/today.e2e.ts +``` + +## Manual end-to-end loop (human) + +1. `bun run dev`, open the app — root should land on **/today** with greeting + date; + "Today" nav item active. +2. Seed context: create a recipe; add a prepped meal (2 portions); plan today's lunch + from the prepped meal and dinner from the recipe. +3. Today shows coverage chips (lunch have-it, dinner per pantry) and two meal cards. +4. Tap **Log it** on lunch → instant "Eaten · time", prepped portions show 1 left in + pantry, verdict prompt appears inline. Tap **Again** → Keeper mark. +5. Planner (/plan) shows lunch as logged. +6. **Log a meal** → sheet: slot defaults sensibly; tap **I ate something** → logged, + sheet closes with calm confirmation. +7. Reopen sheet: "Recent" shows the logged items; the kept meal appears under Keepers + with a times-made count. +8. Next day (or adjust clock): recap card asks about unrated dinner; verdict from the + recap card sticks. +9. Nudge banner: within a slot window with an unlogged planned meal, banner appears; + **Not now** hides it for the day (survives reload — localStorage). +10. Responsive pass: mobile (bottom sheet, FAB), desktop (4-col meal grid) per mockups; + keyboard: sheet closes on Escape, focus returns to the opener; verdict buttons are + 44 px and announce pressed state. diff --git a/.specswarm/features/006-today-logging/research.md b/.specswarm/features/006-today-logging/research.md new file mode 100644 index 0000000..57f0ae6 --- /dev/null +++ b/.specswarm/features/006-today-logging/research.md @@ -0,0 +1,92 @@ +# Research: Today dashboard, one-tap meal logging & meal verdicts + +## R1 — Verdict shape: three-way enum on the log row + +**Decision:** `meal_verdict AS ENUM ('KEEP','FINE','REST')` as a nullable column on +`meal_logs`; no separate verdict table, no denormalised copy on recipes. +Canonical mockups + shipped `.nk-verdict` primitives use the three-way control; the +four-value `MealRating` in the domain spec is the superseded earlier design (recorded in +spec Clarifications). A verdict belongs to an eating occurrence (you can love a recipe on +Tuesday and set it aside after the fourth reheat); per-source marks (Keeper) are the +*latest* verdict per source, derived at query time. Rejected: separate `meal_verdicts` +append-only table (over-modelling — one mutable column with a trigger guard preserves +append-only occurrence facts with far less machinery); reuse of `user_recipe_meta.rating` +(different concept: deliberate 1–5 recipe review vs. instant meal verdict; also logs are +frequently not recipe-backed). + +## R2 — Append-only with annotation window + +**Decision:** RLS grants INSERT/SELECT/UPDATE (owner-scoped), **no DELETE policy and no +DELETE grant**; a `BEFORE UPDATE` trigger raises unless the only changed columns are +`verdict` and/or `notes`. This satisfies INV-CC-004/REQ-CN-007 (occurrence facts can +never be overwritten) while honouring the design's "set a verdict anytime later". +Rejected: fully immutable rows + verdict side-table (R1); app-layer-only enforcement +(defence in depth is cheap here and pgTAP-testable). + +## R3 — Type↔reference validation via trigger, not CHECK + +**Decision:** enforce INV-XD-001/002 (`FROM_PLAN ⇒ planned_meal_id`, `FROM_PREPPED ⇒ +prepped_meal_id`, plus `FROM_RECIPE ⇒ recipe_id`, `CUSTOM ⇒ name_snapshot`) in a +`BEFORE INSERT` trigger. Source FKs are `ON DELETE SET NULL` for durability of history +(name snapshot preserves display), so a table CHECK would make deleting a source row +fail its cascading UPDATE. Insert-time validation is exactly what the invariants mean. +Cross-user probing is blocked by a SECURITY DEFINER ownership-check trigger (pattern: +`check_source_planned_meal_ownership`, migration 0007); `planned_meals` ownership is +checked via its parent `meal_plans`. + +## R4 — Prepped consumption reuses the ledger, linked by `triggered_by` + +**Decision:** logging FROM_PREPPED calls the existing consume path +(`consumePortions` / `optimisticConsumePortions`), extended with an optional +`triggeredBy` parameter so the CONSUMED event carries the meal-log id — the exact use +reserved in migration 0004's comment. The DB trigger `sync_portions_remaining` stays the +single writer of `portions_remaining` and raises on negative balances (INV-INV-004). +Rejected: DB AFTER-INSERT trigger on `meal_logs` creating the portion event (hides a +cross-aggregate write, complicates optimistic UI reconciliation, and INV-CON-003 +explicitly allows client-logic orchestration). + +## R5 — Multi-write orchestration: log first, follow-ups reported + +**Decision:** insert the `meal_logs` row first (the user's truth), then run follow-ups +sequentially: CONSUMED portion event (FROM_PREPPED) or planned-meal safe-flip +(FROM_PLAN, `UPDATE … WHERE id = ? AND status = 'PLANNED'`). Follow-up failures keep the +log, surface a calm notice, and are reported per step — the established +`completeTrip` pattern from feature 005. Rejected: transactional RPC (needs a Postgres +function + loses optimistic granularity; the failure mode is benign and self-describing). + +## R6 — "Today", slots, and nudge windows are client-local + +**Decision:** "today" = the device's local calendar day; the service takes explicit +`[dayStartISO, dayEndISO)` bounds computed client-side (same approach as planning's week +math). Slot windows are fixed constants (breakfast 06:00–10:00, lunch 11:00–14:00, +dinner 17:00–21:00, otherwise snack) used for both the Quick Log default slot and the +nudge banner; `deriveNudge(now, plannedMeals, logs, dismissals)` is a pure function. +Nudge dismissals live in `localStorage` keyed `nk-nudge-dismissed::` — +device-local by design (no server round-trip for a courtesy banner). Rejected: +user-configurable windows (belongs to reminders/007). + +## R7 — Recents & keepers derivation + +**Decision:** one query over the user's last 100 logs (excluding QUICK_LOG), grouped +client-side by source identity (`recipe_id` → `prepped source` → lowercased +`name_snapshot`): *recents* = latest N distinct sources; *keepers* = sources whose most +recent non-null verdict is KEEP, with `timesMade` = occurrence count. Client-side +grouping keeps PostgREST usage trivial and the logic unit-testable; 100 rows is ample +for a personal log. Rejected: SQL view/RPC (premature; revisit when suggestion features +need server-side aggregation). + +## R8 — Coverage card reuses feature 004 fulfillment verbatim + +**Decision:** the day-coverage chips and per-card primary action call the existing +fulfillment derivation (`derivePlannedMealFulfillment` seam from feature 004) against +the same inventory snapshot the planner uses; logged meals display as "Eaten" regardless +of fulfillment. No new derivation logic. Rejected: persisting coverage (fulfillment is +display-time by design). + +## R9 — Sheet accessibility done properly this time + +**Decision:** the Quick Log sheet implements Escape-to-close, backdrop click, focus +restoration to the invoking control, and `inert` on the background app shell — the gaps +past reviews flagged on planning's sheets. Scoped to the new sheet; retrofitting +planning's sheets is separate cleanup. Verdict buttons follow the mockup contract: +`aria-pressed`, tap-again-to-clear, ≥44 px targets, icon + label always. diff --git a/.specswarm/features/006-today-logging/spec.md b/.specswarm/features/006-today-logging/spec.md new file mode 100644 index 0000000..d941ff5 --- /dev/null +++ b/.specswarm/features/006-today-logging/spec.md @@ -0,0 +1,259 @@ +--- +parent_branch: feat/006-today-logging +feature_number: 006 +status: Implemented — pending manual verification + /ss:ship +created_at: 2026-07-09T15:30:00-05:00 +references_consulted: + - nexus-kitchen-requirements.md + - nexus-kitchen-domain-specification.md + - nexus-kitchen-invariants.md + - nexus-kitchen-differentiator.md + - nexus-kitchen-design-system.md + - design/screens/web-today.html, tablet-today.html, mobile-today.html, mobile-log.html +--- + +# Feature: Today dashboard, one-tap meal logging & meal verdicts + +## Overview + +The app can plan, stock, and shop — but it has no daily surface and no way to say "I ate." +This feature adds the **Today dashboard** as the app's home screen and closes the supply +loop with **one-tap meal logging** and an optional, shame-free **meal verdict** +(Again / Fine / Not for me). Logging a prepped meal draws down the portion ledger, keeping +HAVE_IT truthful; verdicts feed future suggestion features so good options resurface +without re-deciding. + +Concretely, delivers: + +- A `/today` home surface: greeting, per-slot meal cards with status, day-coverage + summary, quick actions, and an attention list for prepped food about to turn + (Domain Specification §4.2; REQ-UX-011/012/013). +- One-tap logging of planned meals (REQ-MR-006, REQ-NT-006) — the first writer of the + `PLANNED → LOGGED` transition shipped in feature 003 (INV-PL-005). +- A Quick Log sheet: slot picker, "I ate something" zero-detail log (REQ-MR-009), + prepped meals, recents, and keepers (REQ-MR-008), per `design/screens/mobile-log.html`. +- Prepped-meal logs consume portions through the append-only portion ledger + (INV-XD-003, INV-CC-006), linking the ledger event to the log entry. +- Optional three-way verdict capture at log time or later, plus a "How were these?" + recap for recent unrated meals (REQ-MR-007 as amended below). +- A gentle, client-side mealtime nudge banner (visual cue only — push reminders are + feature 007). + +## Scope + +**In scope:** `meal_logs` table + RLS; log domain module; Today route/components; +Quick Log sheet; verdict capture/recall; client-side nudge banner; nav/home wiring. + +**Out of scope:** push/scheduled reminders (§3.6.1, REQ-MR-001..005 — feature 007); +visual timeline/history (§3.6.3, REQ-MR-010..012); nutrition capture on logs +(REQ-NT-009 opt-in, deferred); variety/rotation consumers of verdict data (§3.7); +household sharing; meal-prep sessions; recipe 1–5 star ratings (already shipped in 001, +separate concept per Domain Specification `UserRecipeMeta`). + +## Clarifications + +### Session 2026-07-09 + +- Q: The SRS/domain spec define a four-value `MealRating` (HATED/OK/LIKED/LOVED), but the + canonical mockups and shipped `base.css` primitives implement a three-value **verdict** + (`keep` "Again" / `fine` "It was fine" / `rest` "Not for me") via `.nk-verdict` and + `--verdict-*` tokens. Which wins? → A: **The design mockups win** (project rule: + `design/screens/*.html` are canonical). The verdict enum is `KEEP | FINE | REST`; + `KEEP` absorbs LIKED/LOVED, `FINE` = OK, `REST` is the shame-free framing of HATED + ("set it aside" — a verdict on the food, not the person). REQ-MR-007 is satisfied in + substance; the deviation is recorded here. +- Q: `MealLog` is append-only (INV-CC-004, REQ-CN-007), yet the design lets users set a + verdict "anytime later". → A: The log **occurrence** (what/when/how much) is immutable + after insert; `verdict` and `notes` are late-arriving annotations and the only mutable + fields, enforced by a DB trigger. No LWW overwrite of occurrence facts is possible. +- Q: Domain entity lists five log types but INV-XD-001/002 only constrain FROM_PLAN and + FROM_PREPPED. → A: Ship all five types (`FROM_PLAN, FROM_RECIPE, FROM_PREPPED, + QUICK_LOG, CUSTOM`); enforce the type↔reference rules at insert time (references may + later become NULL if the source row is deleted; the name snapshot preserves display). +- Q: Undo for accidental one-tap logs? → A: **Deferred.** No DELETE policy in v1; the + confirmation is calm, not modal. Revisit alongside the timeline feature where a log can + be reviewed. Recorded as A-004. +- Q: Does the Today reminder banner require the reminders backend? → A: No — it is a + purely client-side visual cue derived from the clock, today's plan, and today's logs + (REQ-UX-011). Push delivery is feature 007. Dismissal is local to the device and day. + +## User Scenarios + +### Primary: lunch, one tap + +Jordan planned Marinara pasta (prepped, 3 portions left) for lunch. Around noon the Today +screen shows a gentle "It's around lunchtime" banner. Jordan taps **Log it** on the lunch +card. The card flips to "Eaten · 12:04pm" immediately, portions drop to 2, and an +unobtrusive "How was it?" appears. Jordan ignores it — that's fine. + +### Quick log: "I ate something" + +Mid-afternoon, Jordan ate crackers at the desk. From Today, **Log a meal** opens the sheet; +slot defaults to Snack; one tap on **I ate something** records it with no details. Total: +3 taps, no typing, no judgment. + +### Verdict later + +The next morning the recap card asks "How were these?" for yesterday's unrated lentil +curry. Jordan taps **Again**. Lentil curry now carries a Keeper mark and appears in the +Keepers group of the log sheet with "made 3 times". + +### Dinner not covered + +Dinner's salmon is MUST_ACQUIRE. Its card offers **Add to list** (not "Log it") and the +coverage card says "Tonight's salmon is the only gap — one thing to pick up, and the +day's set." + +### Edge cases + +- No plan at all today → coverage card invites planning ("Nothing planned — that's okay. + Want to pick something easy?"); meal grid shows empty-slot invitations; Quick Log still + fully works. +- Logging a prepped meal with 1 portion left → succeeds, portions hit 0; the item stops + appearing in the sheet's prepped group. +- Two rapid taps on **Log it** → exactly one log (button disables optimistically). +- Log insert succeeds but portion consumption or status flip fails (offline blip) → log + is kept; calm notice "Logged — we'll tidy the pantry count when you're back online"; + no crash, no rollback of the log (FR-TL-014). +- Planned meal's recipe deleted after logging → log still displays via name snapshot. +- Verdict tapped again → deselects back to unrated (never required). +- Day boundary: "today" is the user's local calendar day; logs at 11:58pm belong to that + day; the dashboard rolls over naturally on next render. + +## Functional Requirements + +### Today surface + +- FR-TL-001: The system shall provide a Today dashboard as the app's home surface: the + root path redirects to `/today` and the existing "Today" navigation item activates + (REQ-UX-012, Domain Specification §4.2). +- FR-TL-002: The Today screen shall show a time-of-day greeting and the full date with + calm, pressure-free copy per the canonical mockups (design voice rules). +- FR-TL-003: The Today screen shall list today's planned meals as slot-labelled cards in + slot order, each showing the meal name, source tag (e.g. "Prepped · N left"), and + status: logged ("Eaten · h:mm") vs not yet (REQ-UX-013, Domain Specification §4.2). +- FR-TL-004: Each unlogged planned-meal card shall present exactly one primary action + derived from its fulfillment state: **Log it** (HAVE_IT / CAN_MAKE_IT) or **Add to + list** (MUST_ACQUIRE, navigating to shopping) (REQ-UX-001; feature 004 derivation). +- FR-TL-005: The Today screen shall show a day-coverage card summarising each planned + slot's fulfillment as chips plus one supportive sentence (REQ-UX-011/013). +- FR-TL-006: Empty meal slots shall show a low-pressure invitation ("Add a snack — + optional") linking to the planner; absence of a plan is never framed as failure + (REQ-UX-014, design voice rules). +- FR-TL-007: The Today screen shall offer quick actions: Plan the week, Build shopping + list, Browse the pantry (Domain Specification §4.2). ("Start a prep session" waits for + the prep feature.) +- FR-TL-008: The Today screen shall show an attention list of prepped meals with portions + remaining that expire within 3 days, each linking to the planner ("Plan it in") + (differentiator: surface "what's about to turn"). + +### One-tap logging + +- FR-TL-009: Users shall log a planned meal with a single tap; the log records source + reference, name snapshot, slot, and the planned servings, applied optimistically + (REQ-MR-006, REQ-NT-006, REQ-CN-003). +- FR-TL-010: Logging a planned meal shall transition that planned meal `PLANNED → LOGGED` + with `logged_at` set (INV-PL-005, Domain Specification §5.4); an already-LOGGED planned + meal shall not be double-flipped. +- FR-TL-011: A Quick Log sheet shall be reachable from Today, offering: a slot picker + defaulting to the current mealtime, "I ate something" zero-detail logging (REQ-MR-009), + today-ready prepped meals with portion counts, recent meals, and keepers (REQ-MR-008), + per `mobile-log.html`. +- FR-TL-012: Logging a prepped meal shall consume the logged servings from the portion + ledger as a CONSUMED event linked to the log entry (`triggered_by` = log id), never + writing the portion count directly (INV-XD-003, INV-INV-004/010/011, INV-CC-006). +- FR-TL-013: Every log path reachable from Today shall complete in at most 3 taps with + immediate visual feedback and a calm confirmation (REQ-UX-003, REQ-UX-015). +- FR-TL-014: If the log record persists but a follow-up write fails (portion event or + planned-meal flip), the log shall be kept and a calm non-blocking notice shown; partial + failure shall never crash or silently disappear (pattern precedent: shopping trip + completion). + +### Verdicts + +- FR-TL-015: At log time the user may optionally set a three-way verdict — KEEP + ("Again"), FINE ("It was fine"), REST ("Not for me") — always skippable and + deselectable, labels always paired with icons (REQ-MR-007 as amended; design system + valence rules). +- FR-TL-016: A verdict (and notes) may be set or changed on an existing log at any later + time; all other log fields are immutable after insert (INV-CC-004, REQ-CN-007). +- FR-TL-017: Today shall show a "How were these?" recap of up to 3 recent unrated logs + (last 48 h, excluding zero-detail QUICK_LOG entries) with inline verdict capture; the + module disappears when there is nothing to ask. +- FR-TL-018: Wherever previously logged meals resurface (recents, keepers), the system + shall show the source's current verdict mark; the Keepers group lists sources whose + most recent verdict is KEEP, with a times-made count. + +### Gentle nudge + +- FR-TL-019: During a mealtime window whose slot has a planned, unlogged meal, Today + shall show a gentle banner ("It's around lunchtime… No rush.") with **Log** and **Not + now**; "Not now" dismisses that slot for the rest of the local day on that device. No + notification is sent (REQ-UX-011; push is feature 007). + +### Data & integrity + +- FR-TL-020: Meal logs shall be stored in a `meal_logs` table: UUID PK, owner-scoped + default-deny RLS, `timestamptz` UTC, insert-time validation that the log type's + required reference and name snapshot are present (INV-XD-001/002), cross-user + reference guards, and no DELETE capability in v1 (INV-SEC-004/006, INV-DB-001/002/011). +- FR-TL-021: Database types shall be regenerated and mapped to camelCase app types; UI + code shall never import generated row types directly (established convention). + +## Success Criteria + +- From a cold open of the app, logging today's planned lunch takes 1 tap on the meal card + (≤3 taps including any scrolling context), and the card reflects "Eaten" in under + 200 ms perceived (optimistic). +- Logging a prepped meal visibly decrements its portion count everywhere (Today, pantry) + without any direct write to the portion column; the ledger gains exactly one CONSUMED + event referencing the log. +- A planned meal logged via Today reads LOGGED with a timestamp in the planner. +- "I ate something" produces a log with no name, no reference, and no verdict — and zero + validation errors. +- A user who never sets a verdict experiences no prompts beyond the passive recap card. +- Another user's meal logs are invisible and unwritable (RLS proven both directions by + pgTAP). +- All automated gates green: `check`, `lint`, unit, build, pgTAP; e2e for the today/log + flow passes locally. + +## Key Entities + +- **MealLog** — one eating occurrence. `id`, `ownerId`, `logType` (FROM_PLAN / + FROM_RECIPE / FROM_PREPPED / QUICK_LOG / CUSTOM), optional source refs + (`plannedMealId`, `recipeId`, `preppedMealId`), `nameSnapshot`, `mealSlot?`, + `servings` (default 1), `loggedAt`, `verdict?` (KEEP/FINE/REST), `notes?`. + Deferred fields from the domain entity: `nutritionInfo` (opt-in nutrition, later), + `householdId` FK enforcement (household feature). +- **PortionEvent (existing)** — gains its intended `triggered_by` link: CONSUMED events + created by prepped-meal logs carry the log's id. +- **PlannedMeal (existing)** — first writer of `status = LOGGED` + `logged_at`. + +## Assumptions + +- A-001: **Slot windows** for nudges/defaults are fixed client constants (breakfast + 06–10, lunch 11–14, dinner 17–21, else snack); personalisation belongs to the + reminders feature. +- A-002: **Keepers/recents derive from logs** at query time; no denormalised verdict on + recipes. `user_recipe_meta.times_cooked` is not written by logging (cooking ≠ eating); + times-made counts come from log aggregation. +- A-003: **Low-stock attention items** ship only if the pantry module already exposes a + low-stock signal; otherwise the attention list is expiring-prepped only (matches + mockup content, avoids scope creep into pantry). +- A-004: **No undo/delete of logs in v1** (append-only); accidental logs are tolerated + until the timeline feature adds review affordances. +- A-005: Deferred per corpus but out of this feature: reminders backend (007), visual + timeline (§3.6.3), nutrition on logs, variety consumers, household visibility of logs. + +## Sources + +| Source | Sections informing this spec | +|--------|------------------------------| +| nexus-kitchen-requirements.md | §3.6.2 (REQ-MR-006..009), REQ-NT-006, §6 (REQ-UX-001/003/011/012/013/014/015), REQ-CN-003/007, Appendix A P1 | +| nexus-kitchen-domain-specification.md | MealLog entity + MealLogType + MealRating, §4.2 Home Screen Flow, §4.8 Log flow, §5.4 PlannedMeal state machine | +| nexus-kitchen-invariants.md | INV-XD-001/002/003, INV-PL-005, INV-CC-004/006, INV-CON-003/004, INV-SEC-004/006, INV-DB-001/002/011, INV-INV-004/010/011, INV-MOD-001/002 | +| nexus-kitchen-differentiator.md | capture feeds the operations loop; "what's about to turn" | +| design/screens/*.html + base.css | canonical Today/Log layouts, `.nk-verdict` three-way control | + +No section was fabricated without a corresponding source citation or a resolved +Clarification entry. diff --git a/.specswarm/features/006-today-logging/tasks.md b/.specswarm/features/006-today-logging/tasks.md new file mode 100644 index 0000000..54eab88 --- /dev/null +++ b/.specswarm/features/006-today-logging/tasks.md @@ -0,0 +1,89 @@ +# Tasks: Today dashboard, one-tap meal logging & meal verdicts + +**Feature**: 006 | **Branch**: `feat/006-today-logging` +**Plan**: [plan.md](./plan.md) | **Spec**: [spec.md](./spec.md) + + + + +User stories (from spec, priority order): + +- **US1 (P1)** — See my day: Today dashboard with meal cards, coverage, quick actions. (FR-TL-001..008) +- **US2 (P1)** — One-tap logging: planned meals, Quick Log sheet, prepped draw-down. (FR-TL-009..014) +- **US3 (P2)** — Verdicts: capture at log time or later, recap, keeper marks. (FR-TL-015..018) +- **US4 (P3)** — Gentle mealtime nudge banner. (FR-TL-019) + +## Phase 2: Foundational (blocks all stories) + +- [X] T001 Create migration: `meal_log_type` + `meal_verdict` enums, `meal_logs` table with CHECKs, `meal_logs_validate_source` + SECURITY DEFINER ownership + `meal_logs_annotation_only` triggers, RLS enable + owner SELECT/INSERT/UPDATE policies (no DELETE), grants (no DELETE to authenticated), realtime publication, indexes, `set_updated_at` trigger per data-model.md — supabase/migrations/0009_meal_logs.sql +- [X] T002 Apply migration locally and regenerate DB types (`bun run db:types`) — src/lib/database.types.ts +- [X] T003 pgTAP suite: RLS isolation both directions; UPDATE of verdict/notes allowed, occurrence columns rejected; DELETE rejected; source-validation per log type both ways; cross-user planned/recipe/prepped refs rejected; QUICK_LOG nameless allowed — supabase/tests/meal_logs_rls.test.sql +- [X] T004 [P] Log domain types: enum/row aliases, `MealLog`, `MealLogDraft` union, `toMealLog` mapper — src/lib/log/types.ts +- [X] T005 [P] Pure slot/time helpers: `slotForTime`, local day bounds, `deriveNudge` + unit tests — src/lib/log/slots.ts, src/lib/log/slots.spec.ts +- [X] T006 [P] Pure derivations: `groupLogSources`, `keepers`, `recents`, `unratedRecent`, `deriveDayCoverage` + unit tests — src/lib/log/derive.ts, src/lib/log/derive.spec.ts +- [X] T007 Thread optional `{ triggeredBy }` through `consumePortions` → CONSUMED event `triggered_by`, and through `optimisticConsumePortions`; extend existing specs — src/lib/pantry/preppedMealService.ts, src/lib/pantry/preppedMealStore.svelte.ts, src/lib/pantry/preppedMealService.spec.ts + +## Phase 3: US2 — One-tap logging (MVP core) + +- [X] T008 [US2] `logService`: `createLog(draft)` (draft→insert mapping incl. snapshots), `setVerdict`, `fetchLogsBetween`, `fetchRecentLogs`, `markPlannedMealLogged` safe-flip; `LogError` calm errors + unit tests (makeChain mock) — src/lib/log/logService.ts, src/lib/log/logService.spec.ts +- [X] T009 [US2] `logStore`: today/recents state + getters, `loadToday`, `loadRecents`, orchestrated `logMeal` (optimistic append, insert-failure rollback, follow-up consume/flip with calm `logNotice` on partial failure, in-flight double-tap guard), `rateLog` optimistic + unit tests — src/lib/log/logStore.svelte.ts, src/lib/log/logStore.spec.ts +- [X] T010 [US2] Public exports barrel — src/lib/log/index.ts + +**Checkpoint**: `logMeal` fully unit-tested against mocks; DB invariants pgTAP-proven; no UI yet. + +## Phase 4: US1 — Today dashboard + +- [X] T011 [US1] Route shell + home rewire: `/today` page, root redirect → `/today`, Today nav item `href` enabled — src/routes/today/+page.svelte, src/routes/+page.svelte, src/routes/+layout.svelte +- [X] T012 [US1] `TodayDashboard` shell: greeting/date, section order per web-today.html, loads plan-for-today + inventory + logs via existing stores + logStore — src/lib/components/today/TodayDashboard.svelte +- [X] T013 [P] [US1] `TodayMealCard`: slot label, tile, name, prepped tag with portions, states (Eaten · time / Have it / Need to get), single primary action Log-it / Add-to-list (FR-TL-004), double-tap guard — src/lib/components/today/TodayMealCard.svelte +- [X] T014 [P] [US1] `DayCoverageCard`: chips + calm headline from `deriveDayCoverage` — src/lib/components/today/DayCoverageCard.svelte +- [X] T015 [P] [US1] `AttentionList`: prepped ≤3 days to expiry with portions, "Plan it in" links; hidden when empty — src/lib/components/today/AttentionList.svelte +- [X] T016 [P] [US1] Quick actions grid + empty-slot invitation cards per mockups — src/lib/components/today/TodayDashboard.svelte + +**Checkpoint**: Today renders real plan/pantry data; planned-meal one-tap logging works end-to-end. + +## Phase 5: US2 — Quick Log sheet + +- [X] T017 [US2] `QuickLogSheet` per mobile-log.html: slot picker (default `slotForTime`), "I ate something", prepped group (portions), Recent, Keepers groups; proper dialog a11y (Escape, backdrop, focus restore, `inert` background — research R9); mobile FAB + desktop entry — src/lib/components/today/QuickLogSheet.svelte, src/lib/components/today/TodayDashboard.svelte + +## Phase 6: US3 — Verdicts + +- [X] T018 [P] [US3] `VerdictControl`: wraps `.nk-verdict` (Again / It was fine / Not for me), aria-pressed, tap-again-to-clear, ≥44px targets — src/lib/components/shared/VerdictControl.svelte +- [X] T019 [US3] Verdict wiring: inline "How was it?" on just-logged card, sheet footer capture, verdict marks + times-made in Recent/Keepers groups — src/lib/components/today/TodayMealCard.svelte, src/lib/components/today/QuickLogSheet.svelte +- [X] T020 [US3] `RecapCard`: up to 3 unrated last-48h logs with inline VerdictControl; hidden when empty — src/lib/components/today/RecapCard.svelte + +## Phase 7: US4 — Nudge + +- [X] T021 [US4] `NudgeBanner`: `deriveNudge` + localStorage day/slot dismissal, Log + Not now actions — src/lib/components/today/NudgeBanner.svelte + +## Phase 8: Polish & verification + +- [X] T022 e2e: extend mock backend with `meal_logs` (+ planned_meals status flip, portion_events); flow test — view today, one-tap log flips card + decrements portions, quick "ate something", verdict set/clear, RLS-shaped isolation not applicable (mock), double-tap yields one log — tests/today.e2e.ts +- [X] T023 `bun run check` + `bun run lint` + `bun run test:unit -- --run` + `bun run build` + `bunx supabase test db` all green; svelte-autofixer clean on new components — src/lib/log/index.ts +- [X] T024 Adversarial reviews: supabase-rls-reviewer (0009 + pgTAP) and svelte5-reviewer (today components); triage + fix criticals — .specswarm/quality-analysis-006-today-logging.md +- [X] T025 Quality analysis + metrics entry — .specswarm/quality-analysis-006-today-logging.md, .specswarm/metrics.json +- [ ] T026 Manual quickstart verification (quickstart.md loop) incl. responsive + a11y pass — .specswarm/features/006-today-logging/quickstart.md + +## Dependencies + +T001 → T002 → {T003, T004} ; T004 → {T005, T006, T008} ; T007 ∥ after T002 ; +T008 → T009 → T010 → T011 → T012 → {T013..T016} → T017 → {T018 → T019 → T020} → T021 → +T022 → T023 → T024 → T025 → T026. [P] tasks within a phase parallelize. + +## Implementation strategy + +MVP increment = Phases 2–4 (Today + planned-meal one-tap logging). Sheet, verdicts, and +nudge layer on without schema churn. Pure logic (T005/T006) and pantry threading (T007) +parallelize against service work. Component behaviour is covered by e2e + manual +quickstart because the browser vitest project hangs in agent sandboxes. + +## Verification notes (2026-07-09) + +All automated gates green: svelte-check 0/0, lint clean, 266/266 unit (45 new), +99/99 pgTAP (26 new), 25/25 e2e (8 new), static build clean, types regenerated. +Adversarial reviews done and criticals fixed — (1) annotation trigger now permits the +FKs' ON DELETE SET NULL transition (logged sources are deletable again; regression +pgTAP added), (2) portion_events.triggered_by gained a real FK + SECURITY DEFINER +ownership guard, (3) the Quick Log sheet stays open when a log insert fails and shows +the error inline. T026 (manual quickstart: responsive + a11y pass per quickstart.md) +remains open for a human session. diff --git a/.specswarm/metrics.json b/.specswarm/metrics.json index 170470e..5e82324 100644 --- a/.specswarm/metrics.json +++ b/.specswarm/metrics.json @@ -40,6 +40,21 @@ "reviews": "supabase-rls-reviewer + svelte5-reviewer dispatched; criticals fixed (cross-user FK guard, inert/Escape on sheets)", "infra_repair": "0008_data_api_grants.sql (CLI auto_expose_new_tables flip 2026-05-30)", "completed_at": "2026-06-11" + }, + "006-today-logging": { + "quality_score": 91, + "tasks_total": 26, + "tasks_completed": 25, + "tasks_outstanding": "T026 manual browser quickstart (needs human session)", + "unit_tests": { + "server": "266/266", + "new": 45 + }, + "pgtap_tests": "99/99 (26 new)", + "e2e_tests": "25/25 (8 new, tests/today.e2e.ts)", + "reviews": "supabase-rls-reviewer + svelte5-reviewer; criticals fixed (SET NULL cascade vs annotation trigger, triggered_by FK + ownership guard, sheet stays open on failed log)", + "infra_repair": ".prettierignore /.impeccable/ cache exclusion", + "completed_at": "2026-07-09" } } } \ No newline at end of file diff --git a/.specswarm/quality-analysis-006-today-logging.md b/.specswarm/quality-analysis-006-today-logging.md new file mode 100644 index 0000000..ac8bedf --- /dev/null +++ b/.specswarm/quality-analysis-006-today-logging.md @@ -0,0 +1,70 @@ +# Quality analysis — 006 Today dashboard, one-tap meal logging & meal verdicts + +**Date:** 2026-07-09 · **Branch:** `feat/006-today-logging` · **Score: 91/100** + +## Evidence + +| Gate | Result | +|------|--------| +| `bun run check` (svelte-check) | 518 files, 0 errors, 0 warnings | +| `bun run lint` (prettier + eslint) | clean (3 justified `svelte/prefer-svelte-reactivity` disables for transient timestamps / non-reactive Set) | +| Unit (vitest, server project) | 266/266 across 28 files — 45 new (slots 14, derive 13, logService 10, logStore 8) | +| pgTAP (`supabase test db`) | 99/99 — 26 new in `meal_logs_rls.test.sql` | +| e2e (Playwright, mock backend) | 25/25 — 8 new in `tests/today.e2e.ts` | +| `bun run build` (adapter-static) | clean | +| svelte-autofixer | 0 issues on TodayDashboard; suggestions triaged (2 acted on, see below) | +| Types | `database.types.ts` regenerated from 0009 (drift gate satisfied) | + +## Adversarial review outcomes + +**supabase-rls-reviewer** — +1. CRITICAL (also caught by self-test before the report landed): the annotation-only + trigger rejected the FKs' own `ON DELETE SET NULL` cascade, making any logged + recipe/planned meal/prepped meal undeletable. **Fixed**: reference columns may + transition value → NULL (never to a different row); pgTAP regression tests added + (delete-with-log succeeds, snapshot survives, ref-rewrite still rejected). +2. WARN: `portion_events.triggered_by` became load-bearing with no FK/ownership + validation — arbitrary UUIDs (incl. another user's log id) accepted. **Fixed** in + 0009: real FK (`REFERENCES meal_logs ON DELETE SET NULL`) + SECURITY DEFINER + ownership guard; pgTAP cross-user test added. + All other posture confirmed sound (default-deny, DEFINER safety, no upsert bypass, + grants, realtime, REVOKE DELETE effective for authenticated and anon). + +**svelte5-reviewer** — +1. WARN: the Quick Log sheet closed even when the insert failed (logMeal resolves + null rather than throwing), hiding the error behind the closed sheet. **Fixed**: + sheet closes only on a truthy saved log; `logError()` now also renders inside the + sheet (`role="alert"`). +2. NIT (inherited): sheet close button is 36 px < `--tap-min`, but exactly matches the + canonical `mobile-log.html` mockup — flagged to design, not changed. + Runes usage, SPA compliance, tokens, `{@html}` absence, dialog a11y (Escape / + backdrop / focus restore / `inert`), and optimistic rollback logic confirmed clean. + +**svelte-autofixer suggestions acted on**: load `$effect` re-keyed to the civil date so +minute ticks don't refetch; localStorage-backed dismissals moved to `$derived.by` with +a version counter. + +## Infra repair + +- `.prettierignore`: added `/.impeccable/` (design-hook cache tripped `bun run lint`). +- `bun run db:types` invokes bare `supabase` (not `bunx supabase`) and truncates + `database.types.ts` before failing when the CLI isn't on PATH — worked around + locally; script fix left for a chore PR. + +## Deductions (−9) + +- −3 T026 manual quickstart (responsive + a11y pass) still open — needs a human session. +- −2 nudge banner has no e2e (clock-window dependent); covered by 6 unit tests on + `deriveNudge` only. +- −2 component behaviour relies on e2e + manual pass rather than browser-project vitest + (project-wide constraint: browser lane hangs in agent sandboxes). +- −1 sheet close button under `--tap-min` (canonical-mockup tension, recorded above). +- −1 `check_meal_log_source_ownership`/`check_portion_event_trigger_ownership` block + service-role-context inserts that carry references (auth.uid() is NULL there) — + consistent with the 0007 precedent, but a future Edge Function writer must know. + +## Constitution + +P1–P15 walked in plan.md Constitution Check: PASS (SPA-only, runes, tokens, no +{@html}, anon-key only, RLS-before-exposure, UUID/timestamptz, calm copy, append-only +ledgers, online-first optimistic-with-rollback all verified by gates above). diff --git a/src/lib/components/shared/VerdictControl.svelte b/src/lib/components/shared/VerdictControl.svelte new file mode 100644 index 0000000..8a38140 --- /dev/null +++ b/src/lib/components/shared/VerdictControl.svelte @@ -0,0 +1,56 @@ + + + +
+ {#each OPTIONS as opt (opt.verdict)} + + {/each} +
+ + diff --git a/src/lib/components/today/AttentionList.svelte b/src/lib/components/today/AttentionList.svelte new file mode 100644 index 0000000..a1226a6 --- /dev/null +++ b/src/lib/components/today/AttentionList.svelte @@ -0,0 +1,100 @@ + + + +
+
+ When you have a moment +
+ {#each expiringPrepped as meal (meal.id)} +
+ + + {meal.name} — {meal.portions_remaining} + portion{meal.portions_remaining === 1 ? '' : 's'}, best by {bestBy(meal.expiration_date)} + + Plan it in +
+ {/each} + {#each lowStock as item (item.id)} +
+ + {item.name} is running low + Add to list +
+ {/each} +
+ + diff --git a/src/lib/components/today/DayCoverageCard.svelte b/src/lib/components/today/DayCoverageCard.svelte new file mode 100644 index 0000000..0d96007 --- /dev/null +++ b/src/lib/components/today/DayCoverageCard.svelte @@ -0,0 +1,122 @@ + + + +
+ +
+ {coverage.headline} +

{coverage.detail}

+
+ {#if coverage.slots.length > 0} +
+ {#each coverage.slots as slot (slot.label)} + + + {slot.label} · {CHIP[slot.state].suffix} + + {/each} +
+ {/if} +
+ + diff --git a/src/lib/components/today/NudgeBanner.svelte b/src/lib/components/today/NudgeBanner.svelte new file mode 100644 index 0000000..3c3e31d --- /dev/null +++ b/src/lib/components/today/NudgeBanner.svelte @@ -0,0 +1,86 @@ + + + +
+ +
+ It's around {slotWord}time. +

Your {nudge.name} is ready whenever you are. No rush.

+
+
+ + +
+
+ + diff --git a/src/lib/components/today/QuickLogSheet.svelte b/src/lib/components/today/QuickLogSheet.svelte new file mode 100644 index 0000000..276dc3d --- /dev/null +++ b/src/lib/components/today/QuickLogSheet.svelte @@ -0,0 +1,490 @@ + + + + + + + + diff --git a/src/lib/components/today/RecapCard.svelte b/src/lib/components/today/RecapCard.svelte new file mode 100644 index 0000000..01d2e15 --- /dev/null +++ b/src/lib/components/today/RecapCard.svelte @@ -0,0 +1,88 @@ + + + +
+ {#each logs as log (log.id)} +
+
+ +
+ {mealLogName(log)} + + {whenLabel(log.loggedAt)}{log.mealSlot + ? ` · ${mealSlotLabel(log.mealSlot).toLowerCase()}` + : ''} · not yet rated + +
+
+ onRate(log.id, v)} /> +
+ {/each} +
+ + diff --git a/src/lib/components/today/TodayDashboard.svelte b/src/lib/components/today/TodayDashboard.svelte new file mode 100644 index 0000000..f003951 --- /dev/null +++ b/src/lib/components/today/TodayDashboard.svelte @@ -0,0 +1,495 @@ + + + + Today · Nexus Kitchen + + +
+
+
+
+

{greeting}.

+

{dateLine} · here's your day, no pressure.

+
+ +
+ + {#if logError()} + + {/if} + {#if logNotice()} + + {/if} + + + + {#if nudge} + { + const meal = todaysMeals.find((m) => m.id === nudge.plannedMealId); + if (meal) logPlanned(meal); + }} + onDismiss={() => dismissNudge(nudge.slot)} + /> + {/if} + +

Today's meals

+
+ {#each todaysMeals as meal (meal.id)} + p.id === meal.preppedMealId)?.portions_remaining ?? null) + : null} + {busy} + onLog={() => logPlanned(meal)} + onRate={handleRate} + /> + {/each} + {#each emptySlots as slot (slot)} + + + {EMPTY_SLOT_COPY[slot]} + optional + + {/each} +
+ +
+
+

Quick actions

+ +
+
+ {#if recapLogs.length > 0} +

How were these?

+ + {/if} + {#if attentionPrepped.length > 0 || attentionLow.length > 0} +

+ Keep things moving +

+ + {/if} +
+
+
+ + +
+ +{#if sheetOpen} + (sheetOpen = false)} + onLogSomething={logSomething} + onLogPrepped={logPrepped} + onLogSource={logSource} + /> +{/if} + + diff --git a/src/lib/components/today/TodayMealCard.svelte b/src/lib/components/today/TodayMealCard.svelte new file mode 100644 index 0000000..6f575b2 --- /dev/null +++ b/src/lib/components/today/TodayMealCard.svelte @@ -0,0 +1,254 @@ + + + +
+
+ + {mealSlotLabel(meal.mealSlot)} +
+ +
{name}
+
+ {#if eaten} + + + Eaten{eatenTime ? ` · ${eatenTime}` : ''} + + {#if log && log.verdict === 'KEEP'} + + Keeper + + {:else if log && log.verdict === 'REST'} + + Set aside + + {:else if log && log.verdict === null} +
+ How was it? + onRate(log.id, v)} /> +
+ {/if} + {:else} +
+ {#if req} + + + {req.label} + + {/if} + {#if meal.source === 'PREPPED' && preppedPortionsLeft !== null} + + + Prepped · {preppedPortionsLeft} left + + {/if} +
+
+ {#if fulfillment?.state === 'MUST_ACQUIRE'} + + Add to list + + {:else} + + {/if} +
+ {/if} +
+
+ + diff --git a/src/lib/database.types.ts b/src/lib/database.types.ts index f901b4d..9eb3a49 100644 --- a/src/lib/database.types.ts +++ b/src/lib/database.types.ts @@ -28,6 +28,82 @@ export type Database = { }; public: { Tables: { + meal_logs: { + Row: { + created_at: string; + household_id: string | null; + id: string; + log_type: Database['public']['Enums']['meal_log_type']; + logged_at: string; + meal_slot: Database['public']['Enums']['meal_slot'] | null; + name_snapshot: string | null; + notes: string | null; + owner_id: string; + planned_meal_id: string | null; + prepped_meal_id: string | null; + recipe_id: string | null; + servings: number; + updated_at: string; + verdict: Database['public']['Enums']['meal_verdict'] | null; + }; + Insert: { + created_at?: string; + household_id?: string | null; + id?: string; + log_type: Database['public']['Enums']['meal_log_type']; + logged_at?: string; + meal_slot?: Database['public']['Enums']['meal_slot'] | null; + name_snapshot?: string | null; + notes?: string | null; + owner_id?: string; + planned_meal_id?: string | null; + prepped_meal_id?: string | null; + recipe_id?: string | null; + servings?: number; + updated_at?: string; + verdict?: Database['public']['Enums']['meal_verdict'] | null; + }; + Update: { + created_at?: string; + household_id?: string | null; + id?: string; + log_type?: Database['public']['Enums']['meal_log_type']; + logged_at?: string; + meal_slot?: Database['public']['Enums']['meal_slot'] | null; + name_snapshot?: string | null; + notes?: string | null; + owner_id?: string; + planned_meal_id?: string | null; + prepped_meal_id?: string | null; + recipe_id?: string | null; + servings?: number; + updated_at?: string; + verdict?: Database['public']['Enums']['meal_verdict'] | null; + }; + Relationships: [ + { + foreignKeyName: 'meal_logs_planned_meal_id_fkey'; + columns: ['planned_meal_id']; + isOneToOne: false; + referencedRelation: 'planned_meals'; + referencedColumns: ['id']; + }, + { + foreignKeyName: 'meal_logs_prepped_meal_id_fkey'; + columns: ['prepped_meal_id']; + isOneToOne: false; + referencedRelation: 'prepped_meals'; + referencedColumns: ['id']; + }, + { + foreignKeyName: 'meal_logs_recipe_id_fkey'; + columns: ['recipe_id']; + isOneToOne: false; + referencedRelation: 'recipes'; + referencedColumns: ['id']; + } + ]; + }; meal_plans: { Row: { created_at: string; @@ -660,11 +736,13 @@ export type Database = { [_ in never]: never; }; Functions: { - seed_demo_data: { Args: { p_owner?: string }; Returns: Json }; + [_ in never]: never; }; Enums: { defrost_state: 'NOT_APPLICABLE' | 'FROZEN' | 'DEFROSTING' | 'READY'; + meal_log_type: 'FROM_PLAN' | 'FROM_RECIPE' | 'FROM_PREPPED' | 'QUICK_LOG' | 'CUSTOM'; meal_slot: 'BREAKFAST' | 'LUNCH' | 'DINNER' | 'SNACK'; + meal_verdict: 'KEEP' | 'FINE' | 'REST'; planned_meal_source: 'RECIPE' | 'PREPPED' | 'STORE_BOUGHT' | 'QUICK'; planned_meal_status: 'PLANNED' | 'LOGGED' | 'SKIPPED' | 'SWAPPED'; portion_event_kind: 'INITIALIZED' | 'CONSUMED' | 'ADJUSTED'; @@ -811,7 +889,9 @@ export const Constants = { public: { Enums: { defrost_state: ['NOT_APPLICABLE', 'FROZEN', 'DEFROSTING', 'READY'], + meal_log_type: ['FROM_PLAN', 'FROM_RECIPE', 'FROM_PREPPED', 'QUICK_LOG', 'CUSTOM'], meal_slot: ['BREAKFAST', 'LUNCH', 'DINNER', 'SNACK'], + meal_verdict: ['KEEP', 'FINE', 'REST'], planned_meal_source: ['RECIPE', 'PREPPED', 'STORE_BOUGHT', 'QUICK'], planned_meal_status: ['PLANNED', 'LOGGED', 'SKIPPED', 'SWAPPED'], portion_event_kind: ['INITIALIZED', 'CONSUMED', 'ADJUSTED'], diff --git a/src/lib/log/derive.spec.ts b/src/lib/log/derive.spec.ts new file mode 100644 index 0000000..ec9e7e3 --- /dev/null +++ b/src/lib/log/derive.spec.ts @@ -0,0 +1,186 @@ +import { describe, expect, it } from 'vitest'; +import type { PlannedMeal } from '$lib/planning/types'; +import type { MealLog } from './types'; +import { + deriveDayCoverage, + groupLogSources, + keepers, + recents, + unratedRecent, + type MealWithFulfillment +} from './derive'; + +let seq = 0; +function log(overrides: Partial = {}): MealLog { + seq += 1; + return { + id: `log-${seq}`, + ownerId: 'user-1', + logType: 'CUSTOM', + plannedMealId: null, + recipeId: null, + preppedMealId: null, + nameSnapshot: 'Chili', + mealSlot: 'DINNER', + servings: 1, + loggedAt: '2026-07-09T12:00:00Z', + verdict: null, + notes: null, + createdAt: '2026-07-09T12:00:00Z', + updatedAt: '2026-07-09T12:00:00Z', + ...overrides + }; +} + +function meal(overrides: Partial = {}): PlannedMeal { + seq += 1; + return { + id: `pm-${seq}`, + mealPlanId: 'plan-1', + date: '2026-07-09', + mealSlot: 'LUNCH', + source: 'QUICK', + recipeId: null, + recipeTitleSnapshot: null, + preppedMealId: null, + preppedNameSnapshot: null, + storeBoughtName: null, + quickMealName: 'Marinara pasta', + servings: 1, + status: 'PLANNED', + sortOrder: 0, + createdAt: '2026-07-09T00:00:00Z', + updatedAt: '2026-07-09T00:00:00Z', + ...overrides + }; +} + +describe('groupLogSources', () => { + it('groups by recipe identity when present, else by normalized name', () => { + const sources = groupLogSources([ + log({ recipeId: 'r-1', nameSnapshot: 'Lentil curry', loggedAt: '2026-07-09T12:00:00Z' }), + log({ recipeId: 'r-1', nameSnapshot: 'Lentil Curry v2', loggedAt: '2026-07-08T12:00:00Z' }), + log({ nameSnapshot: 'Chili', loggedAt: '2026-07-07T12:00:00Z' }), + log({ nameSnapshot: ' chili ', loggedAt: '2026-07-06T12:00:00Z' }) + ]); + expect(sources).toHaveLength(2); + const curry = sources.find((s) => s.key === 'recipe:r-1')!; + expect(curry.timesMade).toBe(2); + expect(curry.name).toBe('Lentil curry'); // newest occurrence names the source + const chili = sources.find((s) => s.key === 'name:chili')!; + expect(chili.timesMade).toBe(2); + }); + + it('takes the verdict of the most recent rated occurrence', () => { + const [chili] = groupLogSources([ + log({ nameSnapshot: 'Chili', verdict: null, loggedAt: '2026-07-09T12:00:00Z' }), + log({ nameSnapshot: 'Chili', verdict: 'REST', loggedAt: '2026-07-08T12:00:00Z' }), + log({ nameSnapshot: 'Chili', verdict: 'KEEP', loggedAt: '2026-07-01T12:00:00Z' }) + ]); + expect(chili.latestVerdict).toBe('REST'); + }); + + it('excludes bare quick logs — "something" is not a resurfaceable source', () => { + expect(groupLogSources([log({ logType: 'QUICK_LOG', nameSnapshot: null })])).toHaveLength(0); + }); +}); + +describe('recents / keepers', () => { + const sources = groupLogSources([ + log({ nameSnapshot: 'Chili', verdict: 'KEEP', loggedAt: '2026-07-05T12:00:00Z' }), + log({ nameSnapshot: 'Chili', loggedAt: '2026-07-03T12:00:00Z' }), + log({ nameSnapshot: 'Takeout', verdict: 'REST', loggedAt: '2026-07-08T12:00:00Z' }), + log({ nameSnapshot: 'Stir fry', verdict: 'KEEP', loggedAt: '2026-07-09T12:00:00Z' }) + ]); + + it('recents returns newest-first, limited', () => { + expect(recents(sources, 2).map((s) => s.name)).toEqual(['Stir fry', 'Takeout']); + }); + + it('keepers returns only KEEP sources, most-made first', () => { + expect(keepers(sources).map((s) => s.name)).toEqual(['Chili', 'Stir fry']); + }); +}); + +describe('unratedRecent', () => { + const now = new Date('2026-07-09T18:00:00Z'); + + it('keeps unrated, recent, non-quick logs, newest first and limited', () => { + const picked = unratedRecent( + [ + log({ nameSnapshot: 'A', loggedAt: '2026-07-09T12:00:00Z' }), + log({ nameSnapshot: 'B', loggedAt: '2026-07-08T12:00:00Z' }), + log({ nameSnapshot: 'Rated', verdict: 'FINE', loggedAt: '2026-07-09T11:00:00Z' }), + log({ logType: 'QUICK_LOG', nameSnapshot: null, loggedAt: '2026-07-09T10:00:00Z' }), + log({ nameSnapshot: 'Too old', loggedAt: '2026-07-06T12:00:00Z' }) + ], + now, + 2 + ); + expect(picked.map((l) => l.nameSnapshot)).toEqual(['A', 'B']); + }); +}); + +describe('deriveDayCoverage', () => { + it('invites planning when nothing is planned, without judgment', () => { + const coverage = deriveDayCoverage([]); + expect(coverage.slots).toEqual([]); + expect(coverage.headline).toBe("Nothing planned — that's okay."); + }); + + it('rolls slot states up with logged meals as EATEN and worst-state-wins', () => { + const entries: MealWithFulfillment[] = [ + { meal: meal({ mealSlot: 'BREAKFAST', status: 'LOGGED' }), fulfillment: null }, + { meal: meal({ mealSlot: 'LUNCH' }), fulfillment: 'HAVE_IT' }, + { meal: meal({ mealSlot: 'DINNER' }), fulfillment: 'MUST_ACQUIRE' } + ]; + const coverage = deriveDayCoverage(entries); + expect(coverage.slots.map((s) => [s.label, s.state])).toEqual([ + ['Breakfast', 'EATEN'], + ['Lunch', 'HAVE_IT'], + ['Dinner', 'MUST_ACQUIRE'] + ]); + expect(coverage.headline).toBe('One small gap today.'); + expect(coverage.detail).toContain('Marinara pasta'); + }); + + it('celebrates a fully covered day calmly', () => { + const coverage = deriveDayCoverage([ + { meal: meal({ mealSlot: 'LUNCH' }), fulfillment: 'HAVE_IT' } + ]); + expect(coverage.headline).toBe("You're covered today."); + }); + + it('mentions cooking when coverage needs it', () => { + const coverage = deriveDayCoverage([ + { meal: meal({ mealSlot: 'DINNER' }), fulfillment: 'CAN_MAKE_IT' } + ]); + expect(coverage.slots[0].state).toBe('CAN_MAKE_IT'); + expect(coverage.detail).toContain('cooking'); + }); + + it('counts multiple gaps plainly', () => { + const coverage = deriveDayCoverage([ + { meal: meal({ mealSlot: 'LUNCH' }), fulfillment: 'MUST_ACQUIRE' }, + { meal: meal({ mealSlot: 'DINNER' }), fulfillment: 'MUST_ACQUIRE' } + ]); + expect(coverage.headline).toBe('A few things to pick up.'); + expect(coverage.detail).toContain('2 meals'); + }); + + it('reports an all-logged day as done', () => { + const coverage = deriveDayCoverage([ + { meal: meal({ mealSlot: 'LUNCH', status: 'LOGGED' }), fulfillment: null } + ]); + expect(coverage.headline).toBe('All logged for today.'); + }); + + it('orders slots by band with Anytime last', () => { + const coverage = deriveDayCoverage([ + { meal: meal({ mealSlot: null }), fulfillment: 'HAVE_IT' }, + { meal: meal({ mealSlot: 'DINNER' }), fulfillment: 'HAVE_IT' }, + { meal: meal({ mealSlot: 'BREAKFAST' }), fulfillment: 'HAVE_IT' } + ]); + expect(coverage.slots.map((s) => s.label)).toEqual(['Breakfast', 'Dinner', 'Anytime']); + }); +}); diff --git a/src/lib/log/derive.ts b/src/lib/log/derive.ts new file mode 100644 index 0000000..d48c9ce --- /dev/null +++ b/src/lib/log/derive.ts @@ -0,0 +1,182 @@ +// Pure derivations over meal logs (feature 006, research R7/R8): recents, keepers, +// the unrated recap, and the day-coverage rollup. All client-side and I/O-free — +// suggestion features later may push these server-side if aggregation outgrows this. + +import type { MealSlot, PlannedMeal } from '$lib/planning/types'; +import { MEAL_SLOTS, mealSlotLabel, plannedMealName } from '$lib/planning/types'; +import type { FulfillmentState } from '$lib/planning/fulfillment'; +import type { MealLog, MealVerdict } from './types'; +import { mealLogName } from './types'; + +/** One distinct "thing you've eaten", grouped across its log occurrences. */ +export interface LoggedSource { + /** recipe: when recipe-backed, else name:. */ + key: string; + name: string; + recipeId: string | null; + /** Verdict of the most recent occurrence that has one; null = never rated. */ + latestVerdict: MealVerdict | null; + timesMade: number; + lastLoggedAt: string; + lastSlot: MealSlot | null; +} + +function sourceKey(log: MealLog): string | null { + if (log.logType === 'QUICK_LOG') return null; // "something" is not a resurfaceable source + if (log.recipeId) return `recipe:${log.recipeId}`; + const name = log.nameSnapshot?.trim().toLowerCase(); + return name ? `name:${name}` : null; +} + +/** + * Group logs (newest first) into distinct sources. Grouping is by recipe identity + * when present, otherwise by normalized name — so two batches of "Chili" count as + * one source with timesMade 2 (R7). + */ +export function groupLogSources(logs: readonly MealLog[]): LoggedSource[] { + const ordered = [...logs].sort((a, b) => b.loggedAt.localeCompare(a.loggedAt)); + const byKey = new Map(); + for (const log of ordered) { + const key = sourceKey(log); + if (key === null) continue; + const existing = byKey.get(key); + if (existing) { + existing.timesMade += 1; + if (existing.latestVerdict === null && log.verdict !== null) { + existing.latestVerdict = log.verdict; + } + } else { + byKey.set(key, { + key, + name: mealLogName(log), + recipeId: log.recipeId, + latestVerdict: log.verdict, + timesMade: 1, + lastLoggedAt: log.loggedAt, + lastSlot: log.mealSlot + }); + } + } + return [...byKey.values()]; +} + +/** Latest `limit` distinct sources, newest first (Quick Log "Recent" group). */ +export function recents(sources: readonly LoggedSource[], limit: number): LoggedSource[] { + return [...sources].sort((a, b) => b.lastLoggedAt.localeCompare(a.lastLoggedAt)).slice(0, limit); +} + +/** Sources whose most recent verdict is KEEP, most-made first ("Keepers" group). */ +export function keepers(sources: readonly LoggedSource[]): LoggedSource[] { + return sources + .filter((s) => s.latestVerdict === 'KEEP') + .sort((a, b) => b.timesMade - a.timesMade || b.lastLoggedAt.localeCompare(a.lastLoggedAt)); +} + +/** + * Recap candidates (FR-TL-017): unrated, within the last 48 h, excluding bare + * QUICK_LOG entries (nothing meaningful to rate), newest first. + */ +export function unratedRecent(logs: readonly MealLog[], now: Date, limit: number): MealLog[] { + const cutoff = new Date(now.getTime() - 48 * 60 * 60 * 1000).toISOString(); + return logs + .filter( + (log) => + log.verdict === null && + log.logType !== 'QUICK_LOG' && + log.loggedAt >= cutoff && + log.loggedAt <= now.toISOString() + ) + .sort((a, b) => b.loggedAt.localeCompare(a.loggedAt)) + .slice(0, limit); +} + +// --------------------------------------------------------------------------- +// Day coverage (FR-TL-005, research R8) — rolls feature 004's per-meal +// fulfillment up to slot chips + one calm sentence. Never persisted. +// --------------------------------------------------------------------------- + +export interface MealWithFulfillment { + meal: PlannedMeal; + /** null = not tracked (QUICK) or inputs still loading — treated as covered-enough. */ + fulfillment: FulfillmentState | null; +} + +export type CoverageState = 'EATEN' | 'HAVE_IT' | 'CAN_MAKE_IT' | 'MUST_ACQUIRE'; + +export interface SlotCoverage { + slot: MealSlot | null; + label: string; + state: CoverageState; +} + +export interface DayCoverage { + slots: SlotCoverage[]; + headline: string; + detail: string; +} + +const SLOT_ORDER: readonly (MealSlot | null)[] = [...MEAL_SLOTS, null]; + +function slotState(entries: MealWithFulfillment[]): CoverageState { + if (entries.every(({ meal }) => meal.status === 'LOGGED')) return 'EATEN'; + const open = entries.filter(({ meal }) => meal.status === 'PLANNED'); + if (open.some(({ fulfillment }) => fulfillment === 'MUST_ACQUIRE')) return 'MUST_ACQUIRE'; + if (open.some(({ fulfillment }) => fulfillment === 'CAN_MAKE_IT')) return 'CAN_MAKE_IT'; + return 'HAVE_IT'; +} + +/** Calm, factual coverage summary — gaps are stated without judgment (spec voice rules). */ +export function deriveDayCoverage(meals: readonly MealWithFulfillment[]): DayCoverage { + if (meals.length === 0) { + return { + slots: [], + headline: "Nothing planned — that's okay.", + detail: 'Want to pick something easy for later? The planner is one tap away.' + }; + } + + const bySlot = new Map(); + for (const entry of meals) { + const list = bySlot.get(entry.meal.mealSlot) ?? []; + list.push(entry); + bySlot.set(entry.meal.mealSlot, list); + } + + const slots: SlotCoverage[] = SLOT_ORDER.filter((slot) => bySlot.has(slot)).map((slot) => ({ + slot, + label: mealSlotLabel(slot), + state: slotState(bySlot.get(slot)!) + })); + + const gaps = meals.filter( + ({ meal, fulfillment }) => meal.status === 'PLANNED' && fulfillment === 'MUST_ACQUIRE' + ); + const cooking = meals.some( + ({ meal, fulfillment }) => meal.status === 'PLANNED' && fulfillment === 'CAN_MAKE_IT' + ); + + if (gaps.length === 0) { + if (slots.every((s) => s.state === 'EATEN')) { + return { slots, headline: 'All logged for today.', detail: 'Nothing left to think about.' }; + } + return { + slots, + headline: "You're covered today.", + detail: cooking + ? 'Everything you planned is on hand — some of it just wants cooking.' + : "Everything's either eaten or ready to go." + }; + } + if (gaps.length === 1) { + return { + slots, + headline: 'One small gap today.', + detail: `${plannedMealName(gaps[0].meal)} needs a quick pickup — one thing, and the day's set.` + }; + } + return { + slots, + headline: 'A few things to pick up.', + detail: `${gaps.length} meals want ingredients — the shopping list can gather them.` + }; +} diff --git a/src/lib/log/index.ts b/src/lib/log/index.ts new file mode 100644 index 0000000..67101a4 --- /dev/null +++ b/src/lib/log/index.ts @@ -0,0 +1,58 @@ +// Public surface of the log module (feature 006). UI imports from here; the +// generated database types never leak past types.ts. + +export { + mealLogName, + toMealLog, + verdictLabel, + type MealLog, + type MealLogDraft, + type MealLogType, + type MealVerdict +} from './types'; + +export { + LogError, + createLog, + fetchLogsBetween, + fetchRecentLogs, + markPlannedMealLogged, + setVerdict +} from './logService'; + +export { + deriveNudge, + localDateISO, + localDayBounds, + nudgeDismissKey, + slotForTime, + type NudgeState +} from './slots'; + +export { + deriveDayCoverage, + groupLogSources, + keepers, + recents, + unratedRecent, + type CoverageState, + type DayCoverage, + type LoggedSource, + type MealWithFulfillment, + type SlotCoverage +} from './derive'; + +export { + clearLogError, + clearLogNotice, + loadRecents, + loadToday, + logError, + logLoading, + logMeal, + logNotice, + rateLog, + recentLogs, + recentSources, + todayLogs +} from './logStore.svelte'; diff --git a/src/lib/log/logService.spec.ts b/src/lib/log/logService.spec.ts new file mode 100644 index 0000000..7d92492 --- /dev/null +++ b/src/lib/log/logService.spec.ts @@ -0,0 +1,217 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Mock } from 'vitest'; +import type { PlannedMeal } from '$lib/planning/types'; +import { + LogError, + createLog, + draftColumns, + fetchLogsBetween, + fetchRecentLogs, + markPlannedMealLogged, + setVerdict +} from './logService'; + +vi.mock('$lib/supabaseClient', () => ({ + supabase: { from: vi.fn() } +})); +vi.mock('$lib/session/session.svelte', () => ({ + ensureSession: vi.fn().mockResolvedValue({ id: 'user-1' }) +})); + +import { supabase } from '$lib/supabaseClient'; + +// Chainable mock builder — thenable so plain `await builder` works too +function makeChain(result: { data: unknown; error: unknown }) { + const chain: Record = {}; + const methods = ['select', 'order', 'insert', 'update', 'eq', 'neq', 'gte', 'lt', 'limit']; + for (const m of methods) { + chain[m] = vi.fn().mockReturnValue(chain); + } + chain.single = vi.fn().mockResolvedValue(result); + chain.then = (resolve: (v: unknown) => unknown) => Promise.resolve(result).then(resolve); + return chain; +} + +function makeLogRow(overrides: Record = {}) { + return { + id: 'log-1', + owner_id: 'user-1', + household_id: null, + log_type: 'QUICK_LOG', + planned_meal_id: null, + recipe_id: null, + prepped_meal_id: null, + name_snapshot: null, + meal_slot: 'SNACK', + servings: '1', + logged_at: '2026-07-09T12:00:00Z', + verdict: null, + notes: null, + created_at: '2026-07-09T12:00:00Z', + updated_at: '2026-07-09T12:00:00Z', + ...overrides + }; +} + +function plannedMeal(overrides: Partial = {}): PlannedMeal { + return { + id: 'pm-1', + mealPlanId: 'plan-1', + date: '2026-07-09', + mealSlot: 'LUNCH', + source: 'PREPPED', + recipeId: null, + recipeTitleSnapshot: null, + preppedMealId: 'prep-1', + preppedNameSnapshot: 'Marinara pasta', + storeBoughtName: null, + quickMealName: null, + servings: 2, + status: 'PLANNED', + sortOrder: 0, + createdAt: '2026-07-09T00:00:00Z', + updatedAt: '2026-07-09T00:00:00Z', + ...overrides + }; +} + +describe('draftColumns', () => { + it('maps a fromPlan draft with snapshot, slot, servings and refs', () => { + expect(draftColumns({ kind: 'fromPlan', plannedMeal: plannedMeal() })).toEqual({ + log_type: 'FROM_PLAN', + planned_meal_id: 'pm-1', + recipe_id: null, + prepped_meal_id: 'prep-1', + name_snapshot: 'Marinara pasta', + meal_slot: 'LUNCH', + servings: 2, + verdict: null + }); + }); + + it('maps fromPrepped with default servings 1', () => { + expect( + draftColumns({ + kind: 'fromPrepped', + preppedMeal: { id: 'prep-2', name: 'Chili' }, + slot: 'DINNER' + }) + ).toMatchObject({ log_type: 'FROM_PREPPED', prepped_meal_id: 'prep-2', servings: 1 }); + }); + + it('maps a bare quick log with no name and carries a log-time verdict', () => { + expect(draftColumns({ kind: 'quick', slot: 'SNACK', verdict: 'FINE' })).toEqual({ + log_type: 'QUICK_LOG', + meal_slot: 'SNACK', + verdict: 'FINE' + }); + }); + + it('maps custom and fromRecipe names', () => { + expect(draftColumns({ kind: 'custom', name: 'Takeout', slot: null })).toMatchObject({ + log_type: 'CUSTOM', + name_snapshot: 'Takeout' + }); + expect( + draftColumns({ kind: 'fromRecipe', recipeId: 'r-1', name: 'Lentil curry', slot: 'DINNER' }) + ).toMatchObject({ log_type: 'FROM_RECIPE', recipe_id: 'r-1', name_snapshot: 'Lentil curry' }); + }); +}); + +describe('logService', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('createLog', () => { + it('inserts the draft columns with the given logged_at and maps the row', async () => { + expect.hasAssertions(); + const chain = makeChain({ data: makeLogRow(), error: null }); + (supabase.from as Mock).mockReturnValue(chain); + + const saved = await createLog({ kind: 'quick', slot: 'SNACK' }, '2026-07-09T12:00:00Z'); + + expect(supabase.from).toHaveBeenCalledWith('meal_logs'); + expect(chain.insert).toHaveBeenCalledWith( + expect.objectContaining({ log_type: 'QUICK_LOG', logged_at: '2026-07-09T12:00:00Z' }) + ); + expect(saved.servings).toBe(1); // numeric string coerced + expect(saved.logType).toBe('QUICK_LOG'); + }); + + it('throws a calm LogError on failure', async () => { + expect.hasAssertions(); + const chain = makeChain({ data: null, error: { message: 'boom' } }); + (supabase.from as Mock).mockReturnValue(chain); + + await expect(createLog({ kind: 'quick', slot: null })).rejects.toBeInstanceOf(LogError); + }); + }); + + describe('setVerdict', () => { + it('updates only the verdict column (annotation window)', async () => { + expect.hasAssertions(); + const chain = makeChain({ data: makeLogRow({ verdict: 'KEEP' }), error: null }); + (supabase.from as Mock).mockReturnValue(chain); + + const saved = await setVerdict('log-1', 'KEEP'); + + expect(chain.update).toHaveBeenCalledWith({ verdict: 'KEEP' }); + expect(chain.eq).toHaveBeenCalledWith('id', 'log-1'); + expect(saved.verdict).toBe('KEEP'); + }); + }); + + describe('fetchLogsBetween', () => { + it('queries the half-open window, newest first', async () => { + expect.hasAssertions(); + const chain = makeChain({ data: [makeLogRow()], error: null }); + (supabase.from as Mock).mockReturnValue(chain); + + const logs = await fetchLogsBetween('2026-07-09T05:00:00Z', '2026-07-10T05:00:00Z'); + + expect(chain.gte).toHaveBeenCalledWith('logged_at', '2026-07-09T05:00:00Z'); + expect(chain.lt).toHaveBeenCalledWith('logged_at', '2026-07-10T05:00:00Z'); + expect(logs).toHaveLength(1); + }); + }); + + describe('fetchRecentLogs', () => { + it('excludes bare quick logs and limits', async () => { + expect.hasAssertions(); + const chain = makeChain({ data: [], error: null }); + (supabase.from as Mock).mockReturnValue(chain); + + await fetchRecentLogs(50); + + expect(chain.neq).toHaveBeenCalledWith('log_type', 'QUICK_LOG'); + expect(chain.limit).toHaveBeenCalledWith(50); + }); + }); + + describe('markPlannedMealLogged', () => { + it('safe-flips only PLANNED meals and reports success', async () => { + expect.hasAssertions(); + const chain = makeChain({ data: [{ id: 'pm-1' }], error: null }); + (supabase.from as Mock).mockReturnValue(chain); + + const flipped = await markPlannedMealLogged('pm-1', '2026-07-09T12:00:00Z'); + + expect(supabase.from).toHaveBeenCalledWith('planned_meals'); + expect(chain.update).toHaveBeenCalledWith({ + status: 'LOGGED', + logged_at: '2026-07-09T12:00:00Z' + }); + expect(chain.eq).toHaveBeenCalledWith('status', 'PLANNED'); + expect(flipped).toBe(true); + }); + + it('reports false when another writer already flipped it', async () => { + expect.hasAssertions(); + const chain = makeChain({ data: [], error: null }); + (supabase.from as Mock).mockReturnValue(chain); + + await expect(markPlannedMealLogged('pm-1', '2026-07-09T12:00:00Z')).resolves.toBe(false); + }); + }); +}); diff --git a/src/lib/log/logService.ts b/src/lib/log/logService.ts new file mode 100644 index 0000000..75b1333 --- /dev/null +++ b/src/lib/log/logService.ts @@ -0,0 +1,150 @@ +// Typed data-access layer for meal logging, built on supabase-js / PostgREST. +// Every call runs under the caller's RLS context — no owner filters; the database +// enforces isolation. Errors surface as LogError with calm messages. +// +// Occurrence facts are append-only (INV-CC-004): the only UPDATE this service ever +// issues touches verdict/notes, matching the DB's annotation-only trigger. + +import { supabase } from '$lib/supabaseClient'; +import { ensureSession } from '$lib/session/session.svelte'; +import { plannedMealName } from '$lib/planning/types'; +import { + toMealLog, + type MealLog, + type MealLogDraft, + type MealLogInsert, + type MealVerdict +} from './types'; + +/** A normalized application error carrying a friendly message plus the original for logging. */ +export class LogError extends Error { + readonly cause?: unknown; + constructor(message: string, cause?: unknown) { + super(message); + this.name = 'LogError'; + this.cause = cause; + } +} + +/** Ensure a session exists, throwing a LogError if sign-in failed. */ +async function requireSession() { + const user = await ensureSession(); + if (!user) throw new LogError("We couldn't start a session. Try refreshing the page."); + return user; +} + +/** Map a creation draft to insert columns (type↔reference rules live in the draft union). */ +export function draftColumns(draft: MealLogDraft): MealLogInsert { + const verdict = draft.verdict ?? null; + switch (draft.kind) { + case 'fromPlan': + return { + log_type: 'FROM_PLAN', + planned_meal_id: draft.plannedMeal.id, + recipe_id: draft.plannedMeal.recipeId, + prepped_meal_id: draft.plannedMeal.preppedMealId, + name_snapshot: plannedMealName(draft.plannedMeal), + meal_slot: draft.plannedMeal.mealSlot, + servings: draft.plannedMeal.servings, + verdict + }; + case 'fromPrepped': + return { + log_type: 'FROM_PREPPED', + prepped_meal_id: draft.preppedMeal.id, + name_snapshot: draft.preppedMeal.name, + meal_slot: draft.slot, + servings: draft.servings ?? 1, + verdict + }; + case 'fromRecipe': + return { + log_type: 'FROM_RECIPE', + recipe_id: draft.recipeId, + name_snapshot: draft.name, + meal_slot: draft.slot, + verdict + }; + case 'quick': + return { log_type: 'QUICK_LOG', meal_slot: draft.slot, verdict }; + case 'custom': + return { + log_type: 'CUSTOM', + name_snapshot: draft.name, + meal_slot: draft.slot, + verdict + }; + } +} + +/** Insert one meal log from a draft. Returns the persisted, mapped row. */ +export async function createLog(draft: MealLogDraft, loggedAt?: string): Promise { + await requireSession(); + const columns = draftColumns(draft); + const { data, error } = await supabase + .from('meal_logs') + .insert(loggedAt ? { ...columns, logged_at: loggedAt } : columns) + .select() + .single(); + if (error) throw new LogError("We couldn't save that log. Please try again.", error); + return toMealLog(data); +} + +/** Set or clear the verdict on an existing log (the annotation window, research R2). */ +export async function setVerdict(logId: string, verdict: MealVerdict | null): Promise { + await requireSession(); + const { data, error } = await supabase + .from('meal_logs') + .update({ verdict }) + .eq('id', logId) + .select() + .single(); + if (error) throw new LogError("We couldn't save that. Please try again.", error); + return toMealLog(data); +} + +/** Logs with logged_at in [fromISO, toISO), newest first (the Today window). */ +export async function fetchLogsBetween(fromISO: string, toISO: string): Promise { + await requireSession(); + const { data, error } = await supabase + .from('meal_logs') + .select('*') + .gte('logged_at', fromISO) + .lt('logged_at', toISO) + .order('logged_at', { ascending: false }); + if (error) throw new LogError("We couldn't load today's logs. Please try again.", error); + return (data ?? []).map(toMealLog); +} + +/** Latest non-QUICK_LOG logs for recents/keepers grouping (research R7). */ +export async function fetchRecentLogs(limit = 100): Promise { + await requireSession(); + const { data, error } = await supabase + .from('meal_logs') + .select('*') + .neq('log_type', 'QUICK_LOG') + .order('logged_at', { ascending: false }) + .limit(limit); + if (error) throw new LogError("We couldn't load your recent meals. Please try again.", error); + return (data ?? []).map(toMealLog); +} + +/** + * Safe-flip a planned meal PLANNED → LOGGED (FR-TL-010, INV-PL-005). The status + * predicate makes the transition happen at most once — returns false when another + * writer (or a second tap) got there first. + */ +export async function markPlannedMealLogged( + plannedMealId: string, + loggedAt: string +): Promise { + await requireSession(); + const { data, error } = await supabase + .from('planned_meals') + .update({ status: 'LOGGED', logged_at: loggedAt }) + .eq('id', plannedMealId) + .eq('status', 'PLANNED') + .select('id'); + if (error) throw new LogError("We couldn't update your plan. Please try again.", error); + return (data ?? []).length > 0; +} diff --git a/src/lib/log/logStore.spec.ts b/src/lib/log/logStore.spec.ts new file mode 100644 index 0000000..0f3ae10 --- /dev/null +++ b/src/lib/log/logStore.spec.ts @@ -0,0 +1,233 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Mock } from 'vitest'; +import type { MealLog } from './types'; + +vi.mock('./logService', () => ({ + createLog: vi.fn(), + setVerdict: vi.fn(), + fetchLogsBetween: vi.fn().mockResolvedValue([]), + fetchRecentLogs: vi.fn().mockResolvedValue([]), + markPlannedMealLogged: vi.fn().mockResolvedValue(true) +})); +vi.mock('$lib/pantry/preppedMealStore.svelte', () => ({ + optimisticConsumePortions: vi.fn().mockResolvedValue(undefined) +})); +vi.mock('$lib/planning/planStore.svelte', () => ({ + markMealLoggedLocally: vi.fn() +})); + +import { createLog, markPlannedMealLogged, setVerdict } from './logService'; +import { optimisticConsumePortions } from '$lib/pantry/preppedMealStore.svelte'; +import { markMealLoggedLocally } from '$lib/planning/planStore.svelte'; +import { + clearLogError, + clearLogNotice, + loadToday, + logError, + logMeal, + logNotice, + rateLog, + todayLogs +} from './logStore.svelte'; +import type { PlannedMeal } from '$lib/planning/types'; + +let seq = 0; +function savedLog(overrides: Partial = {}): MealLog { + seq += 1; + return { + id: `srv-${seq}`, + ownerId: 'user-1', + logType: 'QUICK_LOG', + plannedMealId: null, + recipeId: null, + preppedMealId: null, + nameSnapshot: null, + mealSlot: 'SNACK', + servings: 1, + loggedAt: new Date().toISOString(), + verdict: null, + notes: null, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + ...overrides + }; +} + +function plannedMeal(overrides: Partial = {}): PlannedMeal { + seq += 1; + return { + id: `pm-${seq}`, + mealPlanId: 'plan-1', + date: '2026-07-09', + mealSlot: 'LUNCH', + source: 'QUICK', + recipeId: null, + recipeTitleSnapshot: null, + preppedMealId: null, + preppedNameSnapshot: null, + storeBoughtName: null, + quickMealName: 'Marinara pasta', + servings: 1, + status: 'PLANNED', + sortOrder: 0, + createdAt: '2026-07-09T00:00:00Z', + updatedAt: '2026-07-09T00:00:00Z', + ...overrides + }; +} + +async function resetStore() { + // Module state persists across tests: drain today's logs via a fresh load. + await loadToday(); + clearLogError(); + clearLogNotice(); +} + +describe('logStore.logMeal', () => { + beforeEach(async () => { + vi.clearAllMocks(); + (createLog as Mock).mockImplementation(async () => savedLog()); + await resetStore(); + }); + + it('appends optimistically and reconciles with the server row', async () => { + expect.hasAssertions(); + const saved = savedLog({ nameSnapshot: 'Chili', logType: 'CUSTOM' }); + (createLog as Mock).mockResolvedValue(saved); + + const result = await logMeal({ kind: 'custom', name: 'Chili', slot: 'DINNER' }); + + expect(result).toEqual(saved); + expect(todayLogs()).toContainEqual(saved); + expect(todayLogs().some((l) => l.id.startsWith('temp-'))).toBe(false); + }); + + it('rolls back the optimistic row and reports calmly when the insert fails', async () => { + expect.hasAssertions(); + (createLog as Mock).mockRejectedValue(new Error("We couldn't save that log.")); + + const result = await logMeal({ kind: 'quick', slot: null }); + + expect(result).toBeNull(); + expect(todayLogs()).toHaveLength(0); + expect(logError()).toBe("We couldn't save that log."); + }); + + it('consumes prepped portions attributed to the saved log (FR-TL-012)', async () => { + expect.hasAssertions(); + const saved = savedLog({ logType: 'FROM_PREPPED', preppedMealId: 'prep-1' }); + (createLog as Mock).mockResolvedValue(saved); + + await logMeal({ + kind: 'fromPrepped', + preppedMeal: { id: 'prep-1', name: 'Chili' }, + slot: 'DINNER', + servings: 2 + }); + + expect(optimisticConsumePortions).toHaveBeenCalledWith('prep-1', 2, { + triggeredBy: saved.id + }); + }); + + it('keeps the log and sets a notice when portion consumption fails (FR-TL-014)', async () => { + expect.hasAssertions(); + const saved = savedLog({ logType: 'FROM_PREPPED', preppedMealId: 'prep-1' }); + (createLog as Mock).mockResolvedValue(saved); + (optimisticConsumePortions as Mock).mockRejectedValue(new Error('offline')); + + const result = await logMeal({ + kind: 'fromPrepped', + preppedMeal: { id: 'prep-1', name: 'Chili' }, + slot: null + }); + + expect(result).toEqual(saved); + expect(todayLogs()).toContainEqual(saved); + expect(logNotice()).toContain('Logged!'); + expect(logError()).toBeNull(); + }); + + it('flips the planned meal and reflects it locally (FR-TL-010)', async () => { + expect.hasAssertions(); + const meal = plannedMeal(); + const saved = savedLog({ logType: 'FROM_PLAN', plannedMealId: meal.id }); + (createLog as Mock).mockResolvedValue(saved); + + await logMeal({ kind: 'fromPlan', plannedMeal: meal }); + + expect(markPlannedMealLogged).toHaveBeenCalledWith(meal.id, saved.loggedAt); + expect(markMealLoggedLocally).toHaveBeenCalledWith(meal.id); + expect(optimisticConsumePortions).not.toHaveBeenCalled(); + }); + + it('logging a prepped-backed planned meal also draws down the ledger (INV-XD-003)', async () => { + expect.hasAssertions(); + const meal = plannedMeal({ source: 'PREPPED', preppedMealId: 'prep-9', servings: 2 }); + const saved = savedLog({ + logType: 'FROM_PLAN', + plannedMealId: meal.id, + preppedMealId: 'prep-9', + servings: 2 + }); + (createLog as Mock).mockResolvedValue(saved); + + await logMeal({ kind: 'fromPlan', plannedMeal: meal }); + + expect(optimisticConsumePortions).toHaveBeenCalledWith('prep-9', 2, { + triggeredBy: saved.id + }); + }); + + it('guards against a rapid double tap — one log per source (FR-TL-013)', async () => { + expect.hasAssertions(); + const meal = plannedMeal(); + let release!: (v: MealLog) => void; + (createLog as Mock).mockImplementation( + () => new Promise((resolve) => (release = resolve)) + ); + + const first = logMeal({ kind: 'fromPlan', plannedMeal: meal }); + const second = logMeal({ kind: 'fromPlan', plannedMeal: meal }); + + release(savedLog({ logType: 'FROM_PLAN', plannedMealId: meal.id })); + const [a, b] = await Promise.all([first, second]); + + expect(createLog).toHaveBeenCalledTimes(1); + expect(b).toBeNull(); + expect(a).not.toBeNull(); + }); +}); + +describe('logStore.rateLog', () => { + beforeEach(async () => { + vi.clearAllMocks(); + await resetStore(); + }); + + it('applies the verdict optimistically and reconciles', async () => { + expect.hasAssertions(); + const saved = savedLog({ logType: 'CUSTOM', nameSnapshot: 'Chili' }); + (createLog as Mock).mockResolvedValue(saved); + await logMeal({ kind: 'custom', name: 'Chili', slot: null }); + (setVerdict as Mock).mockResolvedValue({ ...saved, verdict: 'KEEP' }); + + await rateLog(saved.id, 'KEEP'); + + expect(setVerdict).toHaveBeenCalledWith(saved.id, 'KEEP'); + expect(todayLogs().find((l) => l.id === saved.id)?.verdict).toBe('KEEP'); + }); + + it('rolls the verdict back when the save fails', async () => { + expect.hasAssertions(); + const saved = savedLog({ logType: 'CUSTOM', nameSnapshot: 'Chili' }); + (createLog as Mock).mockResolvedValue(saved); + await logMeal({ kind: 'custom', name: 'Chili', slot: null }); + (setVerdict as Mock).mockRejectedValue(new Error("We couldn't save that.")); + + await rateLog(saved.id, 'REST'); + + expect(todayLogs().find((l) => l.id === saved.id)?.verdict).toBeNull(); + expect(logError()).toBe("We couldn't save that."); + }); +}); diff --git a/src/lib/log/logStore.svelte.ts b/src/lib/log/logStore.svelte.ts new file mode 100644 index 0000000..8924a08 --- /dev/null +++ b/src/lib/log/logStore.svelte.ts @@ -0,0 +1,261 @@ +// Runes-based store for meal logging (feature 006). Holds today's logs plus the +// recent-log pool for recents/keepers/recap, with optimistic writes and server +// reconciliation (P15). `logMeal` orchestrates the multi-write flows: log first +// (the user's truth), then follow-ups — portion consumption for prepped meals, +// the PLANNED → LOGGED flip for planned meals. Follow-up failures keep the log +// and surface a calm notice instead of throwing (research R5, FR-TL-014). + +import { optimisticConsumePortions } from '$lib/pantry/preppedMealStore.svelte'; +import { markMealLoggedLocally } from '$lib/planning/planStore.svelte'; +import { plannedMealName } from '$lib/planning/types'; +import { + createLog, + fetchLogsBetween, + fetchRecentLogs, + markPlannedMealLogged, + setVerdict +} from './logService'; +import { localDayBounds } from './slots'; +import { groupLogSources, type LoggedSource } from './derive'; +import type { MealLog, MealLogDraft, MealVerdict } from './types'; + +// --------------------------------------------------------------------------- +// Module-level reactive state (Svelte 5 runes) +// --------------------------------------------------------------------------- + +let _todayLogs = $state([]); +let _recentLogs = $state([]); +let _loading = $state(false); +let _error = $state(null); +let _notice = $state(null); + +let tempCounter = 0; +// Double-tap guard — bookkeeping only, nothing renders from it. +// eslint-disable-next-line svelte/prefer-svelte-reactivity +const inFlight = new Set(); + +// --------------------------------------------------------------------------- +// Public accessors (getter functions so consumers can read reactive state) +// --------------------------------------------------------------------------- + +export function todayLogs(): MealLog[] { + return _todayLogs; +} +export function recentLogs(): MealLog[] { + return _recentLogs; +} +export function recentSources(): LoggedSource[] { + return groupLogSources(_recentLogs); +} +export function logLoading(): boolean { + return _loading; +} +export function logError(): string | null { + return _error; +} +/** Calm partial-failure notice (FR-TL-014); cleared via clearLogNotice. */ +export function logNotice(): string | null { + return _notice; +} +export function clearLogError(): void { + _error = null; +} +export function clearLogNotice(): void { + _notice = null; +} + +// --------------------------------------------------------------------------- +// Loads +// --------------------------------------------------------------------------- + +// eslint-disable-next-line svelte/prefer-svelte-reactivity -- transient timestamp, never reactive state +export async function loadToday(now: Date = new Date()): Promise { + _loading = true; + _error = null; + try { + const { fromISO, toISO } = localDayBounds(now); + _todayLogs = await fetchLogsBetween(fromISO, toISO); + } catch (err) { + _error = err instanceof Error ? err.message : "We couldn't load today's logs."; + } finally { + _loading = false; + } +} + +export async function loadRecents(): Promise { + try { + _recentLogs = await fetchRecentLogs(); + } catch (err) { + _error = err instanceof Error ? err.message : "We couldn't load your recent meals."; + } +} + +// --------------------------------------------------------------------------- +// Writes +// --------------------------------------------------------------------------- + +/** Identity for the double-tap guard: same source, same moment ⇒ one log. */ +function draftGuardKey(draft: MealLogDraft): string { + switch (draft.kind) { + case 'fromPlan': + return `plan:${draft.plannedMeal.id}`; + case 'fromPrepped': + return `prepped:${draft.preppedMeal.id}`; + case 'fromRecipe': + return `recipe:${draft.recipeId}`; + case 'quick': + case 'custom': + return `once:${draft.kind}`; + } +} + +function upsertLocal(log: MealLog, replaceId?: string): void { + const swap = (list: MealLog[]) => { + const without = replaceId ? list.filter((l) => l.id !== replaceId) : list; + return [log, ...without.filter((l) => l.id !== log.id)]; + }; + _todayLogs = swap(_todayLogs); + if (log.logType !== 'QUICK_LOG') _recentLogs = swap(_recentLogs); +} + +function removeLocal(id: string): void { + _todayLogs = _todayLogs.filter((l) => l.id !== id); + _recentLogs = _recentLogs.filter((l) => l.id !== id); +} + +/** + * One-tap log (FR-TL-009/012/013/014). Optimistic append; insert failure rolls the + * optimistic row back and sets logError. Follow-up failures (portion event, plan + * flip) keep the log and set logNotice. Returns the saved log, or null when the + * insert failed or an identical log is already in flight (double-tap guard). + */ +export async function logMeal(draft: MealLogDraft): Promise { + const guard = draftGuardKey(draft); + if (inFlight.has(guard)) return null; + inFlight.add(guard); + + const tempId = `temp-${++tempCounter}`; + // eslint-disable-next-line svelte/prefer-svelte-reactivity -- transient timestamp, never reactive state + const nowISO = new Date().toISOString(); + const optimistic = optimisticLog(draft, tempId, nowISO); + upsertLocal(optimistic); + _error = null; + + try { + let saved: MealLog; + try { + saved = await createLog(draft, nowISO); + } catch (err) { + removeLocal(tempId); + _error = err instanceof Error ? err.message : "We couldn't save that log."; + return null; + } + upsertLocal(saved, tempId); + + if (draft.kind === 'fromPrepped') { + try { + await optimisticConsumePortions(draft.preppedMeal.id, draft.servings ?? 1, { + triggeredBy: saved.id + }); + } catch { + _notice = "Logged! We couldn't update the pantry count — it'll sort itself out online."; + } + } else if (draft.kind === 'fromPlan') { + try { + await markPlannedMealLogged(draft.plannedMeal.id, saved.loggedAt); + markMealLoggedLocally(draft.plannedMeal.id); + } catch { + _notice = "Logged! We couldn't mark it in your plan — it'll catch up next refresh."; + } + // A planned meal backed by a prepped portion is consumption too (INV-XD-003). + if (draft.plannedMeal.preppedMealId) { + try { + await optimisticConsumePortions(draft.plannedMeal.preppedMealId, saved.servings, { + triggeredBy: saved.id + }); + } catch { + _notice = "Logged! We couldn't update the pantry count — it'll sort itself out online."; + } + } + } + return saved; + } finally { + inFlight.delete(guard); + } +} + +/** Optimistic set/clear of a verdict on a log (FR-TL-015/016). */ +export async function rateLog(logId: string, verdict: MealVerdict | null): Promise { + const beforeToday = _todayLogs; + const beforeRecent = _recentLogs; + const patch = (list: MealLog[]) => list.map((l) => (l.id === logId ? { ...l, verdict } : l)); + _todayLogs = patch(_todayLogs); + _recentLogs = patch(_recentLogs); + + try { + const saved = await setVerdict(logId, verdict); + const reconcile = (list: MealLog[]) => list.map((l) => (l.id === logId ? saved : l)); + _todayLogs = reconcile(_todayLogs); + _recentLogs = reconcile(_recentLogs); + } catch (err) { + _todayLogs = beforeToday; + _recentLogs = beforeRecent; + _error = err instanceof Error ? err.message : "We couldn't save that."; + } +} + +// --------------------------------------------------------------------------- +// Optimistic shapes +// --------------------------------------------------------------------------- + +function optimisticLog(draft: MealLogDraft, id: string, loggedAt: string): MealLog { + const base = { + id, + ownerId: 'me', // placeholder; reconciled with the server row + plannedMealId: null as string | null, + recipeId: null as string | null, + preppedMealId: null as string | null, + nameSnapshot: null as string | null, + mealSlot: null as MealLog['mealSlot'], + servings: 1, + loggedAt, + verdict: draft.verdict ?? null, + notes: null, + createdAt: loggedAt, + updatedAt: loggedAt + }; + switch (draft.kind) { + case 'fromPlan': + return { + ...base, + logType: 'FROM_PLAN', + plannedMealId: draft.plannedMeal.id, + recipeId: draft.plannedMeal.recipeId, + preppedMealId: draft.plannedMeal.preppedMealId, + nameSnapshot: plannedMealName(draft.plannedMeal), + mealSlot: draft.plannedMeal.mealSlot, + servings: draft.plannedMeal.servings + }; + case 'fromPrepped': + return { + ...base, + logType: 'FROM_PREPPED', + preppedMealId: draft.preppedMeal.id, + nameSnapshot: draft.preppedMeal.name, + mealSlot: draft.slot, + servings: draft.servings ?? 1 + }; + case 'fromRecipe': + return { + ...base, + logType: 'FROM_RECIPE', + recipeId: draft.recipeId, + nameSnapshot: draft.name, + mealSlot: draft.slot + }; + case 'quick': + return { ...base, logType: 'QUICK_LOG', mealSlot: draft.slot }; + case 'custom': + return { ...base, logType: 'CUSTOM', nameSnapshot: draft.name, mealSlot: draft.slot }; + } +} diff --git a/src/lib/log/slots.spec.ts b/src/lib/log/slots.spec.ts new file mode 100644 index 0000000..bf8b8bd --- /dev/null +++ b/src/lib/log/slots.spec.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from 'vitest'; +import type { PlannedMeal } from '$lib/planning/types'; +import type { MealLog } from './types'; +import { deriveNudge, localDateISO, localDayBounds, nudgeDismissKey, slotForTime } from './slots'; + +function at(hour: number, minute = 0): Date { + return new Date(2026, 6, 9, hour, minute); // local time, 2026-07-09 +} + +function plannedMeal(overrides: Partial = {}): PlannedMeal { + return { + id: 'pm-1', + mealPlanId: 'plan-1', + date: '2026-07-09', + mealSlot: 'LUNCH', + source: 'QUICK', + recipeId: null, + recipeTitleSnapshot: null, + preppedMealId: null, + preppedNameSnapshot: null, + storeBoughtName: null, + quickMealName: 'Marinara pasta', + servings: 1, + status: 'PLANNED', + sortOrder: 0, + createdAt: '2026-07-09T00:00:00Z', + updatedAt: '2026-07-09T00:00:00Z', + ...overrides + }; +} + +function log(overrides: Partial = {}): MealLog { + return { + id: 'log-1', + ownerId: 'user-1', + logType: 'QUICK_LOG', + plannedMealId: null, + recipeId: null, + preppedMealId: null, + nameSnapshot: null, + mealSlot: 'LUNCH', + servings: 1, + loggedAt: '2026-07-09T12:00:00Z', + verdict: null, + notes: null, + createdAt: '2026-07-09T12:00:00Z', + updatedAt: '2026-07-09T12:00:00Z', + ...overrides + }; +} + +describe('slotForTime', () => { + it('maps the fixed windows to slots (A-001)', () => { + expect(slotForTime(at(6))).toBe('BREAKFAST'); + expect(slotForTime(at(10, 59))).toBe('BREAKFAST'); + expect(slotForTime(at(11))).toBe('LUNCH'); + expect(slotForTime(at(13, 12))).toBe('LUNCH'); + expect(slotForTime(at(14, 59))).toBe('LUNCH'); + expect(slotForTime(at(17))).toBe('DINNER'); + expect(slotForTime(at(21, 30))).toBe('DINNER'); + }); + + it('defaults to SNACK between and outside windows', () => { + expect(slotForTime(at(5))).toBe('SNACK'); + expect(slotForTime(at(15))).toBe('SNACK'); + expect(slotForTime(at(16))).toBe('SNACK'); + expect(slotForTime(at(22))).toBe('SNACK'); + expect(slotForTime(at(2))).toBe('SNACK'); + }); +}); + +describe('localDateISO / localDayBounds', () => { + it('formats the local civil date', () => { + expect(localDateISO(at(13))).toBe('2026-07-09'); + expect(localDateISO(new Date(2026, 0, 5, 0, 0))).toBe('2026-01-05'); + }); + + it('bounds span exactly the local day', () => { + const { fromISO, toISO } = localDayBounds(at(13, 30)); + expect(new Date(fromISO).getTime()).toBe(new Date(2026, 6, 9).getTime()); + expect(new Date(toISO).getTime()).toBe(new Date(2026, 6, 10).getTime()); + }); +}); + +describe('deriveNudge', () => { + it('nudges for a planned, unlogged meal inside its window', () => { + const nudge = deriveNudge(at(12, 10), [plannedMeal()], [], []); + expect(nudge).toEqual({ slot: 'LUNCH', plannedMealId: 'pm-1', name: 'Marinara pasta' }); + }); + + it('stays silent outside breakfast/lunch/dinner windows', () => { + expect(deriveNudge(at(15), [plannedMeal()], [], [])).toBeNull(); + }); + + it('stays silent when the slot was dismissed today', () => { + expect(deriveNudge(at(12), [plannedMeal()], [], ['LUNCH'])).toBeNull(); + }); + + it('stays silent once anything was logged for the slot', () => { + expect(deriveNudge(at(12), [plannedMeal()], [log({ mealSlot: 'LUNCH' })], [])).toBeNull(); + }); + + it('ignores meals that are no longer PLANNED', () => { + expect(deriveNudge(at(12), [plannedMeal({ status: 'LOGGED' })], [], [])).toBeNull(); + }); + + it('ignores meals in other slots', () => { + expect(deriveNudge(at(12), [plannedMeal({ mealSlot: 'DINNER' })], [], [])).toBeNull(); + }); +}); + +describe('nudgeDismissKey', () => { + it('is scoped to date and slot', () => { + expect(nudgeDismissKey('2026-07-09', 'LUNCH')).toBe('nk-nudge-dismissed:2026-07-09:LUNCH'); + }); +}); diff --git a/src/lib/log/slots.ts b/src/lib/log/slots.ts new file mode 100644 index 0000000..3faf71a --- /dev/null +++ b/src/lib/log/slots.ts @@ -0,0 +1,74 @@ +// Slot/time helpers for logging and the gentle nudge (feature 006, research R6). +// Pure: every function takes `now` explicitly — no Date.now() inside — so behaviour +// is fully unit-testable. Slot windows are fixed constants (spec A-001); +// personalisation belongs to the reminders feature. + +import type { MealSlot, PlannedMeal, ISODate } from '$lib/planning/types'; +import { plannedMealName } from '$lib/planning/types'; +import type { MealLog } from './types'; + +/** Local-time hour windows, inclusive of both ends. SNACK is the in-between default. */ +const SLOT_WINDOWS: readonly { slot: MealSlot; from: number; to: number }[] = [ + { slot: 'BREAKFAST', from: 6, to: 10 }, + { slot: 'LUNCH', from: 11, to: 14 }, + { slot: 'DINNER', from: 17, to: 21 } +]; + +/** The slot a log made right now most likely belongs to (Quick Log default). */ +export function slotForTime(now: Date): MealSlot { + const hour = now.getHours(); + const window = SLOT_WINDOWS.find((w) => hour >= w.from && hour <= w.to); + return window?.slot ?? 'SNACK'; +} + +/** The local calendar date, YYYY-MM-DD (planning's civil-date convention, A-006). */ +export function localDateISO(now: Date): ISODate { + const y = now.getFullYear(); + const m = String(now.getMonth() + 1).padStart(2, '0'); + const d = String(now.getDate()).padStart(2, '0'); + return `${y}-${m}-${d}`; +} + +/** [start of local day, start of next local day) as UTC instants for range queries. */ +export function localDayBounds(now: Date): { fromISO: string; toISO: string } { + const start = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + const end = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1); + return { fromISO: start.toISOString(), toISO: end.toISOString() }; +} + +export interface NudgeState { + slot: MealSlot; + plannedMealId: string; + name: string; +} + +/** + * Gentle mealtime nudge (FR-TL-019): show only when the clock is inside a + * breakfast/lunch/dinner window, that slot has a still-PLANNED meal today, nothing + * has been logged for the slot yet, and the user hasn't said "Not now" today. + * Returns null otherwise — silence is the default, never pressure. + */ +export function deriveNudge( + now: Date, + todaysMeals: readonly PlannedMeal[], + todaysLogs: readonly MealLog[], + dismissedSlots: readonly MealSlot[] +): NudgeState | null { + const hour = now.getHours(); + const window = SLOT_WINDOWS.find((w) => hour >= w.from && hour <= w.to); + if (!window) return null; + if (dismissedSlots.includes(window.slot)) return null; + if (todaysLogs.some((log) => log.mealSlot === window.slot)) return null; + + const candidate = todaysMeals.find( + (meal) => meal.mealSlot === window.slot && meal.status === 'PLANNED' + ); + if (!candidate) return null; + + return { slot: window.slot, plannedMealId: candidate.id, name: plannedMealName(candidate) }; +} + +/** localStorage key for a "Not now" dismissal — device-local by design (R6). */ +export function nudgeDismissKey(dateISO: ISODate, slot: MealSlot): string { + return `nk-nudge-dismissed:${dateISO}:${slot}`; +} diff --git a/src/lib/log/types.ts b/src/lib/log/types.ts new file mode 100644 index 0000000..ecf2772 --- /dev/null +++ b/src/lib/log/types.ts @@ -0,0 +1,88 @@ +// Domain types for meal logging (feature 006). CamelCase application shapes mapped +// from the snake_case rows in `$lib/database.types`; the service layer returns these. +// +// Canonical shape: Domain Specification `MealLog` (§4.8), amended by the spec's +// 2026-07-09 clarifications — three-way verdict (KEEP/FINE/REST) supersedes the +// four-value MealRating, and only verdict/notes are mutable after insert (INV-CC-004). + +import type { Enums, Tables, TablesInsert } from '$lib/database.types'; +import type { MealSlot } from '$lib/planning/types'; +import type { PlannedMeal } from '$lib/planning/types'; +import type { PreppedMeal } from '$lib/pantry/types'; + +export type MealLogType = Enums<'meal_log_type'>; +export type MealVerdict = Enums<'meal_verdict'>; + +export type MealLogRow = Tables<'meal_logs'>; +export type MealLogInsert = TablesInsert<'meal_logs'>; + +export interface MealLog { + id: string; + ownerId: string; + logType: MealLogType; + plannedMealId: string | null; + recipeId: string | null; + preppedMealId: string | null; + nameSnapshot: string | null; + mealSlot: MealSlot | null; + servings: number; + loggedAt: string; // ISO timestamptz + verdict: MealVerdict | null; + notes: string | null; + createdAt: string; + updatedAt: string; +} + +/** + * Discriminated creation draft — makes the type↔reference rules (INV-XD-001/002) + * unrepresentable client-side. `verdict` may ride along on any draft (log-time capture). + */ +export type MealLogDraft = { verdict?: MealVerdict | null } & ( + | { kind: 'fromPlan'; plannedMeal: PlannedMeal } + | { + kind: 'fromPrepped'; + preppedMeal: Pick; + slot: MealSlot | null; + servings?: number; + } + | { kind: 'fromRecipe'; recipeId: string; name: string; slot: MealSlot | null } + | { kind: 'quick'; slot: MealSlot | null } + | { kind: 'custom'; name: string; slot: MealSlot | null } +); + +/** Display name for a log wherever it resurfaces ("Something" for bare quick logs). */ +export function mealLogName(log: Pick): string { + return log.nameSnapshot ?? (log.logType === 'QUICK_LOG' ? 'Something' : 'Meal'); +} + +/** Human label for a verdict option — icon copy lives with the control. */ +export function verdictLabel(verdict: MealVerdict): string { + switch (verdict) { + case 'KEEP': + return 'Again'; + case 'FINE': + return 'It was fine'; + case 'REST': + return 'Not for me'; + } +} + +/** Map a DB row to the app shape. `servings` is numeric in PG and may arrive as string. */ +export function toMealLog(row: MealLogRow): MealLog { + return { + id: row.id, + ownerId: row.owner_id, + logType: row.log_type, + plannedMealId: row.planned_meal_id, + recipeId: row.recipe_id, + preppedMealId: row.prepped_meal_id, + nameSnapshot: row.name_snapshot, + mealSlot: row.meal_slot, + servings: Number(row.servings), + loggedAt: row.logged_at, + verdict: row.verdict, + notes: row.notes, + createdAt: row.created_at, + updatedAt: row.updated_at + }; +} diff --git a/src/lib/pantry/preppedMealService.ts b/src/lib/pantry/preppedMealService.ts index 85d1bc5..839b884 100644 --- a/src/lib/pantry/preppedMealService.ts +++ b/src/lib/pantry/preppedMealService.ts @@ -72,13 +72,18 @@ export async function updatePreppedMeal( // Portion operations (all via ledger — never direct column writes) // --------------------------------------------------------------------------- -export async function consumePortions(preppedMealId: string, count: number): Promise { +export async function consumePortions( + preppedMealId: string, + count: number, + opts?: { triggeredBy?: string } // meal_logs.id — the ledger attribution reserved in 0004 +): Promise { if (count <= 0) throw new PreppedMealServiceError('count must be positive'); try { await insertPortionEvent({ preppedMealId, deltaPortions: -count, - kind: 'CONSUMED' + kind: 'CONSUMED', + triggeredBy: opts?.triggeredBy }); } catch (err) { if (err instanceof PortionLedgerError) throw err; diff --git a/src/lib/pantry/preppedMealStore.svelte.ts b/src/lib/pantry/preppedMealStore.svelte.ts index 4eeeebb..e53667b 100644 --- a/src/lib/pantry/preppedMealStore.svelte.ts +++ b/src/lib/pantry/preppedMealStore.svelte.ts @@ -136,7 +136,8 @@ export async function optimisticUpdatePreppedMeal( export async function optimisticConsumePortions( preppedMealId: string, - count: number + count: number, + opts?: { triggeredBy?: string } ): Promise { const prev = _meals.find((m) => m.id === preppedMealId); if (prev) { @@ -147,7 +148,7 @@ export async function optimisticConsumePortions( _meals = _meals.map((m) => (m.id === preppedMealId ? updated : m)); } try { - await consumePortions(preppedMealId, count); + await consumePortions(preppedMealId, count, opts); // Re-sync from DB after trigger runs (portions_remaining is authoritative there) const { data } = await supabase .from('prepped_meals') diff --git a/src/lib/planning/planStore.svelte.ts b/src/lib/planning/planStore.svelte.ts index 557e892..f0fa05a 100644 --- a/src/lib/planning/planStore.svelte.ts +++ b/src/lib/planning/planStore.svelte.ts @@ -320,6 +320,17 @@ export function fulfillmentFor(meal: PlannedMeal): FulfillmentResult | null { } /** Optimistically remove a meal. Returns true on success, false on rollback. */ +/** + * Reflect a PLANNED → LOGGED flip performed by the log module (feature 006, + * FR-TL-010). The durable write is the log module's safe-flip UPDATE; this only + * keeps already-loaded calendar/Today state in sync without a refetch. + */ +export function markMealLoggedLocally(id: string): void { + _meals = _meals.map((m) => + m.id === id && m.status === 'PLANNED' ? { ...m, status: 'LOGGED' } : m + ); +} + export async function removeMeal(id: string): Promise { const before = _meals; const target = _meals.find((m) => m.id === id); diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index e0382c6..38e4fed 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -15,7 +15,7 @@ // Per design/web-*.html: full nav skeleton; unbuilt surfaces render muted (readme: // "Other surfaces are scaffolded in the nav but not yet designed"). const NAV = [ - { label: 'Today', icon: 'ph-house', href: null }, + { label: 'Today', icon: 'ph-house', href: resolve('/today') }, { label: 'Plan', icon: 'ph-calendar-dots', href: resolve('/plan') }, { label: 'Recipes', icon: 'ph-book-open', href: resolve('/recipes') }, { label: 'Pantry', icon: 'ph-jar', href: resolve('/pantry') }, @@ -41,7 +41,7 @@