fix: restore obsolete actions in timeline views

This commit is contained in:
Daniel Volz
2026-03-16 21:39:22 +01:00
committed by GitHub
parent 9e224c0441
commit 934519767a
5 changed files with 539 additions and 239 deletions
+101 -22
View File
@@ -1,5 +1,5 @@
/* biome-ignore-all lint/style/noNestedTernary: timeline rendering uses explicit UI-state branching */
import { Bell, ClipboardList, NotebookPen, Share2 } from "lucide-react";
import { Archive, Bell, ClipboardList, NotebookPen, Share2 } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { ConfirmModal, MedicationAvatar } from "../components";
@@ -85,18 +85,10 @@ export function DashboardPage() {
} = useAppContext();
const [showClearMissedConfirm, setShowClearMissedConfirm] = useState(false);
const [clearingMissed, setClearingMissed] = useState(false);
const [showObsoleteConfirm, setShowObsoleteConfirm] = useState(false);
const [obsoleteCandidate, setObsoleteCandidate] = useState<{ id: number; name: string } | null>(null);
const outOfStockMedicationIds = new Set(
meds.filter((med) => (coverageByMed[getMedDisplayName(med)]?.medsLeft ?? 1) <= 0).map((med) => med.id)
);
const isDoseTakenForDisplay = (doseId: string) => {
const medId = Number.parseInt(doseId.split("-")[0] ?? "", 10);
if (!Number.isNaN(medId) && outOfStockMedicationIds.has(medId)) {
return false;
}
return takenDoses.has(doseId);
};
const isDoseTakenForDisplay = (doseId: string) => takenDoses.has(doseId);
// Get structured reminder data
const reminderData = getReminderStatusData(
@@ -210,6 +202,32 @@ export function DashboardPage() {
}
};
const requestMarkObsolete = (med: { id: number; name: string }) => {
setObsoleteCandidate(med);
setShowObsoleteConfirm(true);
};
const handleConfirmMarkObsolete = async () => {
if (!obsoleteCandidate) return;
try {
const res = await fetch(`/api/medications/${obsoleteCandidate.id}/obsolete`, {
method: "POST",
credentials: "include",
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
await loadMeds();
setShowObsoleteConfirm(false);
setObsoleteCandidate(null);
} catch {
alert(t("common.saveFailed"));
}
};
const handleCancelMarkObsolete = () => {
setShowObsoleteConfirm(false);
setObsoleteCandidate(null);
};
const getTubeUnitLabel = (med: (typeof meds)[number] | undefined, value: number) =>
isLiquidContainerPackageType(med?.packageType) || med?.medicationForm === "liquid"
? t("form.packageAmountUnitMl")
@@ -1034,8 +1052,12 @@ export function DashboardPage() {
? getStockStatus(medCov.daysLeft, medCov.medsLeft, stockThresholds, med?.packageType)
: null;
const status = getVisibleStockStatus(med, rawStatus);
const isLowStock = !isEmpty && status?.className === "warning";
const rowClasses = ["time-row"];
if (isEmpty) rowClasses.push("med-empty");
else if (isLowStock) rowClasses.push("med-low");
return (
<div key={`${day.dateStr}-${item.medName}`} className="time-row">
<div key={`${day.dateStr}-${item.medName}`} className={rowClasses.join(" ")}>
<div className="time-main">
<div className="med-name">
<div
@@ -1082,8 +1104,12 @@ export function DashboardPage() {
const allTaken = people.every((person) =>
isDoseTakenForDisplay(getDoseId(dose.id, person))
);
const doseClasses = ["dose-item", "past"];
if (allTaken) doseClasses.push("all-taken");
if (isEmpty) doseClasses.push("med-empty");
else if (isLowStock) doseClasses.push("med-low");
return (
<div key={dose.id} className={`dose-item past ${allTaken ? "all-taken" : ""}`}>
<div key={dose.id} className={doseClasses.join(" ")}>
<span className="dose-time">{dose.timeStr}</span>
<span className="dose-usage">
<span className="dose-usage-main">
@@ -1251,6 +1277,17 @@ export function DashboardPage() {
confirmVariant="warning"
/>
)}
{showObsoleteConfirm && obsoleteCandidate && (
<ConfirmModal
title={t("medications.obsoleteModal.title")}
message={t("medications.obsoleteModal.message", { name: obsoleteCandidate.name })}
confirmLabel={t("medications.list.markObsolete")}
cancelLabel={t("common.cancel")}
onConfirm={() => void handleConfirmMarkObsolete()}
onCancel={handleCancelMarkObsolete}
confirmVariant="warning"
/>
)}
{/* Today - always visible */}
{todayDay &&
(() => {
@@ -1329,8 +1366,12 @@ export function DashboardPage() {
)
: null;
const visibleStatus = getVisibleStockStatus(med, status);
const isLowStock = !isEmpty && visibleStatus?.className === "warning";
const rowClasses = ["time-row"];
if (isEmpty) rowClasses.push("med-empty");
else if (isLowStock) rowClasses.push("med-low");
return (
<div key={`${day.dateStr}-${item.medName}`} className="time-row">
<div key={`${day.dateStr}-${item.medName}`} className={rowClasses.join(" ")}>
<div className="time-main">
<div className="med-name">
<div
@@ -1371,6 +1412,20 @@ export function DashboardPage() {
</span>
)}
</div>
{isEmpty && med && !med.isObsolete && (
<div className="timeline-obsolete-row">
<button
type="button"
className="timeline-obsolete-btn btn-obsolete"
onClick={() =>
requestMarkObsolete({ id: med.id, name: getMedDisplayName(med) })
}
>
<Archive size={16} aria-hidden="true" />
<span>{t("medications.list.markObsolete")}</span>
</button>
</div>
)}
</div>
<div className="doses-col">
{item.doses.map((dose) => {
@@ -1379,11 +1434,13 @@ export function DashboardPage() {
const allTaken = people.every((person) =>
isDoseTakenForDisplay(getDoseId(dose.id, person))
);
const doseClasses = ["dose-item"];
if (isOverdue) doseClasses.push("overdue");
if (allTaken) doseClasses.push("all-taken");
if (isEmpty) doseClasses.push("med-empty");
else if (isLowStock) doseClasses.push("med-low");
return (
<div
key={dose.id}
className={`dose-item ${isOverdue ? "overdue" : ""} ${allTaken ? "all-taken" : ""}`}
>
<div key={dose.id} className={doseClasses.join(" ")}>
<span className="dose-time">{dose.timeStr}</span>
<span className="dose-usage">
<span className="dose-usage-main">
@@ -1569,7 +1626,7 @@ export function DashboardPage() {
const medCoverage = coverageByMed[item.medName];
const med = meds.find((m) => getMedDisplayName(m) === item.medName);
const depletionTime = depletionByMed[item.medName];
const _isEmpty = medCoverage ? medCoverage.medsLeft <= 0 : false;
const isEmpty = medCoverage ? medCoverage.medsLeft <= 0 : false;
const willBeOutOfStock = typeof depletionTime === "number" && item.lastWhen > depletionTime;
const status = willBeOutOfStock
? { className: "danger", label: "status.outOfStock" }
@@ -1582,8 +1639,12 @@ export function DashboardPage() {
)
: null;
const visibleStatus = getVisibleStockStatus(med, status);
const isLowStock = !isEmpty && visibleStatus?.className === "warning";
const rowClasses = ["time-row"];
if (isEmpty) rowClasses.push("med-empty");
else if (isLowStock) rowClasses.push("med-low");
return (
<div key={`${day.dateStr}-${item.medName}`} className="time-row">
<div key={`${day.dateStr}-${item.medName}`} className={rowClasses.join(" ")}>
<div className="time-main">
<div className="med-name">
<div
@@ -1624,6 +1685,20 @@ export function DashboardPage() {
</span>
)}
</div>
{isEmpty && med && !med.isObsolete && (
<div className="timeline-obsolete-row">
<button
type="button"
className="timeline-obsolete-btn btn-obsolete"
onClick={() =>
requestMarkObsolete({ id: med.id, name: getMedDisplayName(med) })
}
>
<Archive size={16} aria-hidden="true" />
<span>{t("medications.list.markObsolete")}</span>
</button>
</div>
)}
</div>
<div className="doses-col">
{item.doses.map((dose) => {
@@ -1631,8 +1706,12 @@ export function DashboardPage() {
const allTaken = people.every((person) =>
isDoseTakenForDisplay(getDoseId(dose.id, person))
);
const doseClasses = ["dose-item", "future"];
if (allTaken) doseClasses.push("all-taken");
if (isEmpty) doseClasses.push("med-empty");
else if (isLowStock) doseClasses.push("med-low");
return (
<div key={dose.id} className={`dose-item future ${allTaken ? "all-taken" : ""}`}>
<div key={dose.id} className={doseClasses.join(" ")}>
<span className="dose-time">{dose.timeStr}</span>
<span className="dose-usage">
<span className="dose-usage-main">
+11 -4
View File
@@ -1,7 +1,7 @@
/* biome-ignore-all lint/a11y/noLabelWithoutControl: form uses custom inputs and display fields wrapped in label-like layout */
/* biome-ignore-all lint/correctness/useExhaustiveDependencies: modal-history callbacks are intentionally managed outside hook deps */
/* biome-ignore-all lint/suspicious/noArrayIndexKey: local draft intake rows do not have stable ids before persistence */
import { Bell, Eye, Minus, Pencil, Plus, Trash2 } from "lucide-react";
import { Archive, Bell, Eye, Minus, Pencil, Plus, Trash2 } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useSearchParams } from "react-router-dom";
@@ -979,6 +979,16 @@ export function MedicationsPage() {
</button>
)}
<button
type="button"
className="btn-obsolete"
onClick={() => requestMarkObsolete(med)}
aria-label={t("medications.list.markObsolete")}
>
<Archive size={16} aria-hidden="true" />
<span>{t("medications.list.markObsolete")}</span>
</button>
<button
type="button"
className="danger icon-only tooltip-trigger"
onClick={() => requestDeleteMed(med)}
aria-label={t("common.delete")}
@@ -986,9 +996,6 @@ export function MedicationsPage() {
>
<Trash2 size={18} aria-hidden="true" />
</button>
<button className="btn-obsolete" onClick={() => requestMarkObsolete(med)}>
{t("medications.list.markObsolete")}
</button>
</div>
<div className="med-details">
<span>
+77 -16
View File
@@ -1,5 +1,5 @@
/* biome-ignore-all lint/style/noNestedTernary: schedule timeline branches are intentionally explicit */
import { Bell } from "lucide-react";
import { Archive, Bell } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { ConfirmModal, MedicationAvatar } from "../components";
@@ -95,18 +95,10 @@ export function SchedulePage() {
} = useAppContext();
const [showClearMissedConfirm, setShowClearMissedConfirm] = useState(false);
const [clearingMissed, setClearingMissed] = useState(false);
const [showObsoleteConfirm, setShowObsoleteConfirm] = useState(false);
const [obsoleteCandidate, setObsoleteCandidate] = useState<{ id: number; name: string } | null>(null);
const outOfStockMedicationIds = new Set(
meds.filter((med) => (coverageByMed[getMedDisplayName(med)]?.medsLeft ?? 1) <= 0).map((med) => med.id)
);
const isDoseTakenForDisplay = (doseId: string) => {
const medId = Number.parseInt(doseId.split("-")[0] ?? "", 10);
if (!Number.isNaN(medId) && outOfStockMedicationIds.has(medId)) {
return false;
}
return takenDoses.has(doseId);
};
const isDoseTakenForDisplay = (doseId: string) => takenDoses.has(doseId);
const shouldHideNoScheduleStatusForTube = (
med: (typeof meds)[number] | undefined,
@@ -174,6 +166,32 @@ export function SchedulePage() {
}
};
const requestMarkObsolete = (med: { id: number; name: string }) => {
setObsoleteCandidate(med);
setShowObsoleteConfirm(true);
};
const handleConfirmMarkObsolete = async () => {
if (!obsoleteCandidate) return;
try {
const res = await fetch(`/api/medications/${obsoleteCandidate.id}/obsolete`, {
method: "POST",
credentials: "include",
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
await loadMeds();
setShowObsoleteConfirm(false);
setObsoleteCandidate(null);
} catch {
alert(t("common.saveFailed"));
}
};
const handleCancelMarkObsolete = () => {
setShowObsoleteConfirm(false);
setObsoleteCandidate(null);
};
const getTubeUnitLabel = (med: (typeof meds)[number] | undefined, value: number) =>
isLiquidContainerPackageType(med?.packageType) || med?.medicationForm === "liquid"
? t("form.packageAmountUnitMl")
@@ -345,8 +363,16 @@ export function SchedulePage() {
const med = meds.find((m) => getMedDisplayName(m) === item.medName);
const medCov = coverageByMed[item.medName];
const isEmpty = medCov ? medCov.medsLeft <= 0 : false;
const rawStatus = medCov
? getStockStatus(medCov.daysLeft, medCov.medsLeft, settings, med?.packageType)
: null;
const visibleStatus = shouldHideNoScheduleStatusForTube(med, rawStatus) ? null : rawStatus;
const isLowStock = !isEmpty && visibleStatus?.className === "warning";
const rowClasses = ["time-row"];
if (isEmpty) rowClasses.push("med-empty");
else if (isLowStock) rowClasses.push("med-low");
return (
<div key={`${day.dateStr}-${item.medName}`} className="time-row">
<div key={`${day.dateStr}-${item.medName}`} className={rowClasses.join(" ")}>
<div className="time-main">
<div className="med-name">
<MedicationAvatar name={item.medName} imageUrl={med?.imageUrl} size="sm" />
@@ -363,8 +389,12 @@ export function SchedulePage() {
const allTaken = people.every((person) =>
isDoseTakenForDisplay(getDoseId(dose.id, person))
);
const doseClasses = ["dose-item", "past"];
if (allTaken) doseClasses.push("all-taken");
if (isEmpty) doseClasses.push("med-empty");
else if (isLowStock) doseClasses.push("med-low");
return (
<div key={dose.id} className={`dose-item past ${allTaken ? "all-taken" : ""}`}>
<div key={dose.id} className={doseClasses.join(" ")}>
<span className="dose-time">{dose.timeStr}</span>
<span className="dose-usage">
<span className="dose-usage-main">
@@ -517,6 +547,17 @@ export function SchedulePage() {
confirmVariant="warning"
/>
)}
{showObsoleteConfirm && obsoleteCandidate && (
<ConfirmModal
title={t("medications.obsoleteModal.title")}
message={t("medications.obsoleteModal.message", { name: obsoleteCandidate.name })}
confirmLabel={t("medications.list.markObsolete")}
cancelLabel={t("common.cancel")}
onConfirm={() => void handleConfirmMarkObsolete()}
onCancel={handleCancelMarkObsolete}
confirmVariant="warning"
/>
)}
{/* Current and future days */}
{futureDays.map((day) => {
const today = new Date();
@@ -540,8 +581,12 @@ export function SchedulePage() {
? getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, settings, med?.packageType)
: null;
const visibleStatus = shouldHideNoScheduleStatusForTube(med, status) ? null : status;
const isLowStock = !isEmpty && visibleStatus?.className === "warning";
const rowClasses = ["time-row"];
if (isEmpty) rowClasses.push("med-empty");
else if (isLowStock) rowClasses.push("med-low");
return (
<div key={`${day.dateStr}-${item.medName}`} className="time-row">
<div key={`${day.dateStr}-${item.medName}`} className={rowClasses.join(" ")}>
<div className="time-main">
<div className="med-name">
<MedicationAvatar name={item.medName} imageUrl={med?.imageUrl} size="sm" />
@@ -553,6 +598,18 @@ export function SchedulePage() {
<span className={`tag ${visibleStatus.className}`}>{t(visibleStatus.label)}</span>
)}
</div>
{isEmpty && med && !med.isObsolete && (
<div className="timeline-obsolete-row">
<button
type="button"
className="timeline-obsolete-btn btn-obsolete"
onClick={() => requestMarkObsolete({ id: med.id, name: getMedDisplayName(med) })}
>
<Archive size={16} aria-hidden="true" />
<span>{t("medications.list.markObsolete")}</span>
</button>
</div>
)}
</div>
<div className="doses-col">
{item.doses.map((dose) => {
@@ -561,8 +618,12 @@ export function SchedulePage() {
const dayStart = new Date(day.date).setHours(0, 0, 0, 0);
const isPastDay = dayStart < new Date().setHours(0, 0, 0, 0);
const allTaken = people.every((person) => isDoseTakenForDisplay(getDoseId(dose.id, person)));
const doseClasses = ["dose-item"];
if (allTaken) doseClasses.push("all-taken");
if (isEmpty) doseClasses.push("med-empty");
else if (isLowStock) doseClasses.push("med-low");
return (
<div key={dose.id} className={`dose-item ${allTaken ? "all-taken" : ""}`}>
<div key={dose.id} className={doseClasses.join(" ")}>
<span className="dose-time">{dose.timeStr}</span>
<span className="dose-usage">
<span className="dose-usage-main">
+319 -166
View File
@@ -1,3 +1,4 @@
/* biome-ignore-all lint/style/noDescendingSpecificity: legacy shared stylesheet relies on intentional cascade ordering across base and contextual selectors */
@import url("https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&display=swap");
:root {
@@ -577,25 +578,6 @@ body.modal-open {
display: flex;
gap: 0.5rem;
}
.tabs .pill {
cursor: pointer;
transition:
background 150ms ease,
border-color 150ms ease;
background: transparent;
border: 1px solid var(--border-secondary);
color: var(--text-muted);
box-shadow: none;
}
.tabs .pill:hover {
background: var(--btn-ghost-hover);
border-color: var(--accent);
}
.tabs .pill.primary {
background: var(--accent-bg);
border-color: var(--accent);
color: var(--text-primary);
}
.stats {
display: grid;
@@ -732,6 +714,26 @@ body.modal-open {
background: rgba(0, 0, 0, 0.04);
}
.tabs .pill {
cursor: pointer;
transition:
background 150ms ease,
border-color 150ms ease;
background: transparent;
border: 1px solid var(--border-secondary);
color: var(--text-muted);
box-shadow: none;
}
.tabs .pill:hover {
background: var(--btn-ghost-hover);
border-color: var(--accent);
}
.tabs .pill.primary {
background: var(--accent-bg);
border-color: var(--accent);
color: var(--text-primary);
}
.badges {
display: flex;
gap: 0.5rem;
@@ -804,10 +806,6 @@ body.modal-open {
background: var(--bg-tertiary);
}
.med-group-head-toggle:hover .med-group-title {
color: var(--text-primary);
}
.med-group-title {
margin: 0;
font-size: 0.92rem;
@@ -817,6 +815,10 @@ body.modal-open {
color: var(--text-muted);
}
.med-group-head-toggle:hover .med-group-title {
color: var(--text-primary);
}
.med-group-obsolete {
border-color: var(--border-primary);
background: var(--bg-secondary);
@@ -832,17 +834,6 @@ body.modal-open {
border-color: var(--border-primary);
}
.obsolete-row .med-actions button {
opacity: 0.72;
filter: saturate(0.72);
box-shadow: none;
}
.obsolete-row .med-actions button:hover {
opacity: 0.9;
filter: saturate(0.85);
}
@media (max-width: 768px) {
.med-grid {
grid-template-columns: 1fr;
@@ -1024,6 +1015,51 @@ body.modal-open {
flex-wrap: wrap;
margin-top: 0.25rem;
}
.timeline-obsolete-row {
margin-top: 0.15rem;
display: flex;
justify-content: flex-start;
}
.timeline-obsolete-btn {
display: inline-flex;
align-items: center;
gap: 0.45rem;
padding: 0.55rem 0.9rem;
min-height: 2.5rem;
font-size: 0.85rem;
line-height: 1;
background: transparent;
border: 1px solid #f8e38a;
color: #ffd54a;
box-shadow: none;
}
.timeline-obsolete-btn:hover {
background: color-mix(in srgb, #facc15 12%, transparent);
border-color: #ffe27c;
color: #ffe27c;
box-shadow: none;
}
.timeline-obsolete-btn svg {
width: 1rem;
height: 1rem;
flex-shrink: 0;
}
[data-theme="light"] .timeline-obsolete-btn {
background: transparent;
border-color: #d97706;
color: #b45309;
}
[data-theme="light"] .timeline-obsolete-btn:hover {
background: color-mix(in srgb, #f59e0b 10%, transparent);
border-color: #b45309;
color: #92400e;
}
.danger-text {
color: var(--danger);
font-weight: 700;
@@ -1047,6 +1083,153 @@ body.modal-open {
font-weight: 400;
}
button {
padding: 0.7rem 1.25rem;
border-radius: var(--btn-radius);
border: none;
background: var(--btn-primary-bg);
color: white;
cursor: pointer;
font-weight: 600;
font-size: 0.9rem;
box-shadow: var(--btn-shadow);
transition:
background 150ms ease,
box-shadow 150ms ease,
opacity 150ms ease;
}
button:hover {
background: var(--btn-primary-hover);
}
button:active {
box-shadow: var(--btn-shadow);
}
button:focus-visible {
outline: 2px solid var(--accent-light);
outline-offset: 2px;
}
button.icon-only {
min-width: 2.75rem;
min-height: 2.75rem;
padding: 0.5rem;
display: inline-flex;
align-items: center;
justify-content: center;
line-height: 1;
}
button.icon-only svg,
.modal-close svg,
.btn-copy svg,
.share-btn svg {
width: 1.1rem;
height: 1.1rem;
stroke-width: 2;
flex-shrink: 0;
}
/* Secondary button (Edit, etc.) */
button.secondary {
background: var(--bg-tertiary);
color: var(--text-primary);
border: 1px solid var(--border-secondary);
}
button.secondary:hover {
background: var(--bg-secondary);
border-color: var(--accent);
}
[data-theme="light"] button.secondary {
background: var(--bg-tertiary);
}
[data-theme="light"] button.secondary:hover {
background: var(--bg-secondary);
}
/* Success button (Refill, etc.) */
button.success {
background: var(--success);
color: var(--btn-success-text);
border: none;
}
button.success:hover {
filter: brightness(1.1);
}
button.success:disabled {
background: var(--bg-tertiary);
color: var(--text-tertiary);
cursor: not-allowed;
}
/* Primary/Accent button (New entry, Add intake, etc.) */
button.primary {
background: var(--accent);
color: white;
border: none;
}
button.primary:hover {
background: var(--accent-light);
}
button.primary:disabled {
background: var(--bg-tertiary);
color: var(--text-tertiary);
cursor: not-allowed;
}
/* Info button (Edit, secondary actions) */
button.info {
background: #3b82f6;
color: white;
border: none;
}
button.info:hover {
background: #60a5fa;
}
/* Ghost button (Cancel, etc.) */
button.ghost {
background: transparent;
border: 1px solid var(--border-secondary);
color: var(--text-muted);
box-shadow: none;
}
button.ghost:hover {
background: var(--btn-ghost-hover);
}
[data-theme="light"] button.ghost:hover {
background: var(--btn-ghost-hover);
}
/* Navigation button (Back): neutral and low visual urgency */
button.btn-nav {
background: var(--bg-secondary);
border: 1px solid var(--border-secondary);
color: var(--text-primary);
box-shadow: none;
}
button.btn-nav:hover {
background: var(--btn-ghost-hover);
border-color: var(--accent);
}
/* Reversible status-change button (Mark obsolete): warning, not destructive */
button.btn-obsolete {
background: var(--btn-obsolete-bg);
border: 1px solid var(--btn-obsolete-border);
color: var(--btn-obsolete-text);
box-shadow: none;
font-weight: 700;
}
button.btn-obsolete:hover {
background: var(--btn-obsolete-hover);
transform: none;
box-shadow: none;
}
button.btn-obsolete:active {
transform: none;
}
.med-actions {
display: flex;
align-items: center;
@@ -1102,6 +1285,8 @@ body.modal-open {
color: #ffd54a;
background: var(--bg-input);
border: 1px solid var(--border-secondary);
gap: 0.45rem;
white-space: nowrap;
}
.med-actions button.btn-obsolete:hover {
@@ -1353,152 +1538,43 @@ body.modal-open {
margin: 0;
}
button {
padding: 0.7rem 1.25rem;
border-radius: var(--btn-radius);
border: none;
background: var(--btn-primary-bg);
color: white;
cursor: pointer;
font-weight: 600;
font-size: 0.9rem;
box-shadow: var(--btn-shadow);
transition:
background 150ms ease,
box-shadow 150ms ease,
opacity 150ms ease;
}
button:hover {
background: var(--btn-primary-hover);
}
button:active {
box-shadow: var(--btn-shadow);
.obsolete-row .med-actions button {
opacity: 0.72;
filter: saturate(0.72);
box-shadow: none;
}
button:focus-visible {
outline: 2px solid var(--accent-light);
outline-offset: 2px;
.obsolete-row .med-actions button:hover {
opacity: 0.9;
filter: saturate(0.85);
}
button.icon-only {
min-width: 2.75rem;
min-height: 2.75rem;
padding: 0.5rem;
display: inline-flex;
align-items: center;
justify-content: center;
line-height: 1;
}
button.icon-only svg,
.modal-close svg,
.btn-copy svg,
.share-btn svg {
width: 1.1rem;
height: 1.1rem;
stroke-width: 2;
flex-shrink: 0;
}
/* Secondary button (Edit, etc.) */
button.secondary {
background: var(--bg-tertiary);
color: var(--text-primary);
border: 1px solid var(--border-secondary);
}
button.secondary:hover {
background: var(--bg-secondary);
border-color: var(--accent);
}
[data-theme="light"] button.secondary {
background: var(--bg-tertiary);
}
[data-theme="light"] button.secondary:hover {
background: var(--bg-secondary);
}
/* Success button (Refill, etc.) */
button.success {
background: var(--success);
color: var(--btn-success-text);
border: none;
}
button.success:hover {
filter: brightness(1.1);
}
button.success:disabled {
background: var(--bg-tertiary);
color: var(--text-tertiary);
cursor: not-allowed;
}
/* Primary/Accent button (New entry, Add intake, etc.) */
button.primary {
background: var(--accent);
color: white;
border: none;
}
button.primary:hover {
background: var(--accent-light);
}
button.primary:disabled {
background: var(--bg-tertiary);
color: var(--text-tertiary);
cursor: not-allowed;
}
/* Info button (Edit, secondary actions) */
button.info {
background: #3b82f6;
color: white;
border: none;
}
button.info:hover {
background: #60a5fa;
}
/* Ghost button (Cancel, etc.) */
button.ghost {
button.timeline-obsolete-btn.btn-obsolete {
background: transparent;
border: 1px solid var(--border-secondary);
color: var(--text-muted);
border: 1px solid #f8e38a;
color: #ffd54a;
box-shadow: none;
}
button.ghost:hover {
background: var(--btn-ghost-hover);
}
[data-theme="light"] button.ghost:hover {
background: var(--btn-ghost-hover);
}
/* Navigation button (Back): neutral and low visual urgency */
button.btn-nav {
background: var(--bg-secondary);
border: 1px solid var(--border-secondary);
color: var(--text-primary);
button.timeline-obsolete-btn.btn-obsolete:hover {
background: color-mix(in srgb, #facc15 12%, transparent);
border-color: #ffe27c;
color: #ffe27c;
box-shadow: none;
}
button.btn-nav:hover {
background: var(--btn-ghost-hover);
border-color: var(--accent);
}
/* Reversible status-change button (Mark obsolete): warning, not destructive */
button.btn-obsolete {
background: var(--btn-obsolete-bg);
border: 1px solid var(--btn-obsolete-border);
color: var(--btn-obsolete-text);
box-shadow: none;
font-weight: 700;
[data-theme="light"] button.timeline-obsolete-btn.btn-obsolete {
background: transparent;
border-color: #d97706;
color: #b45309;
}
button.btn-obsolete:hover {
background: var(--btn-obsolete-hover);
transform: none;
[data-theme="light"] button.timeline-obsolete-btn.btn-obsolete:hover {
background: color-mix(in srgb, #f59e0b 10%, transparent);
border-color: #b45309;
color: #92400e;
box-shadow: none;
}
button.btn-obsolete:active {
transform: none;
}
/* Danger button (Delete, etc.) */
button.danger {
@@ -2392,6 +2468,42 @@ button.has-validation-error {
border-bottom: none;
padding-bottom: 0;
}
.time-row.med-empty {
border-left: 3px solid var(--danger);
padding-left: 0.6rem;
background: color-mix(in srgb, var(--danger) 8%, transparent);
border-radius: 8px;
}
.time-row.med-low {
border-left: 3px solid var(--warning);
padding-left: 0.6rem;
background: color-mix(in srgb, var(--warning) 10%, transparent);
border-radius: 8px;
}
.time-row.med-low .med-name-text {
color: color-mix(in srgb, var(--warning) 88%, white 12%);
}
.time-row.med-low .tag.subtle {
background: color-mix(in srgb, var(--warning) 16%, transparent);
border-color: color-mix(in srgb, var(--warning) 42%, transparent);
color: color-mix(in srgb, var(--warning) 82%, white 18%);
}
.time-row.med-empty .med-name-text {
color: var(--danger);
text-decoration: line-through;
text-decoration-thickness: 2px;
}
.time-row.med-empty .tag.subtle {
background: color-mix(in srgb, var(--danger) 16%, transparent);
border-color: color-mix(in srgb, var(--danger) 42%, transparent);
color: color-mix(in srgb, var(--danger) 82%, white 18%);
}
.time-main {
display: flex;
flex-direction: column;
@@ -2521,6 +2633,47 @@ button.has-validation-error {
color: var(--text-secondary);
}
.dose-item.med-low,
.dose-item.med-low.all-taken,
.dose-item.med-low.taken,
.dose-item.med-low.future,
.dose-item.med-low.overdue,
.dose-item.med-low.overdue.taken {
border-color: rgba(252, 211, 77, 0.5);
box-shadow: inset 3px 0 0 color-mix(in srgb, var(--warning) 88%, black 12%);
}
.dose-item.med-low:not(.all-taken):not(.taken):not(.overdue):not(.med-empty) {
background: color-mix(in srgb, var(--warning) 9%, var(--accent-bg));
}
.dose-item.med-empty,
.dose-item.med-empty.all-taken,
.dose-item.med-empty.taken,
.dose-item.med-empty.future,
.dose-item.med-empty.overdue,
.dose-item.med-empty.overdue.taken {
background: color-mix(in srgb, var(--danger) 13%, transparent);
border-color: color-mix(in srgb, var(--danger) 46%, transparent);
opacity: 1;
}
.dose-item.med-empty .dose-time,
.dose-item.med-empty .dose-usage {
color: color-mix(in srgb, var(--danger) 82%, white 18%);
text-decoration: line-through;
text-decoration-thickness: 2px;
}
.dose-item.med-empty .dose-person {
background: color-mix(in srgb, var(--danger) 18%, transparent);
}
.dose-item.med-empty .dose-person .person-name,
.dose-item.med-empty .dose-person.taken .person-name {
color: color-mix(in srgb, var(--danger) 80%, white 20%);
}
.dose-time {
font-weight: 600;
color: var(--accent-light);
+31 -31
View File
@@ -45,10 +45,41 @@
gap: 1rem;
}
.refill-preview {
display: inline-flex;
align-items: center;
justify-content: center;
height: 42px;
padding: 0 0.75rem;
background: transparent;
border: 1px dashed var(--success);
border-radius: 6px;
color: var(--success);
font-size: 0.85rem;
font-weight: 600;
flex-shrink: 0;
align-self: flex-end;
box-sizing: border-box;
}
.refill-footer-right .refill-preview {
height: 42px;
}
/* Refill: submit row (button + pill preview) */
.refill-submit-row {
display: flex;
align-items: center;
gap: 1rem;
}
.refill-submit-row button {
height: 42px;
padding: 0 2rem;
min-width: 120px;
flex-shrink: 0;
}
/* Refill modal footer mobile */
@media (max-width: 640px) {
.refill-modal .modal-footer {
@@ -70,20 +101,6 @@
}
}
/* Refill: submit row (button + pill preview) */
.refill-submit-row {
display: flex;
align-items: center;
gap: 1rem;
}
.refill-submit-row button {
height: 42px;
padding: 0 2rem;
min-width: 120px;
flex-shrink: 0;
}
/* Refill: prescription toggle row */
.refill-prescription-row {
display: flex;
@@ -134,23 +151,6 @@
margin-left: auto;
}
.refill-preview {
display: inline-flex;
align-items: center;
justify-content: center;
height: 42px;
padding: 0 0.75rem;
background: transparent;
border: 1px dashed var(--success);
border-radius: 6px;
color: var(--success);
font-size: 0.85rem;
font-weight: 600;
flex-shrink: 0;
align-self: flex-end;
box-sizing: border-box;
}
.refill-section {
margin-top: 0;
}