feat(apns): time-sensitive interruption-level for medication reminders

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) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-21 11:43:41 +02:00
1 parent c47b7dade3
commit 8187d54969
10 files changed
+235 -13

No files matched your search

+4 -3
View File
@@ -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;
+2 -1
View File
@@ -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"));
+2 -1
View File
@@ -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");
+8 -3
View File
@@ -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
@@ -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<DailyCompliancePoint[]> => {
const res = await fetch(
`/api/medications/intake?scope=compliance&days=${days}`,
@@ -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");
+8 -3
View File
@@ -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<AuthUser> {
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");
},
});
@@ -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`
+58
View File
@@ -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,
});
+77
View File
@@ -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,
];
/**