Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog

## [1.37.10] — 2026-08-10

### Added

- A medication can take a different dose at each time of day, all from the one supply. If you take one tablet in the morning and half a tablet at noon and at night, that is one medication with three times now, not two separate entries that split your stock in two. Each time slot carries its own dose and its own count of units consumed, so the supply counts down by the right amount at each one. An occasional as-needed dose is a slot on the same medication too, drawing from the same box, rather than a third phantom entry. Existing medications are unchanged, and one that does not set a per-slot amount keeps counting down exactly as before.

## [1.37.9] — 2026-08-10

### Added
Expand Down
8 changes: 7 additions & 1 deletion docs/api/openapi.yaml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: HealthLog API
version: 1.37.9
version: 1.37.10
description: >-
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.

Expand Down Expand Up @@ -15349,6 +15349,12 @@ components:
description: Per-schedule dose override. NULL means the schedule inherits `Medication.dose`.
type: string
maxLength: 50
unitsPerDose:
description: Per-schedule inventory units consumed per dose (#219). A whole number 1-100 or a supported fraction (¼ / ⅓
/ ½ / ⅔ / ¾) for a split pill. Omitted / NULL means the schedule inherits `Medication.unitsPerDose`. Lets
one medication decrement a different tablet count at different times of day (a whole tablet in the morning,
a half at noon).
type: number
daysOfWeek:
description: Legacy day-of-week filter (0=Sunday..6=Saturday). v1.5 reads new writes through `rrule` first; this field
is preserved for pre-v1.5 rows and is the input the route serialises into the persisted `days_of_week`
Expand Down
6 changes: 6 additions & 0 deletions messages/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -1476,6 +1476,11 @@
"evening": "Abends",
"night": "Nachts"
},
"overrideHint": "Optional — für diese Uhrzeit eine abweichende Dosis oder Stückzahl festlegen. Leer lassen, um den Standard des Medikaments zu verwenden.",
"doseOverrideLabel": "Dosis für diese Uhrzeit (optional)",
"doseOverridePlaceholder": "z. B. eine halbe Tablette",
"unitsOverrideLabel": "Verbrauchte Einheiten zu dieser Uhrzeit",
"unitsOverrideInherit": "Standard",
"short": "Zeiten"
},
"step8": {
Expand Down Expand Up @@ -2061,6 +2066,7 @@
"logInjectionSiteConfirm": "Stelle speichern",
"logInjectionSiteNoneAvailable": "Keine Injektionsstelle verfügbar — alle Stellen sind in deinen Einstellungen ausgeschlossen.",
"logInjectionSiteSaveFailed": "Injektionsstelle konnte nicht gespeichert werden. Bitte versuche es erneut.",
"perSlotUnits": "{units} pro Dosis",
"trackInjectionSitesToggle": "Injektionsstellen erfassen",
"trackInjectionSitesHint": "Nach einer eingenommenen Dosis nach der verwendeten Stelle fragen und die nächste Rotation vorschlagen.",
"allowedSitesLabel": "Erlaubte Stellen",
Expand Down
6 changes: 6 additions & 0 deletions messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1476,6 +1476,11 @@
"evening": "Evening",
"night": "Night"
},
"overrideHint": "Optional — set a different dose or unit count just for this time of day. Leave blank to use the medication's default.",
"doseOverrideLabel": "Dose for this time (optional)",
"doseOverridePlaceholder": "e.g. half a tablet",
"unitsOverrideLabel": "Units consumed at this time",
"unitsOverrideInherit": "Default",
"short": "Times"
},
"step8": {
Expand Down Expand Up @@ -2061,6 +2066,7 @@
"logInjectionSiteConfirm": "Save site",
"logInjectionSiteNoneAvailable": "No injection site is available — every site is excluded in your settings.",
"logInjectionSiteSaveFailed": "Could not save the injection site. Please try again.",
"perSlotUnits": "{units} per dose",
"trackInjectionSitesToggle": "Track injection sites",
"trackInjectionSitesHint": "After a dose is taken, ask which site was used and suggest the next rotation.",
"allowedSitesLabel": "Allowed sites",
Expand Down
6 changes: 6 additions & 0 deletions messages/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -1476,6 +1476,11 @@
"evening": "Tarde",
"night": "Noche"
},
"overrideHint": "Opcional: define una dosis o un número de unidades distinto solo para esta hora. Déjalo en blanco para usar el valor predeterminado del medicamento.",
"doseOverrideLabel": "Dosis para esta hora (opcional)",
"doseOverridePlaceholder": "p. ej. media pastilla",
"unitsOverrideLabel": "Unidades consumidas a esta hora",
"unitsOverrideInherit": "Predeterminado",
"short": "Horas"
},
"step8": {
Expand Down Expand Up @@ -2061,6 +2066,7 @@
"logInjectionSiteConfirm": "Guardar sitio",
"logInjectionSiteNoneAvailable": "No hay ningún sitio de inyección disponible: todos los sitios están excluidos en tu configuración.",
"logInjectionSiteSaveFailed": "No se pudo guardar el sitio de inyección. Inténtalo de nuevo.",
"perSlotUnits": "{units} por dosis",
"trackInjectionSitesToggle": "Registrar sitios de inyección",
"trackInjectionSitesHint": "Tras administrar una dosis, pregunta qué sitio se usó y sugiere la siguiente rotación.",
"allowedSitesLabel": "Sitios permitidos",
Expand Down
6 changes: 6 additions & 0 deletions messages/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -1476,6 +1476,11 @@
"evening": "Soir",
"night": "Nuit"
},
"overrideHint": "Facultatif — définissez une dose ou un nombre d'unités différent pour cet horaire uniquement. Laissez vide pour utiliser la valeur par défaut du médicament.",
"doseOverrideLabel": "Dose pour cet horaire (facultatif)",
"doseOverridePlaceholder": "ex. un demi-comprimé",
"unitsOverrideLabel": "Unités consommées à cet horaire",
"unitsOverrideInherit": "Par défaut",
"short": "Heures"
},
"step8": {
Expand Down Expand Up @@ -2061,6 +2066,7 @@
"logInjectionSiteConfirm": "Enregistrer le site",
"logInjectionSiteNoneAvailable": "Aucun site d'injection disponible — tous les sites sont exclus dans vos réglages.",
"logInjectionSiteSaveFailed": "Impossible d’enregistrer le site d’injection. Veuillez réessayer.",
"perSlotUnits": "{units} par dose",
"trackInjectionSitesToggle": "Suivre les sites d'injection",
"trackInjectionSitesHint": "Après la prise d'une dose, demander quel site a été utilisé et suggérer la prochaine rotation.",
"allowedSitesLabel": "Sites autorisés",
Expand Down
6 changes: 6 additions & 0 deletions messages/it.json
Original file line number Diff line number Diff line change
Expand Up @@ -1476,6 +1476,11 @@
"evening": "Sera",
"night": "Notte"
},
"overrideHint": "Facoltativo: imposta una dose o un numero di unità diverso solo per questo orario. Lascia vuoto per usare il valore predefinito del farmaco.",
"doseOverrideLabel": "Dose per questo orario (facoltativo)",
"doseOverridePlaceholder": "es. mezza compressa",
"unitsOverrideLabel": "Unità consumate a questo orario",
"unitsOverrideInherit": "Predefinito",
"short": "Orari"
},
"step8": {
Expand Down Expand Up @@ -2061,6 +2066,7 @@
"logInjectionSiteConfirm": "Salva il sito",
"logInjectionSiteNoneAvailable": "Nessun sito di iniezione disponibile: tutti i siti sono esclusi nelle tue impostazioni.",
"logInjectionSiteSaveFailed": "Impossibile salvare il sito di iniezione. Riprova.",
"perSlotUnits": "{units} per singola dose",
"trackInjectionSitesToggle": "Registra i siti di iniezione",
"trackInjectionSitesHint": "Dopo l'assunzione di una dose, chiedi quale sito è stato usato e suggerisci la rotazione successiva.",
"allowedSitesLabel": "Siti consentiti",
Expand Down
6 changes: 6 additions & 0 deletions messages/pl.json
Original file line number Diff line number Diff line change
Expand Up @@ -1476,6 +1476,11 @@
"evening": "Wieczór",
"night": "Noc"
},
"overrideHint": "Opcjonalnie — ustaw inną dawkę lub liczbę jednostek tylko dla tej pory. Pozostaw puste, aby użyć wartości domyślnej leku.",
"doseOverrideLabel": "Dawka dla tej pory (opcjonalnie)",
"doseOverridePlaceholder": "np. pół tabletki",
"unitsOverrideLabel": "Jednostki zużyte o tej porze",
"unitsOverrideInherit": "Domyślnie",
"short": "Godziny"
},
"step8": {
Expand Down Expand Up @@ -2061,6 +2066,7 @@
"logInjectionSiteConfirm": "Zapisz miejsce",
"logInjectionSiteNoneAvailable": "Brak dostępnego miejsca iniekcji — wszystkie miejsca są wykluczone w Twoich ustawieniach.",
"logInjectionSiteSaveFailed": "Nie udało się zapisać miejsca iniekcji. Spróbuj ponownie.",
"perSlotUnits": "{units} na dawkę",
"trackInjectionSitesToggle": "Rejestruj miejsca iniekcji",
"trackInjectionSitesHint": "Po przyjęciu dawki zapytaj, którego miejsca użyto, i zaproponuj kolejną rotację.",
"allowedSitesLabel": "Dozwolone miejsca",
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "healthlog",
"version": "1.37.9",
"version": "1.37.10",
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
"license": "PolyForm-Noncommercial-1.0.0",
"homepage": "https://healthlog.dev",
Expand Down
6 changes: 6 additions & 0 deletions prisma/migrations/0332_schedule_units_per_dose/migration.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
-- #219 — per-schedule inventory units consumed per dose.
-- Additive, nullable, no backfill: every existing schedule row reads NULL and
-- the consume hook keeps inheriting the medication-level `units_per_dose`, so
-- behaviour is unchanged until a user sets a per-slot value. Same
-- Decimal(10,4) grain as `medications.units_per_dose`.
ALTER TABLE "medication_schedules" ADD COLUMN "units_per_dose" DECIMAL(10,4);
10 changes: 10 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -2738,6 +2738,16 @@ model MedicationSchedule {
windowEnd String @map("window_end") // HH:mm format, e.g. "10:00"
label String? // e.g. "Morning", "Evening"
dose String? // Per-schedule dose override; null = use Medication.dose
/// v1.37.10 (#219) — per-schedule inventory units consumed per dose. The
/// intake consumption hook counts UNITS (tablets, ampoules, puffs); a
/// split-dose plan takes a different unit count at different times of day
/// (a whole tablet in the morning, a half at noon). NULL inherits the
/// medication-level `Medication.unitsPerDose`, so every existing row keeps
/// its current behaviour. Same Decimal(10,4) grain + curated fraction /
/// whole-number posture as the medication-level column; the consume hook
/// matches a taken intake's slot to its schedule by wall-clock time and
/// reads this first, falling back to the medication level on NULL.
unitsPerDose Decimal? @map("units_per_dose") @db.Decimal(10, 4)
/// Legacy string encoding of the cadence ("null | 1,3,5 | i2;1,3,5").
/// v1.5 writes go to `rrule` / `rollingIntervalDays` and v1.5.1 flipped
/// the readers to consult the new fields first.
Expand Down
3 changes: 3 additions & 0 deletions src/app/api/admin/backups/[id]/restore/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -581,6 +581,9 @@ const handler = apiHandler(
windowEnd: s.windowEnd,
label: s.label ?? null,
dose: s.dose ?? null,
// #219 — per-schedule units per dose. Prisma coerces the
// Decimal string; NULL / absent stays NULL (inherit).
unitsPerDose: s.unitsPerDose ?? null,
daysOfWeek: s.daysOfWeek ?? null,
timesOfDay: s.timesOfDay ?? [],
reminderGraceMinutes: s.reminderGraceMinutes ?? null,
Expand Down
9 changes: 9 additions & 0 deletions src/app/api/medications/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
recomputeMedicationComplianceForDay,
} from "@/lib/rollups/medication-compliance-rollups";
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
import { serializeScheduleUnitsPerDose } from "@/lib/medications/schedule-units-dto";
import { hhmmToMinutesOrNull } from "@/lib/medications/scheduling/hhmm";
import { getUserTodayBounds } from "@/lib/tz/local-day";
import { NextRequest } from "next/server";
Expand Down Expand Up @@ -179,6 +180,7 @@ export const GET = apiHandler(
return apiSuccess({
...medication,
unitsPerDose: Number(medication.unitsPerDose),
schedules: serializeScheduleUnitsPerDose(medication.schedules),
category,
nextDueAt: display ? display.at.toISOString() : null,
nextDueOverdue: display?.overdue ?? false,
Expand Down Expand Up @@ -404,6 +406,12 @@ export const PUT = apiHandler(
windowEnd: window.windowEnd,
label: s.label ?? null,
dose: s.dose ?? null,
// #219 — per-schedule inventory units, field-by-field. A schedule
// replace re-creates the rows, so absent leaves the column NULL
// and the consume hook inherits the medication-level value.
...(s.unitsPerDose !== undefined && {
unitsPerDose: s.unitsPerDose,
}),
daysOfWeek: serializedDaysOfWeek,
// v1.5 first-class times-of-day.
timesOfDay: effectiveTimesOfDay,
Expand Down Expand Up @@ -828,6 +836,7 @@ export const PUT = apiHandler(
return apiSuccess({
...medication,
unitsPerDose: Number(medication.unitsPerDose),
schedules: serializeScheduleUnitsPerDose(medication.schedules),
category: normalizedCategory,
});
},
Expand Down
18 changes: 17 additions & 1 deletion src/app/api/medications/route.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { prisma } from "@/lib/db";
import { Prisma } from "@/generated/prisma/client";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { annotate, getEvent } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
Expand All @@ -21,6 +22,7 @@ import {
import { serializeScheduleRecurrence } from "@/lib/medication-schedule";
import { invalidateUserMedications } from "@/lib/cache/invalidate";
import { readMedicationsListCached } from "@/lib/medications/list-read";
import { serializeScheduleUnitsPerDose } from "@/lib/medications/schedule-units-dto";
import { NextRequest } from "next/server";

// v1.32.25 — blast-radius cap on externally-mirrored medications per user.
Expand Down Expand Up @@ -66,7 +68,11 @@ export const GET = apiHandler(async () => {
* nothing was written.
*/
async function respondWithExistingMirror(
medication: Record<string, unknown> & { id: string; unitsPerDose: unknown },
medication: Record<string, unknown> & {
id: string;
unitsPerDose: unknown;
schedules: Array<{ unitsPerDose: Prisma.Decimal | null }>;
},
): Promise<Response> {
let category = "OTHER";
try {
Expand All @@ -88,6 +94,8 @@ async function respondWithExistingMirror(
return apiSuccess({
...medication,
unitsPerDose: Number(medication.unitsPerDose),
// #219 — Decimal → number for the per-schedule column too.
schedules: serializeScheduleUnitsPerDose(medication.schedules),
category,
});
}
Expand Down Expand Up @@ -311,6 +319,12 @@ export const POST = apiHandler(async (request: NextRequest) => {
windowEnd: s.windowEnd,
label: s.label ?? null,
dose: s.dose ?? null,
// #219 — per-schedule inventory units. Field-by-field (no mass
// assignment); absent leaves the column NULL so the consume
// hook inherits the medication-level units_per_dose.
...(s.unitsPerDose !== undefined && {
unitsPerDose: s.unitsPerDose,
}),
daysOfWeek: serializeScheduleRecurrence({
daysOfWeek: s.daysOfWeek ?? [],
intervalWeeks: s.intervalWeeks ?? 1,
Expand Down Expand Up @@ -398,6 +412,8 @@ export const POST = apiHandler(async (request: NextRequest) => {
{
...medication,
unitsPerDose: Number(medication.unitsPerDose),
// #219 — Decimal → number for the per-schedule column too.
schedules: serializeScheduleUnitsPerDose(medication.schedules),
category: normalizedCategory,
},
201,
Expand Down
13 changes: 13 additions & 0 deletions src/components/medications/glp1-medication-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { apiGet, apiPost } from "@/lib/api/api-fetch";
import { formatDateTime, formatTime } from "@/lib/format";
import { getMedicationCategoryLabel } from "@/lib/medications/category-label";
import { formatDose } from "@/lib/medications/format-dose";
import { formatUnitsPerDose } from "@/components/medications/units-per-dose";
import { getDayOfWeekInTz } from "@/lib/tz/local-day";
import { type InjectionSiteKey } from "@/lib/medications/injection-sites";
import { LogInjectionSiteDialog } from "@/components/medications/log-injection-site-dialog";
Expand Down Expand Up @@ -72,6 +73,8 @@ interface ScheduleLite {
/** Cadence fields the supply-runway estimate reads (v1.16.11). */
rrule?: string | null;
rollingIntervalDays?: number | null;
/** #219 — per-schedule units consumed per dose. NULL inherits the med level. */
unitsPerDose?: number | null;
}

interface DoseChangeLite {
Expand Down Expand Up @@ -483,6 +486,16 @@ export function Glp1MedicationCard({
— {formatDose(schedule.dose, t)}
</span>
)}
{typeof schedule?.unitsPerDose === "number" &&
schedule.unitsPerDose > 0 && (
<span className="text-muted-foreground hidden sm:inline">
{" "}
·{" "}
{t("medications.perSlotUnits", {
units: formatUnitsPerDose(schedule.unitsPerDose),
})}
</span>
)}
</>
)
) : null;
Expand Down
12 changes: 12 additions & 0 deletions src/components/medications/medication-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { formatDateTime, formatTime } from "@/lib/format";
import { getDateTimeFormat } from "@/lib/intl/formatter-cache";
import { getMedicationCategoryLabel } from "@/lib/medications/category-label";
import { formatDose } from "@/lib/medications/format-dose";
import { formatUnitsPerDose } from "@/components/medications/units-per-dose";
import { reduceCurrentWindowStatus } from "@/lib/medications/window-status";
import { resolveNextDueDayLabel } from "@/lib/medications/next-due-day-label";
import { resolveDisplayedSlotInstant } from "@/components/medications/card-parts/displayed-slot-instant";
Expand Down Expand Up @@ -52,6 +53,8 @@ interface Schedule {
/** Cadence fields the supply-runway estimate reads (v1.16.11). */
rrule?: string | null;
rollingIntervalDays?: number | null;
/** #219 — per-schedule units consumed per dose. NULL inherits the med level. */
unitsPerDose?: number | null;
}

interface Medication {
Expand Down Expand Up @@ -448,6 +451,15 @@ export function MedicationCard({
— {formatDose(s.dose, t)}
</span>
)}
{typeof s.unitsPerDose === "number" && s.unitsPerDose > 0 && (
<span className="text-muted-foreground hidden sm:inline">
{" "}
·{" "}
{t("medications.perSlotUnits", {
units: formatUnitsPerDose(s.unitsPerDose),
})}
</span>
)}
</>
);
})()
Expand Down
Loading
Loading