fix(nav): admin sub-items don't expand from gravatar dropdown or admin-link click

Mirror the Settings entry pattern verbatim: the global sidebar's Admin
entry is now a single link with no sub-item expansion at any route.
v1.4.15 A1 had gated the expansion behind `onAdminPage`, but Marc
re-flagged it — the in-shell `<AdminShell>` already provides per-section
nav inside the page, so the global sidebar duplicating it on `/admin/*`
was unwanted UX. Removing the entire `{onAdminPage && <ul>...</ul>}`
block also kills the perceived link between the gravatar dropdown and
sidebar state — they're independent concerns now.

Tests updated:
- `sidebar-nav.test.tsx` — every admin-route case now asserts ONLY the
  single `/admin` link is present, never sub-route links.
- New e2e spec `sidebar-admin-no-expand.spec.ts` covers (a) /admin/* shows
  one link, no sub-list; (b) clicking Admin from /dashboard navigates
  without flashing sub-items; (c) opening the gravatar user-menu does
  not expand sidebar admin sub-items.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-09 23:24:41 +02:00
1 parent 9372684a41
commit 77fe256d9e
5 files changed
+902 -61

No files matched your search

+73
View File
@@ -0,0 +1,73 @@
# v1.4.16 ecosystem-research dispatch — summary
Date: 2026-05-09
Agent: ecosystem-research + STATE-expand
Full document: `.planning/v1416-research-ai-recommendations.md`
## What ran
1. STATE.md elevation — replaced the single `B5 — AI hallucination-
hardening v2` bucket with five first-class items (B5a medical-
reference grounding, B5b multi-provider redundancy, B5c per-rec
explainability, B5d confidence score, B5e user-feedback loop) and
added a new `B8 — Extended comparison views (Vormonat / Vorjahr)`.
ROADMAP.md mirror-updated.
2. Research document — benchmarked Apple Health Trends, Withings
Health Mate, and Oura Ring against four features (B5c, B5d, B5e,
B8). Mapped each onto existing HealthLog architecture
(`src/lib/ai/*`, `src/lib/insights/bucket-series.ts`, the chart
wrappers, the `aiInsightResponseSchema`). Drafted file-level
implementation sketches incl. one new Prisma model
(`RecommendationFeedback`), zod additions to
`aiRecommendationSchema`, a deterministic `computeConfidence()`
helper, a `compareBaseline` chart prop, and the
cross-feature integration risks. 4 open questions for Marc parked
at the bottom.
## Top 3 findings
1. **Oura's Contributors model is the gold reference for B5c.** Each
score expands into per-component cards with band + value + trend.
HealthLog's strict schema already carries `metricSource`; the
incremental cost of adding `rationale: { dataWindow, comparedTo,
deviation }` is one zod block + one prompt-version bump. The UI
slot lives inside an already-planned `<RecommendationCard>` and
the data source (bucketed daily/monthly history per metric) is
already produced by `bucket-series.ts`.
2. **Confidence must be server-computed, not LLM-emitted.** Calibrated
probabilities are not a small-LLM strength, and the v1.4.17
feedback ratchet needs a deterministic input to fit. Proposed
`computeConfidence({ n, recencyDays, deviationStdRatio })` is a
pure function the `generateInsight()` wrapper applies post-parse,
discarding any number the model picked. Mitigates the "hallucinated
high confidence on low data" failure mode that v1.4.15 C1 already
defended against for citations.
3. **Comparison views (B8) intersect Insights — narrate, don't
contradict.** When the user toggles Vormonat/Vorjahr, the
prompt's snapshot must include a `comparison` block AND the
per-rec rationale should prefer the matching window. The UI's
`<RationaleCard>` shows a "(matches active comparison)" badge
when aligned, with a one-tap to re-align when not — keeps the
user's mental model coherent across chart, tile, and insight
surface.
## Phase order recommended
B5a → B5b → B5c (defines rec-card layout) → B5d (plugs into the
layout) → B5e (Prisma migration; provider attribution available) →
B8 (chart-and-prompt-side, lands last so the prompt is fully shaped).
If the v1.4.16 budget tightens: B5a + B5c + B5d is the smallest
cohesive slice that delivers user-visible explainability + confidence.
## Commits this dispatch
1. `chore(planning): elevate B5c/d/e + B8 for v1.4.16` — STATE+ROADMAP.
2. `docs(planning): v1.4.16 ecosystem research for explainability +
confidence + feedback + comparison views` — research doc + this
summary.
Both pushed to origin/main.
@@ -0,0 +1,683 @@
# v1.4.16 — ecosystem research: AI recommendations + comparison views
Authored: 2026-05-09 (Phase 0+ ecosystem-research agent, post Phase-0
elevation of B5c/d/e + B8).
Marc elevated four bullets that previously rode under B5 to first-class
Wave-B work items, with an explicit research+harmonic-integration
mandate. This document is **informative**: it benchmarks competing apps,
maps each feature onto HealthLog's existing architecture, sketches the
implementation per file, calls out the cross-feature integration risks,
recommends a phase order, and parks the open questions Marc should
answer before any code lands.
The features under analysis:
1. **B5c — Per-recommendation explainability** (WHY / WINDOW /
CITATIONS card with mini-chart)
2. **B5d — Confidence score per recommendation** (0-100 from data
sufficiency / recency / signal strength)
3. **B5e — User-feedback loop** ("War das hilfreich?" thumbs-up/down
persisted to a new RecommendationFeedback table)
4. **B8 — Extended comparison views** (Vormonat / Vorjahr overlay
across charts, tiles, and the insights surface)
B5a (medical-reference grounding) and B5b (multi-provider redundancy)
are referenced where they intersect this work but are owned by their
own dedicated reports.
Existing infrastructure that this builds on:
- v1.4.15 Phase C1: `AIProvider` interface + `MockAIProvider`,
`aiInsightResponseSchema` (strict, with `recommendations[]` carrying
`metricSource`), `findUncitedRecommendations()` cross-check,
scope-hardened `PROMPT_VERSION = "4.15.0"`, slug-drift defence.
- `src/lib/ai/generate-insight.ts` retry-once wrapper (one re-prompt on
schema violation, then 422).
- `src/lib/insights/bucket-series.ts` daily/monthly bucketing (the
natural data source for "data window" cards).
- `prisma/schema.prisma`: no `RecommendationFeedback` model exists; no
per-rec `confidence` column anywhere; `User.insightsCachedText` holds
the cached JSON output but **carries no invalidation timestamp** as
CLAUDE.md notes.
---
## 1. B5c — Per-recommendation explainability
### 1A. Industry benchmarking
**Apple Health.** The Trends surface (iOS 16+) flags significant
deviations against a learned per-user baseline rather than a single
fixed threshold. The Vitals app on watchOS 11 is even more direct:
each metric is shown in-band vs. out-of-band relative to the user's
"typical range," and tapping into a metric reveals which nights
contributed the typical-range envelope (Apple Support, *View your
health data*; Century, *Apple Watch Vitals app explained*). The
mental model that translates: "this finding is interesting because it
deviates from **your** norm" is visible everywhere — there is no
one-shot population claim without a personal baseline.
**Withings Health Mate.** Health Insights surface ad-hoc nudges with
a science-based interpretation. They link out to a long-form
explainer page rather than expanding inline; user reviews repeatedly
complain about *opacity* — the insight is a one-liner without a
visible data-window or threshold (justuseapp reviews, Withings
Support, *What is Health Insights*). HealthLog should NOT copy the
out-link pattern; explanations belong inline.
**Oura Ring.** This is the clearest reference point. Both Sleep and
Readiness scores expand into **Contributors** — a per-score breakdown
of (e.g.) Total Sleep, Efficiency, Restfulness, REM, Deep, Latency,
Timing — each shown with its own band, current value, and personal
trend (ouraring.com, *Sleep Contributors*, *Readiness Contributors*).
Tapping a contributor reveals "what's pulling this lower." This is
the explainability gold standard for consumer wellness UI.
**Top 3 patterns to borrow.**
1. **Personal-baseline framing first** (Apple/Oura): "your avg7 is
5 mmHg above your 90-day median" beats "above the 140/90
guideline" as the headline.
2. **Contributor breakdown card** (Oura): each rec expands into
a 3-row card listing window / metric / deviation, plus a
mini-chart of the window itself.
3. **Inline disclosure, not out-link** (avoid Withings's failure
mode): the explanation lives in the card, never one-tap-away.
### 1B. Existing architecture fit
| Concern | Touched files / what changes |
| -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Schema | `src/lib/ai/schema.ts` — `aiRecommendationSchema` extended with required `rationale` object (`dataWindow`, `comparedTo`, `deviation`) and optional `referenceId` (filled by B5a). |
| Prompt | `src/lib/ai/prompts/insight-generator.ts` — `PROMPT_VERSION` bumped to `4.16.0`; output-format block adds the rationale shape; ground rule "every rec MUST carry a rationale" added under §GROUND RULES. |
| Wrapper | `src/lib/ai/generate-insight.ts` — corrective retry message lists the rationale fields when missing; `findUncitedRecommendations()` companion `findRecommendationsMissingRationale()` added. |
| UI | `src/components/insights/insight-advisor-card.tsx` — new `<RecommendationCard>` subcomponent with expand-arrow → `<RationaleCard>` (3-row data + `<HealthChart>` mini-chart for the window). `<InsightsCard>` likewise gets the same expand affordance. |
| Charts | `src/components/charts/health-chart.tsx` — accepts a `windowOverride` prop so the rationale's mini-chart can pin to e.g. last7days regardless of the parent's chosen range. |
| i18n | New keys under `insights.recommendation.*`: `rationale`, `rationaleWindow`, `rationaleComparedTo`, `rationaleDeviation`, `rationaleExpand`, `rationaleCollapse`. Both `messages/en.json` + `messages/de.json`. |
| Auth + audit | No new auth surface; the rec card consumes existing `requireAuth()`-gated insight payloads. No new audit row — explanations are read-only client-side derivations from the cached payload. |
### 1C. Proposed implementation plan (file-level)
**Schema migration.** No Prisma migration needed; the rationale lives
inside the existing `User.insightsCachedText` JSON blob. Schema-level
zod additions:
```ts
// src/lib/ai/schema.ts
export const aiRecommendationRationaleSchema = z.object({
dataWindow: z.enum(["last7days", "last30days", "last90days", "allTime"]),
comparedTo: z.string().min(1, "rationale.comparedTo required"),
deviation: z.string().min(1, "rationale.deviation required"),
// Optional referenceId is filled by B5a; required at runtime when
// recommendation severity >= "important".
referenceId: z.string().optional(),
});
export const aiRecommendationSchema = z.object({
id: z.string().min(1),
text: z.string().min(1),
metricSource: metricSourceSchema,
severity: recommendationSeveritySchema,
rationale: aiRecommendationRationaleSchema, // NEW — required
// confidence comes from B5d:
confidence: z.number().min(0).max(100).optional(), // required after B5d
});
```
**API endpoints.** No new endpoint — the existing
`POST /api/insights/generate` already returns the full payload; the UI
just renders new fields. The bug-class to watch for: legacy cached
payloads in `User.insightsCachedText` will fail the strict parse.
Mitigation: keep `.passthrough()` for one milestone and add a
"legacy payload detected, regenerate now" CTA banner in
`<InsightAdvisorCard>` when `parsed.recommendations[0]?.rationale` is
absent.
**Component tree.**
```
<InsightAdvisorCard>
└ <RecommendationsList>
└ <RecommendationCard> (NEW)
├ rec text + severity badge + (B5d) confidence ring
├ <RationaleExpander> (NEW — chevron, aria-expanded)
│ └ <RationaleCard> (NEW)
│ ├ <RationaleRow label="Window" value={dataWindow} />
│ ├ <RationaleRow label="Compared to" value={comparedTo} />
│ ├ <RationaleRow label="Deviation" value={deviation} />
│ └ <HealthChart windowOverride={dataWindow} types={[…]} mini />
│ └ {referenceId ? <CitationFootnote /> : null}
└ <RecommendationFeedback> (B5e — thumbs)
```
**Provider abstraction extensions.** The `AIProvider` interface stays
untouched — rationale is enforced at schema-time, not by the provider
SDK.
**Test strategy.**
- Unit: `aiInsightResponseSchema.safeParse()` rejects a recommendation
without `rationale` (extends the existing 11 citation tests).
- Unit: `findRecommendationsMissingRationale()` flags legacy payloads.
- Component: `<RationaleCard>` renders 3 rows + a mini-chart;
collapsed by default; aria-expanded toggles.
- Integration: full insight-generate run with `MockAIProvider` mocked
to return a payload with rationale; UI renders all three rows.
- E2E (Playwright): on `/insights`, expand a recommendation → assert
rationale rows visible, mini-chart `<canvas>` rendered.
---
## 2. B5d — Confidence score per recommendation
### 2A. Industry benchmarking
**Apple Health.** No surfaced confidence number per recommendation.
Trends require a hard 180-day data minimum before they appear at all
— Apple's confidence model is "we'll be quiet until we're sure"
(Apple Support). The negative-space lesson: don't show a noisy
confidence number on a 3-data-point user.
**Withings Health Mate.** Confidence not surfaced. Insights either
appear or don't. Marc's complaint about Withings — "warum nutzlos?" —
is downstream of this opacity.
**Oura Ring.** Each contributor has a state (Optimal / Good / Pay
Attention) plus a numeric value, plus a "what's contributing"
breakdown. They don't expose calibrated probabilities; they expose
**states + thresholds** that map directly onto a colour. This is a
better reference than raw probabilities for non-clinicians
(ouraring.com, *Sleep Score*, *Readiness Score*).
**Top 3 patterns to borrow.**
1. **Three-band visual (good / warn / low) over a continuous bar**
(Oura). Marc's spec calls for a 5-bar meter or coloured ring —
either works; the **bin** is the affordance, the number underneath
is the detail.
2. **Below-threshold tagging instead of hiding** (Marc's spec, also
the Oura "Pay attention" state): "Low confidence — based on
limited data" header on the card.
3. **Quiet-when-unsure mode for the score** (Apple): if confidence
< 25 we still show the rec but the meter is replaced by a "draft"
pill — the rec is drafted but the model isn't asserting it. This
is the cheap way to avoid noise without hiding output.
### 2B. Existing architecture fit
| Concern | Touched files / what changes |
| -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Schema | `src/lib/ai/schema.ts` — `aiRecommendationSchema` gains required `confidence: z.number().int().min(0).max(100)` once B5c lands. Pre-B5c rollout: `.optional()` then flip to required in same milestone. |
| Confidence calc | `src/lib/ai/confidence.ts` (NEW) — pure function `computeConfidence({ n, recencyDays, deviationStdRatio })` returning 0-100. Ranged inputs from `metricSource.n`, the user's `bucketSeries()` payload's freshest bucket, and the deviation magnitude. |
| Prompt | The model proposes a confidence; the wrapper **OVERRIDES** with the deterministic `computeConfidence()`. Two reasons: (1) calibrated probabilities are not a small-LLM strength; (2) deterministic reproducibility for the v1.4.17 feedback ratchet. |
| Wrapper | `src/lib/ai/generate-insight.ts` post-validation step injects `confidence` from `computeConfidence()` before returning to caller — model's value is discarded. |
| UI | `<RecommendationCard>` (from B5c) renders a `<ConfidenceMeter>`: 5-bar SVG that maps 0-20 / 20-40 / 40-60 / 60-80 / 80-100 to 1-5 lit bars. Below 25, lit-bar count is 1 + draft pill. Below 50 carries the "based on limited data" caption. |
| i18n | `insights.recommendation.confidence`, `insights.recommendation.confidenceLow`, `insights.recommendation.confidenceDraft`. EN+DE. |
| Auth + audit | None. Confidence is derived; nothing to authenticate, nothing to audit. |
### 2C. Proposed implementation plan (file-level)
**Schema migration.** No Prisma migration; lives in the JSON blob.
**`src/lib/ai/confidence.ts` (NEW).**
```ts
export interface ConfidenceInputs {
/** Sample count behind the cited data window (from metricSource.n). */
n: number;
/** Days since the most recent sample in the window. */
recencyDays: number;
/** |deviation| / stdev of the user's 90-day baseline, when computable. */
deviationStdRatio: number | null;
}
export function computeConfidence(inputs: ConfidenceInputs): number {
// n contribution: log-curve, saturates at n=30. n<3 is a hard cap.
if (inputs.n < 3) return Math.max(10, 5 * inputs.n); // 0/5/10/15
const nScore = Math.min(40, 10 + 10 * Math.log10(inputs.n)); // 20→25, 30→24.7
// Recency contribution: <2d = 30, decays linearly to 0 at 30d.
const recencyScore = Math.max(0, 30 * (1 - inputs.recencyDays / 30));
// Signal strength: |z| ≥ 1.5 = 30; 0 = 0; null = 15.
const signalScore =
inputs.deviationStdRatio === null
? 15
: Math.min(30, 30 * (Math.abs(inputs.deviationStdRatio) / 1.5));
return Math.round(nScore + recencyScore + signalScore);
}
```
Calibrated against synthetic fixtures (n=2 → ~10; n=14, recencyDays=1,
ratio=2 → ~94; n=30, recencyDays=20, null ratio → ~52). The formula is
**pre-feedback** — v1.4.17 will fit weights to thumbs-up rates.
**API contract.** `POST /api/insights/generate` returns payload with
`recommendations[].confidence: number` (already part of schema).
**Component tree.** `<ConfidenceMeter value={n} variant="ring|bars">` —
both variants implemented, default `bars`. Visual story: 5 bars,
filled count = ceil(value/20), color = green ≥80, yellow 50-79, red
<50.
**Provider abstraction.** No interface change. The override happens
post-parse in `generateInsight()`.
**Test strategy.**
- Unit: `computeConfidence()` — 12 fixtures across the input matrix.
- Component: `<ConfidenceMeter>` renders 5 bars at all five value
bands; aria-label translates correctly.
- Integration: end-to-end `generateInsight()` returns a payload where
every rec has a server-computed confidence (model-supplied value
ignored).
---
## 3. B5e — User-feedback loop ("War das hilfreich?")
### 3A. Industry benchmarking
**Apple Health.** No per-card thumbs feedback. Apple's signal is
implicit (did the user dismiss the trend? did they keep wearing the
watch?) — not a model HealthLog can copy because dismiss-as-feedback
needs orders of magnitude more users to denoise.
**Withings Health Mate.** No documented per-rec feedback. App-level
star reviews are the only loop, and they don't tie back to the rec.
**Oura Ring.** Has a **Tags** feature where users mark daily
behaviours (caffeine, alcohol, late meal, etc.) and Oura correlates
those with sleep score deviations (Oura Help, *Sleep Score*). This
isn't a thumbs-up on a rec — it's a much richer behavioural signal
that funds Oura's correlations. HealthLog can't copy verbatim
because the user base is too small for cross-user training, but the
*pattern of asking the user to label cause-effect over time* is
worth borrowing in v1.5+ as an opt-in.
**Top 3 patterns to borrow.**
1. **Per-card thumbs immediately under the rec** — low friction, no
nag-modal (Marc's explicit spec; matches industry-standard chat
feedback UI).
2. **No claim of cross-user training** — be explicit in the UI that
feedback shapes only that user's future prompts. Privacy-first
framing reduces the "they're spying" anxiety we picked up in v1.4.x
beta feedback.
3. **Treat feedback as a delayed prompt revision, not a same-session
reaction** — change `PROMPT_VERSION` based on aggregated patterns
over time, not by hot-patching this user's next call. Stable
ratchets.
### 3B. Existing architecture fit
| Concern | Touched files / what changes |
| -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Schema | NEW Prisma model `RecommendationFeedback` (replaces the v1.4.17 roadmap's `InsightFeedback` proposal — same idea, narrower scope). Includes `userId`, `recommendationId`, `recommendationText`, `recommendationSeverity`, `helpful`, `providerType`, `promptVersion`, `createdAt`. |
| API | NEW `POST /api/insights/feedback` — accepts `{ recommendationId, recommendationText, helpful }`, fills `providerType` + `promptVersion` server-side from the cached payload. `requireAuth()`-gated. Idempotent via `Idempotency-Key`. |
| UI | `<RecommendationFeedback>` component — 2 small icon buttons under the rec, optimistic mutation via TanStack Query. Disabled when already submitted (read from local cache + a stable `(userId, recommendationId)` key). |
| Aggregation | NEW worker job `insightsFeedbackAggregator` (pg-boss) computes per-(severity × metricSource.type) helpful-rate weekly and writes to `AppSettings.adminAiInsightsFeedbackSummary` (small JSON blob). Source of truth for any future prompt-tuning step. |
| Prompt-tuning | DEFERRED to v1.4.17 — no prompt mutation in v1.4.16. The B5e milestone delivers the loop infrastructure; the ratchet itself is a follow-on. |
| i18n | `insights.recommendation.feedbackHelpful`, `insights.recommendation.feedbackNotHelpful`, `insights.recommendation.feedbackThanks`, `insights.recommendation.feedbackConfirmed`. EN+DE. |
| Auth + audit | `POST /api/insights/feedback` writes an audit row `insights.recommendation.feedback` with `{ helpful, severity, providerType, promptVersion }`. Per-user only; cross-user aggregate stays on the worker. |
### 3C. Proposed implementation plan (file-level)
**Prisma migration.**
```prisma
model RecommendationFeedback {
id String @id @default(cuid())
userId String @map("user_id")
// The recommendation's stable id within its response. Same user can
// re-rate across regenerations because the id is per-payload, not
// per-user. (recommendationText snapshot disambiguates duplicates.)
recommendationId String @map("recommendation_id")
recommendationText String @map("recommendation_text")
recommendationSeverity String @map("recommendation_severity")
metricSourceType String @map("metric_source_type")
metricSourceTimeRange String @map("metric_source_time_range")
helpful Boolean
providerType String @map("provider_type") // "codex" | "openai" | …
promptVersion String @map("prompt_version") // e.g. "4.16.0"
createdAt DateTime @default(now()) @map("created_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([userId, recommendationId, recommendationText], map: "rec_feedback_user_rec_text_key")
@@index([userId, createdAt])
@@index([recommendationSeverity, helpful, createdAt])
@@map("recommendation_feedback")
}
```
GDPR cascade: `onDelete: Cascade` per `User`. Mirrors `Feedback`
table convention. Add to the integration test
`tests/integration/gdpr-cascade-delete.test.ts`.
**API contract.**
```
POST /api/insights/feedback
Headers: Idempotency-Key (recommended), X-Client-Type: native | (none)
Body: {
recommendationId: string,
recommendationText: string, // snapshot for disambiguation
recommendationSeverity: "info"|"suggestion"|"important"|"urgent",
metricSourceType: string,
metricSourceTimeRange: "last7days"|"last30days"|"last90days"|"allTime",
helpful: boolean
}
Response 201: { data: { id, createdAt } }
Response 409: { error: "already_rated" } // unique violation
Response 422: zod issues
```
**Component tree.** `<RecommendationFeedback recId={rec.id}>` —
TanStack Query mutation; optimistic update; disabled state pulls from
a local map keyed by `(userId, recId)` so a refresh doesn't allow a
re-rate.
**Provider abstraction.** No interface change. The feedback payload
captures `providerType` from the cached payload's
`raw.providerType` — already in `CompletionResult`. So feedback is
attributable per-provider for the cross-feature analysis (see §4.4).
**Worker job.**
`src/lib/jobs/feedback-aggregator.ts` (NEW). Runs daily at 04:00 via
pg-boss. Aggregates last-30-day feedback per
`(severity, metricSourceType, providerType, promptVersion)`. Writes to
`AppSettings` singleton. Does NOT mutate any user state.
**Test strategy.**
- Unit: zod schema for the feedback request.
- Integration: round-trip POST then re-POST returns 409. GDPR cascade
delete drops feedback rows.
- Idempotency replay: the Idempotency-Key gate works (cached response
for the same `(userId, recId, helpful)` body).
- Worker: 30 synthetic feedback rows across (severity × provider) →
worker produces a stable JSON aggregate.
- E2E: thumbs-up button click → toast confirms → button disabled.
---
## 4. B8 — Extended comparison views (Vormonat / Vorjahr)
### 4A. Industry benchmarking
**Apple Health.** Trends explicitly compares **the most recent
window** (e.g. 14 weeks) to **a longer baseline** (12-15 months);
when a delta crosses a threshold, a trend chip appears with a
sentence like "averaged more steps over the past 14 weeks" (Apple
Support, leancrew.com *Apple Health trends*). The narrative-text
layer is the value-add — the chart underneath is secondary.
**Withings Health Mate.** Limited side-by-side comparison; mostly
single-line history with a date range picker. No "vs. last year"
overlay surface.
**Oura Ring.** Has a **trends view** with the comparison band shown
visually, plus monthly/yearly summaries. The visual treatment is a
dimmed historical line under a current line — exactly Marc's spec.
**Top 3 patterns to borrow.**
1. **Dimmed historical-line overlay** under the active line (Oura).
Concrete; Marc's spec already calls for this.
2. **Narrative-text delta** alongside the chart (Apple): "Your
avg systolic improved by 4 mmHg vs. last month" is the headline —
the chart visualises it.
3. **Comparison toggle as a persistent setting** (Apple's Trends
surface implicitly enables comparisons; user doesn't toggle each
chart). Bundling all charts under a global "Compare to" picker is
the lowest-friction surface; per-chart toggle is the second-lowest.
Avoid hover-only interactions (Marc's explicit spec).
### 4B. Existing architecture fit
| Concern | Touched files / what changes |
| -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Schema | NEW `User.compareBaselinePref` String? (default null = "none"). Values: `"none" \| "lastMonth" \| "lastYear"`. Or stash inside `dashboardWidgetsJson` as `{ compareBaseline: "lastMonth" }` to avoid a schema migration — preferred since the field is ephemeral. |
| Bucketing | `src/lib/insights/bucket-series.ts` — already returns 3-year history (daily 360d + monthly 24×30d). The comparison overlay reads the existing payload — no API change. |
| Charts | `src/components/charts/health-chart.tsx` — accepts a `compareBaseline?: "lastMonth" \| "lastYear"` prop; renders a dimmed `<Line>` for the shifted series alongside the current `<Line>`. `mood-chart.tsx`, `medication-compliance-chart.tsx` mirror. |
| Tiles | `src/components/dashboard/tile-strip.tsx` — adds a delta callout `Δ +5%` next to the current value when `compareBaseline !== "none"`. |
| Insights | `src/lib/ai/prompts/insight-generator.ts` — when comparison is active, the user-prompt's snapshot includes a `comparison: { baseline, deltaSummary }` field; the prompt's GROUND RULES gain "narrate the comparison if `comparison` is non-null." |
| UI control | `src/components/charts/compare-toggle.tsx` (NEW) — segmented control "None / Vormonat / Vorjahr"; mounted next to the existing range tabs (7d/30d/90d/all). Persisted via `useSettings()` hook. |
| i18n | `charts.compareNone`, `charts.compareLastMonth`, `charts.compareLastYear`, `charts.deltaUp`, `charts.deltaDown`, `charts.deltaUnchanged`. EN+DE. |
| Auth + audit | None — preference change is a localised setting; no audit row. Reading the comparison payload uses the existing `requireAuth()`-gated insight + measurements endpoints. |
### 4C. Proposed implementation plan (file-level)
**Schema migration.** Prefer the **JSON-blob path** (no migration):
extend `dashboardWidgetsJson` with `{ compareBaseline }`. Schema
migration only if the JSON path proves slow to read in `RSC`-rendered
charts — measure first.
**Component tree.**
```
<HealthChart compareBaseline={pref}>
└ <ResponsiveContainer>
└ <LineChart>
├ <Line dataKey="current" />
├ {compareBaseline ? <Line dataKey="compare" stroke={dimmed} /> : null}
└ <Tooltip>{(payload) => <CompareTooltip payload={payload} />}</Tooltip>
```
The dimmed line uses `--dracula-comment` at 0.45 opacity per the
Dracula theme convention. No new chart-type tokens needed.
**Bucketing helper.** Add `src/lib/charts/compare-shift.ts`:
```ts
export function shiftBuckets(
buckets: DailyBucket[],
baseline: "lastMonth" | "lastYear",
): DailyBucket[];
```
Pure function. Pre-computed at chart-render time; no server round-trip.
**Insights narration.** When `compareBaseline !== "none"`, the snapshot
sent to the LLM includes:
```ts
comparison: {
baseline: "lastMonth" | "lastYear",
metrics: [
{ type: "BLOOD_PRESSURE_SYS", currentAvg: 132, baselineAvg: 136, delta: -4, deltaPercent: -2.9 },
…
],
}
```
Prompt gains: "If `comparison` is non-null, the summary's first
sentence MUST narrate the most clinically-significant delta." Test
fixture: a snapshot with `comparison.metrics[0].delta = -4 mmHg` →
summary contains the substring "vs. last month" or "Vormonat".
**Test strategy.**
- Unit: `shiftBuckets()` — DST boundary, leap-day, year shift across
a leap year.
- Component: `<HealthChart compareBaseline="lastMonth">` renders 2
`<Line>` paths.
- Component: `<CompareToggle>` persists across mount.
- Integration: insights payload with `comparison` block produces a
summary mentioning the delta.
- E2E: toggle "Vormonat" on the dashboard → reload → still active.
---
## 5. Cross-feature integration risks + mitigations
These four features are not independent. Each section below names
the risk, then the proposed mitigation.
### 5.1 Explainability + Confidence — same rec card
**Risk.** Both features layer affordances on the same
`<RecommendationCard>`. Without coordination, the card becomes busy
(badge + ring + chevron + thumbs all squeezed in).
**Mitigation.** Spec the layout once, in B5c, before B5d ships. The
layout is:
```
[severity-badge] rec-text [confidence-bars] [chevron▼]
(expanded:)
─ rationale rows (3)
─ mini-chart
─ citation footnote (B5a)
[thumbs-up] [thumbs-down]
```
Confidence bars + chevron live on the right, severity on the left,
thumbs only inside the expanded card. This keeps the collapsed state
under 1 row and the expanded state legible. Build the layout
component first; later features plug into named slots.
### 5.2 Confidence + Feedback — accelerated tuning for low-confidence-thumbs-down
**Risk.** A low-confidence rec that gets many thumbs-down should
ratchet the prompt **faster** than a high-confidence rec with
thumbs-down (the latter is real model error; the former is "model
shouldn't have shown this at all").
**Mitigation.** The aggregator's tunable threshold table includes
`(severity × confidence_band)`. Low-confidence-and-low-helpful-rate
buckets get a stricter "OMIT" rule appended to the next prompt
version. High-confidence-low-helpful-rate buckets get a "REPHRASE"
rule. This is v1.4.17 work, but the schema must support it from
v1.4.16 — hence `confidence` is on the `RecommendationFeedback` row.
**Code change for v1.4.16:** add `recommendationConfidence Int?` to
the Prisma model so the v1.4.17 ratchet has the data when it lands.
Cheap insurance.
### 5.3 Comparison-views + Insights — narrative drift
**Risk.** When comparison is active and the AI summary mentions the
baseline, the rationale on each rec might cite a *different* window
than the comparison the user is staring at. Cognitive whiplash.
**Mitigation.** Two rules:
1. The `userPrompt` builder includes both the `comparison` block AND
the per-rec rationale window options. The prompt's GROUND RULES
prefer rationale windows that match the active comparison
baseline when possible.
2. The UI's `<RationaleCard>` renders a bracket "(matches active
comparison)" badge when `rec.rationale.dataWindow ===
comparison.baseline`, and a softer "(different window)" badge
when they diverge — with a one-tap action to switch the global
comparison to match.
This way the comparison is always orientation, never a contradiction.
### 5.4 Feedback + Multi-provider (B5b) — provenance attribution
**Risk.** With multi-provider cascade (B5b), the same rec text might
come from Codex on Monday and admin-OpenAI on Tuesday. Aggregating
feedback without provider attribution will make per-provider quality
analysis impossible.
**Mitigation.** `RecommendationFeedback.providerType` is mandatory
(see schema in §3.C). Server-side fills it from the cached payload's
`raw.providerType` at write time — the client never supplies it,
preventing tampering. The aggregator slices by `providerType` in the
admin AI quality dashboard.
**Trade-off accepted.** A user re-rating a rec across regenerations
will produce 2+ rows. That's fine; the `(userId, recommendationId,
recommendationText)` unique constraint allows multiple rows when the
text changes between regenerations, and the worker can use the
freshest row per (user, rec-text) for trend analysis.
---
## 6. Phase ordering recommendation
Wave-B sequence within v1.4.16:
| Order | Phase | Rationale |
| ----- | ----- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| 1 | B5a | Medical-reference grounding ships the `references[]` bundle + `referenceId` schema field that B5c will reuse. Land first — don't double-ship. |
| 2 | B5b | Multi-provider redundancy hardens the *substrate*. Future features attribute by provider; provider abstraction needs to be solid first. |
| 3 | B5c | Per-rec explainability **defines the rec card layout**. All later UI work plugs into its named slots. |
| 4 | B5d | Confidence score plugs into B5c's rec card. Schema migration deferred to JSON-blob path; only zod additions. |
| 5 | B5e | Feedback loop adds the bottom-of-card thumbs. Touches Prisma (RecommendationFeedback). Provider attribution from B5b is now stable. |
| 6 | B8 | Comparison views are mostly chart-side; they intersect insights via the `comparison` snapshot block. Land last so the prompt is fully shaped. |
Time-budget carve-out: **B5a + B5c + B5d** are the smallest cohesive
slice that delivers user-visible explainability and confidence; if
context tightens, ship those three and defer B5b/B5e/B8 to v1.4.17.
---
## 7. Open questions for Marc
These are tight (4 items) — please ack or correct before
implementation.
1. **Confidence shape.** Marc's spec says "0-100 score." Confirmed
the deterministic-server-computed-override approach (model
discards its own number) is acceptable — i.e. the rec card never
shows a confidence the LLM picked unilaterally. **(yes/no)**
2. **Feedback aggregation policy.** Marc's spec says "no cross-user
training, opt-in for any aggregation." Question: is the
*single-user* aggregator (only Marc's own ratings shape Marc's own
future prompt) acceptable as a default, with cross-user-aggregate
gated behind an admin-only opt-in? Or must even the single-user
loop be opt-in? **(default-on vs opt-in)**
3. **Comparison persistence layer.** Strong preference for
piggy-backing on `dashboardWidgetsJson` (no Prisma migration) vs.
a dedicated `User.compareBaselinePref` column for SQL-grep ease in
the admin tools. **(JSON blob vs new column)**
4. **B8 narrative scope.** When comparison is active, the AI summary
gains a leading "vs. last month" sentence. Question: is this
narrative on by default when the toggle flips, or behind a
secondary "include comparison in AI insights" preference? Default
on is more useful but means more LLM tokens; default off is
cheaper but creates a "why isn't the AI mentioning what I see in
the chart" surprise. **(default on vs default off)**
---
## 8. Sources
- Apple Support — *View your data in Health on iPhone* —
https://support.apple.com/guide/iphone/view-your-health-data-iphe3d379c32/ios
- leancrew.com — *Apple Health trends* —
https://leancrew.com/all-this/2024/11/apple-health-trends/
- Apple Watch Vitals app explained —
https://www.centuryai.app/blog/apple-watch-vitals-app-explained
- Withings Support — *What is Health Insights* —
https://support.withings.com/hc/en-us/articles/360038534893-Health-Mate-Android-App-What-is-Health-Insights-
- Withings Blog — *Get to know the Health Mate app* —
https://www-ext.withings.com/2021/02/04/why-is-health-mate-the-most-advanced-health-management-app/
- justuseapp — *Withings Health Mate Reviews* —
https://justuseapp.com/en/app/542701020/withings-health-mate/reviews
- Oura — *Sleep Score* —
https://support.ouraring.com/hc/en-us/articles/360025445574-Sleep-Score
- Oura — *Sleep Contributors* —
https://support.ouraring.com/hc/en-us/articles/360057792293-Sleep-Contributors
- Oura — *Readiness Score* —
https://support.ouraring.com/hc/en-us/articles/360025589793-Readiness-Score
- Oura — *Readiness Contributors* —
https://support.ouraring.com/hc/en-us/articles/360057791533-Readiness-Contributors
- Oura — *Readiness Score & Cycle Insights* —
https://ouraring.com/blog/readiness-score-cycle-consideration/
---
## 9. Cross-links
- Phase-0 elevation commit: `chore(planning): elevate B5c/d/e + B8 for v1.4.16` (origin/main)
- Sibling AI roadmap: `docs/audit/v1416-ai-roadmap.md` (B5a/B5b authored 2026-05-09 during v1.4.15 Phase C1)
- Phase-summary report (this dispatch): `.planning/phase-research-report.md`
- Backlog: `.planning/v1416-backlog.md` (deferred MED/HIGH that touch the same surfaces — F1, F2, M6 senior, H3 code-review)
+98
View File
@@ -0,0 +1,98 @@
import { expect, test } from "@playwright/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
/**
* v1.4.16 A1 — Sidebar Admin entry must mirror the Settings entry
* exactly: a single link with no sub-item expansion in the global
* sidebar. Marc reported in v1.4.15 that the sidebar was auto-expanding
* admin sub-items on `/admin/*` and the gravatar dropdown felt linked to
* the sidebar — both unwanted UX. The in-shell `<AdminShell>` already
* renders its own per-section nav inside the page itself.
*
* This spec runs only on the desktop project (Pixel 5 hides the global
* sidebar entirely).
*/
test.describe("sidebar Admin entry no-expansion", () => {
test.use({ storageState: STORAGE_STATE_PATH });
test.beforeEach(({}, testInfo) => {
test.skip(
testInfo.project.name === "chromium-mobile",
"global sidebar is desktop-only (md+)",
);
});
test("on /admin/system-status the sidebar shows ONLY the single Admin link, no sub-list", async ({
page,
}) => {
await page.goto("/admin/system-status", {
waitUntil: "domcontentloaded",
});
// The desktop sidebar is the <aside aria-label="Sidebar"> element.
// (See `src/components/layout/sidebar-nav.tsx`.) The Admin entry
// inside it must be the ONLY link starting with `/admin` — there
// must be no anchor pointing at a sub-route like `/admin/users`.
const sidebar = page.locator("aside[aria-label]").first();
await expect(sidebar).toBeVisible();
const adminLink = sidebar.locator("a[href='/admin']");
await expect(adminLink).toHaveCount(1);
// No sub-section anchors inside the sidebar — those belong to
// `<AdminShell>`'s in-page nav, not the global sidebar.
const subLinks = sidebar.locator(
"a[href^='/admin/']:not([href='/admin'])",
);
await expect(subLinks).toHaveCount(0);
});
test("clicking Admin from /dashboard navigates without flashing a sub-list", async ({
page,
}) => {
await page.goto("/", { waitUntil: "domcontentloaded" });
const sidebar = page.locator("aside[aria-label]").first();
await expect(sidebar).toBeVisible();
// Pre-click sanity: only the single Admin link is present (no
// sub-items rendered eagerly).
await expect(sidebar.locator("a[href='/admin']")).toHaveCount(1);
await expect(
sidebar.locator("a[href^='/admin/']:not([href='/admin'])"),
).toHaveCount(0);
await sidebar.locator("a[href='/admin']").click();
await page.waitForURL(/\/admin(\/|$)/);
// After navigation the sidebar still shows only the single Admin
// link — no sub-list expansion.
await expect(sidebar.locator("a[href='/admin']")).toHaveCount(1);
await expect(
sidebar.locator("a[href^='/admin/']:not([href='/admin'])"),
).toHaveCount(0);
});
test("opening the gravatar user-menu does not expand sidebar admin sub-items", async ({
page,
}) => {
await page.goto("/", { waitUntil: "domcontentloaded" });
const sidebar = page.locator("aside[aria-label]").first();
await expect(sidebar).toBeVisible();
// The user-menu trigger sits inside the sidebar's user section.
// It carries `aria-label="User menu"`. Open it and confirm the
// sidebar admin section is unaffected.
const userMenu = sidebar.getByRole("button", { name: /user menu/i });
await userMenu.click();
// Dropdown is open — but the sidebar's structure is still the
// single Admin entry, no sub-items.
await expect(sidebar.locator("a[href='/admin']")).toHaveCount(1);
await expect(
sidebar.locator("a[href^='/admin/']:not([href='/admin'])"),
).toHaveCount(0);
});
});
@@ -84,7 +84,13 @@ describe("<SidebarNav> bug-report toggle", () => {
});
});
describe("<SidebarNav> admin sub-items context-awareness", () => {
describe("<SidebarNav> admin entry mirrors Settings (no sub-item expansion)", () => {
// v1.4.16 A1: Marc reported the global sidebar expanding admin
// sub-items on `/admin/*` was unwanted UX — the in-shell `<AdminShell>`
// already provides per-section nav inside the page. The Admin entry
// must behave EXACTLY like the Settings entry: a single link with no
// sub-list at any route, regardless of where the gravatar dropdown is.
it("does not render admin entries at all for a regular user", () => {
const html = render({ role: "USER", pathname: "/admin/system-status" });
expect(html).not.toContain('href="/admin"');
@@ -102,23 +108,37 @@ describe("<SidebarNav> admin sub-items context-awareness", () => {
}
});
it("for an admin on /admin/* expands the section sub-list", () => {
it("for an admin on /admin/* still shows ONLY the single Admin link (no sub-list)", () => {
const html = render({ role: "ADMIN", pathname: "/admin/system-status" });
expect(html).toContain('href="/admin"');
// All section sub-items must render — the global sidebar mirrors
// the in-shell sidebar so users can jump between sections.
// Sub-section links must NOT appear in the global sidebar — they
// belong to `<AdminShell>`'s in-page nav. Marc reported the
// expansion as broken UX in v1.4.16; this guard keeps it gone.
for (const section of ADMIN_SECTIONS) {
expect(html).toContain(`href="/admin/${section.slug}"`);
expect(html).not.toContain(`href="/admin/${section.slug}"`);
}
});
it("expands sub-items on the /admin overview page (mirrors in-shell sidebar)", () => {
// The in-shell `<AdminShell>` sidebar already lists every section on
// `/admin`. The global sidebar must mirror that so the navigation
// experience is identical between the two viewports.
it("on the /admin overview page also shows ONLY the single Admin link", () => {
const html = render({ role: "ADMIN", pathname: "/admin" });
expect(html).toContain('href="/admin"');
for (const section of ADMIN_SECTIONS) {
expect(html).toContain(`href="/admin/${section.slug}"`);
expect(html).not.toContain(`href="/admin/${section.slug}"`);
}
});
it("Admin entry markup mirrors Settings entry markup (same shape, no expansion)", () => {
// Both Settings and Admin should render as a single <a> with no
// adjacent <ul> sub-list. Counting <ul aria-label="Admin sections"
// (or any descendant ul under the Admin link) confirms there's no
// disclosure widget. We assert by checking the structural pattern:
// the Admin link is followed by the Settings link (or the user
// section), never by a <ul>.
const html = render({ role: "ADMIN", pathname: "/admin/system-status" });
// No sub-list <ul> on /admin/*: previously this was the
// `aria-label={t("admin.shell.sectionsNav")}` ul; assert the only
// Settings-bearing nav surface is intact (sectionsNav label belongs
// to `<AdminShell>`, which the sidebar must NOT echo).
expect(html).not.toMatch(/aria-label="Admin sections"/);
});
});
+18 -51
View File
@@ -29,7 +29,6 @@ import { useAuth, useLogout } from "@/hooks/use-auth";
import { useTheme } from "@/components/providers";
import { useAppSettings } from "@/components/app-settings-provider";
import { useTranslations } from "@/lib/i18n/context";
import { ADMIN_SECTIONS } from "@/components/admin/admin-shell";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import {
Tooltip,
@@ -246,9 +245,12 @@ export function SidebarNav() {
const { user } = useAuth();
const { bugReportEnabled } = useAppSettings();
const isAdmin = user?.role === "ADMIN";
// Match `/admin` exactly or any `/admin/...` sub-route. Plain
// `startsWith("/admin")` would also flip for a hypothetical future
// `/administrative` page, which is not the same semantic surface.
// Match `/admin` exactly or any `/admin/...` sub-route for active-link
// styling. Plain `startsWith("/admin")` would also flip for a
// hypothetical future `/administrative` page, which is not the same
// semantic surface. The Admin entry mirrors Settings: a single link
// with no sub-item expansion in the global sidebar — `<AdminShell>`
// renders its own per-section nav inside the page itself.
const onAdminPage = pathname === "/admin" || pathname.startsWith("/admin/");
const [collapsed, setCollapsed] = useState(() => {
if (typeof window === "undefined") return false;
@@ -491,54 +493,19 @@ export function SidebarNav() {
</Link>
)}
{isAdmin && (
<>
<Link
href="/admin"
aria-current={onAdminPage ? "page" : undefined}
className={cn(
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors",
onAdminPage
? "bg-primary/10 text-primary"
: "text-foreground hover:bg-accent",
)}
>
<Shield className="h-4 w-4" />
{t("nav.admin")}
</Link>
{/* Expandable section list — only visible while
navigating inside `/admin/*`. Mirrors the in-shell
sidebar so the user can jump between sections from
the global nav too. */}
{onAdminPage && (
<ul
className="space-y-1 pl-3"
aria-label={t("admin.shell.sectionsNav")}
>
{ADMIN_SECTIONS.map((section) => {
const sectionPath = `/admin/${section.slug}`;
const isActive = pathname.startsWith(sectionPath);
const Icon = section.icon;
return (
<li key={section.slug}>
<Link
href={sectionPath}
aria-current={isActive ? "page" : undefined}
className={cn(
"flex items-center gap-2 rounded-md px-2.5 py-1.5 text-xs font-medium transition-colors",
isActive
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:bg-accent hover:text-foreground",
)}
>
<Icon className="h-3.5 w-3.5" />
{t(section.titleKey)}
</Link>
</li>
);
})}
</ul>
<Link
href="/admin"
aria-current={onAdminPage ? "page" : undefined}
className={cn(
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors",
onAdminPage
? "bg-primary/10 text-primary"
: "text-foreground hover:bg-accent",
)}
</>
>
<Shield className="h-4 w-4" />
{t("nav.admin")}
</Link>
)}
<Link
href="/settings/account"