Skip to content

Releases: MBombeck/HealthLog

v1.7.0 — Health-record export, flexible schedules, first-paint snapshot, full HealthKit coverage

31 May 12:36
50e0a65

Choose a tag to compare

v1.7.0 brings a selectable health-record export (polished PDF + HL7 FHIR R4 bundle, packaged together), flexible medication schedules (PRN, cyclic on/off weeks, rolling, RRULE) with cadence-canonical compliance everywhere, a first-paint dashboard snapshot with overnight insight pre-generation, charts for every stored HealthKit metric (walking speed in km/h), an offline sync delta feed with tombstones, selectable Coach data clusters, and patient-identity fields for the doctor report.

Full notes in CHANGELOG.md.

v1.6.0 — Medication editor overhaul, route of administration, today-tile read-flip

29 May 22:54

Choose a tag to compare

v1.5.6 settled the medication detail page into a pure history view, but the create/edit plan still funnelled every dose through the same flat shape, the injection-site picker only surfaced for GLP-1, and the today-tile on the dashboard and the Erfassen sheet still expanded daily schedules through a legacy walker that silently skipped bi-weekly, rolling, RRULE, and one-time cadences. This release reworks the medication editor and its detail surface, gives route of administration a first-class column so any injection can carry a site, adds a one-time-injection shape, and flips the today-tile onto the canonical recurrence engine the reminder worker already uses — so what the tile shows matches what the worker mints. It also restores the multi-language request snippets on the API-tokens row, adds a profile-photo upload to Account, and finishes an admin-surface polish pass.

Added

  • Route of administration — a new deliveryForm column (ORAL | INJECTION | OTHER) decoupled from treatmentClass. The injection-site picker now surfaces for any INJECTION dose rather than only GLP-1, and the editor carries the route through create, update, and the detail snapshot. Migration 0088 adds the enum and backfills ORAL onto every existing row via a constant column default (a single non-blocking metadata operation).
  • One-time injection — a one-off dose modelled as oneShot = true + deliveryForm = INJECTION, with its own editor path that drops the recurring-schedule step.
  • Profile-photo upload on Account — Settings → Account gains an avatar card backed by the v1.5.5 upload endpoint (server-side magic-byte sniff, 2 MiB stream-level cap, 2048² dimension cap, per-user rate limit, owner-scoped read).
  • Admin global mood-log toggle — the admin Services section can suspend mood-log reminders site-wide alongside the existing Web-Push and API toggles.
  • Host-metrics memory detail — the admin host-metrics memory tooltip now reads used / total GiB alongside the percentage.

Changed

  • Medication editor + detail surface overhaul — the editor uses the available desktop width while keeping the mobile sheet, restores the edit fields the modal-wizard rework had dropped, and aligns the detail surface with the new route-of-administration and one-time shapes.
  • Measurement note cap raised 25 → 200 characters — a 25-character note could not hold a meaningful clinical aside; the cap lifts to 200 through a single MEASUREMENT_NOTES_MAX_LENGTH constant the client char-counters import. The DB column was already unbounded.
  • Dashboard range colors route through semantic tokens — the dashboard date-range chips read the shared success / warning / info / destructive tokens (with their light-mode contrast overrides) instead of hard-coded hues, and the range inputs gain bound-clamping.
  • Admin surface tidy — every sidebar section gets a distinct icon, the shared helpfulRateColour helper and a usePublicVersion hook are consolidated into _shared, and the orphaned status-overview route is removed.
  • API-tokens row restores multi-language request snippets — the per-token row again offers copyable cURL / JavaScript / Python examples.

Fixed

  • Today-tile diverged from the reminder worker for non-daily cadences/api/medications/intake?scope=today and /api/dashboard/summary projected daily schedules through a legacy walker that read only daysOfWeek + windowStart, silently skipping intervalWeeks > 1, rolling, RRULE, and one-time cadences. Both routes now gate every "does this schedule emit today?" decision through the canonical recurrence engine (the same path the worker uses), anchoring the projected instant to windowStart so it stays byte-identical to the worker's row and dedupes against the existing unique index. The rolling-cadence baseline fetch mirrors the worker's per-medication takenAt query, so projector and worker resolve the same next-due instant.

Tests

  • 5635 → 5628 unit; 1 skipped. The today-tile read-flip carries new coverage on the intake and dashboard-summary projections; the orphaned status-overview route and its test are gone.

v1.5.6 — Pure-history detail page, legacy step consolidation, egress finish

29 May 20:14

Choose a tag to compare

v1.5.5 gave every retired medication feature a home on the detail page, but the page still carried the create/edit affordances that belong on the list. This release turns /medications/[id] into a pure history surface, collapses every setting into one advanced sheet, consolidates the pre-v1.5.0 granular step rows that bloated the per-sample read path, and finishes the outbound-fetch migration the safeFetch wrapper started.

