Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
37 changes: 37 additions & 0 deletions .specswarm/features/006-today-logging/checklists/requirements.md
Original file line number Diff line number Diff line change
@@ -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.
72 changes: 72 additions & 0 deletions .specswarm/features/006-today-logging/contracts/logService.md
Original file line number Diff line number Diff line change
@@ -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<MealLog>;

/** Set or clear the verdict on an existing log (annotation window, R2). */
export async function setVerdict(logId: string, verdict: MealVerdict | null): Promise<MealLog>;

/** Logs in [fromISO, toISO) for the current user, newest first. */
export async function fetchLogsBetween(fromISO: string, toISO: string): Promise<MealLog[]>;

/** Latest `limit` non-QUICK_LOG logs (for recents/keepers grouping). */
export async function fetchRecentLogs(limit?: number): Promise<MealLog[]>; // default 100

/** Safe-flip: PLANNED → LOGGED with logged_at; returns false if already flipped. */
export async function markPlannedMealLogged(plannedMealId: string, loggedAt: string): Promise<boolean>;
```

```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<void>;
export async function loadRecents(): Promise<void>;

/**
* 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<MealLog | null>;

/** Optimistic verdict set/clear on a log (recap card + sheet footer). */
export async function rateLog(logId: string, verdict: MealVerdict | null): Promise<void>;
```

```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<PortionEvent>;
// 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) |
167 changes: 167 additions & 0 deletions .specswarm/features/006-today-logging/data-model.md
Original file line number Diff line number Diff line change
@@ -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:<id> | prepped:<id|name> | name:<lower>
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.
Loading
Loading