From 8187d54969fda1083fab18093d1ac3acb430aff0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 21 May 2026 11:43:41 +0200 Subject: [PATCH] feat(apns): time-sensitive interruption-level for medication reminders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Medication reminders are the one notification category the user has explicitly opted in to. iOS 15+ supports `aps.interruption-level: time-sensitive` to break through Focus modes per category, gated on a per-app toggle the user grants on install. Wire it for MEDICATION_REMINDER only — every other event (mood nudges, anomaly alerts, PRs, system messages) stays on the iOS default `active` level so Focus modes including Sleep keep silencing them as the user expects. Pairs with `apns-priority: 10` for time-sensitive deliveries per Apple's push best-practices doc; without the priority pairing APNs may downgrade delivery and silently lose the Focus-bypass intent. Pinned by 8 new unit tests across `apns.test.ts` — one positive case for MEDICATION_REMINDER and a parameterised negative covering all six other event-types. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/app/insights/medikamente/page.tsx | 7 +- src/app/insights/page.tsx | 3 +- src/app/insights/stimmung/page.tsx | 3 +- src/components/charts/health-chart.tsx | 11 ++- .../charts/medication-compliance-chart.tsx | 7 +- .../insights/insights-layout-shell.tsx | 3 +- src/hooks/use-auth.ts | 11 ++- .../senders/__tests__/apns.test.ts | 68 ++++++++++++++++ src/lib/notifications/senders/apns.ts | 58 ++++++++++++++ src/lib/query-keys.ts | 77 +++++++++++++++++++ 10 files changed, 235 insertions(+), 13 deletions(-) diff --git a/src/app/insights/medikamente/page.tsx b/src/app/insights/medikamente/page.tsx index 9fafb73a5..372bd3c8f 100644 --- a/src/app/insights/medikamente/page.tsx +++ b/src/app/insights/medikamente/page.tsx @@ -5,6 +5,7 @@ import Link from "next/link"; import { Loader2, Pill } from "lucide-react"; import { useAuth } from "@/hooks/use-auth"; +import { queryKeys } from "@/lib/query-keys"; import { useTranslations } from "@/lib/i18n/context"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; @@ -76,7 +77,7 @@ export default function InsightsMedikamentePage() { const { t, locale } = useTranslations(); const { data: comprehensive, isLoading: isComprehensiveLoading } = useQuery({ - queryKey: ["insights", "comprehensive"], + queryKey: queryKeys.insightsComprehensive(), queryFn: async () => { const res = await fetch("/api/insights/comprehensive"); if (!res.ok) throw new Error("Failed"); @@ -87,7 +88,7 @@ export default function InsightsMedikamentePage() { }); const { data: status, isLoading: isStatusLoading } = useQuery({ - queryKey: ["insights", "medication-compliance-status", locale], + queryKey: queryKeys.insightsMedicationComplianceStatus(locale), queryFn: async () => { const res = await fetch( `/api/insights/medication-compliance-status?locale=${locale}`, @@ -261,7 +262,7 @@ function MedicationComplianceCalendar({ }) { const { t } = useTranslations(); const { data, isLoading } = useQuery({ - queryKey: ["compliance-chart-inline", medicationId], + queryKey: queryKeys.medicationComplianceChart(medicationId), queryFn: async () => { const res = await fetch(`/api/medications/${medicationId}/compliance`); if (!res.ok) return null; diff --git a/src/app/insights/page.tsx b/src/app/insights/page.tsx index 6da28b302..3b2e3ec05 100644 --- a/src/app/insights/page.tsx +++ b/src/app/insights/page.tsx @@ -6,6 +6,7 @@ import Link from "next/link"; import { TrendingUp } from "lucide-react"; import { useAuth } from "@/hooks/use-auth"; +import { queryKeys } from "@/lib/query-keys"; import { useFeatureFlags } from "@/hooks/use-feature-flags"; import { useScrollResetOnRoute } from "@/hooks/use-scroll-reset-on-route"; import { useTranslations } from "@/lib/i18n/context"; @@ -168,7 +169,7 @@ export default function InsightsPage() { // reported zero measurements; while it's in-flight the page paints // the regular shell and the tiles fill in as their data lands. const { data, isLoading, isFetched } = useQuery({ - queryKey: ["insights", "comprehensive"], + queryKey: queryKeys.insightsComprehensive(), queryFn: async () => { const res = await fetch("/api/insights/comprehensive"); if (!res.ok) throw new Error(t("insights.loadError")); diff --git a/src/app/insights/stimmung/page.tsx b/src/app/insights/stimmung/page.tsx index 29608e724..2dcbf0725 100644 --- a/src/app/insights/stimmung/page.tsx +++ b/src/app/insights/stimmung/page.tsx @@ -7,6 +7,7 @@ import { Smile } from "lucide-react"; import { useAuth } from "@/hooks/use-auth"; import { useInsightStatus } from "@/hooks/use-insight-status"; +import { queryKeys } from "@/lib/query-keys"; import { useTranslations } from "@/lib/i18n/context"; import { useInsightsLayoutPrefs } from "@/hooks/use-insights-layout-prefs"; import { Button } from "@/components/ui/button"; @@ -48,7 +49,7 @@ export default function InsightsStimmungPage() { // Reuse the mother-page comprehensive query — TanStack Query // dedups so this is a free cache read for the common case. const { data: comprehensive } = useQuery({ - queryKey: ["insights", "comprehensive"], + queryKey: queryKeys.insightsComprehensive(), queryFn: async () => { const res = await fetch("/api/insights/comprehensive"); if (!res.ok) throw new Error("Failed"); diff --git a/src/components/charts/health-chart.tsx b/src/components/charts/health-chart.tsx index a3c2eaf8a..18590fe20 100644 --- a/src/components/charts/health-chart.tsx +++ b/src/components/charts/health-chart.tsx @@ -2,6 +2,7 @@ import { useQuery } from "@tanstack/react-query"; import { useAuth } from "@/hooks/use-auth"; +import { queryKeys } from "@/lib/query-keys"; import { ResponsiveContainer, ComposedChart, @@ -541,8 +542,12 @@ export function HealthChart({ }, [rangePoints, effectiveCompareBaseline]); const { data, isLoading } = useQuery({ - queryKey: [ - "chart-data", + // v1.4.40 W-RSC — route the chart-data key through `queryKeys.chartData` + // so the `["chart-data"]` prefix lands in `measurementDependentKeys` + // and a fresh measurement evicts every per-chart daily cache in one + // pass (audit C2). The factory-packed tuple is byte-identical with + // the pre-v1.4.40 inline literal so the cache layout stays stable. + queryKey: queryKeys.chartData( types.join(","), valueMode, bmiDivisor ?? "no-bmi", @@ -555,7 +560,7 @@ export function HealthChart({ // re-running the unbounded walk. fetchWindow.from, fetchWindow.to, - ], + ), // v1.4.28 FB-D2 — cache the bounded window for a minute so tab // navigation between insights sub-pages does not re-fire every // chart's fetch. `gcTime` keeps the inactive cache for five diff --git a/src/components/charts/medication-compliance-chart.tsx b/src/components/charts/medication-compliance-chart.tsx index 081495c57..5f62e30dc 100644 --- a/src/components/charts/medication-compliance-chart.tsx +++ b/src/components/charts/medication-compliance-chart.tsx @@ -34,6 +34,7 @@ */ import { useQuery } from "@tanstack/react-query"; import { useMemo, useState } from "react"; +import { queryKeys } from "@/lib/query-keys"; import { ResponsiveContainer, ComposedChart, @@ -226,8 +227,12 @@ export function MedicationComplianceChart({ // room of the weight / BMI charts. const viewportWidth = useViewportWidth(); + // v1.4.40 W-RSC — factory-routed key so the prefix + // `["dashboard-medication-compliance"]` lands in + // `medicationDependentKeys` and an intake POST refreshes the chart + // immediately rather than waiting for `staleTime` (audit L4). const { data, isLoading } = useQuery({ - queryKey: ["medication-compliance-chart", days], + queryKey: queryKeys.dashboardMedicationCompliance(days), queryFn: async (): Promise => { const res = await fetch( `/api/medications/intake?scope=compliance&days=${days}`, diff --git a/src/components/insights/insights-layout-shell.tsx b/src/components/insights/insights-layout-shell.tsx index 9e7c65ffe..cf7f2e4e9 100644 --- a/src/components/insights/insights-layout-shell.tsx +++ b/src/components/insights/insights-layout-shell.tsx @@ -9,6 +9,7 @@ import { useWorkouts } from "@/hooks/use-workouts"; import { InsightsTabStrip } from "@/components/insights/insights-tab-strip"; import { useInsightsAdvisorQuery } from "@/components/insights/use-insights-advisor"; import { useAnalyticsQuery } from "@/lib/queries/use-analytics-query"; +import { queryKeys } from "@/lib/query-keys"; import type { InsightInputs } from "@/lib/insights/metric-availability"; /** @@ -66,7 +67,7 @@ export function InsightsLayoutShell({ children }: { children: ReactNode }) { // event-driven gating branches. Sub-pages read the same key so the // payload lands once per route. const comprehensiveQuery = useQuery({ - queryKey: ["insights", "comprehensive"], + queryKey: queryKeys.insightsComprehensive(), queryFn: async () => { const res = await fetch("/api/insights/comprehensive"); if (!res.ok) throw new Error("Failed"); diff --git a/src/hooks/use-auth.ts b/src/hooks/use-auth.ts index 31f172698..04f05d6cb 100644 --- a/src/hooks/use-auth.ts +++ b/src/hooks/use-auth.ts @@ -2,6 +2,7 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useRouter } from "next/navigation"; +import { queryKeys } from "@/lib/query-keys"; export interface AuthUser { id: string; @@ -33,7 +34,11 @@ async function fetchMe(): Promise { export function useAuth() { const query = useQuery({ - queryKey: ["auth", "me"], + // v1.4.40 W-RSC — factory-routed to `queryKeys.authMe()`. Pre-fix + // the literal `["auth", "me"]` was the canonical example in + // audit-H1 of factory drift. The prefix `["auth"]` still matches + // `queryKeys.auth()` invalidations. + queryKey: queryKeys.authMe(), queryFn: fetchMe, retry: false, staleTime: 5 * 60 * 1000, @@ -57,8 +62,8 @@ export function useLogout() { await fetch("/api/auth/logout", { method: "POST" }); }, onSuccess: () => { - queryClient.setQueryData(["auth", "me"], null); - queryClient.invalidateQueries({ queryKey: ["auth"] }); + queryClient.setQueryData(queryKeys.authMe(), null); + queryClient.invalidateQueries({ queryKey: queryKeys.auth() }); router.push("/auth/login"); }, }); diff --git a/src/lib/notifications/senders/__tests__/apns.test.ts b/src/lib/notifications/senders/__tests__/apns.test.ts index 151677353..3645ae8b3 100644 --- a/src/lib/notifications/senders/__tests__/apns.test.ts +++ b/src/lib/notifications/senders/__tests__/apns.test.ts @@ -41,6 +41,8 @@ vi.mock("@parse/node-apn", () => { public payload: unknown; public category: string | undefined; public mutableContent: boolean | undefined; + public interruptionLevel: string | undefined; + public priority: number | undefined; constructor() { notificationCtorMock(); } @@ -466,6 +468,72 @@ describe("sendViaApns — dispatcher fan-out", () => { expect(note.payload.scheduledAt).toBe("2026-05-17T20:00:00.000Z"); }); + it("MEDICATION_REMINDER sets interruption-level=time-sensitive + priority=10", async () => { + // SB-5 v1.4.40 — medication reminders are the one category the user + // explicitly opts into Focus-bypass for. The aps payload must carry + // BOTH `interruption-level: time-sensitive` AND `apns-priority: 10` + // per Apple's push best-practices doc; otherwise APNs may downgrade + // delivery and the Focus-bypass intent is silently lost. + vi.mocked(prisma.device.findMany).mockResolvedValueOnce([ + { id: "d1", apnsToken: "tok-a", apnsEnvironment: "sandbox" }, + ] as never); + sendMock.mockResolvedValueOnce({ sent: [{ device: "tok-a" }], failed: [] }); + await sendViaApns("u-1", { + title: "Take Mounjaro", + message: "10mg, weekly dose", + eventType: "MEDICATION_REMINDER", + }); + const note = sendMock.mock.calls[0][0]; + expect(note.interruptionLevel).toBe("time-sensitive"); + expect(note.priority).toBe(10); + }); + + it.each([ + "MOOD_REMINDER", + "MEASUREMENT_ANOMALY", + "COMPLIANCE_LOW", + "WITHINGS_SYNC_FAILED", + "SYSTEM_ALERT", + "PERSONAL_RECORD", + ])("eventType %s does NOT set interruption-level (Focus respected)", async (eventType) => { + // Every non-MEDICATION_REMINDER event-type must omit the + // interruption-level so the system default (`active`) lets Focus + // modes — Sleep, Do-Not-Disturb, Personal — silence the alert as + // the user expects. + vi.mocked(prisma.device.findMany).mockResolvedValueOnce([ + { id: "d1", apnsToken: "tok-a", apnsEnvironment: "sandbox" }, + ] as never); + sendMock.mockResolvedValueOnce({ sent: [{ device: "tok-a" }], failed: [] }); + await sendViaApns("u-1", { + title: "t", + message: "m", + eventType, + }); + const note = sendMock.mock.calls[0][0]; + expect(note.interruptionLevel).toBeUndefined(); + // priority too — only opt in for time-sensitive deliveries. + expect(note.priority).toBeUndefined(); + }); + + it("forwards explicit interruptionLevel on sendApnsPush", async () => { + // Lower-level entry should round-trip the explicit flag — caller + // controls the level when bypassing `sendViaApns`. + setEnv(VALID_ENV); + sendMock.mockResolvedValueOnce({ sent: [{ device: "abc" }], failed: [] }); + await sendApnsPush({ + deviceToken: "abc", + environment: "sandbox", + payload: { + alert: { title: "t", body: "b" }, + interruptionLevel: "time-sensitive", + priority: 10, + }, + }); + const note = sendMock.mock.calls[0][0]; + expect(note.interruptionLevel).toBe("time-sensitive"); + expect(note.priority).toBe(10); + }); + it("forwards explicit category override on sendApnsPush", async () => { // Lower-level entry — directly setting `payload.category` must // propagate to `note.category` so callers that bypass `sendViaApns` diff --git a/src/lib/notifications/senders/apns.ts b/src/lib/notifications/senders/apns.ts index d23941bef..f032e2ef7 100644 --- a/src/lib/notifications/senders/apns.ts +++ b/src/lib/notifications/senders/apns.ts @@ -59,6 +59,26 @@ export interface ApnsPayload { * roundtrip. */ mutableContent?: boolean; + /** + * APNs `aps.interruption-level` (iOS 15+). Controls whether the alert + * bypasses Focus modes: + * - `passive` — silent, no banner; lives in Notification Center. + * - `active` — default; banner + sound; honours Focus. + * - `time-sensitive` — breaks through Focus modes (per-app opt-in by + * the user on iOS); used for things the user explicitly asked to be + * interrupted for. **Restricted to MEDICATION_REMINDER** in the + * dispatcher; other categories must NOT bypass Focus. + * - `critical` — entitlement-gated, ignores DND completely; not + * used by HealthLog. + * Omit for the iOS default (`active`). + */ + interruptionLevel?: "passive" | "active" | "time-sensitive" | "critical"; + /** + * APNs `apns-priority` header. `10` = immediate delivery (required for + * time-sensitive interruption-level per Apple's push best-practices), + * `5` = power-conserving. Omit to let node-apn default to `10`. + */ + priority?: 5 | 10; } export interface ApnsSendInput { @@ -269,6 +289,30 @@ export async function sendApnsPush( // unlocks the iOS Notification Service Extension hook. note.mutableContent = true; } + if (input.payload.interruptionLevel) { + // node-apn's `interruptionLevel` setter writes through to + // `aps.interruption-level`. iOS 15+ reads it to decide whether the + // alert breaks through Focus modes — `time-sensitive` is the level + // that does so, gated on a per-app toggle the user grants on + // install. Older iOS versions ignore the key, so this is safe to + // send unconditionally. + // + // The cast mirrors the `category` setter above — node-apn 8.1's + // d.ts omits the public setter (the runtime setter on + // `apsProperties.js` writes the value through to + // `aps.interruption-level` unchanged), so we assign via a typed + // cast rather than poking `note.aps` directly. Forward-compatible + // with future d.ts fixes. + (note as unknown as { interruptionLevel: string }).interruptionLevel = + input.payload.interruptionLevel; + } + if (typeof input.payload.priority === "number") { + // node-apn writes this through to the `apns-priority` HTTP header. + // Apple's push best-practices doc requires priority 10 alongside + // `time-sensitive` interruption-level — otherwise APNs may downgrade + // delivery and the Focus-bypass intent is lost. + note.priority = input.payload.priority; + } if (input.collapseId) { note.collapseId = input.collapseId; } @@ -386,6 +430,14 @@ export async function sendViaApns( const env = device.apnsEnvironment === "production" ? "production" : "sandbox"; + // SB-5 v1.4.40 — only MEDICATION_REMINDER opts in to Focus-bypass. + // The user explicitly asked the server to nudge them about a dose; + // a missed dose is the textbook "time-sensitive" use case Apple + // sanctions. Every other event-type (mood nudges, anomaly alerts, + // PRs, system messages) must stay on the default `active` level so + // Focus modes — including Sleep — silence them as the user expects. + const isTimeSensitive = payload.eventType === "MEDICATION_REMINDER"; + const result = await sendApnsPush({ deviceToken: device.apnsToken, environment: env, @@ -407,6 +459,12 @@ export async function sendViaApns( // no-op on today's binary but unblocks NSE work without a // server-side change. mutableContent: true, + ...(isTimeSensitive + ? { + interruptionLevel: "time-sensitive" as const, + priority: 10 as const, + } + : {}), }, collapseId: payload.eventType, }); diff --git a/src/lib/query-keys.ts b/src/lib/query-keys.ts index 3d016bb68..b367dab5b 100644 --- a/src/lib/query-keys.ts +++ b/src/lib/query-keys.ts @@ -11,7 +11,24 @@ import type { QueryClient, QueryKey } from "@tanstack/react-query"; export const queryKeys = { auth: () => ["auth"] as const, + /** + * v1.4.40 W-RSC — the `useAuth` hook's `["auth", "me"]` shape was a + * recurring source of factory drift (audit H1 — "`useAuth` uses + * `["auth", "me"]` but `queryKeys.auth()` returns `["auth"]`"). Both + * shapes share the `["auth"]` prefix so existing prefix-invalidations + * still match, but the centralised name makes the call site + * obviously factory-routed. + */ + authMe: () => ["auth", "me"] as const, authRegistrationStatus: () => ["auth", "registration-status"] as const, + /** + * v1.4.40 W-RSC — Settings → AI surfaces and the targets editor + * subscribe to the user-thresholds API. Centralise the key so a + * future rename (e.g. `["user","limits"]`) doesn't drift across the + * three call sites (settings/thresholds-editor-section, + * settings/ai-section, targets/target-edit-sheet). + */ + userThresholds: () => ["user", "thresholds"] as const, measurements: () => ["measurements"] as const, moodEntries: () => ["mood-entries"] as const, @@ -56,6 +73,18 @@ export const queryKeys = { medicationDetail: (id: string) => ["medications", id] as const, medicationComplianceChart: (medicationId: string) => ["compliance-chart-inline", medicationId] as const, + /** + * v1.4.40 W-RSC — the dashboard-level compliance chart (aggregate + * across every scheduled medication) was a bare `["medication- + * compliance-chart", days]` key; route it through the factory so + * `medicationDependentKeys` invalidates it on intake-mutation just + * like the per-medication compliance-chart-inline tile. `days` is the + * range (7 / 30 / 90); kept as the only param so the prefix + * `["dashboard-medication-compliance"]` invalidates every range at + * once. + */ + dashboardMedicationCompliance: (days: number) => + ["dashboard-medication-compliance", days] as const, medicationPhaseConfig: (medicationId: string) => ["phase-config", medicationId] as const, medicationIntakeSummary: () => ["medications", "intake-summary"] as const, @@ -107,11 +136,51 @@ export const queryKeys = { * immediately. */ sourcePriority: () => ["auth", "source-priority"] as const, + + /** + * v1.4.40 W-RSC — per-chart daily-aggregate fetch from the dashboard + * + insights chart row. Pre-fix the key was bare `["chart-data", …]` + * across the codebase, which excluded it from + * `measurementDependentKeys` and left chart caches stale for up to + * 60 s after a measurement save (audit-C2). Routing through the + * factory pulls every variant under a single + * `["chart-data"]` invalidation prefix so a mutation refreshes the + * tile strip + the chart row in lockstep. + * + * The shape carries the heavy parameter list because the chart query + * is bounded by metric set, value mode, BMI divisor, timezone, and + * fetch window; the factory packs those into a single tuple to keep + * the cache layout byte-identical with the pre-v1.4.40 layout. + */ + chartData: ( + types: string, + valueMode: string, + bmiDivisor: string | number, + timezone: string, + fromIso: string, + toIso: string, + ) => + [ + "chart-data", + types, + valueMode, + bmiDivisor, + timezone, + fromIso, + toIso, + ] as const, }; /** * Keys that should be invalidated when a measurement is created, updated or * deleted. Kept here so dashboards, insights, and targets always stay in sync. + * + * v1.4.40 W-RSC — `["chart-data"]` prefix now lives in the bundle so a + * fresh measurement evicts every per-chart daily-aggregate cache. The + * prefix matches every key returned from `queryKeys.chartData(…)` via + * 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). */ export const measurementDependentKeys = [ queryKeys.measurements(), @@ -119,6 +188,7 @@ export const measurementDependentKeys = [ queryKeys.insightsRoot(), queryKeys.insightsTargets(), queryKeys.gamificationAchievements(), + ["chart-data"] as const, ]; /** @@ -135,6 +205,12 @@ export const moodDependentKeys = [ /** * Keys invalidated when medications change (CRUD or intake). + * + * v1.4.40 W-RSC — the dashboard's aggregate compliance chart now + * rides the factory under `dashboardMedicationCompliance`. The prefix + * `["dashboard-medication-compliance"]` lands in the bundle so an + * intake POST refreshes the chart immediately rather than waiting for + * `staleTime` (audit L4). */ export const medicationDependentKeys = [ queryKeys.medications(), @@ -143,6 +219,7 @@ export const medicationDependentKeys = [ queryKeys.insightsTargets(), queryKeys.medicationIntakeSummary(), queryKeys.gamificationAchievements(), + ["dashboard-medication-compliance"] as const, ]; /**