mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
1 parent
9372684a41
commit
77fe256d9e
5 files changed
+902
-61
No files matched your search
@@ -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)
|
||||||
@@ -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", () => {
|
it("does not render admin entries at all for a regular user", () => {
|
||||||
const html = render({ role: "USER", pathname: "/admin/system-status" });
|
const html = render({ role: "USER", pathname: "/admin/system-status" });
|
||||||
expect(html).not.toContain('href="/admin"');
|
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" });
|
const html = render({ role: "ADMIN", pathname: "/admin/system-status" });
|
||||||
expect(html).toContain('href="/admin"');
|
expect(html).toContain('href="/admin"');
|
||||||
// All section sub-items must render — the global sidebar mirrors
|
// Sub-section links must NOT appear in the global sidebar — they
|
||||||
// the in-shell sidebar so users can jump between sections.
|
// 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) {
|
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)", () => {
|
it("on the /admin overview page also shows ONLY the single Admin link", () => {
|
||||||
// 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.
|
|
||||||
const html = render({ role: "ADMIN", pathname: "/admin" });
|
const html = render({ role: "ADMIN", pathname: "/admin" });
|
||||||
|
expect(html).toContain('href="/admin"');
|
||||||
for (const section of ADMIN_SECTIONS) {
|
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"/);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -29,7 +29,6 @@ import { useAuth, useLogout } from "@/hooks/use-auth";
|
|||||||
import { useTheme } from "@/components/providers";
|
import { useTheme } from "@/components/providers";
|
||||||
import { useAppSettings } from "@/components/app-settings-provider";
|
import { useAppSettings } from "@/components/app-settings-provider";
|
||||||
import { useTranslations } from "@/lib/i18n/context";
|
import { useTranslations } from "@/lib/i18n/context";
|
||||||
import { ADMIN_SECTIONS } from "@/components/admin/admin-shell";
|
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
@@ -246,9 +245,12 @@ export function SidebarNav() {
|
|||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const { bugReportEnabled } = useAppSettings();
|
const { bugReportEnabled } = useAppSettings();
|
||||||
const isAdmin = user?.role === "ADMIN";
|
const isAdmin = user?.role === "ADMIN";
|
||||||
// Match `/admin` exactly or any `/admin/...` sub-route. Plain
|
// Match `/admin` exactly or any `/admin/...` sub-route for active-link
|
||||||
// `startsWith("/admin")` would also flip for a hypothetical future
|
// styling. Plain `startsWith("/admin")` would also flip for a
|
||||||
// `/administrative` page, which is not the same semantic surface.
|
// 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 onAdminPage = pathname === "/admin" || pathname.startsWith("/admin/");
|
||||||
const [collapsed, setCollapsed] = useState(() => {
|
const [collapsed, setCollapsed] = useState(() => {
|
||||||
if (typeof window === "undefined") return false;
|
if (typeof window === "undefined") return false;
|
||||||
@@ -491,54 +493,19 @@ export function SidebarNav() {
|
|||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<>
|
<Link
|
||||||
<Link
|
href="/admin"
|
||||||
href="/admin"
|
aria-current={onAdminPage ? "page" : undefined}
|
||||||
aria-current={onAdminPage ? "page" : undefined}
|
className={cn(
|
||||||
className={cn(
|
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors",
|
||||||
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors",
|
onAdminPage
|
||||||
onAdminPage
|
? "bg-primary/10 text-primary"
|
||||||
? "bg-primary/10 text-primary"
|
: "text-foreground hover:bg-accent",
|
||||||
: "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>
|
|
||||||
)}
|
)}
|
||||||
</>
|
>
|
||||||
|
<Shield className="h-4 w-4" />
|
||||||
|
{t("nav.admin")}
|
||||||
|
</Link>
|
||||||
)}
|
)}
|
||||||
<Link
|
<Link
|
||||||
href="/settings/account"
|
href="/settings/account"
|
||||||
|
|||||||
Reference in new issue
Block a user