Merge pull request #242 from MBombeck/release/v1.9.2

Release v1.9.2 — document the medication compliance endpoint
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-06-02 19:19:23 +02:00
commit 744e8e37ec
4 files changed
+374 -2

No files matched your search

+6
View File
@@ -1,5 +1,11 @@
# Changelog
## [1.9.2] — 2026-06-02 — Document the medication compliance endpoint
### Changed
- **The medication compliance endpoint is now in the public API contract.** `GET /api/medications/{id}/compliance` was missing from the OpenAPI document, so a client generating its types from the contract could mistake the separate cadence endpoint (a different shape) for it. The route is now documented with its exact response — the 7- and 30-day adherence summaries, the per-day compliance grid, and the two-row display block — with the field a client should read for the headline 30-day rate and how to draw the per-day history. No behaviour change; the handler was already correct.
## [1.9.1] — 2026-06-02 — No assessment warm on a page visit
### Fixed
+235 -1
View File
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: HealthLog API
version: 1.9.1
version: 1.9.2
description: >-
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
@@ -1146,6 +1146,38 @@ paths:
$ref: "#/components/schemas/ErrorEnvelope"
"422": *a2
"429": *a3
/api/medications/{id}/compliance:
get:
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`).
parameters:
- in: path
name: id
schema:
type: string
required: true
responses:
"200":
description: Compliance response.
content:
application/json:
schema:
$ref: "#/components/schemas/GetMedicationComplianceResponse"
"401": *a1
"404":
description: Medication not found (or owned by another user).
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"422": *a2
"429": *a3
/api/auth/me/coach-prefs:
get:
tags:
@@ -5018,6 +5050,208 @@ components:
additionalProperties: false
description: One expected-vs-actual dose slot for the cadence timeline chart. `status` is one of `taken | skipped |
missed | pending | future` and drives the chip colour.
GetMedicationComplianceResponse:
type: object
properties:
data:
$ref: "#/components/schemas/MedicationComplianceResponse"
error:
type: "null"
meta:
type: object
properties:
requestId:
type: string
additionalProperties: false
required:
- data
- error
additionalProperties: false
MedicationComplianceResponse:
type: object
properties:
compliance7:
$ref: "#/components/schemas/ComplianceResult"
compliance30:
$ref: "#/components/schemas/ComplianceResult"
dailyCompliance:
type: object
propertyNames:
type: string
additionalProperties:
$ref: "#/components/schemas/DailyComplianceEntry"
description: 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.
complianceDisplay:
$ref: "#/components/schemas/ComplianceDisplay"
required:
- compliance7
- compliance30
- dailyCompliance
- complianceDisplay
additionalProperties: false
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.
ComplianceResult:
type: object
properties:
totalExpected:
type: integer
minimum: 0
maximum: 9007199254740991
description: "Full denominator over the window: `taken + skipped + missed`. Cadence-aware and clamped to the
medication's `createdAt`."
taken:
type: integer
minimum: 0
maximum: 9007199254740991
skipped:
type: integer
minimum: 0
maximum: 9007199254740991
description: Doses the user explicitly skipped — excluded from the `rate` denominator.
missed:
type: integer
minimum: 0
maximum: 9007199254740991
rate:
type: integer
minimum: 0
maximum: 100
description: Adherence percentage `round(taken / (taken + missed) * 100)` — `skipped` is excluded from the denominator.
streak:
type: integer
minimum: 0
maximum: 9007199254740991
description: Consecutive days with every due dose taken.
required:
- totalExpected
- taken
- skipped
- missed
- rate
- streak
additionalProperties: false
description: Rolling-window adherence summary. `compliance30` is the authoritative 'last 30 days, taken vs expected'
read — clients should display `rate` and use `totalExpected` as the denominator rather than re-deriving it from
the daily map.
DailyComplianceEntry:
type: object
properties:
expected:
type: integer
minimum: 0
maximum: 9007199254740991
description: Engine-computed due-slot count for the day. Equals `expectedCount`; kept for existing consumers.
expectedCount:
type: integer
minimum: 0
maximum: 9007199254740991
description: True due-slot count for the day (additive field clients key off so they don't infer due-ness from
`expected`).
due:
type: boolean
description: "`expectedCount > 0`. Paint a per-day glyph as expected/missed ONLY when `due === true`; off-cadence /
pre-creation / PRN days are not misses."
taken:
type: integer
minimum: 0
maximum: 9007199254740991
skipped:
type: integer
minimum: 0
maximum: 9007199254740991
onTime:
type: integer
minimum: 0
maximum: 9007199254740991
description: Doses taken in the on-time band, including the `early` bucket (early counts as compliant).
late:
type: integer
minimum: 0
maximum: 9007199254740991
veryLate:
type: integer
minimum: 0
maximum: 9007199254740991
early:
description: Doses taken before the on-time band's grace start; already folded into `onTime`, surfaced separately for
consumers that differentiate.
type: integer
minimum: 0
maximum: 9007199254740991
required:
- expected
- expectedCount
- due
- taken
- skipped
- onTime
- late
- veryLate
additionalProperties: false
description: Per-day compliance cell with the timing breakdown that drives the history glyph track.
ComplianceDisplay:
type: object
properties:
shortDays:
type: integer
exclusiveMinimum: 0
maximum: 9007199254740991
longDays:
type: integer
exclusiveMinimum: 0
maximum: 9007199254740991
expectedShort:
type: integer
minimum: 0
maximum: 9007199254740991
expectedLong:
type: integer
minimum: 0
maximum: 9007199254740991
minStableDoses:
type: integer
minimum: 0
maximum: 9007199254740991
short:
type: object
properties:
rate:
type: integer
minimum: 0
maximum: 100
streak:
type: integer
minimum: 0
maximum: 9007199254740991
required:
- rate
- streak
additionalProperties: false
long:
type: object
properties:
rate:
type: integer
minimum: 0
maximum: 100
required:
- rate
additionalProperties: false
required:
- shortDays
- longDays
- expectedShort
- expectedLong
- minStableDoses
- short
- long
additionalProperties: false
description: The two-row card block whose windows scale with dosing cadence (dense meds keep 7 / 30 days, sparse meds
step both windows up). NOT the 30-day denominator — read `compliance30.totalExpected` for that.
GetCoachPrefsResponse:
type: object
properties:
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "healthlog",
"version": "1.9.1",
"version": "1.9.2",
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
"license": "AGPL-3.0-only",
"homepage": "https://healthlog.dev",
+132
View File
@@ -1092,6 +1092,109 @@ const medicationCadenceResponse = z
"Cadence + compliance read for a single medication. `next` is the upcoming-dose envelope (null when the course has ended or the rolling clock has no pinning intake yet); `timeline` walks the requested `windowDays` worth of slots in ascending time order.",
});
const complianceResult = z
.object({
totalExpected: z
.number()
.int()
.nonnegative()
.describe("Full denominator over the window: `taken + skipped + missed`. Cadence-aware and clamped to the medication's `createdAt`."),
taken: z.number().int().nonnegative(),
skipped: z
.number()
.int()
.nonnegative()
.describe("Doses the user explicitly skipped — excluded from the `rate` denominator."),
missed: z.number().int().nonnegative(),
rate: z
.number()
.int()
.min(0)
.max(100)
.describe("Adherence percentage `round(taken / (taken + missed) * 100)` — `skipped` is excluded from the denominator."),
streak: z
.number()
.int()
.nonnegative()
.describe("Consecutive days with every due dose taken."),
})
.meta({
id: "ComplianceResult",
description:
"Rolling-window adherence summary. `compliance30` is the authoritative 'last 30 days, taken vs expected' read — clients should display `rate` and use `totalExpected` as the denominator rather than re-deriving it from the daily map.",
});
const dailyComplianceEntry = z
.object({
expected: z
.number()
.int()
.nonnegative()
.describe("Engine-computed due-slot count for the day. Equals `expectedCount`; kept for existing consumers."),
expectedCount: z
.number()
.int()
.nonnegative()
.describe("True due-slot count for the day (additive field clients key off so they don't infer due-ness from `expected`)."),
due: z
.boolean()
.describe("`expectedCount > 0`. Paint a per-day glyph as expected/missed ONLY when `due === true`; off-cadence / pre-creation / PRN days are not misses."),
taken: z.number().int().nonnegative(),
skipped: z.number().int().nonnegative(),
onTime: z
.number()
.int()
.nonnegative()
.describe("Doses taken in the on-time band, including the `early` bucket (early counts as compliant)."),
late: z.number().int().nonnegative(),
veryLate: z.number().int().nonnegative(),
early: z
.number()
.int()
.nonnegative()
.optional()
.describe("Doses taken before the on-time band's grace start; already folded into `onTime`, surfaced separately for consumers that differentiate."),
})
.meta({
id: "DailyComplianceEntry",
description:
"Per-day compliance cell with the timing breakdown that drives the history glyph track.",
});
const complianceDisplay = z
.object({
shortDays: z.number().int().positive(),
longDays: z.number().int().positive(),
expectedShort: z.number().int().nonnegative(),
expectedLong: z.number().int().nonnegative(),
minStableDoses: z.number().int().nonnegative(),
short: z.object({
rate: z.number().int().min(0).max(100),
streak: z.number().int().nonnegative(),
}),
long: z.object({ rate: z.number().int().min(0).max(100) }),
})
.meta({
id: "ComplianceDisplay",
description:
"The two-row card block whose windows scale with dosing cadence (dense meds keep 7 / 30 days, sparse meds step both windows up). NOT the 30-day denominator — read `compliance30.totalExpected` for that.",
});
const medicationComplianceResponse = z
.object({
compliance7: complianceResult,
compliance30: complianceResult,
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."),
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.",
});
const insightsComprehensiveResponse = z
.object({
summary: z.string(),
@@ -2194,6 +2297,35 @@ export const openApiPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
},
"/api/medications/{id}/compliance": {
get: {
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`).",
requestParams: {
path: z.object({ id: z.string() }),
},
responses: {
"200": {
description: "Compliance response.",
content: {
"application/json": {
schema: dataEnvelope(
medicationComplianceResponse,
"GetMedicationComplianceResponse",
),
},
},
},
"404": {
description: "Medication not found (or owned by another user).",
content: { "application/json": { schema: errorEnvelope } },
},
...stdResponses,
},
},
},
"/api/auth/me/coach-prefs": {
get: {
tags: ["Auth"],