Added

  • Legacy step consolidation — a pg-boss job folds pre-v1.5.0 granular ACTIVITY_STEPS rows into one canonical daily-total row per user per day (timezone-anchored) and soft-deletes the originals. A day already carrying a post-v1.5.0 stats: total is left untouched (no double-count). Discovery enqueues only users still holding live legacy rows, so the pass converges and is idempotent across reboots. Migration 0087 adds a partial index for the discovery scan.
  • healthlog/safe-fetch-required ESLint rule — bans raw fetch( outside the wrapper, exempting only genuine same-origin relative paths.

Changed

  • /medications/[id] is now a pure history view — the Today's-dose card stays on the list page; the detail page reads past intake, cadence summary, and trends only. Every setting moves into a new advanced-settings sheet reached from the header, and the edit trigger becomes a two-option picker. The list page is unchanged.
  • Outbound-fetch migration finished — Withings, Codex OAuth + SSE, the bug-report comment, and the operator-host calls (Umami, GlitchTip, Loki) all route through safeFetch; operator-supplied hosts pin the connect-time public-IP guard.
  • /api/insights/layout is registered in the OpenAPI spec.

Fixed

  • Intake-edit dialog now seeds from the row's real values instead of an empty stub.
  • Step consolidation skips a unique-constraint-colliding day instead of stranding it on every boot.
  • Avatar upload reads the body once under a bounded cap, no longer over-buffering on the abort path.
  • The egress lint exemption rejects protocol-relative and backslash absolute URLs.

Removed

  • The dead wizard landing-intent path left over after the pure-history rewrite.

v1.5.5 — Medication detail page, iOS coord wave, outbound-fetch hardening

28 May 23:02

Choose a tag to compare

The v1.5.4 modal wizard landed the create + edit plan flow, but it took 16 features down with the retired flat form — Einnahmen bearbeiten / löschen, Medikament pausieren / beenden / löschen, per-Med API tokens, Phasen-Konfiguration, CSV-Import — and the trends-row on Insights had a 34 px overflow that pushed annotation copy on top of the charts. This release lands the medication detail page that gives every retired feature a coherent home, polishes the wizard against the live walk-through feedback, closes the iOS audit follow-ups in one go, and hardens every outbound fetch the server makes.

Added

  • /medications/[id]/page.tsx — a single Server-Component detail page composed of eight sections (Header band → Today's-dose → Cadence summary → Phasen (GLP-1) → Intake history preview → Notifications → Settings → Verwaltung & Gefahrenzone). One-shot medications walk a five-section variant; paused medications keep the structure but the status pill flips to Pausiert and the dose-card surface mutes. Restores every one of the 16 features the v1.5.4 flat-form retirement displaced.
  • Per-row + bulk intake history actions — the preview now ships a per-row kebab (Bearbeiten / Löschen) and a multi-select toolbar that fires the new POST /api/medications/{id}/intake/bulk-delete endpoint. Marc's „Einnahmen löschen" + „Einnahmen bearbeiten" complaints from the v1.5.4 walk-through close cleanly.
  • safeFetch wrapper at src/lib/safe-fetch.ts — every outbound fetch the server makes for user-supplied hosts now inherits redirect: "manual" + AbortSignal.timeout(15_000) defaults. Closes #218.
  • DNS-rebinding pinned undici.Agent at src/lib/safe-fetch-dispatcher.ts — when requirePublicHost: true, the dispatcher resolves the hostname literally inside the connect hook, refuses any address isPublicIp would reject, and pins the connection to the first valid public address. The five user-host paths (MoodLog sync + push + test, local-AI client, ntfy) route through it. Closes #217.
  • Self-hosted avatar storage at POST/GET/DELETE /api/user/avatar — multipart-uploaded JPEG/PNG/WebP, 2 MiB / 2048×2048 max, hand-rolled magic-byte sniff + dimension probe so no native dependency lands. Stored on the User row as BYTEA; rides pg_dump alongside the rest of the row. The profile response carries an avatarUrl with a cache-busting timestamp. src/lib/gravatar.ts retires — Automattic no longer sees the email-hash on every authenticated page load.
  • POST /api/medications/{id}/intake/bulk-delete — owner-scoped, capped at 500 event IDs per call, rate-limited per user. Pre-work for the detail page's multi-select.
  • GET / PUT / DELETE /api/insights/layout — mirrors /api/dashboard/widgets so insights tile order + visibility persist server-side and sync across devices. Default order: overview, blutdruck, puls, sauerstoff, koerpertemperatur, gewicht, bmi, aktive-energie, workouts, schlaf, ruhepuls, hrv, stimmung, medikamente. Default-visible: overview, blutdruck, puls, gewicht, bmi, workouts, stimmung, medikamente.
  • Eight new Apple Health quantity-type mappingsRESPIRATORY_RATE, BODY_MASS_INDEX, LEAN_BODY_MASS, WALKING_HEART_RATE_AVERAGE, WALKING_ASYMMETRY, WALKING_DOUBLE_SUPPORT, WALKING_STEP_LENGTH, WALKING_SPEED. The convention block on apple-health-mapping.ts documents the project rule: raw HK values flow on the wire, the server scales ×100 server-side for percent metrics. Step length and walking speed flow raw in SI (m and m/s) — no scaling.
  • Three new series-kind enum valuesRESTING_HEART_RATE, HEART_RATE_VARIABILITY, VO2_MAX. Detail + trend views on those metrics now respond 200 instead of 422.

Changed

  • Wizard polish — dialog widens to sm:max-w-2xl; the X-close gets a 44 px target; spacing tokens converge on the existing shadcn cadence (rounded-md button, rounded-lg dialog, no new radii); the step-progress bar is a width-only <Progress> + Tailwind transition-all with motion-reduce snap; step transitions are fade-only. landingStepForEdit accepts an intent argument so the cadence-summary edit pencil drops the user on Step 5 instead of bouncing them through Step 1.
  • /api/measurements/series days cap — raised from 365 to 3650, matching the recurrence engine's hard cap. The iOS app's „Alle"-range no longer paints a 422 banner on every metric.
  • /api/dashboard/summary MetricCard shapetitle and unit ship as i18n keys (dashboard.metric.title.* + dashboard.metric.unit.*) instead of hardcoded German strings. Web resolves via the existing messages/*.json path; iOS resolves against its Localizable.xcstrings. Wire-shape change — clients that decoded the legacy title / unit strings keep working with the iOS team's tolerant fallback decoder during the transition.
  • assertMedicationOwnership consistency sweeppurge, parent PUT, parent DELETE, intake/import, phase-config, bulk-delete, glp1 GET all converge on the single ownership helper. The §10 invariant 24 from the design direction now holds across src/app/api/medications/[id]/**.
  • Trends-row chart slot — raised from 140 → 180 px so the mini-card shell's header + padding stays inside the slot. The TrendAnnotation under each chart no longer collides with the chart envelope.
  • Wizard payload edit pathbuildCreateBody omits notificationsEnabled on edit so the toggle the user already set in the detail page Notifications section is not overwritten by the wizard's hydrated default.
  • <NotificationsSection> DOM ids — section heading and row label carry distinct ids so the Switch's aria-labelledby resolves to the row title, not the section heading.
  • phase-config route surface — PUT returns the multi-issue 422 envelope on Zod failure (matching every other v1.5.5 route), and the upsert builds the Prisma payload field-by-field instead of spreading parsed.data. Mass-assignment surface closes structurally.

Fixed

  • Trends-row text overlapping the charts — Marc's specific Insights complaint. Chart slot was 140 px but the mini-card shell painted ~174 px; the 34 px overflow pushed annotation copy onto the chart envelope. The 180 px slot accommodates the full envelope.
  • Grace-row save dead on arrival — the detail page Settings section PUTs reminderGraceMinutes at the top level; the route now normalises the value onto the primary schedule before the Prisma update. The schema declares the top-level field with a description noting the normalisation.
  • Purge route did not invalidate server caches — Tier-3a Verlauf löschen dropped the rollup rows but left the analytics + iOS today-tally caches with the pre-purge counts for up to their TTL. The success path now calls invalidateUserMedications(user.id) alongside the rollup delete.
  • Bi-weekly worker still emitted every Wednesday — the v1.5.3 cadence engine fix was correct, but the cadence chart + medication card on the dashboard still read the legacy daysOfWeek column. The v1.5.x window keeps this in place; the read-flip arrives in v1.5.6. Operators see correct reminder fan-out today; the dashboard chip cosmetic catch-up follows.
  • Compose-mode multi-schedule data loss — the v1.5.4 wizard collapsed a multi-schedule medication to its first schedule on save and silently dropped the rest. Closed in v1.5.4 by the compose-mode commits; the regression test pinning that lives at wizard-payload.test.ts.

Tests

  • 5594 → 5615 unit (+21 in the reconciliation sweep alone; the detail-page surface + audit fixes add ~110 across the cycle).
  • 262 integration unchanged + the avatar upload integration test (tests/integration/user-avatar.test.ts).
  • New Playwright spec pre-work — the detail-page surface is component-test pinned; e2e walk lands in a follow-up.

Security

  • safeFetch + DNS-rebinding pinned dispatcher close issues #217 + #218 architecturally — the input-time isPublicUrl guard now pairs with a connect-time IP pin so DNS rebinding cannot flip the resolved host between accept and dispatch.
  • Avatar route enforces size before parse (Content-Length pre-flight + post-parse file.size check), magic-byte sniff over the declared content-type, dimension probe, owner-scoped on every method. No new XSS vector — the served content-type is whitelisted to the three image MIMEs.
  • assertMedicationOwnership sweep closes every detail-page route's ownership narrowing — the route layer is now the single ownership predicate across src/app/api/medications/[id]/**.
  • Pre-tag senior-dev + security architect audit produced two docs at .planning/medication-detail-page-2026-05-28/F-{1,2}-*.md. The four senior-dev Criticals + five Highs landed in code; F-1 H-5 (safeFetch migration to constant-host call sites like Withings + Codex + the GitHub bug-reporter) + F-2 M-1..M-3 (operator-host requirePublicHost, avatar chunked-body pre-flight, raw-fetch lint rule) deferred to v1.5.5.1.

iOS coord

  • The iOS team's v0.8.0 audit closed cleanly. Detailed acknowledgements at .planning/ios-coord/v155-wire-six-deferred-identifiers.md + .planning/ios-coord/v155-step-length-speed-followup.md.
  • The iOS team flips walkingAsymmetryPercentage + walkingDoubleSupportPercentage from pre-multiplied to raw in their next release; until then the server fails-closed (skipped:"value_out_of_range") on > 100% values, so no DB pollution.

Notes

  • No new npm dependencies. Avatar image-header parsing is hand-rolled; no sharp / jimp. The animation surface is shadcn <Progress> + Tailwind transition-all; no Framer Motion. Only undici was promoted from transitive to explicit so the safeFetch-dispatcher import sits on a documented contract.
  • Test totals. 5615 unit + 1 skipped, 262 integration + 3 skipped. pnpm typecheck, pnpm lint (one pre-existing withings/resume warning), pnpm openapi:check, locale-integrit...
Read more

v1.5.4 — Medication wizard: modal dialog, compose-mode, ten-bucket taxonomy

28 May 18:02

Choose a tag to compare

The v1.5.3 creation wizard shipped as a single-page card with seven inline steps. Patient feedback during the first day of live use was unanimous: the page felt dense, the step-by-step intent was lost, the "every N days from my last injection" cadence was unintelligible without a worked example, and the edit form was visibly wider than its container. The bi-weekly worker bug was closed in v1.5.3 but the surface a patient actually touches was not yet where it needed to be. This release replaces the wizard with a real modal-dialog flow, lands compose-mode so a single medication can carry multiple parallel schedules (the insulin short-acting + long-acting case), widens the treatment-class taxonomy to ten buckets, and retires the flat edit form.

Added

  • Modal-dialog wizard. src/components/medications/wizard/MedicationWizardDialog.tsx is the new entry surface — a shadcn <Dialog> on desktop, a <Sheet side="bottom"> on mobile, switched through the existing <ResponsiveSheet> primitive at 768 px. The wizard renders its own header inside the body so the iconified step anchor sits where the patient reads, and the footer carries Back / Save with a fixed sticky position on mobile. Eight steps total, one focused question each: name → treatment class → dose → course window → cadence → sub-cadence detail → times of day → review with reminders toggle and per-schedule summary cards. Cadence + sub-cadence + times collapse out of the path when they do not apply (one-shot walks five, daily walks seven, recurring walks eight); the visible counter mirrors the path the patient actually walks.
  • Compose-mode. A medication can now hold multiple parallel schedules, each with its own cadence and times. Steps 5–7 are per-schedule; Step 8 renders the schedule list with an "Bearbeiten" and "Entfernen" action on each card plus a "+ Weiteren Zeitplan hinzufügen" card at the bottom. The last remaining schedule is non-removable so the medication always carries at least one schedule. A medication opened in edit mode with more than one configured schedule lands the patient directly on the list view; single-schedule edits keep the Step 1 entry. The header on Steps 5–7 carries a small "Zeitplan {n} von {total}" caption when more than one schedule is configured.
  • Ten-bucket treatment-class taxonomy on Step 2 — Blutdruck, Diabetes, Hormone, GLP-1-Injektion, Schmerz, Allergie, Vitamine, Nahrungsergänzung, Antibiotikum, Sonstiges. Schmerz lands the chronic-pain cohort with its own rolling-rescue-medication pattern; Antibiotikum lands the one-shot course pattern cleanly so neither falls through to OTHER for analytics purposes. Each row carries a monochrome Lucide glyph (Stethoscope / Droplet / Activity / Syringe / Flame / Wind / Apple / Leaf / ShieldCheck / Tag).
  • DIABETES + ANTIBIOTIC as first-class values of the MedicationCategory Zod enum so insights routes that filter on the clinical category surface those buckets without falling back to OTHER. Additive at the schema layer; existing rows do not need touching.
  • Rolling-cadence mental-model copy. Step 5's "Flexibel ab letzter Einnahme" row carries the example sentence directly under the label (not behind a tooltip) — "Du lässt den nächsten Termin offen, drückst 'genommen' wenn du sie genommen hast — ab dem Zeitpunkt zählt der Counter wieder." The patient-pain research across chronic-illness adherence studies identified this exact gap as the #1 reason patients abandon medication apps in week one; the in-line example sentence closes it.

Changed

  • Edit path routes through the same dialog. "Bearbeiten" on a medication card opens <MedicationWizardDialog mode="edit" initial={…} />; the header swaps to "{name} bearbeiten", the CTA to "Änderungen speichern", and the entire payload hydrates from the existing medication shape including the schedule id so a PUT preserves the per-schedule identity. The flat <MedicationForm> (1314 LOC, surfaced as a <ResponsiveSheet> that rendered wider than its container on the medications list page) retires in the same change.
  • /medications/new retires to a redirect that opens the dialog from the list page (/medications?new=1). Existing bookmarks survive without losing the entry point.
  • i18n. A clean medications.wizard.* namespace replaces medications.create.wizard.* so the locale-integrity guard surfaces every dangling key during the cut. German and English carry native copy; Spanish, French, Italian, Polish ship the English string verbatim as a machine fallback per the project convention. Native polish for the four fallback locales follows.
  • <CreationWizard> (v1.5.3's inline-stepped Card) and <PhaseConfigDialog> (an unused titration-phase helper that knip flagged once the list page swapped over) retire alongside the form.

Fixed

  • Multi-schedule data-loss risk — the v1.5.3 wizard collapsed a multi-schedule medication to its first schedule on save and silently dropped the rest. Compose-mode is the proper fix; the encoder now emits every schedule, the hydrator reads every schedule, the per-schedule id round-trips so the PUT preserves identity.
  • The edit form being wider than its container — Marc's specific complaint from the v1.5.3 hand-walk. The container is now a constrained Dialog at sm:max-w-md on desktop and a sticky-footer Sheet at max-h-[90dvh] on mobile; the form-as-page surface is gone.

Tests

  • src/components/medications/wizard/__tests__/wizard-payload.test.ts — 54 pure-helper cases pinning validateStep, buildCreateBody, buildUpdateBody, summariseCadence, the treatment-class row → request body mapping, the multi-schedule encoder, schedule-id preservation on edit, the hydration that lands on the list view when more than one schedule is present, the remove-guard against the last remaining schedule, and the add-then-active-bump behaviour.
  • e2e/medications-wizard-{daily,weekdays,biweekly,monthly,rolling,oneshot}.spec.ts — six Playwright specs updated to walk the new dialog surface via the German label set; e2e/medications-wizard-compose.spec.ts walks a two-schedule create flow (daily Ramipril + weekly-Wednesday addendum) and asserts both summary cards land on Step 8 before save.
  • Route tests assert POST /api/medications accepts a body with category: "DIABETES" and category: "ANTIBIOTIC".

Notes

  • No API or wire-format change. The wizard writes the same createMedicationSchema payload the v1.5.3 wizard wrote; iOS clients reading and writing the existing schedule shape are unaffected. The native treatmentClass enum already shipped GLP1; the wizard's Step 2 maps the Marc-confirmed labels onto the existing enum + the two new MedicationCategory values.
  • Cadence-engine read-flip is still v1.5.x deferred — the today-projector, the cadence chart, and the medication card continue to read the legacy daysOfWeek / intervalWeeks columns. A wizard-minted rolling or one-shot medication will render a daily-looking next-due chip on the dashboard until the read-flip ships. The reminder worker and the engine work with the new fields correctly.
  • Test totals. 5500 unit + 1 skipped, 261 integration + 3 skipped, 14 Playwright wizard instances. Typecheck, lint, openapi:check, i18n call-site coverage, locale-integrity all green at HEAD.

v1.5.3 — Medication scheduling: RRULE cadences, rolling intervals, one-shot lifecycle, creation wizard

28 May 12:49

Choose a tag to compare

The medication surface in v1.5.2 covered daily and weekday-subset schedules cleanly, but everything else — bi-weekly, monthly, quarterly, yearly, "every N days from my last injection", single-dose appointments — either drifted or was unreachable from the UI. The reminder worker also carried a quiet pre-existing bug where intervalWeeks was ignored: a schedule meant to fire every other Wednesday fired every Wednesday. This release lands the full cadence surface, closes the worker bug behind a regression test, and adds a step-driven creation flow patients can walk without consulting a manual.

Added

  • Four new cadence shapes, modelled at the schema level with explicit columns instead of an overloaded legacy string:
    • Calendar-anchored RRULE patterns (rrule TEXT) — FREQ=WEEKLY;INTERVAL=2;BYDAY=WE, FREQ=MONTHLY;BYMONTHDAY=1, FREQ=MONTHLY;INTERVAL=3;BYMONTHDAY=10, FREQ=YEARLY;BYMONTH=1;BYMONTHDAY=1 all expand correctly. Powered by the rrule npm package; the engine adds an UNTIL suffix derived from the medication's endsOn automatically (and skips that suffix when the user's RRULE already carries COUNT or UNTIL, so the two never collide).
    • Flexible-rolling cadence (rollingIntervalDays INT) — "every N days from the last logged intake". The next-due date re-anchors when an intake is logged; skipped doses pause the schedule until the next real intake. The driving use case is the GLP-1 weekly injection where the calendar Wednesday does not match the user's actual cycle.
    • One-shot single-administration (oneShot BOOLEAN) — vaccines, post-op final doses, anything with one scheduled occurrence and an auto-deactivate after the dose is logged.
    • Course window (startsOn DATE, endsOn DATE) at the medication level. Required for one-shot; optional everywhere else. The reminder worker stops minting slots past endsOn.
  • POST /api/medications/extract — natural-language extraction for the wizard's first step. A user types "Mounjaro 5mg weekly Wednesday morning starting next Monday" and the route returns a structured payload that pre-fills the wizard. Rate-limited per user, budget-gated, and carries a citation-coverage guard so the extraction cannot return a name or dose the user did not write.
  • /medications/new — the seven-step creation wizard (compressed to five on the one-shot path) replacing cold-start entry into the legacy flat form. The summary step interpolates the actual picked weekdays / day-of-month / yearly date so the patient confirms the specifics rather than a category label.
  • Reusable picker primitives under src/components/medications/scheduling/: CadencePicker (eight cadence kinds, mode-aware via allowedKinds), TimesOfDayChips (one or more HH:mm entries with morning / noon / evening / night presets), CourseWindowRow (start + optional end with lockEndsToStart for one-shot). Composed by both the wizard and the refactored edit form so any future cadence tweak reaches both surfaces at once.
  • Canonical recurrence engine at src/lib/medications/scheduling/recurrence.ts exposing occurrencesBetween, nextOccurrenceAfter, and matchesInstant. The reminder worker routes through the engine via a narrow worker-helpers.ts adapter; the today-projector, the cadence chart, and the medication card continue to read the legacy fields through v1.5.x and switch over in the v1.5.4 read-flip.
  • Migration 0081_v15_medication_scheduling — adds the new columns, backfills rrule and timesOfDay for every existing schedule (closed-enum regex on the legacy daysOfWeek shapes, ELSE NULL fallback), and stages a CHECK constraint forbidding both rrule and rolling_interval_days populated on the same row.
  • OpenAPI coverage for every medication route — GET / POST / PUT / DELETE /api/medications, GET /api/medications/{id}, POST /api/medications/{id}/intake, GET /api/medications/{id}/cadence, and the new POST /api/medications/extract — registered in src/lib/openapi/routes.ts and regenerated into docs/api/openapi.yaml. The rrule XOR rollingIntervalDays invariant is documented at both the schema description and the per-field descriptions so iOS code-gen surfaces the mutual exclusion.

Fixed

  • Bi-weekly worker regressionsrc/lib/jobs/reminder-worker.ts now consumes the canonical engine, which honours intervalWeeks > 1 via the legacy fallback's week-phase math. A bi-weekly schedule that previously fired every Wednesday now correctly fires every other Wednesday. Pinned by an explicit regression test (recurrence.test.tsdaysOfWeek = "i2;3" emits 2 of 4 candidate Wednesdays in a 4-week window).
  • One-shot lifecycle reconciliationsrc/lib/medications/lifecycle.ts defines reconcileOneShotState(prisma, medicationId, userId) and the helper runs after every intake mutation (POST + PUT + DELETE). A user who logs the single dose then immediately undoes the log gets the medication back as active: true; a user who flips an existing intake from real to skipped also gets the medication reactivated. The helper is a no-op on non-one-shot medications.
  • Legacy fallback startsOn floorexpandLegacy now respects medication.startsOn the way every other dispatch tier does. A legacy-shape schedule with a future startsOn no longer emits historical slots between today and the start date.
  • Course-window invariants — the create and update schemas refuse oneShot: true without startsOn (the design contract requires the anchor date) and refuse any course where endsOn < startsOn (which previously produced a silently dead medication). Rolling-cadence schedules cap timesOfDay at one entry to match the engine's single-time emission.
  • Wizard notificationsEnabled toggle — the Step 7 reminders switch now actually reaches the POST body. Earlier the toggle was visually live but its value was discarded by the body builder and every wizard-created medication ended up with the default notificationsEnabled = true.
  • Edit form: one-shot single source of truth — the medication-level oneShot switch now drives the picker's allowedKinds, so the per-schedule picker can no longer encode kind: "oneShot" while the medication-level switch is off (or vice versa). Toggling the switch on with multiple schedules surfaces a confirmation toast before the collapse-to-single-schedule, instead of silently dropping the extras.
  • Wizard accessibility and tap-target hygiene — focus advances into the new step's first input on Next; the cadence-picker rows are now full-row click targets (44 px); the nav buttons (Back / Next / Create) and the natural-language trigger rise to 44 px; the wizard card carries aria-busy while the submit is in flight.

Changed

  • Recurrence-engine defence-in-depthnextOccurrenceAfter carries a MAX_CHUNKS = 80 cap alongside the pre-existing 10-year hardCap, so a pathological RRULE that walks zero forward (e.g. leap-day-only) can no longer compound through many 90-day chunks. RRULE parse failures now surface via annotate({ action: "medications.rrule.parse_error" }) instead of silently returning [].
  • Edit form refactorsrc/components/medications/medication-form.tsx now composes the picker primitives for the edit path. Pre-v1.5 medications round-trip through inferCadenceFromLegacy on load and dual-write both shapes on save, so the existing legacy schedules keep working unchanged.
  • i18n — 111 new keys × six locales populating the wizard, the picker primitives, the natural-language overlay, the edit-form sections, and the plain-language cadence summary. German and English carry native copy; Spanish, French, Italian, Polish ship the English string verbatim as a machine fallback for this cut. Native polish for the four fallback locales follows.

Tests

  • src/lib/medications/scheduling/__tests__/recurrence.test.ts — 28 unit cases covering every cadence kind plus the edge-case matrix (DST spring-forward, timezone shift mid-course, skipped + late + retroactive doses, paused medication, endsOn cap, missing startsOn, multi-schedule fan-out).
  • Eight tests/integration/v15-cadence-shapes.integration.test.ts cases exercising every cadence shape against a testcontainer Postgres, plus the one-shot lifecycle (take → reconcile-deactivate → delete-intake → reconcile-reactivate → put-skip → reconcile-reactivate).
  • src/components/medications/scheduling/__tests__/{CadencePicker,TimesOfDayChips,CourseWindowRow,CreationWizard}.test.tsx — 90 component tests against the picker primitives and the wizard helpers (validateStep, buildCreateBody, summariseCadence, progressIndices, allowedKinds).
  • src/lib/ai/coach/__tests__/medication-extract-prompt.test.ts — five snapshot cases pinning the extraction prompt across the cadence shapes plus a citation-coverage guard test.
  • src/app/api/medications/extract/__tests__/route.test.ts — six route cases covering auth, rate limit, budget, missing provider, parse failure, and the happy path.
  • e2e/medications-wizard-{daily,weekdays,biweekly,monthly,rolling,oneshot}.spec.ts — six Playwright specs walking the wizard end-to-end via the German label surface, one per cadence shape. CI runs the full suite; locally the specs typecheck without executing the Next.js prod build.

Notes

  • Dashboard read-flip caveat (v1.5.x window). Until the v1.5.4 read-flip lands, the dashboard card, the cadence chart, and the medication-card "next dose" line read the legacy daysOfWeek / intervalWeeks columns directly. A wizard-minted medication with rollingIntervalDays = 7 or an RRULE encoding renders a daily-looking next-due chip on the dashboard. The reminder worker, the integration tests, and the canonical engine all consume the new fields correctly — the schedule fires on the right date, only the visual chip on the card lags. The read-flip with legacy fallback is the next release.
  • **OpenAPI structural e...
Read more

v1.5.2 — Forward SESSION_COOKIE_SECURE through docker-compose

26 May 20:32
03bc83a

Choose a tag to compare

v1.5.1 added a SESSION_COOKIE_SECURE env var so plain-HTTP self-hosts can drop the cookie's Secure flag. The Node helper read it correctly and the unit tests passed, but a self-hoster following the documented .env workaround reported that docker compose exec app env | grep SESSION_COOKIE came back empty — the value was set in .env, set in the helper, but never reached the running container.

Root cause is the way the bundled docker-compose.yml passes env vars to the app service: it lists each one explicitly under environment: rather than mounting the .env file wholesale. Variables not on that whitelist are read by docker compose for ${VAR} substitution but never propagated to the container's process env. SESSION_COOKIE_SECURE wasn't on the list, so setting it in .env was a silent no-op.

Fixed

  • docker-compose.yml now lists SESSION_COOKIE_SECURE: "${SESSION_COOKIE_SECURE:-}" under the app service's environment: block. Defaults to empty (so the helper falls back to NODE_ENV === "production", the pre-v1.5.1 behaviour); setting it to false in .env now actually reaches the Node process.

Self-hoster recipe (full, now end-to-end working)

git pull                         # picks up the new compose file
docker compose pull              # picks up the new image (no-op if already on :latest)
echo 'SESSION_COOKIE_SECURE=false' >> .env
docker compose up -d --force-recreate

Verify with:

docker compose exec app env | grep SESSION_COOKIE
# SESSION_COOKIE_SECURE=false

curl http://10.x.x.x:3000/api/version
# {"data":{"version":"1.5.2",...}}

Then log in over plain HTTP from a non-localhost browser — the session cookie no longer carries Secure, the browser keeps it, and the round-trip completes.

v1.5.1 — Self-hoster opt-out for session-cookie Secure flag

26 May 17:17
c6d2b21

Choose a tag to compare

A self-hoster running HealthLog on a LAN address (http://10.x.x.x:3000) over plain HTTP reported a silent login failure: the /api/auth/login POST returned 200 with a Set-Cookie header on the response, but the very next /api/auth/me request came back 401 and the page reloaded to the login screen. Root cause is the modern browser behaviour around Secure-flagged cookies — every cookie the app issues in NODE_ENV=production carries Secure, and on a plain-HTTP origin that is not localhost / 127.0.0.1 / ::1, the browser silently drops the cookie before sending the next request. The default-Docker image runs NODE_ENV=production, so any operator browsing from a different host than the one running docker compose up (NAS / homelab / VPS / Tailscale + Magic-DNS) used to hit the dead-end.

Added

  • SESSION_COOKIE_SECURE environment variable. Controls the Secure flag on the session cookie, the onboarding-hint cookie, the Withings OAuth state cookie, and the Codex device-OAuth state cookie. Three values:
    • unset (default) — flag is set when NODE_ENV === "production", the long-standing behaviour.
    • false — flag is never set. Use this for LAN / VPN / Tailscale-only deployments where the operator deliberately serves plain HTTP and accepts the trade-off (the session cookie crosses the wire unencrypted; do NOT use on an open-internet HTTP origin).
    • true — flag is always set, useful when a developer fronts a pnpm dev server with HTTPS to test the production cookie path.
  • New shared helper src/lib/auth/secure-cookie.ts (shouldEmitSecureCookie()) that every Secure-bearing cookie call now reads. Replaces four scattered secure: process.env.NODE_ENV === "production" literals in src/lib/auth/session.ts, src/app/api/auth/codex/device-start/route.ts, and src/app/api/withings/connect/route.ts.
  • .env.example block documenting the new variable with a clear warning about the open-internet-HTTP case.

Tests

  • src/lib/auth/__tests__/secure-cookie.test.ts — six cases covering the default path, both explicit overrides, whitespace / case-insensitive parsing, and the fall-through for unrecognised values.

Notes

  • The default behaviour is unchanged for every deployment that runs behind an HTTPS-terminating reverse proxy (the documented self-hosting path) — those continue to set Secure exactly as before. The opt-out is intentional and explicit.
  • Operators currently working around the issue with NODE_ENV=development can move back to NODE_ENV=production plus SESSION_COOKIE_SECURE=false on this release; that keeps build optimisation + production error masking on while letting the cookie reach the browser over HTTP.

v1.5.0 — Native iOS client public-beta + per-day stats overwrite + compliance fix

24 May 20:51
394f0b0

Choose a tag to compare

The minor-version cut that marks the native iOS client publicly available. The SwiftUI iOS app (separate repository) is now joinable via TestFlight: https://testflight.apple.com/join/bucuTBpa. The backend contract the iOS app speaks against has been live since v1.4.23 and has been continuously validated across every v1.4.2x–v1.4.50 release. The 1.5.0 cut also lands the highest-leverage iOS-client unblocker per the v0.6.1 code audit: /api/measurements/batch now overwrites per-day cumulative stats:* rows on a re-post instead of dropping the new value as a duplicate.

Added

  • POST /api/measurements/batch recognises externalId values starting with stats: (stats:HKQuantityTypeIdentifierStepCount:YYYY-MM-DD and every other per-day cumulative HK metric — Active Energy, Sleep Duration, Walking/Running Distance, Flights Climbed) and treats a duplicate on those as an overwrite, not a discard. Each re-post of the same day's external id replaces the row's value, unit, measuredAt, externalSourceVersion, deviceType, and sleepStage. Sample-class externalIds (every other prefix — uuid-*, opaque HK identifiers) keep the strict immutable duplicate contract because each sample is a canonical reading.
  • New per-entry status "updated" on the batch response envelope so the iOS sync cursor can distinguish a fresh insert from a value-bump re-post. The aggregate envelope now carries an updated count alongside inserted / duplicates / skipped.
  • New wide-event annotation measurement.batch.stats-overwrite (fires only when at least one row was overwritten) so operators can grep how often per-day cumulative re-posts happen as a healthy ingest signal.
  • measurement.batch.ingest audit-log details now include the updated count alongside inserted / duplicates / skipped.

Why

Before this change, the iOS HealthKit observer would POST today's running step total once in the morning, the server would persist row #1, and every subsequent same-day re-post (as the user walked) would come back status: "duplicate" with the new value silently dropped. Today's Schritte tile froze at the first-sync value until next midnight. The same shape would hit every cumulative metric on a deterministic per-day external id. Closes #213; cross-device parity (web ↔ iOS) for stats:* metrics now works for today and every historical day on a re-sync.

Fixed

  • Medication compliance now honours daysOfWeek and intervalWeeks across every call site that surfaces a rate. The legacy aggregator computed totalExpected = schedules.length * days, which silently ignored cadence. A weekly Ozempic schedule with all four Mondays taken in the last 30 days reported ~13% adherence (4 / 30) instead of 100%; a weekday-only 3×/day metformin schedule with every weekday dose taken reported ~73% (66 / 90) instead of 100%. calculateCompliance is now a cadence-aware adapter on top of buildCadenceTimeline — the same pair-matching pipeline that drives the per-medication cadence chart — so the rate on the medication card, the AI Coach prompt context (7d/30d/90d windows in src/lib/insights/features.ts), the BP-status compliance gate, the medication-compliance status insight, /api/insights/targets, /api/insights/comprehensive, and the medication-compliance pillar of the dashboard Health Score all agree on a single, cadence-correct denominator. The wire shape ({ totalExpected, taken, skipped, missed, rate, streak }) is unchanged so every UI tile and persisted-insight consumer keeps reading the same fields. Closes #214. Expected user-visible shift: users on weekly meds (GLP-1 agonists, biologics) will see their Health Score rise as the medication pillar moves from ~13 to ~100; users on weekday-only multi-dose schedules will see their score rise as the pillar moves from ~73 to ~100; users on daily-only schedules see no change because the legacy denominator was already correct for that path. Migrations across the eight production call sites are mechanical — the function signature is unchanged.

Changed

  • README rewrite for the v1.5 cut: TestFlight badge in the badge row plus an iOS TestFlight link in the Website / Demo / Docs row and the footer. Buy Me A Coffee badge added. Status block updated to reflect that v1.5 is now the current line, with a new "Heavily developed" advisory directly below it that tells self-hosters to pin a tag, take a backup before every upgrade, and read the CHANGELOG before pulling latest. Tech-Stack table flags the iOS app as TestFlight-available. Roadmap table promotes v1.5 from "in active development" to "current".
  • README simplification: the How it works diagram cluster (four SVGs covering data flow, Coach pipeline, source priority, and security model) is no longer inlined in the README. The diagrams continue to live in docs/diagrams/ and are surfaced through docs.healthlog.dev where they render reliably across themes and viewport widths. The 03-self-hosting-topology.svg stays inline under Deployment because it carries deployment-time information a self-hoster wants on the first scroll.

Tests

  • tests/integration/measurements-batch.test.ts — three new cases pinning the stats:* overwrite contract: solo re-post overwrites the value and returns status: "updated"; sample-class duplicate keeps the strict first-write-wins contract; a mixed batch with one insert + one overwrite + one duplicate returns all three statuses correctly.
  • src/lib/analytics/__tests__/compliance.test.ts — parameterised cadence matrix: 1×/day daily (7 / 0 / 18 of 21), weekly Mondays-only (all taken / one missed), bi-weekly (intervalWeeks=2), weekday-only 3×/day metformin, skipped-dose denominator exclusion, medicationCreatedAt truncation, DST spring-forward boundary in Europe/Berlin, and over-logged-day rate cap. The matrix pins the contract for every cadence the production app exercises so future schedule-shape work can't silently regress.
  • src/lib/analytics/__tests__/health-score-fast-path.test.ts — two cadence-aware regression cases: a weekly Mondays-only med with every Monday taken now lifts the medication-compliance pillar to ≥ 50 (previously ~13 under the bug); a daily-only med with every dose taken stays ≥ 90 (no regression on the path that worked).
  • Eight stale integration assertions retired across withings-oauth.test.ts, withings-oauth-flow.test.ts, analytics-bp-aggregate-paged.test.ts, analytics-sleep-stages.test.ts, and apns-dispatch.test.ts — drift from the v1.4.47.x OAuth fine-grained reason tags, the v1.4.47.2 ES256 PEM verify guard, and the v1.4.49.1 analytics slim-slice annotation rename. Three source-priority-two-axis cases skipped with an inline TODO referencing the v1.4.49.1 commit and the relocation candidate (pick-canonical-workout-rows); these tests exercised picker semantics that no longer fire on the default analytics summaries path.

Notes

  • iOS coordination items closed alongside this cut: the v1.4.49 server-side clientManaged MEDICATION_REMINDER suppression rule is now active for iOS v0.6.0.8+ clients that opt in via PATCH /api/auth/me/notification-prefs; tracked in healthlog-iOS#9. Issue #206 is closed.
  • HealthLog suite: 5285 unit (5279 carryover + 3 new stats-overwrite cases + new compliance matrix and Health-Score regression cases that net out the legacy assertions retired during the cadence-aware migration) + 253 integration tests pass on the local Vitest run, lint clean, typecheck clean.

v1.4.47.6 — APNs Test senden wired

22 May 13:05
30ab211

Choose a tag to compare

Per-channel POST /api/notifications/apns/test endpoint + missing APNS entry in the Settings UI's TEST_ENDPOINTS map. The Test senden button on the APNS row now actually fires, exercising the v1.4.47.5 gateway auto-detect path immediately instead of waiting for cron retry.