mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
Merge pull request #1007 from muhdusama/medfix/apple-health-scheduled-mirror
fix: allow scheduled Apple Health medication mirrors
This commit is contained in:
28 files changed
+531
-62
No files matched your search
@@ -13,6 +13,7 @@ import { prisma } from "@/lib/db";
|
||||
import {
|
||||
buildComplianceMedicationContext,
|
||||
buildMedicationComplianceBundle,
|
||||
expectsDoses,
|
||||
lastNonSkippedTakenAt,
|
||||
type ComplianceDisplay,
|
||||
type ComplianceResult,
|
||||
@@ -33,12 +34,38 @@ const EVENT_FETCH_WINDOW_DAYS = 366;
|
||||
|
||||
/** The cached response body (the exact public wire shape). */
|
||||
export interface CompliancePayload {
|
||||
/**
|
||||
* False only when adherence is not meaningful for this medication.
|
||||
* Today that means a scheduled (non-PRN) medication with no local schedule,
|
||||
* such as an Apple Health mirror whose cadence remains source-owned.
|
||||
*/
|
||||
applicable: boolean;
|
||||
notApplicableReason: "NO_LOCAL_SCHEDULE" | null;
|
||||
compliance7: ComplianceResult;
|
||||
compliance30: ComplianceResult;
|
||||
dailyCompliance: Record<string, DailyComplianceEntry>;
|
||||
complianceDisplay: ComplianceDisplay;
|
||||
complianceDisplay: ComplianceDisplay | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy-compatible placeholder for a medication whose adherence percentage
|
||||
* is explicitly not applicable.
|
||||
*
|
||||
* Released iOS 1.0.3 requires `compliance7` and `compliance30` to decode as
|
||||
* non-null `ComplianceWindowResult` objects. Keep those fields structurally
|
||||
* present while `applicable=false` remains the authoritative semantic signal
|
||||
* for clients that understand the newer contract. All-zero values avoid
|
||||
* reviving the old vacuous 100% result.
|
||||
*/
|
||||
const NOT_APPLICABLE_LEGACY_COMPLIANCE: ComplianceResult = {
|
||||
totalExpected: 0,
|
||||
taken: 0,
|
||||
skipped: 0,
|
||||
missed: 0,
|
||||
rate: 0,
|
||||
streak: 0,
|
||||
};
|
||||
|
||||
/** The medication slice the payload builder consumes. */
|
||||
export interface ComplianceMedicationInput {
|
||||
id: string;
|
||||
@@ -46,6 +73,7 @@ export interface ComplianceMedicationInput {
|
||||
startsOn: Date | null;
|
||||
endsOn: Date | null;
|
||||
oneShot: boolean;
|
||||
asNeeded: boolean;
|
||||
schedules: Parameters<typeof buildMedicationComplianceBundle>[1];
|
||||
/** v1.16.3 — archived schedule eras for era-aware compliance. */
|
||||
scheduleRevisions?: Parameters<
|
||||
@@ -132,6 +160,27 @@ export async function buildCompliancePayload(
|
||||
userId: string,
|
||||
userTz: string,
|
||||
): Promise<CompliancePayload> {
|
||||
// A scheduled medication with ZERO local schedules has no local expected
|
||||
// dose grid. This is a real shape for Apple Health mirrors: the source owns
|
||||
// the cadence while HealthLog stores the mirrored medication itself. The
|
||||
// legacy arithmetic intentionally answers 100 % when there are zero
|
||||
// expected doses, which is mathematically consistent but misleading when
|
||||
// rendered as adherence. Mark this shape explicitly not-applicable BEFORE
|
||||
// reading intake history or invoking the arithmetic.
|
||||
//
|
||||
// Keep PRN behaviour unchanged. The batched endpoint already excludes PRN
|
||||
// medications, and a direct per-id read retains its existing payload.
|
||||
if (!medication.asNeeded && !expectsDoses(medication)) {
|
||||
return {
|
||||
applicable: false,
|
||||
notApplicableReason: "NO_LOCAL_SCHEDULE",
|
||||
compliance7: NOT_APPLICABLE_LEGACY_COMPLIANCE,
|
||||
compliance30: NOT_APPLICABLE_LEGACY_COMPLIANCE,
|
||||
dailyCompliance: {},
|
||||
complianceDisplay: null,
|
||||
};
|
||||
}
|
||||
|
||||
// v1.15.9 — pin a single `now` and thread it into every cadence
|
||||
// computation so no block can straddle a day boundary on a slow request.
|
||||
const now = new Date();
|
||||
@@ -236,6 +285,8 @@ export async function buildCompliancePayload(
|
||||
}
|
||||
|
||||
return {
|
||||
applicable: true,
|
||||
notApplicableReason: null,
|
||||
compliance7: bundle.compliance7,
|
||||
compliance30: bundle.compliance30,
|
||||
dailyCompliance,
|
||||
|
||||
@@ -1413,7 +1413,7 @@ export const medicationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["Medications"],
|
||||
summary: "Batched adherence read for every medication of the caller",
|
||||
description:
|
||||
"Returns one compact adherence row per medication the caller owns (active + paused), ordered by `createdAt DESC` — the single round trip the medication cards consume instead of fanning out one `/api/medications/{id}/compliance` request per card. Each row carries the 7-/30-day summaries and the cadence-scaled display block; the per-day grid stays on the per-medication endpoint. Pure computation — no writes. Served through the same per-medication server cache as the per-id read, so the two endpoints warm each other.",
|
||||
"Returns one compact row per non-PRN medication. A scheduled medication with no local schedule remains in the array with applicable=false and reason NO_LOCAL_SCHEDULE. compliance7/compliance30 stay non-null as all-zero compatibility placeholders for released clients; aware clients must ignore those percentages when applicable=false. complianceDisplay is null.",
|
||||
responses: {
|
||||
...recordRefusal(),
|
||||
"200": {
|
||||
@@ -1436,7 +1436,7 @@ export const medicationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["Medications"],
|
||||
summary: "Adherence read for a medication",
|
||||
description:
|
||||
"Returns the 7- and 30-day adherence summaries, the per-day compliance grid for the history glyph track, and the two-row display block. Pure computation — no writes. Day boundaries are resolved in the user's IANA timezone, and the expected-dose denominator is cadence-aware (RRULE / rolling / one-shot / PRN / cyclic) and clamped to the medication's `createdAt`. Read `compliance30` for the headline 30-day taken-vs-expected percentage; build the per-day glyph track from `dailyCompliance` (draw a cell only where `due === true`).",
|
||||
"Returns the cadence-aware adherence payload. A scheduled medication with no local schedule returns applicable=false, reason NO_LOCAL_SCHEDULE, non-null all-zero compliance7/compliance30 compatibility placeholders, a null display block, and an empty daily grid. Aware clients must not render the placeholder percentages. Medications with a local schedule preserve the existing arithmetic, including windows in which no dose is due.",
|
||||
requestParams: {
|
||||
path: z.object({ id: z.string() }),
|
||||
},
|
||||
|
||||
@@ -755,32 +755,61 @@ export const complianceDisplay = z
|
||||
|
||||
export const medicationComplianceResponse = z
|
||||
.object({
|
||||
compliance7: complianceResult,
|
||||
compliance30: complianceResult,
|
||||
applicable: z
|
||||
.boolean()
|
||||
.describe(
|
||||
"Whether a local adherence percentage applies to this medication.",
|
||||
),
|
||||
notApplicableReason: z
|
||||
.literal("NO_LOCAL_SCHEDULE")
|
||||
.nullable()
|
||||
.describe(
|
||||
"Reason adherence is not applicable. NO_LOCAL_SCHEDULE means the medication has no HealthLog-owned expected-dose grid.",
|
||||
),
|
||||
compliance7: complianceResult.describe(
|
||||
"Seven-day adherence summary. When applicable is false, this remains a non-null all-zero compatibility placeholder for released clients and must not be rendered as a percentage.",
|
||||
),
|
||||
compliance30: complianceResult.describe(
|
||||
"Thirty-day adherence summary. When applicable is false, this remains a non-null all-zero compatibility placeholder for released clients and must not be rendered as a percentage.",
|
||||
),
|
||||
dailyCompliance: z
|
||||
.record(z.string(), dailyComplianceEntry)
|
||||
.describe(
|
||||
"Flat per-day map keyed `YYYY-MM-DD` in the user timezone, one entry per day for up to 90 days back, clamped to the medication's `createdAt` (so a recently-created med has fewer entries). No weekly/monthly collapse — this is the raw daily grid.",
|
||||
"Flat per-day map keyed YYYY-MM-DD in the user timezone. Empty when adherence is not applicable.",
|
||||
),
|
||||
complianceDisplay: complianceDisplay
|
||||
.nullable()
|
||||
.describe(
|
||||
"Cadence-scaled display block, or null when adherence is not applicable.",
|
||||
),
|
||||
complianceDisplay,
|
||||
})
|
||||
.meta({
|
||||
id: "MedicationComplianceResponse",
|
||||
description:
|
||||
"Adherence read for a single medication. `compliance30` is the authoritative 30-day taken-vs-expected summary; `dailyCompliance` is the per-day grid for the history glyph track. The graded raw→week→month→year series used elsewhere for AI prompts does NOT apply here — this response is never downsampled.",
|
||||
"Adherence read for a single medication. A scheduled medication with no local schedule returns applicable=false and NO_LOCAL_SCHEDULE rather than a vacuous 100 percent. Scheduled medications that have a local schedule keep the existing cadence-aware arithmetic, including windows with no doses due. PRN behaviour is unchanged.",
|
||||
});
|
||||
|
||||
export const medicationComplianceSummaryEntry = z
|
||||
.object({
|
||||
medicationId: z.string(),
|
||||
compliance7: complianceResult,
|
||||
compliance30: complianceResult,
|
||||
complianceDisplay,
|
||||
applicable: z
|
||||
.boolean()
|
||||
.describe(
|
||||
"Whether this medication has a local expected-dose grid and therefore a displayable adherence percentage.",
|
||||
),
|
||||
notApplicableReason: z.literal("NO_LOCAL_SCHEDULE").nullable(),
|
||||
compliance7: complianceResult.describe(
|
||||
"Seven-day adherence summary, or an all-zero compatibility placeholder when applicable is false.",
|
||||
),
|
||||
compliance30: complianceResult.describe(
|
||||
"Thirty-day adherence summary, or an all-zero compatibility placeholder when applicable is false.",
|
||||
),
|
||||
complianceDisplay: complianceDisplay.nullable(),
|
||||
})
|
||||
.meta({
|
||||
id: "MedicationComplianceSummaryEntry",
|
||||
description:
|
||||
"Compact per-medication adherence row for the batched card read: the 7-/30-day summaries plus the cadence-scaled display block. The per-day `dailyCompliance` grid is NOT on this shape — read the per-medication `/compliance` endpoint for the history glyph track.",
|
||||
"Compact per-medication adherence row for the batched card read. Zero-local-schedule scheduled medications remain present with applicable=false so clients render a settled not-applicable state instead of a loading skeleton.",
|
||||
});
|
||||
|
||||
// v1.16.5 — schedule-era management (the Zeitplan-tab history timeline).
|
||||
|
||||
@@ -15,9 +15,11 @@ import type { ComplianceDisplay } from "@/lib/analytics/compliance";
|
||||
*/
|
||||
export interface MedicationComplianceSummaryEntry {
|
||||
medicationId: string;
|
||||
applicable: boolean;
|
||||
notApplicableReason: "NO_LOCAL_SCHEDULE" | null;
|
||||
compliance7: { rate: number; streak: number };
|
||||
compliance30: { rate: number };
|
||||
complianceDisplay?: ComplianceDisplay;
|
||||
complianceDisplay: ComplianceDisplay | null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -228,7 +228,10 @@ export const createMedicationSchema = z
|
||||
path: ["asNeeded"],
|
||||
})
|
||||
.refine(
|
||||
(b) => b.asNeeded === true || (!!b.schedules && b.schedules.length >= 1),
|
||||
(b) =>
|
||||
(b.externalSource === "APPLE_HEALTH" && b.externalId !== undefined) ||
|
||||
b.asNeeded === true ||
|
||||
(!!b.schedules && b.schedules.length >= 1),
|
||||
{
|
||||
message: "Mindestens ein Zeitfenster",
|
||||
path: ["schedules"],
|
||||
|
||||
Reference in new issue
Block a user