mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
Merge pull request #242 from MBombeck/release/v1.9.2
Release v1.9.2 — document the medication compliance endpoint
This commit is contained in:
4 files changed
+374
-2
No files matched your search
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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"],
|
||||
|
||||
Reference in new issue
Block a user