diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index e0389c36a..67461df4e 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -5828,8 +5828,9 @@ paths: - Medications summary: Batched adherence read for every medication of the caller description: Returns one compact row per non-PRN medication. A scheduled medication with no local schedule remains in - the array with applicable=false, reason NO_LOCAL_SCHEDULE, and null percentage/display fields so card and table - clients do not mistake it for 100 percent or leave it loading. + 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: "200": description: One adherence row per medication. @@ -5847,8 +5848,10 @@ paths: - Medications summary: Adherence read for a medication description: Returns the cadence-aware adherence payload. A scheduled medication with no local schedule returns - applicable=false, reason NO_LOCAL_SCHEDULE, null percentage/display fields, and an empty daily grid. Medications - with a local schedule preserve the existing arithmetic, including windows in which no dose is due. + 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. parameters: - in: path name: id @@ -42941,13 +42944,11 @@ components: const: NO_LOCAL_SCHEDULE - type: "null" compliance7: - anyOf: - - $ref: "#/components/schemas/ComplianceResult" - - type: "null" + description: Seven-day adherence summary, or an all-zero compatibility placeholder when applicable is false. + $ref: "#/components/schemas/ComplianceResult" compliance30: - anyOf: - - $ref: "#/components/schemas/ComplianceResult" - - type: "null" + description: Thirty-day adherence summary, or an all-zero compatibility placeholder when applicable is false. + $ref: "#/components/schemas/ComplianceResult" complianceDisplay: anyOf: - $ref: "#/components/schemas/ComplianceDisplay" @@ -43203,15 +43204,13 @@ components: description: Reason adherence is not applicable. NO_LOCAL_SCHEDULE means the medication has no HealthLog-owned expected-dose grid. compliance7: - anyOf: - - $ref: "#/components/schemas/ComplianceResult" - - type: "null" - description: Seven-day adherence summary, or null when applicable is false. + description: 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. + $ref: "#/components/schemas/ComplianceResult" compliance30: - anyOf: - - $ref: "#/components/schemas/ComplianceResult" - - type: "null" - description: Thirty-day adherence summary, or null when applicable is false. + description: 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. + $ref: "#/components/schemas/ComplianceResult" dailyCompliance: type: object propertyNames: diff --git a/messages/pl.json b/messages/pl.json index e3b2ab22f..9276ce2cd 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -1208,6 +1208,8 @@ "veryOverdue": "Mocno po terminie", "complianceWindow": "Przestrzeganie {days} dni", "complianceUnavailable": "Nie udało się wczytać danych o przestrzeganiu", + "complianceNotApplicable": "Przestrzeganie zaleceń nie ma zastosowania", + "complianceNotApplicableReason": "W HealthLog nie zapisano lokalnego harmonogramu.", "complianceInfo": "Udział zarejestrowanych dawek względem oczekiwanych. Plany tygodniowe liczone są tydzień po tygodniu.", "complianceInfoLabel": "Co oznacza przestrzeganie zaleceń?", "lastIntake": "Ostatnie przyjęcie:", diff --git a/src/app/api/medications/[id]/compliance/__tests__/route.test.ts b/src/app/api/medications/[id]/compliance/__tests__/route.test.ts index 5c290f7ef..7e39647d6 100644 --- a/src/app/api/medications/[id]/compliance/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/compliance/__tests__/route.test.ts @@ -175,8 +175,22 @@ describe("GET /api/medications/[id]/compliance - applicability", () => { expect(data).toEqual({ applicable: false, notApplicableReason: "NO_LOCAL_SCHEDULE", - compliance7: null, - compliance30: null, + compliance7: { + totalExpected: 0, + taken: 0, + skipped: 0, + missed: 0, + rate: 0, + streak: 0, + }, + compliance30: { + totalExpected: 0, + taken: 0, + skipped: 0, + missed: 0, + rate: 0, + streak: 0, + }, dailyCompliance: {}, complianceDisplay: null, }); diff --git a/src/app/api/medications/compliance/__tests__/route.test.ts b/src/app/api/medications/compliance/__tests__/route.test.ts index ea031b4c6..f2e70fc43 100644 --- a/src/app/api/medications/compliance/__tests__/route.test.ts +++ b/src/app/api/medications/compliance/__tests__/route.test.ts @@ -59,6 +59,15 @@ import { checkRateLimit } from "@/lib/rate-limit"; const TZ = "UTC"; +const LEGACY_EMPTY_COMPLIANCE = { + totalExpected: 0, + taken: 0, + skipped: 0, + missed: 0, + rate: 0, + streak: 0, +}; + const SESSION_OK = { session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, user: { @@ -165,14 +174,46 @@ describe("GET /api/medications/compliance", () => { medicationId: "mirror-1", applicable: false, notApplicableReason: "NO_LOCAL_SCHEDULE", - compliance7: null, - compliance30: null, + compliance7: LEGACY_EMPTY_COMPLIANCE, + compliance30: LEGACY_EMPTY_COMPLIANCE, complianceDisplay: null, }, ]); expect(prisma.medicationIntakeEvent.findMany).not.toHaveBeenCalled(); }); + it("keeps a mixed scheduled/mirror batch decodable by the legacy non-null contract", async () => { + vi.mocked(prisma.medication.findMany).mockResolvedValue([ + medication("med-1"), + { + ...medication("mirror-1"), + externalSource: "APPLE_HEALTH", + externalId: "hk-concept-1", + schedules: [], + }, + ] as never); + + const res = await (GET as (req: Request) => Promise)( + new Request("http://localhost"), + ); + expect(res.status).toBe(200); + const body = await res.json(); + const items = body.data as Array>; + + expect(items).toHaveLength(2); + expect(items[0]?.applicable).toBe(true); + expect(items[0]?.compliance7).toBeTruthy(); + expect(items[0]?.compliance30).toBeTruthy(); + expect(items[1]).toMatchObject({ + medicationId: "mirror-1", + applicable: false, + notApplicableReason: "NO_LOCAL_SCHEDULE", + compliance7: LEGACY_EMPTY_COMPLIANCE, + compliance30: LEGACY_EMPTY_COMPLIANCE, + complianceDisplay: null, + }); + }); + it("reads each medication through the shared per-medication cache cell", async () => { vi.mocked(prisma.medication.findMany).mockResolvedValue([ medication("med-1"), diff --git a/src/components/medications/detail/medication-detail-tabs.tsx b/src/components/medications/detail/medication-detail-tabs.tsx index aa716c458..fb71596c3 100644 --- a/src/components/medications/detail/medication-detail-tabs.tsx +++ b/src/components/medications/detail/medication-detail-tabs.tsx @@ -382,8 +382,8 @@ export function MedicationDetailTabs({ return await apiGet<{ applicable: boolean; notApplicableReason: "NO_LOCAL_SCHEDULE" | null; - compliance7: { rate: number; streak: number } | null; - compliance30: { rate: number } | null; + compliance7: { rate: number; streak: number }; + compliance30: { rate: number }; complianceDisplay: ComplianceDisplay | null; }>(`/api/medications/${id}/compliance`); } catch { diff --git a/src/lib/medications/compliance-payload.ts b/src/lib/medications/compliance-payload.ts index 3abfbe3c2..10944c561 100644 --- a/src/lib/medications/compliance-payload.ts +++ b/src/lib/medications/compliance-payload.ts @@ -41,12 +41,31 @@ export interface CompliancePayload { */ applicable: boolean; notApplicableReason: "NO_LOCAL_SCHEDULE" | null; - compliance7: ComplianceResult | null; - compliance30: ComplianceResult | null; + compliance7: ComplianceResult; + compliance30: ComplianceResult; dailyCompliance: Record; 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; @@ -155,8 +174,8 @@ export async function buildCompliancePayload( return { applicable: false, notApplicableReason: "NO_LOCAL_SCHEDULE", - compliance7: null, - compliance30: null, + compliance7: NOT_APPLICABLE_LEGACY_COMPLIANCE, + compliance30: NOT_APPLICABLE_LEGACY_COMPLIANCE, dailyCompliance: {}, complianceDisplay: null, }; diff --git a/src/lib/openapi/routes/medications/paths.ts b/src/lib/openapi/routes/medications/paths.ts index 4e3e5c263..17c8f8d9f 100644 --- a/src/lib/openapi/routes/medications/paths.ts +++ b/src/lib/openapi/routes/medications/paths.ts @@ -1413,7 +1413,7 @@ export const medicationPaths: NonNullable = { tags: ["Medications"], summary: "Batched adherence read for every medication of the caller", description: - "Returns one compact row per non-PRN medication. A scheduled medication with no local schedule remains in the array with applicable=false, reason NO_LOCAL_SCHEDULE, and null percentage/display fields so card and table clients do not mistake it for 100 percent or leave it loading.", + "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 = { tags: ["Medications"], summary: "Adherence read for a medication", description: - "Returns the cadence-aware adherence payload. A scheduled medication with no local schedule returns applicable=false, reason NO_LOCAL_SCHEDULE, null percentage/display fields, and an empty daily grid. Medications with a local schedule preserve the existing arithmetic, including windows in which no dose is due.", + "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() }), }, diff --git a/src/lib/openapi/routes/medications/schemas.ts b/src/lib/openapi/routes/medications/schemas.ts index e431046f9..02b1a3908 100644 --- a/src/lib/openapi/routes/medications/schemas.ts +++ b/src/lib/openapi/routes/medications/schemas.ts @@ -766,16 +766,12 @@ export const medicationComplianceResponse = z .describe( "Reason adherence is not applicable. NO_LOCAL_SCHEDULE means the medication has no HealthLog-owned expected-dose grid.", ), - compliance7: complianceResult - .nullable() - .describe( - "Seven-day adherence summary, or null when applicable is false.", - ), - compliance30: complianceResult - .nullable() - .describe( - "Thirty-day adherence summary, or null when applicable is false.", - ), + 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( @@ -802,8 +798,12 @@ export const medicationComplianceSummaryEntry = z "Whether this medication has a local expected-dose grid and therefore a displayable adherence percentage.", ), notApplicableReason: z.literal("NO_LOCAL_SCHEDULE").nullable(), - compliance7: complianceResult.nullable(), - compliance30: complianceResult.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({ diff --git a/src/lib/queries/use-medication-compliance-summary.ts b/src/lib/queries/use-medication-compliance-summary.ts index c6a6ceb39..d58e11641 100644 --- a/src/lib/queries/use-medication-compliance-summary.ts +++ b/src/lib/queries/use-medication-compliance-summary.ts @@ -17,8 +17,8 @@ export interface MedicationComplianceSummaryEntry { medicationId: string; applicable: boolean; notApplicableReason: "NO_LOCAL_SCHEDULE" | null; - compliance7: { rate: number; streak: number } | null; - compliance30: { rate: number } | null; + compliance7: { rate: number; streak: number }; + compliance30: { rate: number }; complianceDisplay: ComplianceDisplay | null; }