feat: stack related date fields and clarify share stock labels (#422)

* feat: stack related date fields and clarify share stock labels

* test: cover stacked date pairs and share labels
This commit is contained in:
Daniel Volz
2026-03-12 21:32:56 +01:00
committed by GitHub
parent 3fda41e501
commit d0837a7281
12 changed files with 238 additions and 63 deletions
+21 -19
View File
@@ -421,17 +421,27 @@ export function MobileEditModal({
<span className="field-error">{fieldErrors.genericName}</span>
)}
</label>
<label className="full">
{t("form.medicationStartDate")}
<DateInput
value={form.medicationStartDate}
onChange={(e) => onHandleValueChange("medicationStartDate", e.target.value)}
placeholder={t("common.optional")}
/>
{!readOnlyMode && dateConsistencyError && (
<span className="field-error">{dateConsistencyError}</span>
)}
</label>
<div className="full date-pair-group">
<label className="date-pair-field">
{t("form.medicationStartDate")}
<DateInput
value={form.medicationStartDate}
onChange={(e) => onHandleValueChange("medicationStartDate", e.target.value)}
placeholder={t("common.optional")}
/>
{!readOnlyMode && dateConsistencyError && (
<span className="field-error">{dateConsistencyError}</span>
)}
</label>
<label className="date-pair-field">
{t("form.medicationEndDate")}
<DateInput
value={form.medicationEndDate}
onChange={(e) => onHandleValueChange("medicationEndDate", e.target.value)}
placeholder={t("common.optional")}
/>
</label>
</div>
<label className="full">
{t("form.packageType")}
<select
@@ -446,14 +456,6 @@ export function MobileEditModal({
))}
</select>
</label>
<label className="full">
{t("form.medicationEndDate")}
<DateInput
value={form.medicationEndDate}
onChange={(e) => onHandleValueChange("medicationEndDate", e.target.value)}
placeholder={t("common.optional")}
/>
</label>
{allowsPillFormSelection(form.packageType) && (
<label className="full">
{t("form.pillForm")}
+4 -4
View File
@@ -585,14 +585,14 @@
"name": "Name",
"package": "Packung",
"stock": "Bestand",
"daysLeft": "Tage übrig",
"daysLeft": "Geschätzte Resttage",
"nextIntake": "Nächste Einnahme",
"depletion": "Aufgebraucht",
"priority": "Priorität"
"priority": "Bestandswarnung"
},
"priority": {
"normal": "Normal",
"high": "Hoch"
"normal": "Bestand ok",
"high": "Niedriger Bestand"
},
"stock": {
"of": "{{current}} von {{capacity}}"
+4 -4
View File
@@ -585,14 +585,14 @@
"name": "Name",
"package": "Package",
"stock": "Stock",
"daysLeft": "Days left",
"daysLeft": "Estimated days left",
"nextIntake": "Next intake",
"depletion": "Depletion",
"priority": "Priority"
"priority": "Stock alert"
},
"priority": {
"normal": "Normal",
"high": "High"
"normal": "Stock OK",
"high": "Low stock"
},
"stock": {
"of": "{{current}} of {{capacity}}"
+21 -11
View File
@@ -677,8 +677,10 @@ export function DashboardPage() {
<span>{t("table.dailyConsumption")}</span>
<span>{t("table.stockDetails")}</span>
<span>{t("table.daysLeft")}</span>
<span>{t("table.runsOut")}</span>
<span>{t("table.expiry")}</span>
<span className="date-pair-stack-header">
<span className="date-pair-label">{t("table.runsOut")}</span>
<span className="date-pair-label">{t("table.expiry")}</span>
</span>
<span>{t("table.status")}</span>
</div>
{coverage.all.map((row) => {
@@ -806,15 +808,23 @@ export function DashboardPage() {
<span data-label={t("table.daysLeft")} className={textClass}>
{formatNumber(row.daysLeft)}
</span>
<span data-label={t("table.runsOut")}>{row.depletionDate ?? "-"}</span>
<span data-label={t("table.expiry")} className={expiryClass}>
{med?.expiryDate
? new Date(med.expiryDate).toLocaleDateString(getSystemLocale(i18n.language), {
day: "2-digit",
month: "short",
year: "2-digit",
})
: "-"}
<span className="date-pair-stack">
<span className="date-pair-entry">
<span className="date-pair-label">{t("table.runsOut")}</span>
<span className="date-pair-value">{row.depletionDate ?? "-"}</span>
</span>
<span className="date-pair-entry">
<span className="date-pair-label">{t("table.expiry")}</span>
<span className={`date-pair-value ${expiryClass}`}>
{med?.expiryDate
? new Date(med.expiryDate).toLocaleDateString(getSystemLocale(i18n.language), {
day: "2-digit",
month: "short",
year: "2-digit",
})
: "-"}
</span>
</span>
</span>
<span data-label={t("table.status")} className={status ? `status-chip ${status.className}` : ""}>
{status ? t(status.label) : "-"}
+21 -19
View File
@@ -1247,17 +1247,27 @@ export function MedicationsPage() {
<span className="field-error">{fieldErrors.genericName}</span>
)}
</label>
<label>
{t("form.medicationStartDate")}
<DateInput
value={form.medicationStartDate}
onChange={(e) => handleValueChange("medicationStartDate", e.target.value)}
placeholder={t("common.optional")}
/>
{!readOnlyView && dateConsistencyError && (
<span className="field-error">{dateConsistencyError}</span>
)}
</label>
<div className="full date-pair-group">
<label className="date-pair-field">
{t("form.medicationStartDate")}
<DateInput
value={form.medicationStartDate}
onChange={(e) => handleValueChange("medicationStartDate", e.target.value)}
placeholder={t("common.optional")}
/>
{!readOnlyView && dateConsistencyError && (
<span className="field-error">{dateConsistencyError}</span>
)}
</label>
<label className="date-pair-field">
{t("form.medicationEndDate")}
<DateInput
value={form.medicationEndDate}
onChange={(e) => handleValueChange("medicationEndDate", e.target.value)}
placeholder={t("common.optional")}
/>
</label>
</div>
<label>
{t("form.packageType")}
<select
@@ -1272,14 +1282,6 @@ export function MedicationsPage() {
))}
</select>
</label>
<label>
{t("form.medicationEndDate")}
<DateInput
value={form.medicationEndDate}
onChange={(e) => handleValueChange("medicationEndDate", e.target.value)}
placeholder={t("common.optional")}
/>
</label>
{allowsPillFormSelection(form.packageType) && (
<label>
{t("form.pillForm")}
+18 -4
View File
@@ -244,8 +244,12 @@ export function SharedOverviewPage() {
<th>{t("sharedOverview.columns.package")}</th>
<th>{t("sharedOverview.columns.stock")}</th>
<th>{t("sharedOverview.columns.daysLeft")}</th>
<th>{t("sharedOverview.columns.nextIntake")}</th>
<th>{t("sharedOverview.columns.depletion")}</th>
<th>
<div className="date-pair-stack-header">
<span className="date-pair-label">{t("sharedOverview.columns.nextIntake")}</span>
<span className="date-pair-label">{t("sharedOverview.columns.depletion")}</span>
</div>
</th>
<th>{t("sharedOverview.columns.priority")}</th>
</tr>
</thead>
@@ -276,8 +280,18 @@ export function SharedOverviewPage() {
})}
</td>
<td>{medication.daysLeft === null ? "-" : medication.daysLeft}</td>
<td>{formatDate(medication.nextIntakeDate, locale)}</td>
<td>{formatDate(medication.depletionDate, locale)}</td>
<td>
<div className="date-pair-stack">
<div className="date-pair-entry">
<span className="date-pair-label">{t("sharedOverview.columns.nextIntake")}</span>
<span className="date-pair-value">{formatDate(medication.nextIntakeDate, locale)}</span>
</div>
<div className="date-pair-entry">
<span className="date-pair-label">{t("sharedOverview.columns.depletion")}</span>
<span className="date-pair-value">{formatDate(medication.depletionDate, locale)}</span>
</div>
</div>
</td>
<td>
{medication.priority === null ? (
"-"
+61 -1
View File
@@ -1668,6 +1668,46 @@ textarea.auto-resize {
grid-column: 1 / -1;
}
.date-pair-group {
display: grid;
gap: 0.9rem;
}
.date-pair-field {
min-width: 0;
}
.date-pair-stack,
.date-pair-stack-header {
display: grid;
gap: 0.45rem;
min-width: 0;
}
.date-pair-entry {
display: flex;
flex-direction: column;
gap: 0.15rem;
min-width: 0;
}
.date-pair-label {
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--text-secondary);
line-height: 1.2;
}
.date-pair-value {
font-size: 0.9rem;
font-weight: 600;
color: var(--text-primary);
line-height: 1.3;
word-break: break-word;
}
.form-category {
grid-column: 1 / -1;
display: grid;
@@ -2785,7 +2825,7 @@ button.has-validation-error {
.table-8 .table-head,
.table-8 .table-row {
grid-template-columns: minmax(130px, 1.4fr) 90px 130px 70px 95px 95px 90px 95px;
grid-template-columns: minmax(130px, 1.4fr) 90px 130px 70px 95px minmax(130px, 1.15fr) 95px;
}
.email-sent-status {
@@ -2884,6 +2924,26 @@ button.has-validation-error {
flex-shrink: 0;
text-align: left;
}
.table-row > .date-pair-stack {
gap: 0.45rem;
}
.table-row > .date-pair-stack::before {
display: none;
content: none;
}
.table-row > .date-pair-stack .date-pair-entry {
flex-direction: row;
justify-content: space-between;
align-items: flex-start;
gap: 0.75rem;
}
.table-row > .date-pair-stack .date-pair-label {
margin-right: 0.75rem;
flex-shrink: 0;
}
.table-row > .date-pair-stack .date-pair-value {
text-align: right;
}
/* First span (name cell) - centered horizontal layout */
.table-row > span:first-child {
justify-content: flex-start;
+6 -1
View File
@@ -153,7 +153,7 @@
.shared-overview-table {
width: 100%;
border-collapse: collapse;
min-width: 860px;
min-width: 780px;
}
.shared-overview-table th,
@@ -268,6 +268,11 @@
word-break: break-word;
}
.shared-overview-table .date-pair-stack-header,
.shared-overview-table .date-pair-stack {
min-width: 150px;
}
@media (max-width: 600px) {
.shared-schedule-page {
padding: 1rem;
@@ -161,6 +161,18 @@ describe("MobileEditModal", () => {
expect(screen.getByText(/form\.genericName/i)).toBeInTheDocument();
});
it("groups medication start and end date fields in one stacked date pair", () => {
render(<MobileEditModal {...defaultProps} />);
const datePairGroup = document.querySelector(".date-pair-group");
expect(datePairGroup).toBeInTheDocument();
const dateFields = Array.from(datePairGroup?.querySelectorAll(".date-pair-field") ?? []);
expect(dateFields).toHaveLength(2);
expect(dateFields[0]).toHaveTextContent("form.medicationStartDate");
expect(dateFields[1]).toHaveTextContent("form.medicationEndDate");
});
it("renders packs input", () => {
render(<MobileEditModal {...defaultProps} />);
@@ -416,6 +416,33 @@ describe("DashboardPage", () => {
expect(screen.getByText(/table\.daysLeft/i)).toBeInTheDocument();
});
it("renders runs-out and expiry as a stacked date pair in overview rows", () => {
mockContextValue = createMockAppContext({
meds: mockMeds,
coverage: mockCoverage,
});
render(
<MemoryRouter>
<DashboardPage />
</MemoryRouter>
);
const headerPair = document.querySelector(".table-head .date-pair-stack-header");
expect(headerPair).toBeInTheDocument();
expect(headerPair).toHaveTextContent("table.runsOut");
expect(headerPair).toHaveTextContent("table.expiry");
const rowPair = document.querySelector(".table-row .date-pair-stack");
expect(rowPair).toBeInTheDocument();
const rowEntries = Array.from(rowPair?.querySelectorAll(".date-pair-entry") ?? []);
expect(rowEntries).toHaveLength(2);
expect(rowEntries[0]).toHaveTextContent("table.runsOut");
expect(rowEntries[0]).toHaveTextContent("2025-02-15");
expect(rowEntries[1]).toHaveTextContent("table.expiry");
});
it("renders multiple cards", () => {
render(
<MemoryRouter>
@@ -219,6 +219,19 @@ describe("MedicationsPage", () => {
expect(screen.getByText(/form\.genericName/i)).toBeInTheDocument();
});
it("renders medication start and end dates as one desktop date pair group", () => {
renderPage();
openNewMedicationForm();
const datePairGroup = document.querySelector(".date-pair-group");
expect(datePairGroup).toBeInTheDocument();
const dateFields = Array.from(datePairGroup?.querySelectorAll(".date-pair-field") ?? []);
expect(dateFields).toHaveLength(2);
expect(dateFields[0]).toHaveTextContent("form.medicationStartDate");
expect(dateFields[1]).toHaveTextContent("form.medicationEndDate");
});
it("shows submit button in form mode", () => {
renderPage();
openNewMedicationForm();
@@ -1,6 +1,8 @@
import { render, screen, waitFor } from "@testing-library/react";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import de from "../../i18n/de.json";
import en from "../../i18n/en.json";
import { SharedOverviewPage } from "../../pages/SharedOverviewPage";
function renderSharedOverview(path: string) {
@@ -62,9 +64,37 @@ describe("SharedOverviewPage", () => {
expect(screen.getAllByText("Acetylsalicylic Acid").length).toBeGreaterThan(0);
});
const headerPair = document.querySelector(".shared-overview-table .date-pair-stack-header");
expect(headerPair).toBeInTheDocument();
expect(headerPair).toHaveTextContent("sharedOverview.columns.nextIntake");
expect(headerPair).toHaveTextContent("sharedOverview.columns.depletion");
const rowPair = document.querySelector(".shared-overview-table .date-pair-stack");
expect(rowPair).toBeInTheDocument();
const rowEntries = Array.from(rowPair?.querySelectorAll(".date-pair-entry") ?? []);
expect(rowEntries).toHaveLength(2);
expect(rowEntries[0]).toHaveTextContent("sharedOverview.columns.nextIntake");
expect(rowEntries[1]).toHaveTextContent("sharedOverview.columns.depletion");
expect(screen.getAllByText("sharedOverview.columns.daysLeft")).toHaveLength(2);
expect(screen.getByText("sharedOverview.columns.priority")).toBeInTheDocument();
expect(screen.getAllByText("sharedOverview.priority.normal")).toHaveLength(2);
expect(globalThis.fetch).toHaveBeenCalledWith("/api/share/abcdef0123456789/overview");
});
it("keeps the updated shared overview stock wording in English and German", () => {
expect(en.sharedOverview.columns.daysLeft).toBe("Estimated days left");
expect(en.sharedOverview.columns.priority).toBe("Stock alert");
expect(en.sharedOverview.priority.normal).toBe("Stock OK");
expect(en.sharedOverview.priority.high).toBe("Low stock");
expect(de.sharedOverview.columns.daysLeft).toBe("Geschätzte Resttage");
expect(de.sharedOverview.columns.priority).toBe("Bestandswarnung");
expect(de.sharedOverview.priority.normal).toBe("Bestand ok");
expect(de.sharedOverview.priority.high).toBe("Niedriger Bestand");
});
it("renders not found state for missing token", async () => {
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: false,