feat(medications): add the track-intake switch to the medication pages

The detail page leads the schedule tab with the switch and a one-line
explanation of each state; with tracking off it drops the reminder card,
the next-dose and adherence rows, and shows the stored schedule as the
record. The wizard summary carries the same switch. Cards, the table and
the detail header mark a record-only medication, which leaves the take and
skip buttons, the take-all set, the log-dose dialog and the dashboard quick
add. Strings in all seven locales.
This commit is contained in:
Marc-André Bombeck committed 2026-09-25 08:57:10 +02:00
1 parent 934131ee3e
commit 700de6d228
21 files changed
+431 -91

No files matched your search

+11
View File
@@ -1201,6 +1201,16 @@
"yesterday": "Gestern",
"tomorrow": "Morgen",
"asNeededMarker": "Bei Bedarf",
"recordOnlyBadge": "Nur dokumentiert",
"trackIntake": {
"title": "Einnahmen erfassen",
"label": "Einnahmen erfassen",
"helperOn": "Dosen werden nach diesem Plan fällig, mit Erinnerungen und Therapietreue.",
"helperOff": "Nur dokumentiert: nichts wird fällig, keine Erinnerungen, keine Therapietreue.",
"enabledToast": "Einnahmen werden erfasst",
"disabledToast": "Einnahmen werden nicht erfasst",
"toggleFailed": "Einnahme-Erfassung konnte nicht geändert werden."
},
"withoutNotification": "Ohne Benachrichtigung",
"pausedSince": "Pausiert seit",
"takeNow": "Jetzt einnehmen",
@@ -1927,6 +1937,7 @@
"nextDoseLabel": "Nächste Dosis",
"nextDoseNone": "Keine anstehende Dosis.",
"nextDoseAsNeeded": "Bei Bedarf — keine geplante Dosis.",
"nextDoseNotTracked": "Nicht erfasst, nur dokumentiert.",
"pausedHint": "Pausiert — keine Erinnerungen.",
"reminderLabel": "Erinnerung",
"reminderOn": "Aktiv",
+11
View File
@@ -1201,6 +1201,16 @@
"yesterday": "Yesterday",
"tomorrow": "Tomorrow",
"asNeededMarker": "As needed",
"recordOnlyBadge": "Record only",
"trackIntake": {
"title": "Intake tracking",
"label": "Track intake",
"helperOn": "Doses fall due on this schedule, with reminders and adherence.",
"helperOff": "Kept as a record: nothing falls due, no reminders, no adherence.",
"enabledToast": "Intake tracking on",
"disabledToast": "Intake tracking off",
"toggleFailed": "Could not change intake tracking."
},
"withoutNotification": "Without notification",
"pausedSince": "Paused since",
"takeNow": "Take now",
@@ -1927,6 +1937,7 @@
"nextDoseLabel": "Next dose",
"nextDoseNone": "No upcoming dose.",
"nextDoseAsNeeded": "As needed — no planned dose.",
"nextDoseNotTracked": "Not tracked, kept as a record.",
"pausedHint": "Paused — no reminders.",
"reminderLabel": "Reminder",
"reminderOn": "On",
+11
View File
@@ -1201,6 +1201,16 @@
"yesterday": "Ayer",
"tomorrow": "Mañana",
"asNeededMarker": "Según necesidad",
"recordOnlyBadge": "Solo registro",
"trackIntake": {
"title": "Registro de tomas",
"label": "Registrar tomas",
"helperOn": "Las dosis tocan según este horario, con recordatorios y adherencia.",
"helperOff": "Se guarda como registro: nada queda pendiente, sin recordatorios ni adherencia.",
"enabledToast": "Registro de tomas activado",
"disabledToast": "Registro de tomas desactivado",
"toggleFailed": "No se pudo cambiar el registro de tomas."
},
"withoutNotification": "Sin notificación",
"pausedSince": "En pausa desde",
"takeNow": "Tomar ahora",
@@ -1927,6 +1937,7 @@
"nextDoseLabel": "Próxima dosis",
"nextDoseNone": "No hay dosis pendiente.",
"nextDoseAsNeeded": "Según necesidad — sin dosis programada.",
"nextDoseNotTracked": "Sin registro de tomas, se guarda como registro.",
"pausedHint": "En pausa — sin recordatorios.",
"reminderLabel": "Recordatorio",
"reminderOn": "Activado",
+11
View File
@@ -1201,6 +1201,16 @@
"yesterday": "Hier",
"tomorrow": "Demain",
"asNeededMarker": "Au besoin",
"recordOnlyBadge": "Pour mémoire",
"trackIntake": {
"title": "Suivi des prises",
"label": "Suivre les prises",
"helperOn": "Les doses sont dues selon cet horaire, avec rappels et observance.",
"helperOff": "Conservé pour mémoire : rien n’est dû, pas de rappel, pas d’observance.",
"enabledToast": "Suivi des prises activé",
"disabledToast": "Suivi des prises désactivé",
"toggleFailed": "Impossible de modifier le suivi des prises."
},
"withoutNotification": "Sans notification",
"pausedSince": "En pause depuis",
"takeNow": "Prendre maintenant",
@@ -1927,6 +1937,7 @@
"nextDoseLabel": "Prochaine dose",
"nextDoseNone": "Aucune dose à venir.",
"nextDoseAsNeeded": "Au besoin — aucune dose planifiée.",
"nextDoseNotTracked": "Prises non suivies, conservé pour mémoire.",
"pausedHint": "En pause — aucun rappel.",
"reminderLabel": "Rappel",
"reminderOn": "Activé",
+11
View File
@@ -1201,6 +1201,16 @@
"yesterday": "Ieri",
"tomorrow": "Domani",
"asNeededMarker": "Al bisogno",
"recordOnlyBadge": "Solo annotato",
"trackIntake": {
"title": "Registrazione assunzioni",
"label": "Registra le assunzioni",
"helperOn": "Le dosi sono dovute secondo questo programma, con promemoria e aderenza.",
"helperOff": "Solo annotato: nulla è dovuto, niente promemoria, niente aderenza.",
"enabledToast": "Registrazione assunzioni attiva",
"disabledToast": "Registrazione assunzioni disattivata",
"toggleFailed": "Impossibile modificare la registrazione delle assunzioni."
},
"withoutNotification": "Senza notifica",
"pausedSince": "In pausa dal",
"takeNow": "Assumi ora",
@@ -1927,6 +1937,7 @@
"nextDoseLabel": "Prossima dose",
"nextDoseNone": "Nessuna dose in programma.",
"nextDoseAsNeeded": "Al bisogno — nessuna dose programmata.",
"nextDoseNotTracked": "Assunzioni non registrate, solo annotato.",
"pausedHint": "In pausa — nessun promemoria.",
"reminderLabel": "Promemoria",
"reminderOn": "Attivo",
+11
View File
@@ -1201,6 +1201,16 @@
"yesterday": "어제",
"tomorrow": "내일",
"asNeededMarker": "필요시",
"recordOnlyBadge": "기록만",
"trackIntake": {
"title": "복용 기록",
"label": "복용 기록하기",
"helperOn": "이 일정에 따라 복용 시각이 되고, 알림과 복약 순응도가 적용돼요.",
"helperOff": "기록으로만 남겨요: 예정된 복용, 알림, 복약 순응도가 없어요.",
"enabledToast": "복용 기록 켜짐",
"disabledToast": "복용 기록 꺼짐",
"toggleFailed": "복용 기록 설정을 바꾸지 못했어요."
},
"withoutNotification": "알림 없음",
"pausedSince": "일시중지 시작",
"takeNow": "지금 복용",
@@ -1927,6 +1937,7 @@
"nextDoseLabel": "다음 복용",
"nextDoseNone": "예정된 복용이 없어요.",
"nextDoseAsNeeded": "필요시 복용 — 예정된 복용이 없어요.",
"nextDoseNotTracked": "복용을 기록하지 않아요. 기록으로만 남겨요.",
"pausedHint": "일시중지 — 알림이 오지 않아요.",
"reminderLabel": "알림",
"reminderOn": "켜짐",
+11
View File
@@ -1201,6 +1201,16 @@
"yesterday": "Wczoraj",
"tomorrow": "Jutro",
"asNeededMarker": "W razie potrzeby",
"recordOnlyBadge": "Tylko zapis",
"trackIntake": {
"title": "Rejestrowanie przyjęć",
"label": "Rejestruj przyjęcia",
"helperOn": "Dawki przypadają według tego harmonogramu, z przypomnieniami i przestrzeganiem.",
"helperOff": "Tylko zapis: nic nie przypada, bez przypomnień i bez przestrzegania.",
"enabledToast": "Rejestrowanie przyjęć włączone",
"disabledToast": "Rejestrowanie przyjęć wyłączone",
"toggleFailed": "Nie udało się zmienić rejestrowania przyjęć."
},
"withoutNotification": "Bez powiadomienia",
"pausedSince": "Wstrzymane od",
"takeNow": "Przyjmij teraz",
@@ -1927,6 +1937,7 @@
"nextDoseLabel": "Następna dawka",
"nextDoseNone": "Brak nadchodzącej dawki.",
"nextDoseAsNeeded": "W razie potrzeby — brak zaplanowanej dawki.",
"nextDoseNotTracked": "Przyjęcia nie są rejestrowane, tylko zapis.",
"pausedHint": "Wstrzymano — brak przypomnień.",
"reminderLabel": "Przypomnienie",
"reminderOn": "Włączone",
+27 -19
View File
@@ -105,6 +105,12 @@ interface Medication {
oneShot?: boolean;
/** v1.16.11 (#316) — as-needed (PRN): no schedules, never due. */
asNeeded?: boolean;
/**
* v1.39.1 (#1033) — false keeps the medication as a record: served with
* `schedules: []` (the stored rows ride `recordedSchedules`), never due,
* and left out of the take-all and log-dose flows.
*/
trackIntake?: boolean;
/** v1.9.0 — optional WHO ATC classification code for the FHIR export. */
atcCode?: string | null;
/** v1.9.0 — optional RxNorm RxCUI (secondary FHIR coding). */
@@ -647,25 +653,27 @@ export default function MedicationsPageClient() {
<LogIntakeDialog
open={logIntakeOpen}
onOpenChange={setLogIntakeOpen}
medications={activeMeds.map((m) => ({
id: m.id,
name: m.name,
dose: m.dose,
active: m.active,
lastTakenAt: m.lastTakenAt,
todayEventCount: m.todayEventCount ?? 0,
nextDueAt: m.nextDueAt,
nextDueOverdue: m.nextDueOverdue,
schedules: m.schedules.map((s) => ({
windowStart: s.windowStart,
windowEnd: s.windowEnd,
daysOfWeek: s.daysOfWeek,
label: s.label,
dose: s.dose,
timesOfDay: s.timesOfDay,
doseWindows: s.doseWindows,
})),
}))}
medications={activeMeds
.filter((m) => m.trackIntake !== false)
.map((m) => ({
id: m.id,
name: m.name,
dose: m.dose,
active: m.active,
lastTakenAt: m.lastTakenAt,
todayEventCount: m.todayEventCount ?? 0,
nextDueAt: m.nextDueAt,
nextDueOverdue: m.nextDueOverdue,
schedules: m.schedules.map((s) => ({
windowStart: s.windowStart,
windowEnd: s.windowEnd,
daysOfWeek: s.daysOfWeek,
label: s.label,
dose: s.dose,
timesOfDay: s.timesOfDay,
doseWindows: s.doseWindows,
})),
}))}
/>
)}
</div>
@@ -80,6 +80,8 @@ interface Schedule extends ScheduleWindowInput {
export interface MedicationOption extends DefaultMedicationOption {
dose: string;
schedules: Schedule[];
/** v1.39.1 (#1033) — false: kept as a record, not offered for a dose. */
trackIntake?: boolean;
}
interface MedicationIntakeQuickAddProps {
@@ -138,7 +140,9 @@ export function MedicationIntakeQuickAdd({
const medications = useMemo(
() =>
Array.isArray(medicationsRaw)
? medicationsRaw.filter((m) => m.active)
? // v1.39.1 (#1033) — a medication with intake tracking off is
// kept as a record; the dose capture does not offer it.
medicationsRaw.filter((m) => m.active && m.trackIntake !== false)
: [],
[medicationsRaw],
);
@@ -133,6 +133,14 @@ export interface MedicationCardBodyProps {
*/
asNeeded?: boolean;
/**
* v1.39.1 (#1033) — intake tracking off. The medication is shown as a
* record: no compliance block (the batched read excludes it, so a
* skeleton would spin forever), no open-cycle slot, and no take / skip
* actions, because nothing is due and doses are not logged from here.
*/
recordOnly?: boolean;
/**
* v1.16.11 — projected supply runway in whole days, set ONLY while it
* sits below the user's low-stock threshold (the variants gate it).
@@ -182,6 +190,7 @@ export function MedicationCardBody({
onRetryCompliance,
currentCycle,
asNeeded = false,
recordOnly = false,
lowStockRunwayDays = null,
intakeLoading,
onRecordIntake,
@@ -306,6 +315,7 @@ export function MedicationCardBody({
notice now costs zero layout shift. */}
{active &&
!asNeeded &&
!recordOnly &&
(complianceNotApplicable ? (
<MedicationComplianceNotApplicable
lowStockRunwayDays={lowStockRunwayDays}
@@ -334,7 +344,7 @@ export function MedicationCardBody({
one xs text line on every active card: the descriptor rides the
compliance query, and a line appearing after the fetch resolved
used to grow the card and shift the grid row (CLS). */}
{active && !asNeeded && (
{active && !asNeeded && !recordOnly && (
<div className="min-h-4">
{currentCycle && <MedicationCycleStatus cycle={currentCycle} />}
</div>
@@ -342,7 +352,7 @@ export function MedicationCardBody({
{/* Quick actions — bottom-pinned so the action rows align across a
grid row regardless of how much content sits above. */}
{active && (
{active && !recordOnly && (
<div className="mt-auto pt-0">
<MedicationIntakeActions
intakeLoading={intakeLoading}
@@ -6,6 +6,8 @@ interface MedicationStateBadgesProps {
notificationsEnabled: boolean;
active: boolean;
pausedAt: string | null;
/** v1.39.1 (#1033) — intake tracking off: kept as a record only. */
recordOnly?: boolean;
}
/**
@@ -18,12 +20,22 @@ export function MedicationStateBadges({
notificationsEnabled,
active,
pausedAt,
recordOnly = false,
}: MedicationStateBadgesProps) {
const { t } = useTranslations();
return (
<>
{!notificationsEnabled && (
{recordOnly && (
<Badge
variant="secondary"
className="text-xs"
data-slot="medication-record-only-badge"
>
{t("medications.recordOnlyBadge")}
</Badge>
)}
{!notificationsEnabled && !recordOnly && (
<Badge variant="secondary" className="text-xs">
{t("medications.withoutNotification")}
</Badge>
@@ -91,6 +91,7 @@ import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { formatDose } from "@/lib/medications/format-dose";
import type { MedicationPayload } from "@/components/medications/wizard/wizard-payload";
import type { ComplianceDisplay } from "@/lib/analytics/compliance";
import { IntakeTrackingBody } from "@/components/medications/sections/intake-tracking-section";
// v1.15.20 — Recharts stays out of the detail page's initial bundle:
// both chart bodies load through `next/dynamic` (client-only) with the
@@ -163,6 +164,13 @@ export interface MedicationDetailSnapshot {
* estimate; the estimate remains only as the stale-payload fallback.
*/
runwayDays?: number | null;
/**
* v1.39.1 (#1033) — false keeps the medication as a record. The detail
* GET then serves `schedules: []` and the stored rows in
* `recordedSchedules`; the page shows (and edits) the stored rows.
*/
trackIntake?: boolean;
recordedSchedules?: ScheduleSnapshot[];
schedules: ScheduleSnapshot[];
}
@@ -234,6 +242,7 @@ function snapshotToWizardPayload(
endsOn: med.endsOn ? new Date(med.endsOn) : null,
oneShot: med.oneShot ?? false,
asNeeded: med.asNeeded ?? false,
trackIntake: med.trackIntake !== false,
schedules: med.schedules.map((s) => ({
id: s.id,
windowStart: s.windowStart,
@@ -250,10 +259,23 @@ function snapshotToWizardPayload(
}
export function MedicationDetailTabs({
medication,
medication: served,
}: {
medication: MedicationDetailSnapshot;
}) {
// v1.39.1 (#1033) — a record-only medication arrives with `schedules: []`
// (so clients that derive due doses from it stay silent) and its stored
// schedule in `recordedSchedules`. This page is where the record is read
// and edited, so it works on the stored rows; nothing on it derives a due
// dose from them (next-due and compliance come from the server).
const recordOnly = served.trackIntake === false;
const medication = useMemo<MedicationDetailSnapshot>(
() =>
served.recordedSchedules
? { ...served, schedules: served.recordedSchedules }
: served,
[served],
);
const { t } = useTranslations();
const fmt = useFormatters();
const { canManageDomain, inSharedRecord } = useRecordCapabilities();
@@ -381,7 +403,8 @@ export function MedicationDetailTabs({
try {
return await apiGet<{
applicable: boolean;
notApplicableReason: "NO_LOCAL_SCHEDULE" | null;
notApplicableReason:
"NO_LOCAL_SCHEDULE" | "INTAKE_NOT_TRACKED" | null;
compliance7: { rate: number; streak: number };
compliance30: { rate: number };
complianceDisplay: ComplianceDisplay | null;
@@ -483,6 +506,7 @@ export function MedicationDetailTabs({
oneShot={oneShot}
asNeeded={asNeeded}
startsOn={medication.startsOn}
recordOnly={recordOnly}
/>
<Tabs
@@ -521,11 +545,13 @@ export function MedicationDetailTabs({
value={
!medication.active
? t("medications.detail.uebersicht.pausedHint")
: asNeeded
? t("medications.detail.uebersicht.nextDoseAsNeeded")
: medication.nextDueAt
? fmt.dateTime(medication.nextDueAt)
: t("medications.detail.uebersicht.nextDoseNone")
: recordOnly
? t("medications.detail.uebersicht.nextDoseNotTracked")
: asNeeded
? t("medications.detail.uebersicht.nextDoseAsNeeded")
: medication.nextDueAt
? fmt.dateTime(medication.nextDueAt)
: t("medications.detail.uebersicht.nextDoseNone")
}
jumpLabel={
asNeeded
@@ -537,7 +563,7 @@ export function MedicationDetailTabs({
/>
{/* v1.16.11 — as-needed never reminds; the row would only
mislead (the cron skips schedule-less medications). */}
{!asNeeded && (
{!asNeeded && !recordOnly && (
<StatusRow
label={t("medications.detail.uebersicht.reminderLabel")}
value={
@@ -595,7 +621,7 @@ export function MedicationDetailTabs({
</ul>
</MedicationDetailSection>
{!asNeeded && (
{!asNeeded && !recordOnly && (
<MedicationDetailSection
titleId="medication-uebersicht-compliance-heading"
title={t("medications.detail.uebersicht.complianceTitle")}
@@ -642,6 +668,21 @@ export function MedicationDetailTabs({
dissolved Erinnerung tab). Cadence-kind stays structural (the
hero's "Vollständig bearbeiten"). */}
<TabsContent value="zeitplan" className="space-y-6 pt-2">
{/* v1.39.1 (#1033) — the intake-tracking switch leads the tab:
it decides whether the times below are a dose plan or a
record. */}
<MedicationDetailSection
titleId="medication-zeitplan-track-intake-heading"
title={t("medications.trackIntake.title")}
dataSlot="medication-zeitplan-track-intake"
>
<IntakeTrackingBody
key={recordOnly ? "off" : "on"}
medicationId={id}
trackIntake={!recordOnly}
/>
</MedicationDetailSection>
{medication.schedules.length > 0 ? (
<MedicationDetailSection
titleId="medication-zeitplan-times-heading"
@@ -670,48 +711,50 @@ export function MedicationDetailTabs({
</MedicationDetailSection>
) : null}
<MedicationDetailSection
titleId="medication-zeitplan-reminder-heading"
title={t("medications.detail.zeitplan.reminderTitle")}
dataSlot="medication-zeitplan-reminder"
>
<div className="space-y-4">
<NotificationsBody
medicationId={id}
notificationsEnabled={medication.notificationsEnabled}
/>
<GraceRow
medicationId={id}
schedules={medication.schedules.map((s) => ({
windowStart: s.windowStart,
windowEnd: s.windowEnd,
label: s.label,
dose: s.dose,
daysOfWeek: s.daysOfWeek,
timesOfDay: s.timesOfDay,
rrule: s.rrule,
rollingIntervalDays: s.rollingIntervalDays,
reminderGraceMinutes: s.reminderGraceMinutes,
scheduleType: s.scheduleType,
cyclicOnWeeks: s.cyclicOnWeeks,
cyclicOffWeeks: s.cyclicOffWeeks,
doseWindows: s.doseWindows,
}))}
/>
{/* Quiet cross-link: WHICH channels deliver the reminder
{!recordOnly && (
<MedicationDetailSection
titleId="medication-zeitplan-reminder-heading"
title={t("medications.detail.zeitplan.reminderTitle")}
dataSlot="medication-zeitplan-reminder"
>
<div className="space-y-4">
<NotificationsBody
medicationId={id}
notificationsEnabled={medication.notificationsEnabled}
/>
<GraceRow
medicationId={id}
schedules={medication.schedules.map((s) => ({
windowStart: s.windowStart,
windowEnd: s.windowEnd,
label: s.label,
dose: s.dose,
daysOfWeek: s.daysOfWeek,
timesOfDay: s.timesOfDay,
rrule: s.rrule,
rollingIntervalDays: s.rollingIntervalDays,
reminderGraceMinutes: s.reminderGraceMinutes,
scheduleType: s.scheduleType,
cyclicOnWeeks: s.cyclicOnWeeks,
cyclicOffWeeks: s.cyclicOffWeeks,
doseWindows: s.doseWindows,
}))}
/>
{/* Quiet cross-link: WHICH channels deliver the reminder
(push / Telegram / ntfy, quiet hours) is configured
globally, not per medication. */}
<p className="text-muted-foreground text-xs">
<Link
href="/settings/notifications"
className="focus-visible:ring-ring rounded-sm underline underline-offset-2 focus-visible:ring-2 focus-visible:outline-none"
data-slot="zeitplan-manage-channels-link"
>
{t("medications.detail.zeitplan.manageChannels")}
</Link>
</p>
</div>
</MedicationDetailSection>
<p className="text-muted-foreground text-xs">
<Link
href="/settings/notifications"
className="focus-visible:ring-ring rounded-sm underline underline-offset-2 focus-visible:ring-2 focus-visible:outline-none"
data-slot="zeitplan-manage-channels-link"
>
{t("medications.detail.zeitplan.manageChannels")}
</Link>
</p>
</div>
</MedicationDetailSection>
)}
{/* PLANHISTORIE — archived schedule eras (v1.16.3 effective
dating) as a quiet collapsible timeline, plus the manual
@@ -128,6 +128,12 @@ export interface Glp1Medication {
runwayDays?: number | null;
/** v1.17.0 — per-medication reorder lead override (days); null = inherit the user default. */
reorderLeadDays?: number | null;
/**
* v1.39.1 (#1033) — false keeps the medication as a record: the list
* serves `schedules: []` and nothing is due, so the card drops the
* compliance bars and the take / skip actions and shows a badge instead.
*/
trackIntake?: boolean;
schedules: ScheduleLite[];
}
@@ -438,6 +444,7 @@ export function Glp1MedicationCard({
notificationsEnabled={medication.notificationsEnabled}
active={medication.active}
pausedAt={medication.pausedAt}
recordOnly={medication.trackIntake === false}
/>
);
@@ -551,6 +558,7 @@ export function Glp1MedicationCard({
currentCycle={
complianceNotApplicable ? null : (display?.currentCycle ?? null)
}
recordOnly={medication.trackIntake === false}
lowStockRunwayDays={lowStockRunwayDays}
intakeLoading={intakeLoading}
onRecordIntake={(skipped) => recordIntake(skipped, displayedSlot)}
@@ -113,6 +113,12 @@ interface Medication {
* low-stock trigger so the notice lands before the last dose.
*/
reorderLeadDays?: number | null;
/**
* v1.39.1 (#1033) — false keeps the medication as a record: the list
* serves `schedules: []` and nothing is due, so the card drops the
* compliance bars and the take / skip actions and shows a badge instead.
*/
trackIntake?: boolean;
schedules: Schedule[];
}
@@ -377,6 +383,7 @@ export function MedicationCard({
notificationsEnabled={medication.notificationsEnabled}
active={medication.active}
pausedAt={medication.pausedAt}
recordOnly={medication.trackIntake === false}
/>
);
@@ -516,6 +523,7 @@ export function MedicationCard({
currentCycle={
complianceNotApplicable ? null : (display?.currentCycle ?? null)
}
recordOnly={medication.trackIntake === false}
lowStockRunwayDays={lowStockRunwayDays}
intakeLoading={intakeLoading}
onRecordIntake={(skipped) => recordIntake(skipped, displayedSlot)}
@@ -39,6 +39,8 @@ export interface MedicationDetailSummaryProps {
/** v1.16.11 — as-needed (PRN): the cadence line reads "Bei Bedarf". */
asNeeded?: boolean;
startsOn?: string | null;
/** v1.39.1 (#1033) — intake tracking off: a "Record only" badge. */
recordOnly?: boolean;
}
type Status = "active" | "paused" | "ended";
@@ -62,6 +64,7 @@ export function MedicationDetailSummary({
oneShot,
asNeeded = false,
startsOn,
recordOnly = false,
}: MedicationDetailSummaryProps) {
const { t, locale } = useTranslations();
const formatters = useFormatters();
@@ -116,6 +119,14 @@ export function MedicationDetailSummary({
/>
{statusLabel}
</Badge>
{recordOnly && (
<Badge
variant="secondary"
data-slot="medication-detail-record-only-badge"
>
{t("medications.recordOnlyBadge")}
</Badge>
)}
</div>
<p
className="text-muted-foreground text-sm"
@@ -92,6 +92,11 @@ export interface TableMedication {
* "Bei Bedarf" marker, the compliance column shows "–".
*/
asNeeded?: boolean;
/**
* v1.39.1 (#1033) — false keeps the medication as a record: the next-dose
* cell names that, the compliance and action cells show "–".
*/
trackIntake?: boolean;
/** v1.16.10 — dose-derived stock from the list payload; null = inventory tracking off. */
stockDosesRemaining?: number | null;
/** v1.37.19 — server-resolved slot-aware runway (days); null = off/no cadence. */
@@ -511,7 +516,18 @@ function MedicationTableRowItem({
let nextCell: React.ReactNode = (
<span className="text-muted-foreground">–</span>
);
if (medication.asNeeded) {
const recordOnly = medication.trackIntake === false;
if (recordOnly) {
// v1.39.1 (#1033) — kept as a record: nothing is ever due.
nextCell = (
<span
className="text-muted-foreground"
data-slot="medication-table-record-only-marker"
>
{t("medications.recordOnlyBadge")}
</span>
);
} else if (medication.asNeeded) {
// v1.16.11 — a calm marker where next-due normally sits; no due
// pill, no overdue escalation, ever.
nextCell = (
@@ -688,7 +704,7 @@ function MedicationTableRowItem({
<TableCell>{statusCell}</TableCell>
<TableCell className="text-sm">{nextCell}</TableCell>
<TableCell>
{medication.active && !medication.asNeeded ? (
{medication.active && !medication.asNeeded && !recordOnly ? (
complianceCell
) : (
<span className="text-muted-foreground text-sm">–</span>
@@ -696,7 +712,7 @@ function MedicationTableRowItem({
</TableCell>
<TableCell className="text-sm">{stockCell}</TableCell>
<TableCell>
{medication.active && canAddIntake ? (
{medication.active && canAddIntake && !recordOnly ? (
<div className="flex gap-1.5">
<Button
size="icon"
@@ -0,0 +1,89 @@
"use client";
/**
* v1.39.1 (#1033) — the per-medication "track intake" switch.
*
* One switch row, the same shape as the reminder switch beside it: the whole
* row is the hit target, the helper line says what the current state means.
* Flipping writes `PUT /api/medications/[id]` with `{ trackIntake }`; the
* server keeps the schedule either way and archives the moment of the switch,
* so the stretch with tracking off never reads as missed doses.
*/
import { useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { Switch } from "@/components/ui/switch";
import { useTranslations } from "@/lib/i18n/context";
import { invalidateKeys, medicationDependentKeys } from "@/lib/query-keys";
import { apiPut } from "@/lib/api/api-fetch";
const SWITCH_ID = "medication-detail-track-intake-switch";
const ROW_TITLE_ID = "medication-detail-track-intake-label";
const HELPER_ID = "medication-detail-track-intake-helper";
export function IntakeTrackingBody({
medicationId,
trackIntake,
}: {
medicationId: string;
trackIntake: boolean;
}) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const [localTracked, setLocalTracked] = useState(trackIntake);
const [submitting, setSubmitting] = useState(false);
async function flip(next: boolean) {
if (submitting) return;
setSubmitting(true);
const previous = localTracked;
setLocalTracked(next);
try {
await apiPut(`/api/medications/${medicationId}`, { trackIntake: next });
await invalidateKeys(queryClient, medicationDependentKeys);
toast.success(
next
? t("medications.trackIntake.enabledToast")
: t("medications.trackIntake.disabledToast"),
);
} catch {
setLocalTracked(previous);
toast.error(t("medications.trackIntake.toggleFailed"));
} finally {
setSubmitting(false);
}
}
return (
<label
htmlFor={SWITCH_ID}
className="flex items-center justify-between gap-3"
data-slot="medication-track-intake-row"
>
<span className="space-y-1">
<span
id={ROW_TITLE_ID}
className="text-foreground block text-sm font-medium"
>
{t("medications.trackIntake.label")}
</span>
<span id={HELPER_ID} className="text-muted-foreground block text-xs">
{localTracked
? t("medications.trackIntake.helperOn")
: t("medications.trackIntake.helperOff")}
</span>
</span>
<Switch
id={SWITCH_ID}
checked={localTracked}
disabled={submitting}
onCheckedChange={(checked) => void flip(checked)}
aria-labelledby={ROW_TITLE_ID}
aria-describedby={HELPER_ID}
data-slot="medication-track-intake-switch"
/>
</label>
);
}
+7 -1
View File
@@ -82,6 +82,8 @@ export interface DueDerivationMedication {
nextDueOverdue?: boolean;
/** v1.16.11 (#316) — as-needed (PRN): never due, never in the set. */
asNeeded?: boolean;
/** v1.39.1 (#1033) — false: kept as a record, never due, never in the set. */
trackIntake?: boolean;
schedules: DueDerivationSchedule[];
}
@@ -150,7 +152,11 @@ export function deriveDueMedications(
// the cards suppress their pills the same way. As-needed (PRN)
// medications are never due, structurally — even a (bogus)
// compliance row for one must not pull it into the set.
if (!m.active || m.pausedAt || m.asNeeded) continue;
// A medication with intake tracking off is never due either; the list
// already serves it with no schedules, this makes the rule explicit.
if (!m.active || m.pausedAt || m.asNeeded || m.trackIntake === false) {
continue;
}
const nextDueMs = m.nextDueAt ? new Date(m.nextDueAt).getTime() : NaN;
const status = reduceCurrentWindowStatus({
@@ -167,9 +167,39 @@ export function Step8Summary({
)}
</div>
{/* v1.39.1 (#1033) — intake tracking. Off keeps the medication and
its schedule as a record; nothing is due, so the reminders toggle
below goes with it. As-needed is never due either way. */}
{payload.mode !== "asNeeded" && (
<div className="flex items-start justify-between gap-3">
<div className="space-y-1">
<Label
htmlFor="wizard-track-intake"
className="text-sm font-medium"
>
{t("medications.trackIntake.label")}
</Label>
<p className="text-muted-foreground text-xs">
{payload.trackIntake
? t("medications.trackIntake.helperOn")
: t("medications.trackIntake.helperOff")}
</p>
</div>
<Switch
id="wizard-track-intake"
checked={payload.trackIntake}
onCheckedChange={(checked) =>
applyPartial({ trackIntake: checked })
}
data-slot="wizard-track-intake-toggle"
aria-label={t("medications.trackIntake.label")}
/>
</div>
)}
{/* v1.16.11 — an as-needed medication never reminds (no slots),
so the reminders toggle is hidden alongside the schedules. */}
{payload.mode !== "asNeeded" && (
{payload.mode !== "asNeeded" && payload.trackIntake && (
<div className="flex items-start justify-between gap-3">
<div className="space-y-1">
<Label htmlFor="wizard-reminders" className="text-sm font-medium">
@@ -191,20 +221,22 @@ export function Step8Summary({
</div>
)}
{payload.mode !== "asNeeded" && showNoChannelHint && (
<p
className="text-muted-foreground text-xs"
data-slot="wizard-no-channel-hint"
>
{t("medications.wizard.steps.step8.noChannelHint")}{" "}
<Link
href="/settings/notifications"
className="text-primary underline-offset-2 hover:underline"
{payload.mode !== "asNeeded" &&
payload.trackIntake &&
showNoChannelHint && (
<p
className="text-muted-foreground text-xs"
data-slot="wizard-no-channel-hint"
>
{t("medications.wizard.steps.step8.noChannelHintLink")}
</Link>
</p>
)}
{t("medications.wizard.steps.step8.noChannelHint")}{" "}
<Link
href="/settings/notifications"
className="text-primary underline-offset-2 hover:underline"
>
{t("medications.wizard.steps.step8.noChannelHintLink")}
</Link>
</p>
)}
{submitError && (
<p
@@ -274,6 +274,11 @@ export interface WizardPayload {
startsOn: Date | null;
endsOn: Date | null;
notificationsEnabled: boolean;
/**
* v1.39.1 (#1033) — intake tracking. Off keeps the medication, dose and
* schedule as a record: nothing due, no reminders, no adherence.
*/
trackIntake: boolean;
/** Every parallel schedule under construction. Always >= 1 entry. */
schedules: ScheduleDraft[];
/** Which schedule Steps 5-7 currently edit. */
@@ -322,6 +327,7 @@ export function emptyWizardPayload(): WizardPayload {
startsOn: todayUtc(),
endsOn: null,
notificationsEnabled: true,
trackIntake: true,
schedules: [draft],
activeScheduleIndex: 0,
};
@@ -603,6 +609,12 @@ export interface CreateMedicationBody {
* `schedules` array (the route 422s on any entry alongside the flag).
*/
asNeeded: boolean;
/**
* v1.39.1 (#1033) — intake tracking. Sent on create and edit for a
* scheduled medication (the wizard hydrates it from the stored value);
* omitted for an as-needed one, which is never due either way.
*/
trackIntake?: boolean;
schedules: Array<{
id?: string;
windowStart: string;
@@ -813,6 +825,7 @@ export function buildCreateBody(
}),
oneShot: isOneShot,
asNeeded: isAsNeeded,
...(!isAsNeeded && { trackIntake: committed.trackIntake }),
schedules: draftsToEmit.map((draft) =>
encodeScheduleDraft(draft, isOneShot),
),
@@ -1035,6 +1048,8 @@ export interface MedicationPayload {
oneShot: boolean;
/** v1.16.11 — as-needed (PRN) flag; hydrates the "Bei Bedarf" mode. */
asNeeded?: boolean;
/** v1.39.1 (#1033) — intake tracking; absent = tracked. */
trackIntake?: boolean;
schedules: Array<{
id?: string;
windowStart: string;
@@ -1181,6 +1196,7 @@ export function hydrateWizardPayload(
startsOn: initial.startsOn ?? base.startsOn,
endsOn: initial.endsOn,
notificationsEnabled: initial.notificationsEnabled ?? true,
trackIntake: initial.trackIntake !== false,
schedules: drafts,
activeScheduleIndex: 0,
};
@@ -16,7 +16,7 @@ import type { ComplianceDisplay } from "@/lib/analytics/compliance";
export interface MedicationComplianceSummaryEntry {
medicationId: string;
applicable: boolean;
notApplicableReason: "NO_LOCAL_SCHEDULE" | null;
notApplicableReason: "NO_LOCAL_SCHEDULE" | "INTAKE_NOT_TRACKED" | null;
compliance7: { rate: number; streak: number };
compliance30: { rate: number };
complianceDisplay: ComplianceDisplay | null;