Merge v189/fresh into release/v1.18.9

Invalidate the dashboard snapshot on measurement writes and refetch on focus so
fresh and background-synced readings surface without a hard reload; expose the
no-provider state on the briefing read with a connect-a-provider hint; align the
briefing footer caption.
This commit is contained in:
Marc-André Bombeck committed 2026-06-20 12:01:20 +02:00
commit b0e6079858
16 files changed
+242 -27

No files matched your search

+2 -1
View File
@@ -2010,7 +2010,8 @@
"loadingLabel": "Briefing wird zusammengestellt",
"noProviderTitle": "Kein KI-Anbieter verbunden",
"noProviderDescription": "Das Tagesbriefing fasst deine Werte in Klartext zusammen. Dafür braucht HealthLog einen KI-Anbieter — nutze deinen eigenen Schlüssel (OpenAI, Anthropic) oder ein lokales Modell. Deine Daten bleiben dabei bei dir.",
"noProviderAction": "Zu den KI-Einstellungen"
"noProviderAction": "Zu den KI-Einstellungen",
"staleNoProviderHint": "Dieses Briefing kann nicht aktualisiert werden — es ist kein KI-Anbieter verbunden."
},
"trendsRow": {
"title": "Trends",
+2 -1
View File
@@ -2010,7 +2010,8 @@
"loadingLabel": "Synthesising today's briefing",
"noProviderTitle": "No AI provider connected",
"noProviderDescription": "The daily briefing turns your readings into plain-language summary. HealthLog needs an AI provider for that — use your own key (OpenAI, Anthropic) or a local model. Your data stays with you.",
"noProviderAction": "Open AI settings"
"noProviderAction": "Open AI settings",
"staleNoProviderHint": "This briefing can't refresh — no AI provider is connected."
},
"trendsRow": {
"title": "Trends",
+2 -1
View File
@@ -2010,7 +2010,8 @@
"loadingLabel": "Preparando el resumen",
"noProviderTitle": "Ningún proveedor de IA conectado",
"noProviderDescription": "El briefing diario resume tus lecturas en lenguaje claro. HealthLog necesita un proveedor de IA para ello: usa tu propia clave (OpenAI, Anthropic) o un modelo local. Tus datos siguen siendo tuyos.",
"noProviderAction": "Abrir ajustes de IA"
"noProviderAction": "Abrir ajustes de IA",
"staleNoProviderHint": "Este briefing no puede actualizarse: no hay ningún proveedor de IA conectado."
},
"trendsRow": {
"title": "Tendencias",
+2 -1
View File
@@ -2010,7 +2010,8 @@
"loadingLabel": "Préparation du briefing",
"noProviderTitle": "Aucun fournisseur d'IA connecté",
"noProviderDescription": "Le briefing quotidien résume tes mesures en langage clair. HealthLog a besoin d'un fournisseur d'IA pour cela : utilise ta propre clé (OpenAI, Anthropic) ou un modèle local. Tes données restent chez toi.",
"noProviderAction": "Ouvrir les réglages IA"
"noProviderAction": "Ouvrir les réglages IA",
"staleNoProviderHint": "Ce briefing ne peut pas être actualisé — aucun fournisseur d'IA n'est connecté."
},
"trendsRow": {
"title": "Tendances",
+2 -1
View File
@@ -2010,7 +2010,8 @@
"loadingLabel": "Preparazione del briefing",
"noProviderTitle": "Nessun provider IA collegato",
"noProviderDescription": "Il briefing giornaliero riassume le tue misurazioni in linguaggio chiaro. HealthLog ha bisogno di un provider IA per questo: usa la tua chiave (OpenAI, Anthropic) o un modello locale. I tuoi dati restano tuoi.",
"noProviderAction": "Apri impostazioni IA"
"noProviderAction": "Apri impostazioni IA",
"staleNoProviderHint": "Questo briefing non può aggiornarsi: nessun provider IA è collegato."
},
"trendsRow": {
"title": "Tendenze",
+2 -1
View File
@@ -2010,7 +2010,8 @@
"loadingLabel": "Przygotowanie briefingu",
"noProviderTitle": "Brak połączonego dostawcy AI",
"noProviderDescription": "Codzienny briefing podsumowuje Twoje pomiary prostym językiem. HealthLog potrzebuje do tego dostawcy AI — użyj własnego klucza (OpenAI, Anthropic) lub modelu lokalnego. Twoje dane pozostają u Ciebie.",
"noProviderAction": "Otwórz ustawienia AI"
"noProviderAction": "Otwórz ustawienia AI",
"staleNoProviderHint": "Tego briefingu nie można odświeżyć — nie połączono żadnego dostawcy AI."
},
"trendsRow": {
"title": "Trendy",
@@ -565,4 +565,50 @@ describe("GET /api/insights/generate — read-only advisor read", () => {
// No warm enqueued → no convergence poll for the client to run.
expect(body.data.revalidating).toBe(false);
});
// v1.18.9 (#4) — the read path reports provider availability so the
// insights surfaces can pair a stale cached briefing's honest age with
// a connect-provider hint. The cache is served regardless of provider
// state, so `hasProvider: false` is the only honest "can never refresh"
// signal the client has.
it("reports hasProvider: false alongside a served stale briefing", async () => {
vi.mocked(hasUsableStatusProvider).mockResolvedValueOnce(false);
const stale = new Date(Date.now() - 48 * 60 * 60 * 1000);
const cached = { dailyBriefing: { paragraph: "old", keyFindings: [] } };
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
insightsCachedAt: stale,
insightsCachedText: JSON.stringify(cached),
locale: "en",
} as never);
const res = await (GET as unknown as (req: Request) => Promise<Response>)(
new Request("http://localhost/api/insights/generate"),
);
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: { cached: boolean; hasProvider: boolean; revalidating: boolean };
};
// The stale briefing is still delivered (no provider needed to read).
expect(body.data.cached).toBe(true);
// But it can never refresh — no warm enqueued, hasProvider flagged.
expect(body.data.hasProvider).toBe(false);
expect(enqueueForceWarm).not.toHaveBeenCalled();
expect(body.data.revalidating).toBe(false);
});
it("reports hasProvider: true on a normal cached read", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
insightsCachedAt: new Date(),
insightsCachedText: JSON.stringify({
dailyBriefing: { paragraph: "ok", keyFindings: [] },
}),
locale: "en",
} as never);
const res = await (GET as unknown as (req: Request) => Promise<Response>)(
new Request("http://localhost/api/insights/generate"),
);
const body = (await res.json()) as { data: { hasProvider: boolean } };
expect(body.data.hasProvider).toBe(true);
});
});
+19 -3
View File
@@ -257,12 +257,22 @@ export const GET = apiHandler(async (request: NextRequest) => {
const isFresh =
cachedAt !== null && Date.now() - cachedAt.getTime() < BRIEFING_FRESH_MS;
// v1.18.9 (#4) — resolve provider availability once, up front. The
// read path never blocks on or generates through the provider, but it
// must REPORT whether one exists: a provider-less account's cached
// briefing freezes at the last successful generation (the pregenerate
// cron can't refill it), and the UI presented that stale text as
// current. Surfacing `hasProvider: false` lets the insights surfaces
// pair the honest "Stand: vor X Tagen" age with a discreet
// connect-an-AI-provider hint instead of implying the read is live.
const hasProvider = await hasUsableStatusProvider(userId);
// Read-only: never block on the provider. Warm out of band only when the
// cached briefing is stale / missing AND a provider is configured (a
// provider-less account costs one cheap chain-resolve and shows the
// empty / connect-AI state instead of a wasted enqueue).
let revalidating = false;
if (!isFresh && (await hasUsableStatusProvider(userId))) {
if (!isFresh && hasProvider) {
// Resolve the locale the caller is actually reading (cookie /
// Accept-Language fall-back when `User.locale` is unset) and narrow
// non-German to English — the same convention the nightly's
@@ -285,7 +295,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
const legacyPayload = isLegacyInsightPayload(cached);
annotate({
action: { name: "insights.generate.read" },
meta: { cached: true, legacyPayload, revalidating },
meta: { cached: true, legacyPayload, revalidating, hasProvider },
});
return apiSuccess({
insights: cached,
@@ -297,6 +307,10 @@ export const GET = apiHandler(async (request: NextRequest) => {
// briefing lands instead of sitting on the stale payload for the
// rest of the session.
revalidating,
// v1.18.9 (#4) — false when no AI provider is configured anywhere:
// the served briefing can never refresh, so the UI pairs its age
// with a connect-provider affordance.
hasProvider,
});
} catch {
// Invalid cache row — fall through to the empty payload below. The
@@ -307,13 +321,15 @@ export const GET = apiHandler(async (request: NextRequest) => {
annotate({
action: { name: "insights.generate.read" },
meta: { cached: false, revalidating },
meta: { cached: false, revalidating, hasProvider },
});
return apiSuccess({
insights: null,
cached: false,
legacyPayload: false,
revalidating,
// v1.18.9 (#4) — see the cached branch above.
hasProvider,
});
});
+11
View File
@@ -426,6 +426,12 @@ export default function InsightsPage() {
// as its own outcome; swap the futile regenerate CTA for a quiet
// Settings → AI hint instead of an eternal "preparing" loop.
noProvider={advisor.readOutcome === "no-provider"}
// v1.18.9 (#4) — a (stale) cached briefing is shown but no provider
// is connected, so it can never refresh. Pair the relative-age
// footer with a discreet connect-provider hint. Only fires when a
// briefing is actually rendered (the empty-state path owns the
// `noProvider` branch above).
noProviderStale={briefingPayload !== null && !advisor.hasProvider}
/>
) : null,
vitals: <VitalsDashboard batch={dashboardDerived} layout={layout} />,
@@ -480,6 +486,11 @@ export default function InsightsPage() {
// settles false on success AND error, so a no-score account
// collapses the column exactly once.
healthScorePending={analyticsQuery.isPending && !analytics}
// v1.18.9 (#4) — the hero subtitle is the cached briefing paragraph;
// when it can never refresh (no AI provider) pair the "Generated
// <relative>" line with a discreet connect-provider hint, matching
// the briefing card footer. Only when a briefing is actually shown.
noProviderStale={briefingPayload !== null && !advisor.hasProvider}
/>
{/* v1.15.18 — the inline "Anpassen" toggle was removed. Customising the
+44 -9
View File
@@ -108,6 +108,15 @@ interface DailyBriefingProps {
* linking to Settings → AI instead of an eternal "preparing" loop.
*/
noProvider?: boolean;
/**
* v1.18.9 (#4) — no AI provider configured WHILE a (stale) cached
* briefing is still shown. The read path serves the last good briefing
* regardless of provider state, so a provider-less account keeps seeing
* a days-old briefing. When true, the footer pairs the honest relative
* age with a discreet "this can't refresh — connect a provider" hint so
* the staleness reads as intentional, not as a live report.
*/
noProviderStale?: boolean;
/**
* Optional slot for a meta control mounted in the card header — the
* comparison toggle migrates here from the hero in commit 5.
@@ -323,6 +332,7 @@ export function DailyBriefing({
onRegenerate,
regenerating = false,
noProvider = false,
noProviderStale = false,
metaSlot,
}: DailyBriefingProps) {
const { t } = useTranslations();
@@ -407,15 +417,40 @@ export function DailyBriefing({
</div>
</div>
)}
{updatedAt && (
<p
data-slot="daily-briefing-updated"
className="text-muted-foreground border-border/60 border-t pt-3 text-right text-xs"
>
{t("insights.heroGenerated", {
time: formatRelativeTime(updatedAt, t),
})}
</p>
{(updatedAt || noProviderStale) && (
<div className="border-border/60 space-y-1.5 border-t pt-3">
{updatedAt && (
<p
data-slot="daily-briefing-updated"
className="text-muted-foreground text-right text-[11px]"
>
{t("insights.heroGenerated", {
time: formatRelativeTime(updatedAt, t),
})}
</p>
)}
{/* v1.18.9 (#4) — a stale briefing that can never refresh
because no AI provider is connected. State that plainly
and point at Settings → AI, so the days-old read is
understood as held, not presented as current. */}
{noProviderStale && (
<p
data-slot="daily-briefing-stale-no-provider"
className="text-muted-foreground flex flex-wrap items-center justify-end gap-x-1.5 gap-y-1 text-right text-[11px]"
>
<span>
{t("insights.dailyBriefing.staleNoProviderHint")}
</span>
<Link
href="/settings/ai"
data-slot="daily-briefing-stale-no-provider-link"
className="text-foreground/80 hover:text-foreground underline underline-offset-2"
>
{t("insights.dailyBriefing.noProviderAction")}
</Link>
</p>
)}
</div>
)}
</div>
) : noProvider ? (
+37
View File
@@ -1,5 +1,6 @@
"use client";
import Link from "next/link";
import { Sparkles } from "lucide-react";
import { Skeleton } from "@/components/ui/skeleton";
import { useTranslations } from "@/lib/i18n/context";
@@ -51,6 +52,15 @@ interface HeroStripProps {
* Defaults to `new Date()`. Production callers omit this.
*/
now?: Date;
/**
* v1.18.9 (#4) — true when the hero subtitle is drawn from a cached
* briefing that can never refresh because no AI provider is connected.
* The meta row then appends a discreet "connect a provider" link beside
* the "Generated <relative>" age, so the score-area context reads as
* honestly held rather than current. Consistent with the DailyBriefing
* footer hint below.
*/
noProviderStale?: boolean;
/**
* v1.4.20 phase B5 — Personal Health Score panel data. When supplied
* the right side of the hero band paints the score card. When the
@@ -108,6 +118,7 @@ export function HeroStrip({
now,
healthScore,
healthScorePending = false,
noProviderStale = false,
}: HeroStripProps) {
const { t } = useTranslations();
// v1.18.0 R4 — mood-module state so the score card hides its Mood row
@@ -205,6 +216,32 @@ export function HeroStrip({
</span>
</>
)}
{/* v1.18.9 (#4) — the subtitle is a cached briefing that can
never refresh (no AI provider). Append a discreet hint +
Settings → AI link so the relative age above reads as
intentionally held, consistent with the briefing card. */}
{noProviderStale && (
<>
<span aria-hidden="true" className="opacity-50">
·
</span>
<span
data-slot="insights-hero-strip-stale-no-provider"
className="inline-flex flex-wrap items-center gap-x-1.5"
>
<span>
{t("insights.dailyBriefing.staleNoProviderHint")}
</span>
<Link
href="/settings/ai"
data-slot="insights-hero-strip-stale-no-provider-link"
className="text-foreground/80 hover:text-foreground underline underline-offset-2"
>
{t("insights.dailyBriefing.noProviderAction")}
</Link>
</span>
</>
)}
</div>
</div>
@@ -61,6 +61,16 @@ export interface InsightAdvisorPayload {
* instead of waiting for the next mount.
*/
revalidating?: boolean;
/**
* v1.18.9 (#4) — false when no AI provider is configured anywhere. The
* read path serves the last cached briefing regardless (no provider is
* needed to READ the cache), so a provider-less account keeps seeing a
* days-old briefing presented as current. When this is false the
* surfaces pair the briefing's honest relative age with a discreet
* "connect a provider" affordance. Undefined on a cached payload that
* predates the field — treated as "provider present" (no hint).
*/
hasProvider?: boolean;
}
/**
@@ -259,6 +269,16 @@ export interface UseInsightsAdvisorResult {
* regenerate CTA.
*/
readOutcome: AdvisorFetchOutcome | null;
/**
* v1.18.9 (#4) — false when the GET reported no usable AI provider. The
* read path still serves the last cached briefing (no provider is
* needed to read the cache), so this is the ONLY honest signal that a
* shown-but-stale briefing can never refresh. Surfaces pair it with the
* relative-age line to add a discreet connect-provider hint. Defaults
* true (no hint) when the field is absent — a pre-field cached payload
* or an unsettled query.
*/
hasProvider: boolean;
}
/**
@@ -331,5 +351,9 @@ export function useInsightsAdvisorQuery(
regenerateError: (mutation.error as Error | null) ?? null,
regenerateOutcome: mutation.data?.outcome ?? null,
readOutcome: query.data?.outcome ?? null,
// Absent field (pre-v1.18.9 cached payload, or query still settling) →
// assume a provider is present so a transient unknown never flashes a
// false "no provider" hint.
hasProvider: query.data?.payload?.hasProvider ?? true,
};
}
+10
View File
@@ -219,6 +219,16 @@ describe("dependent-key bundles", () => {
expect(keyStrings).toContain(JSON.stringify(["chart-data"]));
});
// v1.18.9 (#38) — the dashboard snapshot must ride the measurement
// bundle too. The hero band / score ring / tile strip read ONE snapshot
// query with refetch-on-mount/focus options that otherwise leave an
// in-app-added reading invisible on the Startseite until the 120 s poll
// or a hard reload. Mirror the v1.16.11 medication-bundle fix.
it("measurementDependentKeys bundles the dashboard snapshot (v1.18.9)", () => {
const keyStrings = measurementDependentKeys.map((k) => JSON.stringify(k));
expect(keyStrings).toContain(JSON.stringify(["dashboard", "snapshot"]));
});
it("moodDependentKeys bundle covers mood + analytics + targets", () => {
const keyStrings = moodDependentKeys.map((k) => JSON.stringify(k));
expect(keyStrings).toContain(JSON.stringify(["mood-entries"]));
@@ -7,10 +7,12 @@
* backgrounded (`refetchIntervalInBackground: false`). The poll rides
* the warm 60 s server cache and never touches the LLM surfaces.
*
* The hook keeps its existing warm-cache options (`staleTime: 60_000`,
* `refetchOnMount: false`, `refetchOnWindowFocus: false`) so a
* return-to-dashboard within a minute stays a free cache hit, and the
* queryKey stays the centralised factory entry.
* The hook keeps its warm-cache options (`staleTime: 60_000`,
* `refetchOnMount: false`) so a return-to-dashboard within a minute
* stays a free cache hit, and the queryKey stays the centralised factory
* entry. v1.18.9 (#38) flips `refetchOnWindowFocus` to `true` so a
* background sync — which fires no client mutation event — surfaces on
* return-to-tab; the `staleTime` gate keeps rapid focus toggles free.
*/
import { describe, it, expect, vi, afterEach } from "vitest";
@@ -70,7 +72,12 @@ describe("useDashboardSnapshot — auto-refresh on an open page", () => {
const opts = lastOpts();
expect(opts.staleTime).toBe(60_000);
expect(opts.refetchOnMount).toBe(false);
expect(opts.refetchOnWindowFocus).toBe(false);
});
it("refetches on window focus so a background sync surfaces on return", () => {
useDashboardSnapshot();
const opts = lastOpts();
expect(opts.refetchOnWindowFocus).toBe(true);
});
it("routes the queryKey through the centralised factory", () => {
+16 -4
View File
@@ -14,9 +14,17 @@
* already passed the auth / onboarding redirect. Firing un-gated
* removes the `/api/auth/me` round-trip from the cold critical path
* (R-firstpaint §1a — the single biggest first-byte win).
* - `staleTime: 60_000` + `refetchOnMount: false` +
* `refetchOnWindowFocus: false` mirror `DASHBOARD_QUERY_OPTS` so a
* return-to-dashboard within a minute is a free cache hit.
* - `staleTime: 60_000` + `refetchOnMount: false` keep a
* return-to-dashboard within a minute a free cache hit.
* - `refetchOnWindowFocus: true` (v1.18.9) closes the background-sync
* freshness gap behind #38: a Withings / Apple-Health / iOS batch
* sync writes the user's data with NO client-side mutation event, so
* the snapshot keys are never invalidated and the dashboard only
* updated on the 120 s poll or a hard reload. Refetch-on-focus means
* returning to the tab after a sync surfaces the new readings within
* a frame. The `staleTime: 60_000` gate makes rapid focus toggles
* cheap (a refetch fires only once the slot is stale), and the warm
* 60 s server SWR cache keeps the focus refetch sub-cost.
* - `refetchInterval: 120_000` + `refetchIntervalInBackground: false`
* keep an open dashboard live: an idle tab polls the snapshot every
* two minutes so freshly-synced Withings / HealthKit readings appear
@@ -162,7 +170,11 @@ export function useDashboardSnapshot(enabled = true) {
enabled,
staleTime: 60_000,
refetchOnMount: false,
refetchOnWindowFocus: false,
// v1.18.9 (#38) — refetch on focus so a background sync (Withings /
// Apple Health / iOS batch), which produces no client mutation event,
// surfaces on return-to-tab. Gated by the 60 s `staleTime` above so a
// rapid focus toggle within the window is still a free cache hit.
refetchOnWindowFocus: true,
refetchInterval: DASHBOARD_REFETCH_INTERVAL_MS,
refetchIntervalInBackground: false,
// v1.16.8 — one retry on network errors / 5xx (never 401/403). A
+11
View File
@@ -58,6 +58,16 @@ export const queryKeys = {
* TanStack's hierarchical-prefix semantics — adding a measurement now
* refreshes the tile strip *and* the chart row in lockstep instead of
* leaving the chart row 60 s stale (audit C2).
*
* v1.18.9 — `dashboardSnapshot` joins the bundle, mirroring the
* v1.16.11 medication fix. The dashboard hero band, score ring, and
* tile strip all read ONE snapshot query configured with
* refetchOnMount/WindowFocus off and a 120 s poll; without the key here
* a blood-pressure (or any) reading added in-app stayed invisible on the
* Startseite until the poll ticked or a hard reload — the reported #38
* stale-read. The manual create / update / delete routes already
* hard-evict the server snapshot bucket (`{ evict: true }`), so the
* refetch this invalidation triggers returns post-write data at once.
*/
export const measurementDependentKeys = [
queryKeys.measurements(),
@@ -65,6 +75,7 @@ export const measurementDependentKeys = [
queryKeys.insightsRoot(),
queryKeys.insightsTargets(),
queryKeys.gamificationAchievements(),
queryKeys.dashboardSnapshot(),
["chart-data"] as const,
// v1.8.5 — re-run the diversity-nudge clustering when readings change.
["measurement-diversity"] as const,