mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
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:
1 parent
c47b7dade3
commit
8187d54969
10 files changed
+235
-13
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user