mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
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:
1 parent
934131ee3e
commit
700de6d228
21 files changed
+431
-91
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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é",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "켜짐",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user