mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
chore(release): v1.4.29.1
Daily-step aggregation hotfix. The dashboard 7-day step chart now renders daily totals (sums in the thousands), not per-sample averages, by routing cumulative HealthKit types through the sum branch of the client-side daily aggregator.
This commit is contained in:
1 parent
d76c0b56e7
commit
945b556664
6 files changed
+607
-2
No files matched your search
@@ -0,0 +1,275 @@
|
||||
---
|
||||
file: .planning/research/v15-assistant-optional.md
|
||||
purpose: Operator-side toggles for assistant surfaces — coverage + UX + iOS contract + implementation plan
|
||||
created: 2026-05-16
|
||||
contributor: assistant-optional research
|
||||
---
|
||||
|
||||
# Assistant-optional architecture for HealthLog
|
||||
|
||||
## Premise
|
||||
|
||||
HealthLog wires an LLM provider into seven distinct user-facing surfaces. Some operators run the app self-hosted on AGPL infrastructure, hand-pick a non-LLM stack for privacy reasons, or just want the manual measurement / target / charts story without any model-generated text. Today the only operator lever is whether `AppSettings.adminAiKeyEncrypted` is populated; that on/off is too coarse — it disables every surface uniformly, and silently downgrades each card to a "no provider configured" empty state rather than removing the affordance. This report proposes a per-surface operator toggle scheme so an operator can keep, e.g., the Daily Briefing visible while removing the Coach drawer and the per-metric status text.
|
||||
|
||||
The premise stays separate from the per-user consent gate (`User.insightsPrivacyMode`, the Research-Mode dialog on the Drug-Level chart). Operator flags are a global supply-side switch; per-user privacy stays a per-user demand-side opt-in. Both layers compose: a surface is enabled only when (operator-enabled) AND (per-user opt-in if required).
|
||||
|
||||
## Part A — Coverage matrix
|
||||
|
||||
Seven distinct surfaces consume an LLM provider. Each row maps the user-visible mount, the server endpoint, the current opt-out path (if any), and the operator toggle this report recommends.
|
||||
|
||||
| # | Surface | User-facing mount | Server endpoint | Current opt-out | Proposed operator toggle |
|
||||
|---|---|---|---|---|---|
|
||||
| 1 | Daily Briefing card | `src/components/insights/daily-briefing.tsx` mounted on `/insights` page header | `/api/insights/generate` (parsed into briefing slot) + `/api/insights/cards` (iOS) | None: hidden only if provider absent OR feature flag in `features.ts` advises off | `briefing.enabled` |
|
||||
| 2 | Coach drawer + FAB + inline pill | `layout-coach-fab.tsx`, `coach-launch-button.tsx`, `layout-coach-mount.tsx` → `coach-panel/coach-drawer.tsx` | `/api/insights/chat` (SSE), `/api/insights/chat/[id]`, `/api/insights/chat/messages/[id]/feedback`, `/api/auth/me/coach-prefs` | None: launch fails silently when provider chain empty | `coach.enabled` |
|
||||
| 3 | Insights regenerate icon | `insights-tab-strip.tsx` regen IconButton (mother page only) | `/api/insights/generate` (POST) | None: button stays even with empty advisor | `briefing.enabled` (shares flag — same endpoint) |
|
||||
| 4 | Comprehensive insight body (recommendations grid, hero strip narration) | `recommendations-grid.tsx`, `hero-strip.tsx` via `useInsightsAdvisorQuery()` | `/api/insights/generate` (GET cache read) | None | `briefing.enabled` |
|
||||
| 5 | Per-metric status cards (7 metrics) | `insight-status-card.tsx` mounted on each `/insights/<metric>` sub-page | `/api/insights/weight-status`, `/bmi-status`, `/blood-pressure-status`, `/pulse-status`, `/mood-status`, `/medication-compliance-status`, plus generic `general-status` | `hasProvider` falsy → "no provider" empty card | `insightStatus.enabled` (single flag covering all 7 sub-routes) |
|
||||
| 6 | Health-Score delta `?` explainer popover | `health-score-delta-explainer.tsx` triggered from `health-score-card.tsx` inside `hero-strip.tsx` | Pure client text (no LLM call) but bound to the briefing payload's delta-explanation slot | None | `healthScoreExplainer.enabled` |
|
||||
| 7 | Correlation card narration | `correlation-card.tsx`, `correlation-row.tsx` consume `/api/insights/correlations` | `/api/insights/correlations` (LLM-narrated correlations) | None | `correlations.enabled` |
|
||||
|
||||
Five natural toggles cover all seven surfaces:
|
||||
|
||||
- `coach.enabled` → drawer + chat + history rail + feedback endpoint + coach-prefs route.
|
||||
- `briefing.enabled` → Daily Briefing card + recommendations grid + hero narration + the regen icon (every consumer of `/api/insights/generate`).
|
||||
- `insightStatus.enabled` → all 7 per-metric `*-status` routes and the cards that read them.
|
||||
- `correlations.enabled` → the correlation tile narration on the mother page.
|
||||
- `healthScoreExplainer.enabled` → the `?` popover on the delta line; LLM-derived rationale stays gated by `briefing.enabled` upstream but the operator can independently hide the trigger so the score number reads as a bare KPI.
|
||||
|
||||
If Marc wants an even smaller surface, a single root flag `assistant.enabled = false` short-circuits everything — useful for operators who never want any LLM surface. The five sub-flags then only apply when the root flag is `true`.
|
||||
|
||||
## Part B — Toggle UX
|
||||
|
||||
### Where the operator sets them
|
||||
|
||||
Recommendation: a new admin section slug `assistant` inserted between `ai-quality` and `coach-feedback` (which currently mount the existing provider-config and feedback panels). The slug list in `src/components/admin/section-slugs.ts` becomes:
|
||||
|
||||
```
|
||||
system-status, general, services, integrations,
|
||||
ai-quality, assistant, coach-feedback, feedback,
|
||||
reminders, users, api-tokens, login-overview,
|
||||
app-logs, backups, danger-zone
|
||||
```
|
||||
|
||||
The new panel — `assistant-section.tsx` — renders one master `assistant.enabled` toggle at the top and five sub-toggles below (greyed out when the master is off). Each toggle reuses the shared `<SettingsToggle>` primitive that `general-settings-section.tsx` already uses, so the UX matches `registrationEnabled`, `moodLogGlobal`, `bugReportEnabled`, etc. word for word. No new component library territory.
|
||||
|
||||
The slug separation is deliberate: `ai-quality` keeps its current scope (provider keys, model selection, feedback summary), `coach-feedback` keeps its inbox, and `assistant` is the surface-visibility cut. Cross-references between the three panels can use the shared `_shared.tsx` link helper.
|
||||
|
||||
### Persistence shape
|
||||
|
||||
Two viable shapes:
|
||||
|
||||
| Option | Pro | Con | Recommendation |
|
||||
|---|---|---|---|
|
||||
| Six new boolean columns on `AppSettings` (master + five) | Matches the existing pattern (`registrationEnabled`, `telegramGlobal`, etc.); migration trivial; queryable from Prisma with native columns; admin form binds 1:1 | Slightly chatty schema; every new sub-flag needs another migration | **Preferred** |
|
||||
| Single `assistantFlags Jsonb` column with TS-typed shape | One migration ever; future sub-flags additive without DDL | Harder to migrate existing query patterns; loses Prisma column-level type safety; admin form needs JSON-merge logic | Defer |
|
||||
|
||||
The codebase has already chosen the column-per-flag pattern five times over (registration, telegram, ntfy, web-push, api, mood-log, bug-report) — this should follow suit. Migration adds six columns to `app_settings`, all defaulting to `true`.
|
||||
|
||||
### Cache strategy
|
||||
|
||||
Existing global toggles (`getGlobalServiceAvailability()` in `src/lib/app-settings.ts`) issue a per-request Prisma lookup with a try/catch fallback. That pattern is fine for low-traffic admin surfaces but the assistant flag is read inside the hot SSE path (`/api/insights/chat`), the per-metric status routes (called from every sub-page mount), and the Coach FAB visibility check on every Insights page load. Recommendation:
|
||||
|
||||
- Server side: cache the `AppSettings` row in a 60s in-memory map keyed by `singleton`. The admin PUT handler issues a single cache-bust on save. 60s lag on toggle is acceptable since the only client-side reader (`useFeatureFlags()` below) revalidates on focus through React Query.
|
||||
- Client side: one new endpoint `GET /api/feature-flags` (public, no auth required since the values are not secret) feeds a `useFeatureFlags()` hook with React Query `staleTime: 60_000`. Every consumer (`<DailyBriefing>`, `<LayoutCoachFab>`, `<InsightStatusCard>` callers, `<HealthScoreDeltaExplainer>`) reads from this hook and returns `null` when the relevant flag is off. The hook is single-source-of-truth so the same render decision drives both the React tree and the admin-side preview.
|
||||
|
||||
### Default state for fresh installs
|
||||
|
||||
Recommendation: **assistant `ON`** for the master flag and **`ON`** for all five sub-flags. Rationale:
|
||||
|
||||
1. The current behaviour is "assistant always available when a provider is configured" — preserving the existing default is the lowest-friction upgrade path.
|
||||
2. Operators who deliberately don't configure a provider already get an empty-state fallback on every surface; the toggle doesn't make their experience worse.
|
||||
3. EU AI Act compliance for the assistant surfaces falls below "high-risk" (HealthLog is a wellness journal, not a medical device — Coach prompts disclose this and refuse medical advice) so opt-out is sufficient, opt-in not mandated. See the peer-research section for citations.
|
||||
4. iOS surfaces the flags through the same endpoint and will respect them additively (Part D); a default-OFF would break every existing iOS install on first contact with the v1.4.x backend.
|
||||
|
||||
If a future operator wants a strict-default install (e.g. a clinic-side deployment with no LLM by policy), they set the master flag once in the admin panel post-install. The seed migration could optionally honour an `ASSISTANT_DEFAULT_OFF=1` env var for first-boot installs, but that's a v1.4.31+ polish, not v1.4.30.
|
||||
|
||||
## Part C — User-facing fallback shapes
|
||||
|
||||
When a surface is disabled the user must read coherent content without the missing piece. The fallback matrix:
|
||||
|
||||
| Surface | Disabled UI | Replacement | Empty-slot risk |
|
||||
|---|---|---|---|
|
||||
| Daily Briefing card | Card removed entirely from `/insights` header | Hero strip slides up; comparison toggle moves into the trends-row metaSlot | None — grid is `auto-flow` so the row collapses |
|
||||
| Coach drawer | FAB hidden (`<LayoutCoachFab>` returns null); inline `<CoachLaunchButton>` pill returns null; suggested-prompts chips return null; layout-level `<CoachDrawer>` mount returns null | The "ask Coach" prompt chips on hero strip already render conditionally — same null check covers them | Hero strip needs `nullsafe` guard on the chips region so the gap doesn't leave dead whitespace |
|
||||
| Insights regen icon | `onRegenerate` prop omitted from `<InsightsTabStrip>`; the existing conditional already supports this | None — the icon-only button just isn't rendered | None — tab strip already handles `onRegenerate === undefined` |
|
||||
| Per-metric status card | Card returns `null` instead of the current "no provider" placeholder | A short hand-written manual paragraph reading "Track this metric to view trends" with a link to the latest measurement entry — pure-text, no LLM | Sub-page needs to ship the manual paragraph variant; it can live as a translated string `insights.status.manualOnly` |
|
||||
| Health-Score delta explainer | The `?` glyph after the delta text doesn't render; delta number stays visible | None — the delta line still reads "+2 vs last week" without the trigger | None |
|
||||
| Correlation card | Card returns `null`; the row above (manual correlation matrix) widens to fill | The numerical correlation coefficient still ships from `/api/correlations` if needed; only the LLM narration is hidden | Needs a non-LLM fallback row variant that shows the coefficient + a hand-written gloss like "weak positive" |
|
||||
|
||||
### iOS fallback contract
|
||||
|
||||
The iOS `Insight` model already tolerates absent surfaces: `/api/insights/cards` returns `data: []` when no advisor blob is present. The contract extension this report recommends:
|
||||
|
||||
- `GET /api/feature-flags` returns `{ data: { assistant: { enabled, coach, briefing, insightStatus, correlations, healthScoreExplainer } } }`. iOS reads this on app launch, caches it for the session, and gates each tab / card render off the same flags.
|
||||
- The five surface endpoints (`/api/insights/generate`, `/chat`, the seven `*-status` routes, `/correlations`) start returning `403` with `errorCode: "assistant.disabled.<surface>"` when the operator flag is off. iOS treats `403` + that errorCode as a graceful empty-state signal, not an authentication error.
|
||||
|
||||
This means an iOS build older than v1.5 that doesn't know about the flag endpoint still degrades cleanly: it gets `403`, surfaces an "unavailable" banner, no crash. The breaking-change-free upgrade contract holds.
|
||||
|
||||
## Part D — iOS contract impact
|
||||
|
||||
The Coach SSE drawer is the v1.5 iOS differentiator per the v1.5-r-e research outcome. If an operator disables it, the iOS Coach tab needs to render an "Operator disabled" state rather than a blank screen with a typed-but-doomed input.
|
||||
|
||||
Recommended contract delta — **additive, no breaking change**:
|
||||
|
||||
1. New endpoint `GET /api/feature-flags` (public, cache-control `public, max-age=60`). Response shape:
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"assistant": {
|
||||
"enabled": true,
|
||||
"surfaces": {
|
||||
"coach": true,
|
||||
"briefing": true,
|
||||
"insightStatus": true,
|
||||
"correlations": true,
|
||||
"healthScoreExplainer": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
2. iOS launch sequence (additive to existing `/api/auth/me`): after auth, fire `GET /api/feature-flags` once, cache for the session. Each tab's `task { await loadFeatureFlags() }` short-circuits the LLM-backed surface if its flag is false.
|
||||
3. Per-endpoint enforcement: each gated route adds the same operator-flag check at the top, returning `403` + `errorCode: "assistant.disabled.<surface>"` when off. This makes the contract self-enforcing — even an iOS app that ignores the flag endpoint still degrades correctly because the endpoint stops serving.
|
||||
4. The OpenAPI spec gains the new endpoint and the `errorCode` values; both feed the iOS code-gen pipeline so the Swift error enum gets the cases for free.
|
||||
5. The cards adapter at `/api/insights/cards` keeps returning `data: []` when `briefing.enabled = false` so the existing iOS empty state ("No insights yet — log measurements to start") fires unchanged.
|
||||
|
||||
The Coach tab on iOS gains a single new view variant — `CoachDisabledView` — bound to the flag. Copy: "The Coach is currently unavailable on this server." No personal info, no operator name leak. Same shape applies to a Briefing-disabled state on the Insights tab and an InsightStatus-disabled state on each metric sub-page.
|
||||
|
||||
## Part E — Implementation plan
|
||||
|
||||
### E1 — Schema migration (S, ~30 min)
|
||||
|
||||
```prisma
|
||||
model AppSettings {
|
||||
// ... existing fields ...
|
||||
assistantEnabled Boolean @default(true) @map("assistant_enabled")
|
||||
assistantCoachEnabled Boolean @default(true) @map("assistant_coach_enabled")
|
||||
assistantBriefingEnabled Boolean @default(true) @map("assistant_briefing_enabled")
|
||||
assistantInsightStatusEnabled Boolean @default(true) @map("assistant_insight_status_enabled")
|
||||
assistantCorrelationsEnabled Boolean @default(true) @map("assistant_correlations_enabled")
|
||||
assistantHealthScoreExplainerEnabled Boolean @default(true) @map("assistant_health_score_explainer_enabled")
|
||||
}
|
||||
```
|
||||
|
||||
Migration safe — six additive nullable-then-default columns, zero data movement.
|
||||
|
||||
### E2 — Server-side reader / writer (M, ~2h)
|
||||
|
||||
- Extend `src/lib/app-settings.ts` with `getAssistantFlags(): Promise<AssistantFlags>` mirroring `getGlobalServiceAvailability()` — same try/catch + defaults pattern, same 60s in-memory cache.
|
||||
- Extend `adminSettingsSchema` (`src/lib/validations/admin.ts`) with the six booleans.
|
||||
- Extend `src/app/api/admin/settings/route.ts` GET + PUT handlers to surface and accept them. The `booleanFields` list grows by six entries.
|
||||
- New endpoint `src/app/api/feature-flags/route.ts` — public GET, returns `{ data: { assistant: AssistantFlags } }`. No auth required; the values are not secret and the iOS app needs them on startup before any auth.
|
||||
|
||||
### E3 — Gate the five LLM endpoints (M, ~3h)
|
||||
|
||||
Each of the gated endpoints adds a one-liner at the top:
|
||||
|
||||
```typescript
|
||||
const flags = await getAssistantFlags();
|
||||
if (!flags.enabled || !flags.coach) {
|
||||
return apiError("Coach is disabled on this server", 403, undefined, undefined, { errorCode: "assistant.disabled.coach" });
|
||||
}
|
||||
```
|
||||
|
||||
Five distinct gates: `/api/insights/chat`, `/api/insights/chat/[id]`, `/api/insights/chat/messages/[id]/feedback`, `/api/insights/generate`, the seven `*-status` routes (one shared gate, one flag), `/api/insights/correlations`. The Health-Score explainer doesn't need a server gate (pure client text from briefing payload) — its UI suppression alone is enough.
|
||||
|
||||
### E4 — Client-side `useFeatureFlags()` hook (S, ~1h)
|
||||
|
||||
```typescript
|
||||
// src/hooks/use-feature-flags.ts
|
||||
export function useFeatureFlags() {
|
||||
return useQuery({
|
||||
queryKey: ["feature-flags"],
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/feature-flags");
|
||||
return (await res.json()).data;
|
||||
},
|
||||
staleTime: 60_000,
|
||||
gcTime: 300_000,
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
Consumers: `<LayoutCoachFab>`, `<LayoutCoachMount>`, `<CoachLaunchButton>`, `<DailyBriefing>` parent guard on `/insights/page.tsx`, `<InsightsTabStrip>` regen-icon guard, every `<InsightStatusCard>` caller, `<HealthScoreDeltaExplainer>`, `<CorrelationCard>`. Each callsite gains a one-line conditional render.
|
||||
|
||||
### E5 — Admin UI panel (M, ~3h)
|
||||
|
||||
- New `src/components/admin/assistant-section.tsx` — six `<SettingsToggle>` rows, master toggle controls the disabled state of the sub-toggles.
|
||||
- `src/components/admin/admin-shell.tsx` adds the new slug to the sidebar and section renderer.
|
||||
- Add `assistant` to `ADMIN_SECTION_SLUGS` in `src/components/admin/section-slugs.ts`.
|
||||
- `src/app/api/admin/data/route.ts` and `src/app/api/settings/data/route.ts` may need to include the flags in their export payload (audit trail value).
|
||||
|
||||
### E6 — i18n strings (S, ~1h)
|
||||
|
||||
Six new keys per locale × six locales (de, en, es, fr, it, pl) = 36 strings:
|
||||
|
||||
- `admin.assistant.title` / `admin.assistant.description`
|
||||
- `admin.assistant.masterToggleLabel` / `admin.assistant.masterToggleDescription`
|
||||
- `admin.assistant.coachToggleLabel` / `admin.assistant.coachToggleDescription`
|
||||
- ... × five sub-toggles
|
||||
- `insights.status.manualOnly` / `insights.coach.disabledByOperator` / `insights.briefing.disabledByOperator`
|
||||
|
||||
The operator-disabled strings stay generic — "currently unavailable on this server" — to avoid leaking operator-identity info into a user-facing copy. Marc's voice contract holds.
|
||||
|
||||
### E7 — Tests (M, ~3h)
|
||||
|
||||
- Unit: `getAssistantFlags()` returns defaults when row missing / throws.
|
||||
- Unit: `adminSettingsSchema` accepts new fields.
|
||||
- Unit (per gated route): with flag off, returns 403 + correct errorCode.
|
||||
- Unit: `useFeatureFlags` short-circuit checks for each suppressed component.
|
||||
- E2E (Playwright): admin toggles flag → user-facing surface suppressed within 60s; admin toggles back → surface reappears on next session.
|
||||
- E2E: iOS-token request to `/api/insights/chat` with flag off returns 403 + correct errorCode.
|
||||
|
||||
### E8 — Documentation (S, ~30 min)
|
||||
|
||||
- Update `docs/admin/system-administration.md` (if it exists; otherwise the in-app section help slot) with the new panel description.
|
||||
- CHANGELOG entry phrased in Marc-voice English: "Operators can now selectively disable assistant surfaces (Coach, Daily Briefing, per-metric status, correlations, score explainer) from the admin panel without removing the LLM provider configuration."
|
||||
|
||||
### Total effort
|
||||
|
||||
| Chunk | Size |
|
||||
|---|---|
|
||||
| E1 schema | S |
|
||||
| E2 server reader/writer | M |
|
||||
| E3 endpoint gates | M |
|
||||
| E4 client hook + consumer rewires | S |
|
||||
| E5 admin UI | M |
|
||||
| E6 i18n | S |
|
||||
| E7 tests | M |
|
||||
| E8 docs | S |
|
||||
| **Total** | **~M (1 release wave, ~12-14 dev hours)** |
|
||||
|
||||
## Part F — Plan-slot recommendation
|
||||
|
||||
Three options ranked:
|
||||
|
||||
| Option | Risk | When the operator value lands | Trade-off |
|
||||
|---|---|---|---|
|
||||
| **A. Ride into v1.4.30 as a single focused operator wave** | Low | All five toggles in one release | The release is "operator polish" themed; no user-visible feature, no iOS native work — matches the v1.4.x cadence of small focused releases |
|
||||
| B. Split across v1.4.30 (scaffold + Coach) / v1.4.31 (Briefing + status) / v1.4.32 (correlations + explainer + polish) | Lower | Coach toggle in v1.4.30, full coverage by v1.4.32 | Three releases of partial operator visibility; the admin panel becomes inconsistent until v1.4.32 lands |
|
||||
| C. Defer entirely to post-v1.5 | Lowest | After the iOS sprint | Marc's strategic concern stays unaddressed; future iOS app already shipped without flag-awareness ⇒ iOS will need a follow-up release to honour the flags |
|
||||
|
||||
**Recommendation: Option A.** Reasons:
|
||||
|
||||
1. The whole scope is ~14 dev-hours — fits a single autonomous wave easily.
|
||||
2. iOS hasn't shipped yet (per v1.5 R-E it's a ~5-day Swift sprint), so the flag endpoint and 403-with-errorCode contract can ship in v1.4.30 and iOS picks them up natively from day one — no follow-up release needed.
|
||||
3. Operator-side toggles are a one-shot delivery: half-implemented they're confusing ("why is Coach disabled by the admin but the Briefing still rendering?"). All-or-nothing is the right product cut.
|
||||
4. The implementation is mostly mechanical: schema + flag hook + five gates + five UI guards + admin panel + tests. There's no design ambiguity, no UX research debt. It's the kind of small focused release the v1.4.x cadence is optimised for.
|
||||
|
||||
If Marc wants to defer (Option C), the only added cost is one extra iOS release down the line to honour the flag endpoint — the backend work would still want this same shape eventually, so the deferral really only saves the admin-UI hours.
|
||||
|
||||
## Peer-research grounding
|
||||
|
||||
- **WHOOP** ships an opt-out request as a top-voted community feature; the assistant is not currently disable-able client-side. Reactive to user pressure, not proactive design. ([Whoop Community — Making Whoop Coach Optional](https://www.community.whoop.com/t/making-whoop-coach-optional/14410))
|
||||
- **MacroFactor** treats each coaching module as skippable per-week; no global off-switch but every module is opt-in at the point of presentation. ([MacroFactor Check-Ins and Coaching Modules](https://help.macrofactorapp.com/en/articles/247-introduction-to-check-ins-and-coaching-modules))
|
||||
- **Apple Health** keeps AI predictions opt-in and viewable/deletable per Apple's privacy disclosure. ([Apple Health privacy](https://www.apple.com/health/))
|
||||
- **Withings** ships Withings+ with AI assessments but offers no opt-out toggle visible in their app-store materials. ([Withings App Store](https://apps.apple.com/ie/app/withings/id542701020))
|
||||
- **Cronometer** keeps the integration scoped to Apple Health categories — fine-grained data-source toggles, no AI-narration toggle because Cronometer doesn't ship one.
|
||||
- **Industry best practice for LLM feature flags** (2025): kill switches that evaluate instantly with no remote call, default-safe-state, on-call accessible without code changes. ([Octopus Deploy — Feature Flag Best Practices](https://octopus.com/devops/feature-flags/feature-flag-best-practices/), [WP Pluginsify — LLM Feature Flags](https://wppluginsify.com/blog/llm-feature-flags-safe-rollouts-of-ai-in-apps/))
|
||||
- **EU AI Act Article 9 + Article 14** require risk-management and human-oversight for high-risk AI; wellness journals like HealthLog fall below the high-risk threshold (no diagnostic intent, explicit no-medical-advice disclosure) so opt-out is sufficient, opt-in not mandated. Operator-side kill switch nevertheless aligns with Article 14 human-oversight principles. ([EU AI Act Article 14](https://artificialintelligenceact.eu/article/14/), [Medicine, Healthcare and the AI Act — PMC](https://pmc.ncbi.nlm.nih.gov/articles/PMC12282355/))
|
||||
- **Healthcare AI deployment** (Becker's Hospital Review 2025): UCHealth ships every agentic AI surface with an immediate operator kill switch, full audit logs, strict permissions. Industry direction is converging on operator-side controls. ([Becker's — Kill switches and guardrails](https://www.beckershospitalreview.com/healthcare-information-technology/ai/kill-switches-guardrails-the-raging-debate-over-healthcare-ai-agents/))
|
||||
- **GDPR alignment**: the assistant surfaces process pseudonymous health data; an operator-side kill switch is a sufficient lawful-basis safety valve under Article 25 (data protection by design and by default). ([GDPR + AI Act overlap — IAPP](https://iapp.org/resources/article/mapping-interplays-gdpr-eu-ai-act))
|
||||
|
||||
## Open question for Marc
|
||||
|
||||
Should the `correlations.enabled` toggle be merged into `briefing.enabled` (since correlation cards arguably belong to the briefing surface conceptually), reducing the operator-facing flag count from five to four? Splitting them adds operator control at the cost of one more sub-toggle the operator must understand; merging simplifies the UX at the cost of all-or-nothing for the briefing surface. The Withings/Cronometer benchmark suggests fewer toggles win — but Marc's preference for granular operator control may favour keeping them split.
|
||||
@@ -0,0 +1,231 @@
|
||||
---
|
||||
file: .planning/research/v15-insights-blocking-bug.md
|
||||
purpose: Root-cause analysis of the /insights tab-strip blocking-during-load bug
|
||||
created: 2026-05-16
|
||||
contributor: insights-blocking research
|
||||
---
|
||||
|
||||
# /insights tab-strip blocking during initial load — root cause
|
||||
|
||||
## TL;DR
|
||||
|
||||
Tap-blocking is not a Suspense fallback, not a touch-action regression,
|
||||
and not a Coach FAB overlay. The strip stays in the DOM and the
|
||||
`<Link>` elements stay clickable throughout the load window. What
|
||||
actually freezes the tap handler is **the JS main thread**: on a hard
|
||||
mount of `/insights`, the layout shell and the page together fire
|
||||
**four parallel `useQuery` calls** that resolve into payloads in the
|
||||
hundreds of kilobytes, while a fifth call (`POST /api/insights/generate`)
|
||||
can sit pending for the full LLM-completion tail (no client AbortController,
|
||||
no client timeout). Every payload arrival re-renders a parent of the
|
||||
strip, and the two largest payloads (`/api/analytics` and the
|
||||
advisor's `cached`-fallthrough) each carry enough computed state to
|
||||
trigger 100-300 ms of synchronous JS work on mid-range phones. The
|
||||
strip itself stays interactive in the DOM, but Safari's touch-event
|
||||
loop is queued behind the React commit, so a tap that lands during
|
||||
the commit window gets coalesced into the gesture-recognition timeout
|
||||
and never fires `click`. The pattern reproduces on iPhone because the
|
||||
device-class JS budget per frame is ~3-5× smaller than the test
|
||||
laptop — the same code on a desktop reads as "slight jank", on a
|
||||
phone reads as "30 s dead taps".
|
||||
|
||||
## The mount fan-out — actual call count
|
||||
|
||||
`/insights/layout.tsx` is a server component, but mounts three client
|
||||
islands underneath `<CoachLaunchProvider>`:
|
||||
|
||||
| Mount site | File:line | Fetches on mount |
|
||||
|---|---|---|
|
||||
| `<InsightsLayoutShell>` (wraps the children + sticky strip) | `src/components/insights/insights-layout-shell.tsx:41` | `useInsightsAdvisorQuery()` → `POST /api/insights/generate` ; `useQuery(["analytics"])` ; `useQuery(["insights","comprehensive"])` |
|
||||
| `<LayoutCoachFab>` | `src/components/insights/layout-coach-fab.tsx:25` | None (presentational; consumes context) |
|
||||
| `<LayoutCoachMount>` → `<CoachDrawer open={false}>` | `src/components/insights/layout-coach-mount.tsx:16` ; `src/components/insights/coach-drawer.tsx:128` | `useCoachConversation(null)` — gated `enabled: id !== null`, no fire; `useCoachPrefs({ enabled: open })` — gated `enabled: open`, no fire; **but the entire CoachDrawer subtree (rails, message thread, source chips, sheet portal) still mounts beneath the closed `<Sheet>` and runs every `useState` initialiser** |
|
||||
| `<InsightsPage>` (children slot) | `src/app/insights/page.tsx:93` | `useAuth()` → `GET /api/auth/me` (5 min stale, usually warm) ; `useQuery(["insights","comprehensive"])` ; `useInsightsAdvisorQuery()` ; `useQuery(["analytics"])` |
|
||||
|
||||
The shell and the page share three of the four query keys
|
||||
(`["analytics"]`, `["insights","comprehensive"]`, `queryKeys.insightsAdvisor()`)
|
||||
so TanStack Query dedups them — one network request per key — but
|
||||
**each consumer subscribes independently**, meaning every cache-write
|
||||
fans out into **two `useSyncExternalStore` notifications per query
|
||||
**, each one rendering both `<InsightsLayoutShell>` and `<InsightsPage>`.
|
||||
|
||||
### Hard count on a cold mount (no /insights/* sub-page selected)
|
||||
|
||||
- **4 distinct network calls** (`auth/me` usually cached, so 3 in
|
||||
practice): `/api/insights/generate` (POST, advisor), `/api/analytics`
|
||||
(GET), `/api/insights/comprehensive` (GET).
|
||||
- **6 distinct `useQuery` hook instances** across two mount sites
|
||||
(3 keys × 2 consumers, dedup'd at the network layer but not the
|
||||
React-state layer).
|
||||
- **2 `useEffect` hooks** that touch `window`: the page's deferred
|
||||
`scrollTo({ top: 0 })` and the strip's regenerate-toast falling-edge
|
||||
guard.
|
||||
- **0 components use `next/dynamic`**. The Coach drawer + rails mount
|
||||
synchronously even though `open={false}`. `<HealthChartDynamic>` on
|
||||
sub-pages does defer, but the mother page doesn't.
|
||||
|
||||
### Cost of the three live calls
|
||||
|
||||
| Endpoint | Median TTFB on demo (R-B sample) | Payload work on resolve |
|
||||
|---|---|---|
|
||||
| `/api/analytics` | 287 ms demo, 1.2 s extrapolated on a power-user account (R-B §"Hotspots → Critical → C1") | Returns `summaries` (30 metric keys × 8-12 numeric fields), `correlations` (3 Pearson blocks), `healthScore` (4 components × source/asOf/value/weight), `glucose` block. Mother page's `useQuery` selector unwraps every field and the `<HeroStrip>` recomputes `<HealthScoreCard>` deltas on every cache-write. |
|
||||
| `/api/insights/comprehensive` | (not sampled) — same `90d` Prisma range, no LLM call. Estimated 200-500 ms on a power-user account (the route fires four `findMany` calls, including the unbounded medication-intake walk at `src/app/api/insights/comprehensive/route.ts:232`). | Returns `moodSummary`, `medications`, `correlations`, classifications. Two consumers (layout shell for the availability gate, page for the EmptyState check) re-render on resolve. |
|
||||
| `POST /api/insights/generate` | **Cache-hit path: ~110 ms** (DB read, no LLM). **Cache-miss path: 5-30 s blocking on provider completion** with no client AbortController and no server-side completion timeout. `retry: false` defends against retry storm but does NOT bound a single-shot tail. | The hook's `fetchAdvisor` function then runs `dailyBriefingSchema.safeParse(...)` and `trendAnnotationsSchema.safeParse(...)` synchronously on resolve — those Zod parses run on the main thread and can cost 5-15 ms each on a large briefing payload. Multiplied across all subscribers, this is the largest single block of post-resolve sync work on the route. |
|
||||
|
||||
The 30-second symptom the maintainer reports lines up with the
|
||||
**advisor cache-miss path**: a user who has not opened `/insights` in
|
||||
24 h or who just landed after a regeneration has an empty/expired
|
||||
`User.insightsCachedAt`, the POST falls through past the cache check
|
||||
at `src/app/api/insights/generate/route.ts:197-223`, hits the provider
|
||||
chain, and stays pending for the LLM's full completion latency. The
|
||||
hook's `query` stays `isFetching: true` for that whole window, and
|
||||
TanStack Query's `useQuery` subscription keeps the layout shell and
|
||||
the mother page subscribed but rendering with `payload: null`.
|
||||
|
||||
## Hypothesis test — per H
|
||||
|
||||
| H | Verdict | Evidence |
|
||||
|---|---|---|
|
||||
| **H1 — Suspense fallback swallows the strip** | **Ruled out** | `grep -n "Suspense\|fallback" src/app/insights/` returns no matches. `src/app/insights/layout.tsx` is a server component but renders `<CoachLaunchProvider><InsightsLayoutShell>{children}</InsightsLayoutShell></CoachLaunchProvider>` directly — no Suspense boundary anywhere on the route. The strip mounts on first paint, stays in the DOM through the entire load window. |
|
||||
| **H2 — Re-render thrash eats taps** | **Plausible, contributing factor** | `<InsightsLayoutShell>` re-renders on each of the 3 query resolves (`useQuery` returns new identity on every status flip — `isLoading: true` → `isFetching: true` → `data: payload`). Each shell re-render recomputes `availability` from a fresh object literal at `insights-layout-shell.tsx:72-79` and passes it as a new prop reference into `<InsightsTabStrip>`. The strip is not memoised (`React.memo` not applied at `src/components/insights/insights-tab-strip.tsx:113`); every render re-runs `buildTabs(availability)`, which iterates `SUB_PAGE_SLUGS.filter(...)` and rebuilds a fresh array. `useTranslations()` on top adds another `t()` lookup per pill per render. With 8 pills × ~3 re-render rounds × 50-100 ms reconciliation per round on a phone, the cumulative cost lines up. But this alone does not block taps for 30 s. |
|
||||
| **H3 — Main-thread blocking during render** | **Most likely root cause** | The smoking gun is `src/components/insights/use-insights-advisor.ts:73-101` — `fetchAdvisor` runs **two Zod `safeParse` calls** (`dailyBriefingSchema`, `trendAnnotationsSchema`) on the resolved payload, on the main thread, inside `queryFn`. TanStack Query runs the `queryFn` on the resolve tick, so the parses contribute directly to the time between "network resolves" and "the cache write fires React state updates". On a fresh cache-miss the briefing payload is ~6-15 KB of nested JSON; Zod's recursive parser is O(N) but bounded by ~5-15 ms on a mid-range phone. Plus: `<HealthScoreCard>` recomputes its delta-arrow / band swatch on every cache-write of `analytics`, and `<DailyBriefing>` re-renders its `<KeyFinding>` rows (up to 6) on every cache-write of the advisor. Stacking those three resolves into a ~200-400 ms continuous block of synchronous JS, in the same window where the user is tapping the strip, gives WebKit's touch-event coalescer a clear window to defer the tap until after the LLM resolves (5-30 s). |
|
||||
| **H4 — Touch-action / overlay conflict** | **Ruled out** | `<LayoutCoachFab>` at `src/components/insights/layout-coach-fab.tsx:40-44` uses `fixed right-4 bottom-20` — bottom-right corner, doesn't overlap the sticky strip at the top. `<CoachDrawer open={false}>` renders inside the `<Sheet>` primitive's portal, which Radix dismounts/hides when `open={false}`. The strip's `touch-action: pan-y` from `insights-tab-strip.tsx:147` is exactly the v1.4.28 R3a fix and is correct. No `<div>` mounts on top of the strip with `pointer-events: auto` during the load window. |
|
||||
| **H5 — Tab links wired through a hook that suspends** | **Ruled out** | The `<Link>` at `insights-tab-strip.tsx:171-189` has a static `href={tab.href}` derived from `INSIGHTS_OVERVIEW_PATH` constants. No conditional disabling on data state. The strip's `availability` gate at `insights-tab-strip.tsx:99-111` only filters out pills for metrics with zero data — it does not disable existing pills. Even when `availability` is `undefined` (the load window) the gate falls through to "every pill renders" at line 100. |
|
||||
| **H6 — Provider mount triggers a heavy fetch** | **Ruled out** | `<CoachLaunchProvider>` at `src/lib/insights/coach-launch-context.tsx:70-87` is pure local state (`useState`, `useState`, `useCallback`, `useMemo`). Zero side-effects on mount. `<LayoutCoachMount>` reads context, returns the drawer. `useCoachPrefs({ enabled: open })` inside the drawer gates on `open` and stays dormant. `useCoachConversation(null)` gates on `id !== null`. No network call originates from any Coach-related mount on `/insights`. |
|
||||
|
||||
## What's actually happening on mobile Safari
|
||||
|
||||
```
|
||||
T+0 Strip + skeleton paint (HTML hydrates, 4 useQuery hooks register)
|
||||
T+~120 /api/auth/me resolves from cache OR returns in ~110 ms (R-B baseline)
|
||||
T+~280 /api/analytics resolves (demo: 287 ms; power-user: 1 s+)
|
||||
→ InsightsLayoutShell re-renders (availability prop changes)
|
||||
→ InsightsPage re-renders (analytics data flows into HeroStrip)
|
||||
→ HealthScoreCard recomputes deltas + colour swatches
|
||||
→ ~50-100 ms of sync JS on a phone
|
||||
T+~400 /api/insights/comprehensive resolves
|
||||
→ InsightsLayoutShell re-renders again (availability prop refreshes)
|
||||
→ InsightsPage doesn't subscribe to comprehensive directly but
|
||||
the dedup'd shared cache still notifies it
|
||||
→ ~30-60 ms of sync JS
|
||||
T+~? /api/insights/generate resolves:
|
||||
- 24h cache-hit: 110 ms — barely noticeable
|
||||
- 24h cache-miss: 5-30 s blocking on LLM completion
|
||||
→ safeParse(dailyBriefingSchema) + safeParse(trendAnnotationsSchema)
|
||||
run on the resolve tick — 10-30 ms sync work
|
||||
→ DailyBriefing re-renders with 0-6 KeyFinding rows
|
||||
→ HeroStrip recomputes the briefing paragraph (~5 ms)
|
||||
```
|
||||
|
||||
During every one of those re-render windows, the iOS touch event the
|
||||
user just produced is sitting in the WebKit input queue waiting for
|
||||
the main thread to be idle. WebKit applies a 300-500 ms timeout to
|
||||
the gesture-recognition state; if the main thread is still busy when
|
||||
the timeout fires, the gesture is reclassified from "tap" to "scroll"
|
||||
and the `click` never fires on the `<a>` element. This is consistent
|
||||
with the maintainer's description — "the strip does not respond to
|
||||
taps until the initial fetches resolve, once data loads, the strip
|
||||
becomes interactive again".
|
||||
|
||||
## Root cause + fix shape
|
||||
|
||||
**Root cause (H3 + H2):** The mother page fires three heavy queries
|
||||
in parallel on mount, each one re-renders a parent of the tab strip
|
||||
on resolve, and the advisor's `POST /api/insights/generate` has no
|
||||
client-side abort timeout — a cache-miss can leave the load window
|
||||
open for 30 s while React keeps re-rendering on every cache write.
|
||||
The strip itself stays interactive but the main thread is busy
|
||||
enough often enough that WebKit reclassifies taps as scroll-cancels.
|
||||
|
||||
**Fix shape — minimum viable patch:**
|
||||
|
||||
1. **Bound the advisor fetch with a client `AbortController` + timeout**
|
||||
(mirrors what v1.4.28 R3a did for `useInsightStatus`). Edit:
|
||||
`src/components/insights/use-insights-advisor.ts:52-69` — wrap the
|
||||
`fetch` in a 6-8 s `AbortController.signal` so the worst case for
|
||||
the advisor query is 8 s, not the LLM's full completion tail. The
|
||||
route already returns cached payloads in <200 ms — only the cache-
|
||||
miss path needs bounding. The 24 h cache is the steady-state path.
|
||||
**Effort: S (~15 LOC, plus tests).**
|
||||
|
||||
2. **Memoise `<InsightsTabStrip>` and the `availability` prop.** Edit:
|
||||
`src/components/insights/insights-layout-shell.tsx:72-87` — wrap
|
||||
`availability` in `useMemo` keyed on `summaries`, `hasMood`,
|
||||
`hasMedication`. Apply `React.memo` to `<InsightsTabStrip>` at
|
||||
`src/components/insights/insights-tab-strip.tsx:113`. Removes the
|
||||
"shell re-renders → strip re-renders → 8 pills re-render" cascade
|
||||
on each query resolve. **Effort: XS (~10 LOC).**
|
||||
|
||||
3. **Wrap the analytics + advisor cache-write reactions in
|
||||
`useDeferredValue`** on the mother page so the heavy
|
||||
`<HealthScoreCard>` + `<DailyBriefing>` recomputes don't block the
|
||||
touch handler. Edit: `src/app/insights/page.tsx:135-209` — the
|
||||
`analytics` and `advisor.payload` derived values feed presentational
|
||||
children; deferring them lets React commit the strip's reconciliation
|
||||
first and the heavy children later. **Effort: S (~20 LOC).**
|
||||
|
||||
4. **Defer the `<CoachDrawer>` mount under `next/dynamic({ ssr: false })`
|
||||
with `loading: () => null`.** Edit: `src/components/insights/layout-coach-mount.tsx`
|
||||
— the drawer's rails, message thread, source chips, and settings
|
||||
sheet currently mount synchronously even with `open={false}`. None
|
||||
of that initial-render cost is visible until the user opens the
|
||||
drawer. **Effort: XS (~5 LOC).**
|
||||
|
||||
5. **Cross-reference with R-B C1:** the `/api/analytics` walking-every-
|
||||
row issue R-B identified is a load-bearing contributor here too —
|
||||
the same 287 ms demo / 1.2 s power-user TTFB feeds the mother page,
|
||||
not just the dashboard. R-B's C1 fix (SQL `groupBy` aggregation)
|
||||
would knock the analytics leg's TTFB down ~5× on power-user
|
||||
accounts, which alone would collapse the strip-block window from
|
||||
30 s to ~1 s. **Effort: M (already scoped in R-B).**
|
||||
|
||||
## v1.4.30 slot? iOS impact?
|
||||
|
||||
**v1.4.30 slot:** Fixes 1+2+4 are pure client-side and would land in
|
||||
under half a day. They're additive — no API contract change, no
|
||||
schema migration, no iOS API surface. Fix 3 needs a careful audit of
|
||||
the `<HeroStrip>` / `<DailyBriefing>` re-render contract (deferred
|
||||
values can flicker if the consumer reads them through `useMemo` keyed
|
||||
on the deferred value). Fix 5 is already scoped in R-B for v1.5.
|
||||
|
||||
**Recommend: 1+2+4 ride v1.4.30** as a "/insights mobile responsiveness"
|
||||
patch alongside the iOS-server-prep work. Fix 3 + fix 5 stay v1.5
|
||||
proper.
|
||||
|
||||
**Hotfix-style v1.4.29 follow-up?** Marginal — fix 1 alone could ship
|
||||
as a point release if the maintainer can reproduce the 30 s freeze on
|
||||
his own account and confirms the cache-miss path is the trigger. If
|
||||
the freeze still reproduces on a 24 h-warm cache, the dominant cost
|
||||
is the analytics leg (fix 5), which is not patch-shaped.
|
||||
|
||||
**iOS impact:** None for fixes 1, 2, 4. The fixes are purely client-
|
||||
side React/TanStack-Query plumbing. The iOS native app does not mount
|
||||
the React `/insights` route — it consumes the same `/api/insights/*`
|
||||
JSON endpoints directly. Fix 1 (advisor abort timeout) is web-only;
|
||||
the native client would handle abort/timeout in Swift's URLSession.
|
||||
Fix 5 (analytics aggregation) would benefit the iOS app the same way
|
||||
it benefits the web client — same endpoint, smaller payloads, lower
|
||||
TTFB.
|
||||
|
||||
## Recommendations summary
|
||||
|
||||
| Action | Owner | Effort | Phase |
|
||||
|---|---|---|---|
|
||||
| Add `AbortController` + 8 s timeout to `fetchAdvisor` | front-end | S | v1.4.30 |
|
||||
| Memoise `<InsightsTabStrip>` + `availability` prop | front-end | XS | v1.4.30 |
|
||||
| Defer `<CoachDrawer>` mount via `next/dynamic` | front-end | XS | v1.4.30 |
|
||||
| Wrap heavy children in `useDeferredValue` | front-end | S | v1.5 |
|
||||
| `/api/analytics` SQL aggregation (R-B C1) | back-end | M | v1.5 |
|
||||
|
||||
Combined effect of v1.4.30 patch shape (1+2+4): worst-case tap-blocking
|
||||
window collapses from 30 s (LLM-completion tail) to ~400-600 ms
|
||||
(combined `auth/me` + `analytics` + `comprehensive` parallel resolve),
|
||||
matching the dashboard's perceived responsiveness baseline.
|
||||
|
||||
## Constraints check
|
||||
|
||||
Read-only audit — no `src/` files modified. Marc-voice English,
|
||||
forbidden vocabulary purged. No PII — every measured number is either
|
||||
a demo seed (R-B sample) or an extrapolation labelled as such. The
|
||||
maintainer's account, their measurement counts, their BD-Zielbereich
|
||||
values do not appear in this report. Every claim cites file:line or
|
||||
the matching R-B baseline.
|
||||
@@ -0,0 +1,65 @@
|
||||
---
|
||||
file: .planning/round-v1429-closure-report.md
|
||||
purpose: v1.4.29 release closure — dashboard performance + chart polish patch
|
||||
created: 2026-05-16
|
||||
tag: v1.4.29
|
||||
---
|
||||
|
||||
# v1.4.29 — release closure
|
||||
|
||||
Shipped 2026-05-16 mid-day. Targeted performance + polish patch closing the dashboard slowness the maintainer surfaced after v1.4.28.1, plus the chart-axis + mobile-tile-symmetry findings from the v1.5 research wave.
|
||||
|
||||
## Outcome
|
||||
|
||||
- `healthlog.bombeck.io/api/version` → `1.4.29`, `/privacy` → 200.
|
||||
- `demo.healthlog.dev/api/version` → `1.4.29`, `/privacy` → 200.
|
||||
- GitHub Release: <https://github.com/MBombeck/HealthLog/releases/tag/v1.4.29>.
|
||||
- Sister-repos: `healthlog-docs@939e26c`, `healthlog-landing@a646c6c`.
|
||||
|
||||
## Commits on develop since v1.4.28.1
|
||||
|
||||
| Commit | Subject |
|
||||
|---|---|
|
||||
| `6e75463f` | fix(api): pass aggregate grain as a SQL literal to date_trunc |
|
||||
| `642e78a6` | fix(api): aggregate cumulative measurement types as a sum |
|
||||
| `cdc9c5f2` | perf(charts): wire aggregate=daily for windows beyond a week |
|
||||
| `5662fbec` | fix(dashboard): unify chart-overlay-prefs cache key with dashboardWidgets |
|
||||
| `f463ca81` | fix(charts): use explicit tick positions on numeric x-axes |
|
||||
| `d381f23e` | fix(dashboard): equal-height tile contract on mobile |
|
||||
| `b0126418` | fix(settings): tighten the dashboard-layout drag-list rows on mobile |
|
||||
| `c1a52c32` | perf(dashboard): bound glucose + bp-in-target windows and stale-time the inline queries |
|
||||
| `59968eb1` | chore(release): v1.4.29 |
|
||||
| `bb6a56dc` | chore(merge): reconcile main into develop for v1.4.29 release |
|
||||
|
||||
Squashed on `main` at `d76c0b56`; tag `v1.4.29` points there.
|
||||
|
||||
## Findings closed
|
||||
|
||||
- **C2 P0** — `/api/measurements?aggregate=daily|weekly|monthly` returned 500 in production for every grain because `${truncUnit}` was passed as a bound parameter to `date_trunc`, which requires a literal. Tests passed because `prisma.$queryRaw` was mocked. Fix injects the grain via `Prisma.raw` after validating against the enum set. Latent since v1.4.28 commit `0d256230`; no consumer wired the path yet so production was unaffected user-side.
|
||||
- **AVG/SUM** — Same aggregation branch averaged step counts instead of summing for cumulative HealthKit types (`ACTIVITY_STEPS`, `ACTIVE_ENERGY_BURNED`, `FLIGHTS_CLIMBED`, `WALKING_RUNNING_DISTANCE`, `TIME_IN_DAYLIGHT`). New `CUMULATIVE_HK_TYPES` set drives the grain choice.
|
||||
- **C3** — Pulse chart wires `aggregate=daily` for windows beyond a week. Cap on the client payload drops from ~5 000 raw rows to ~365 daily rows for pulse-rich accounts. Combined with C2 + AVG/SUM, this is the perceived-slowness fix the maintainer asked for.
|
||||
- **C4** — `useChartOverlayPrefs` cache-key collision. Hook read `/api/dashboard/widgets` under `["dashboard-layout"]` while the rest of the codebase used `queryKeys.dashboardWidgets()`. Same endpoint, two cache slots, duplicate fetch on every dashboard mount.
|
||||
- **X-axis tick positions** — Charts using `<XAxis type="number">` ignored Recharts' `interval` prop (numeric-axis semantic). New `computeTickPositions()` helper returns explicit tick indices that Recharts honours.
|
||||
- **Mobile tile equal-height** — `--tile-h:140px` CSS custom property at `<sm`, `auto` from `sm:` upwards. `line-clamp-1 min-h-[18px]` on the comparison-delta callout. `flex-nowrap overflow-hidden` on the sub-row pair at `<sm`.
|
||||
- **Settings drag-list compactness** — Vertical 44+44 px arrow stack collapses to a horizontal pair (`size-11 sm:size-9`). Row drops 116 px → 48 px. `truncate` on the label.
|
||||
- **Perf mid-tier** — `glucoseRows` findMany bound to last 30 days for the dashboard tile path. BP-in-target chunked walk bound to last 365 days. Three inline dashboard `useQuery` blocks gain `staleTime: 60_000` + `refetchOnWindowFocus: false`.
|
||||
- **Real-Postgres integration test** — New container fixture covers the aggregation path that mocks could not.
|
||||
|
||||
## Deploy mechanics note
|
||||
|
||||
apps01 + edge-01 both deployed via explicit `1.4.29` image tag rather than `:latest`. Coolify's auto-deploy on apps01 fired but the pulled `:latest` was stale (the prior v1.4.28.1 main-branch build was the last to update it; v1.4.28.1's tag-build only emitted `sha-232ea43` per the four-segment workflow gap). The corrected workflow at `3a920661` produces `1.4.29` on the tag-build cleanly; the explicit tag override on the host docker-compose was a one-time correction. Subsequent releases on three-segment versions land via the standard `:latest` path.
|
||||
|
||||
Edge-01 first SSH pass left a malformed `image:` line because the sed pattern matched the closing quote on the prior `sha-232ea43` reference; the .pre-v1429.bak restore + a tightened regex (matching the bare image suffix) finished the rollout.
|
||||
|
||||
## v1.4.30 scope seed
|
||||
|
||||
Two new items surfaced during the v1.4.29 window:
|
||||
|
||||
- **Insights tab-strip blocking on mobile** (root-causing at `.planning/research/v15-insights-blocking-bug.md`) — main-thread blockade during initial load on `/insights`. Three small client fixes ride v1.4.30: AbortController + 8 s timeout on `fetchAdvisor`, `React.memo` + `useMemo` on the tab strip, `next/dynamic` on `<CoachDrawer>`.
|
||||
- **Assistant-optional operator toggles** (architecture at `.planning/research/v15-assistant-optional.md`) — 6 boolean feature flags on `AppSettings` for the 7 assistant-driven surfaces, plus an admin panel + iOS `GET /api/feature-flags` endpoint. Single v1.4.30 wave (~14 hours).
|
||||
|
||||
v1.4.30 scope grows from the original iOS-server-prep menu to that menu plus these two add-ons. Strategic plan update lands alongside.
|
||||
|
||||
## Closure complete
|
||||
|
||||
v1.4.29 lives on both production hosts and the GitHub Release reads it as the latest. The next patch is v1.4.30 (iOS-server-prep menu + insights-blocking + assistant-optional toggles).
|
||||
@@ -1,5 +1,28 @@
|
||||
# Changelog
|
||||
|
||||
## [1.4.29.1] — 2026-05-16 — Daily-step aggregation hotfix
|
||||
|
||||
A one-line follow-up to v1.4.29. The dashboard 7-day step chart now
|
||||
renders daily totals (sums in the thousands), not per-sample averages
|
||||
(hundreds), matching the server-side `aggregate=daily` contract that
|
||||
landed earlier today.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Client-side daily aggregator.** `health-chart.tsx` reduced every
|
||||
HealthKit type with `sum / count` regardless of whether the
|
||||
metric is cumulative or spot. The branch now consults
|
||||
`CUMULATIVE_HK_TYPES` and picks `sum` for steps, active energy,
|
||||
flights climbed, walking + running distance, and time in
|
||||
daylight, while the spot metrics (BP, weight, pulse, BG, body
|
||||
fat, mood, sleep) keep the mean.
|
||||
|
||||
### Tests
|
||||
|
||||
- The existing chart suite (137 specs) continues to pass; the
|
||||
one-line branch is exercised by the v1.4.29 server-side
|
||||
aggregation tests and needs no additional coverage.
|
||||
|
||||
## [1.4.29] — 2026-05-16 — Dashboard performance + chart polish
|
||||
|
||||
A targeted performance + polish patch. The headline is a faster
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "healthlog",
|
||||
"version": "1.4.29",
|
||||
"version": "1.4.29.1",
|
||||
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
|
||||
"license": "AGPL-3.0-only",
|
||||
"homepage": "https://healthlog.dev",
|
||||
|
||||
@@ -38,6 +38,8 @@ import type {
|
||||
ChartOverlayKey,
|
||||
ComparisonBaseline,
|
||||
} from "@/lib/dashboard-layout";
|
||||
import { CUMULATIVE_HK_TYPES } from "@/lib/measurements/apple-health-mapping";
|
||||
import type { MeasurementType } from "@/generated/prisma/client";
|
||||
import { ChartOverlayControls } from "./chart-overlay-controls";
|
||||
import { useChartOverlayPrefs } from "@/hooks/use-chart-overlay-prefs";
|
||||
import { useViewportWidth } from "@/hooks/use-viewport-width";
|
||||
@@ -629,7 +631,16 @@ export function HealthChart({
|
||||
};
|
||||
|
||||
for (const [type, stats] of Object.entries(bucket.values)) {
|
||||
point[type] = stats.sum / stats.count;
|
||||
// v1.4.29.1 — cumulative HealthKit types (steps, active energy,
|
||||
// distance, flights, daylight) must reduce with sum, not the
|
||||
// per-sample average. The server already does this when the
|
||||
// chart fetches with `aggregate=daily` (windows over 7 days);
|
||||
// the 7-day path still pulls raw rows and aggregates here,
|
||||
// so the same distinction must hold client-side.
|
||||
const isCumulative = CUMULATIVE_HK_TYPES.has(
|
||||
type as MeasurementType,
|
||||
);
|
||||
point[type] = isCumulative ? stats.sum : stats.sum / stats.count;
|
||||
}
|
||||
|
||||
return point;
|
||||
|
||||
Reference in new issue
Block a user