feat: embed medication overview into shared links

Closes #424
This commit is contained in:
Daniel Volz
2026-03-14 20:26:17 +01:00
committed by GitHub
parent fd3134be24
commit e0fb77d494
35 changed files with 2607 additions and 1297 deletions
+1
View File
@@ -152,6 +152,7 @@ Share your medication schedule with others via a public link.
### Multi-Person Support
- Manage medications for multiple people
- Share schedules via link. Recipients can mark doses as taken, you see it live
- Optionally embed the medication overview directly on shared links via a settings toggle
### Data Export & Import
- Export all your data (medications, dose history, settings) as JSON
@@ -0,0 +1 @@
ALTER TABLE `user_settings` ADD `share_medication_overview` integer DEFAULT false NOT NULL;
File diff suppressed because it is too large Load Diff
+7
View File
@@ -92,6 +92,13 @@
"when": 1772881208026,
"tag": "0012_add_api_keys_and_package_amount_columns",
"breakpoints": true
},
{
"idx": 13,
"version": "6",
"when": 1773348659979,
"tag": "0013_add_share_medication_overview",
"breakpoints": true
}
]
}
+2
View File
@@ -149,6 +149,8 @@ export async function runAlterMigrations(client: Client): Promise<{ success: boo
`ALTER TABLE user_settings ADD COLUMN last_stock_reminder_med_names text`,
// Added for share stock visibility toggle
`ALTER TABLE user_settings ADD COLUMN share_stock_status integer NOT NULL DEFAULT 1`,
// Added for integrated share overview visibility on shared links
`ALTER TABLE user_settings ADD COLUMN share_medication_overview integer NOT NULL DEFAULT 0`,
// Added for timeline visibility toggles (dashboard + shared schedule)
`ALTER TABLE user_settings ADD COLUMN upcoming_today_only integer NOT NULL DEFAULT 0`,
`ALTER TABLE user_settings ADD COLUMN share_schedule_today_only integer NOT NULL DEFAULT 0`,
+2
View File
@@ -109,6 +109,8 @@ export const userSettings = sqliteTable("user_settings", {
stockCalculationMode: text("stock_calculation_mode", { length: 20 }).notNull().default("automatic"),
// Whether shared schedule links show stock status (Critical/Low/Normal) to intake users
shareStockStatus: integer("share_stock_status", { mode: "boolean" }).notNull().default(true),
// Whether shared schedule links also embed the medication overview section
shareMedicationOverview: integer("share_medication_overview", { mode: "boolean" }).notNull().default(false),
// UI timeline visibility preferences
upcomingTodayOnly: integer("upcoming_today_only", { mode: "boolean" }).notNull().default(false),
shareScheduleTodayOnly: integer("share_schedule_today_only", { mode: "boolean" }).notNull().default(false),
+3
View File
@@ -136,6 +136,7 @@ const settingsExportSchema = z
language: z.string().default("en"),
stockCalculationMode: z.enum(["automatic", "manual"]).default("automatic"),
shareStockStatus: z.boolean().default(true),
shareMedicationOverview: z.boolean().default(false),
})
.optional();
@@ -503,6 +504,7 @@ export async function exportRoutes(app: FastifyInstance) {
language: settings.language,
stockCalculationMode: settings.stockCalculationMode,
shareStockStatus: settings.shareStockStatus,
shareMedicationOverview: settings.shareMedicationOverview ?? false,
}
: undefined;
@@ -793,6 +795,7 @@ export async function exportRoutes(app: FastifyInstance) {
language: importData.settings.language ?? "en",
stockCalculationMode: importData.settings.stockCalculationMode ?? "automatic",
shareStockStatus: importData.settings.shareStockStatus ?? true,
shareMedicationOverview: importData.settings.shareMedicationOverview ?? false,
});
}
+9
View File
@@ -33,6 +33,7 @@ export type UserSettings = {
language: Language;
stockCalculationMode: "automatic" | "manual";
shareStockStatus: boolean;
shareMedicationOverview: boolean;
upcomingTodayOnly: boolean;
shareScheduleTodayOnly: boolean;
swapDashboardMainSections: boolean;
@@ -72,6 +73,7 @@ type SettingsBody = {
language: string;
stockCalculationMode: "automatic" | "manual";
shareStockStatus: boolean;
shareMedicationOverview: boolean;
upcomingTodayOnly: boolean;
shareScheduleTodayOnly: boolean;
swapDashboardMainSections: boolean;
@@ -221,6 +223,7 @@ function getDefaultSettings() {
language: (process.env.DEFAULT_LANGUAGE as "en" | "de") || "en",
stockCalculationMode: (process.env.DEFAULT_STOCK_CALCULATION_MODE as "automatic" | "manual") || "automatic",
shareStockStatus: envBool("DEFAULT_SHARE_STOCK_STATUS", true),
shareMedicationOverview: envBool("DEFAULT_SHARE_MEDICATION_OVERVIEW", false),
upcomingTodayOnly: envBool("DEFAULT_UPCOMING_TODAY_ONLY", false),
shareScheduleTodayOnly: envBool("DEFAULT_SHARE_SCHEDULE_TODAY_ONLY", false),
swapDashboardMainSections: false,
@@ -283,6 +286,7 @@ export async function loadUserSettings(userId: number): Promise<UserSettings> {
language: settings.language as Language,
stockCalculationMode: (settings.stockCalculationMode as "automatic" | "manual") ?? "automatic",
shareStockStatus: settings.shareStockStatus ?? true,
shareMedicationOverview: settings.shareMedicationOverview ?? false,
upcomingTodayOnly: settings.upcomingTodayOnly ?? false,
shareScheduleTodayOnly: settings.shareScheduleTodayOnly ?? false,
swapDashboardMainSections: settings.swapDashboardMainSections ?? false,
@@ -327,6 +331,7 @@ export async function getAllUserSettings(): Promise<UserSettings[]> {
language: settings.language as Language,
stockCalculationMode: (settings.stockCalculationMode as "automatic" | "manual") ?? "automatic",
shareStockStatus: settings.shareStockStatus ?? true,
shareMedicationOverview: settings.shareMedicationOverview ?? false,
upcomingTodayOnly: settings.upcomingTodayOnly ?? false,
shareScheduleTodayOnly: settings.shareScheduleTodayOnly ?? false,
swapDashboardMainSections: settings.swapDashboardMainSections ?? false,
@@ -411,6 +416,7 @@ export async function settingsRoutes(app: FastifyInstance) {
language: settings.language,
stockCalculationMode: settings.stockCalculationMode ?? "automatic",
shareStockStatus: settings.shareStockStatus ?? true,
shareMedicationOverview: settings.shareMedicationOverview ?? false,
upcomingTodayOnly: settings.upcomingTodayOnly ?? false,
shareScheduleTodayOnly: settings.shareScheduleTodayOnly ?? false,
swapDashboardMainSections: settings.swapDashboardMainSections ?? false,
@@ -477,6 +483,7 @@ export async function settingsRoutes(app: FastifyInstance) {
language: { type: "string", enum: ["en", "de"] },
stockCalculationMode: { type: "string", enum: ["automatic", "manual"] },
shareStockStatus: { type: "boolean" },
shareMedicationOverview: { type: "boolean" },
upcomingTodayOnly: { type: "boolean" },
shareScheduleTodayOnly: { type: "boolean" },
swapDashboardMainSections: { type: "boolean" },
@@ -504,6 +511,7 @@ export async function settingsRoutes(app: FastifyInstance) {
language: "en",
stockCalculationMode: "automatic",
shareStockStatus: true,
shareMedicationOverview: false,
upcomingTodayOnly: false,
shareScheduleTodayOnly: false,
swapDashboardMainSections: false,
@@ -554,6 +562,7 @@ export async function settingsRoutes(app: FastifyInstance) {
language: body.language ?? "en",
stockCalculationMode: body.stockCalculationMode ?? "automatic",
shareStockStatus: body.shareStockStatus ?? true,
shareMedicationOverview: body.shareMedicationOverview ?? false,
upcomingTodayOnly: body.upcomingTodayOnly ?? false,
shareScheduleTodayOnly: body.shareScheduleTodayOnly ?? false,
swapDashboardMainSections: body.swapDashboardMainSections ?? false,
+17 -1
View File
@@ -56,6 +56,10 @@ const shareReadResponseSchema = {
sharedBy: { type: "string" },
scheduleDays: { type: "integer" },
medications: { type: "array", items: { type: "object", additionalProperties: true } },
shareMedicationOverview: { type: "boolean" },
medicationOverview: {
anyOf: [{ type: "array", items: { type: "object", additionalProperties: true } }, { type: "null" }],
},
stockThresholds: { type: "object", additionalProperties: { type: "number" } },
stockCalculationMode: { type: "string", enum: ["automatic", "manual"] },
shareStockStatus: { type: "boolean" },
@@ -241,11 +245,23 @@ export async function shareRoutes(app: FastifyInstance) {
};
});
const shareMedicationOverview = settings?.shareMedicationOverview ?? false;
const medicationOverview = shareMedicationOverview
? buildSharedMedicationOverview({
medications: meds,
doses: await db.select().from(doseTracking).where(eq(doseTracking.userId, share.userId)),
thresholdDays: settings?.lowStockDays ?? 30,
showStockStatus: settings?.shareStockStatus ?? true,
})
: null;
return {
takenBy: share.takenBy,
sharedBy: owner?.username ?? null,
scheduleDays: share.scheduleDays,
medications: medicationsWithBlisters,
shareMedicationOverview,
medicationOverview,
stockThresholds: {
lowStockDays: settings?.lowStockDays ?? 30,
normalStockDays: settings?.normalStockDays ?? 60,
@@ -328,7 +344,7 @@ export async function shareRoutes(app: FastifyInstance) {
medications: meds,
doses,
thresholdDays: settings?.lowStockDays ?? 30,
shareStockStatus: settings?.shareStockStatus ?? true,
showStockStatus: settings?.shareStockStatus ?? true,
});
return {
+24 -9
View File
@@ -29,7 +29,7 @@ export type SharedMedicationOverviewItem = {
daysLeft: number | null;
nextIntakeDate: string | null;
depletionDate: string | null;
priority: "normal" | "high" | null;
priority: "normal" | "high" | "out-of-stock" | null;
expiryDate: string | null;
medicationStartDate: string | null;
prescriptionEnabled: boolean;
@@ -135,13 +135,23 @@ function toNullableDate(value: string | null): string | null {
return value.trim() ? value : null;
}
function computeOverviewPriority(
currentStock: number,
daysLeft: number | null,
thresholdDays: number
): "normal" | "high" | "out-of-stock" {
if (currentStock <= 0 || daysLeft === 0) return "out-of-stock";
if (daysLeft !== null && daysLeft <= thresholdDays) return "high";
return "normal";
}
export function buildSharedMedicationOverview(options: {
medications: MedicationRow[];
doses: DoseRow[];
thresholdDays: number;
shareStockStatus: boolean;
showStockStatus?: boolean;
}): SharedMedicationOverviewItem[] {
const { medications: medicationRows, doses, thresholdDays, shareStockStatus } = options;
const { medications: medicationRows, doses, thresholdDays, showStockStatus = true } = options;
const dosesByMedication = new Map<number, DoseRow[]>();
for (const dose of doses) {
@@ -178,7 +188,12 @@ export function buildSharedMedicationOverview(options: {
const daysLeft = dailyDoseRate > 0 ? Math.floor(currentStock / dailyDoseRate) : null;
const depletionDate =
daysLeft === null ? null : toDateOnlyString(new Date(todayDate.getTime() + daysLeft * MS_PER_DAY));
const priority: "normal" | "high" = daysLeft !== null && daysLeft <= thresholdDays ? "high" : "normal";
const priority = computeOverviewPriority(currentStock, daysLeft, thresholdDays);
const visibleCurrentStock = showStockStatus ? currentStock : null;
const visibleCapacity = showStockStatus ? capacity : null;
const visibleDaysLeft = showStockStatus ? daysLeft : null;
const visibleDepletionDate = showStockStatus ? depletionDate : null;
const visiblePriority = showStockStatus ? priority : null;
return {
name: medication.name,
@@ -190,12 +205,12 @@ export function buildSharedMedicationOverview(options: {
pillsPerBlister: medication.pillsPerBlister,
totalPills: medication.totalPills,
looseTablets: medication.looseTablets,
currentStock: shareStockStatus ? currentStock : null,
capacity: shareStockStatus ? capacity : null,
daysLeft: shareStockStatus ? daysLeft : null,
currentStock: visibleCurrentStock,
capacity: visibleCapacity,
daysLeft: visibleDaysLeft,
nextIntakeDate: computeNextIntakeDate(intakes, todayDateOnly),
depletionDate: shareStockStatus ? depletionDate : null,
priority: shareStockStatus ? priority : null,
depletionDate: visibleDepletionDate,
priority: visiblePriority,
expiryDate: toNullableDate(medication.expiryDate),
medicationStartDate: toNullableDate(medication.medicationStartDate),
prescriptionEnabled: medication.prescriptionEnabled ?? false,
+1
View File
@@ -146,6 +146,7 @@ async function createSchema(client: Client) {
language text NOT NULL DEFAULT 'en',
stock_calculation_mode text NOT NULL DEFAULT 'automatic',
share_stock_status integer NOT NULL DEFAULT 1,
share_medication_overview integer NOT NULL DEFAULT 0,
upcoming_today_only integer NOT NULL DEFAULT 0,
share_schedule_today_only integer NOT NULL DEFAULT 0,
swap_dashboard_main_sections integer NOT NULL DEFAULT 0,
+1
View File
@@ -140,6 +140,7 @@ async function createSchema(client: Client) {
language text NOT NULL DEFAULT 'en',
stock_calculation_mode text NOT NULL DEFAULT 'automatic',
share_stock_status integer NOT NULL DEFAULT 1,
share_medication_overview integer NOT NULL DEFAULT 0,
upcoming_today_only integer NOT NULL DEFAULT 0,
share_schedule_today_only integer NOT NULL DEFAULT 0,
swap_dashboard_main_sections integer NOT NULL DEFAULT 0,
+1
View File
@@ -157,6 +157,7 @@ async function createSchema(client: Client) {
language text NOT NULL DEFAULT 'en',
stock_calculation_mode text NOT NULL DEFAULT 'automatic',
share_stock_status integer NOT NULL DEFAULT 1,
share_medication_overview integer NOT NULL DEFAULT 0,
upcoming_today_only integer NOT NULL DEFAULT 0,
share_schedule_today_only integer NOT NULL DEFAULT 0,
swap_dashboard_main_sections integer NOT NULL DEFAULT 0,
+11
View File
@@ -21,6 +21,7 @@ import {
parseIntakeReminderState,
parseReminderState,
parseTakenByJson,
personTakesMedication,
} from "../utils/scheduler-utils.js";
// Helper to convert Blister to Intake for tests
@@ -151,6 +152,16 @@ describe("Scheduler Utils - Timezone Functions", () => {
});
});
describe("Scheduler Utils - Sharing", () => {
it("treats the all-share sentinel as matching intake-specific assignees", () => {
const intakes = [blisterToIntake({ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }, "Max")];
expect(personTakesMedication("all", [], intakes)).toBe(true);
expect(personTakesMedication("Max", [], intakes)).toBe(true);
expect(personTakesMedication("Anna", [], intakes)).toBe(false);
});
});
describe("Scheduler Utils - Blister Parsing", () => {
describe("parseBlisters", () => {
it("should parse valid blister JSON arrays", () => {
+44 -11
View File
@@ -218,13 +218,20 @@ export interface UpdateUserSettingsOptions {
stockCalculationMode?: "automatic" | "manual";
lowStockDays?: number;
shareStockStatus?: boolean;
shareMedicationOverview?: boolean;
}
/**
* Create or update user settings
*/
export async function setUserSettings(client: Client, options: UpdateUserSettingsOptions): Promise<void> {
const { userId, stockCalculationMode = "automatic", lowStockDays = 30, shareStockStatus } = options;
const {
userId,
stockCalculationMode = "automatic",
lowStockDays = 30,
shareStockStatus,
shareMedicationOverview,
} = options;
// Check if settings exist
const existing = await client.execute({
@@ -233,20 +240,46 @@ export async function setUserSettings(client: Client, options: UpdateUserSetting
});
if (existing.rows.length > 0) {
const updateArgs = [stockCalculationMode, lowStockDays] as Array<string | number>;
let updateSql = "UPDATE user_settings SET stock_calculation_mode = ?, low_stock_days = ?";
if (shareStockStatus !== undefined) {
updateSql += ", share_stock_status = ?";
updateArgs.push(shareStockStatus ? 1 : 0);
}
if (shareMedicationOverview !== undefined) {
updateSql += ", share_medication_overview = ?";
updateArgs.push(shareMedicationOverview ? 1 : 0);
}
updateSql += " WHERE user_id = ?";
updateArgs.push(userId);
await client.execute({
sql: `UPDATE user_settings SET stock_calculation_mode = ?, low_stock_days = ?${shareStockStatus !== undefined ? ", share_stock_status = ?" : ""} WHERE user_id = ?`,
args:
shareStockStatus !== undefined
? [stockCalculationMode, lowStockDays, shareStockStatus ? 1 : 0, userId]
: [stockCalculationMode, lowStockDays, userId],
sql: updateSql,
args: updateArgs,
});
} else {
const insertColumns = ["user_id", "stock_calculation_mode", "low_stock_days"];
const insertPlaceholders = ["?", "?", "?"];
const insertArgs = [userId, stockCalculationMode, lowStockDays] as Array<string | number>;
if (shareStockStatus !== undefined) {
insertColumns.push("share_stock_status");
insertPlaceholders.push("?");
insertArgs.push(shareStockStatus ? 1 : 0);
}
if (shareMedicationOverview !== undefined) {
insertColumns.push("share_medication_overview");
insertPlaceholders.push("?");
insertArgs.push(shareMedicationOverview ? 1 : 0);
}
await client.execute({
sql: `INSERT INTO user_settings (user_id, stock_calculation_mode, low_stock_days${shareStockStatus !== undefined ? ", share_stock_status" : ""}) VALUES (?, ?, ?${shareStockStatus !== undefined ? ", ?" : ""})`,
args:
shareStockStatus !== undefined
? [userId, stockCalculationMode, lowStockDays, shareStockStatus ? 1 : 0]
: [userId, stockCalculationMode, lowStockDays],
sql: `INSERT INTO user_settings (${insertColumns.join(", ")}) VALUES (${insertPlaceholders.join(", ")})`,
args: insertArgs,
});
}
}
+1
View File
@@ -292,6 +292,7 @@ export function getAllTakenByForMedication(medicationTakenBy: string[], intakes:
* Check if a person takes this medication (either via medication-level or intake-level takenBy).
*/
export function personTakesMedication(person: string, medicationTakenBy: string[], intakes: Intake[]): boolean {
if (person === "all") return medicationTakenBy.length > 0 || intakes.some((intake) => intake.takenBy !== null);
if (medicationTakenBy.includes(person)) return true;
return intakes.some((intake) => intake.takenBy === person);
}
+2 -64
View File
@@ -4,7 +4,6 @@
*/
import { Check, Copy, Link2, X } from "lucide-react";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
export interface ShareDialogProps {
@@ -41,49 +40,9 @@ export function ShareDialog({
onCopyShareLink,
}: ShareDialogProps) {
const { t } = useTranslation();
const [overviewCopied, setOverviewCopied] = useState(false);
const closeLabel = t("common.close");
const copyLabel = shareCopied ? t("share.copied") : t("share.copyLink");
const overviewCopyLabel = overviewCopied ? t("share.copied") : t("share.copyOverviewLink");
const overviewLink = shareLink ? `${shareLink}/overview` : null;
useEffect(() => {
if (!shareLink) {
setOverviewCopied(false);
}
}, [shareLink]);
const copyOverviewLink = async () => {
if (!overviewLink) return;
const markCopied = () => {
setOverviewCopied(true);
setTimeout(() => setOverviewCopied(false), 2000);
};
if (navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(overviewLink);
markCopied();
return;
} catch {
// Fall back to textarea-based copy.
}
}
const textarea = document.createElement("textarea");
textarea.value = overviewLink;
textarea.style.position = "fixed";
textarea.style.opacity = "0";
document.body.appendChild(textarea);
textarea.select();
try {
document.execCommand("copy");
markCopied();
} finally {
document.body.removeChild(textarea);
}
};
const getPersonLabel = (person: string) => (person === "all" ? t("share.allPeople") : person);
// ESC is handled by the global handler in App.tsx to avoid double history.back()
@@ -152,34 +111,13 @@ export function ShareDialog({
{shareCopied ? <Check size={18} aria-hidden="true" /> : <Copy size={18} aria-hidden="true" />}
</button>
</div>
<p className="share-link-label">{t("share.overviewLink")}</p>
<div className="share-link-box">
<input
type="text"
value={overviewLink ?? ""}
readOnly
className="share-link-input"
onClick={(e) => (e.target as HTMLInputElement).select()}
/>
<button
type="button"
className="btn-copy icon-only tooltip-trigger"
onClick={copyOverviewLink}
aria-label={overviewCopyLabel}
data-tooltip={overviewCopyLabel}
>
{overviewCopied ? <Check size={18} aria-hidden="true" /> : <Copy size={18} aria-hidden="true" />}
</button>
</div>
{shareCopied && <span className="share-copied-hint">{t("share.copied")}</span>}
{overviewCopied && <span className="share-copied-hint">{t("share.copied")}</span>}
<div className="share-dialog-footer">
<button
className="ghost"
onClick={() => {
onShareLinkChange(null);
onShareCopiedChange(false);
setOverviewCopied(false);
}}
>
{t("share.generateAnother")}
@@ -201,7 +139,7 @@ export function ShareDialog({
>
{sharePeople.map((person) => (
<option key={person} value={person}>
{person}
{getPersonLabel(person)}
</option>
))}
</select>
@@ -0,0 +1,194 @@
import { useTranslation } from "react-i18next";
import type { SharedMedicationOverviewItem } from "../types";
import { formatDate } from "../utils/formatters";
import { MedicationAvatar } from "./MedicationAvatar";
function formatPackageInfo(medication: SharedMedicationOverviewItem): string {
if (medication.packageType === "blister") {
return `${medication.packCount} x ${medication.blistersPerPack} x ${medication.pillsPerBlister}`;
}
if (medication.totalPills !== null) {
return `${medication.packCount} x ${medication.totalPills}`;
}
return `${medication.packCount}`;
}
function getOverviewStatus(
priority: SharedMedicationOverviewItem["priority"]
): { className: string; labelKey: string } | null {
if (priority === null) return null;
if (priority === "out-of-stock") {
return { className: "danger", labelKey: "status.outOfStock" };
}
if (priority === "high") {
return { className: "warning", labelKey: "status.lowStock" };
}
return { className: "normal", labelKey: "status.normal" };
}
export interface SharedMedicationOverviewSectionProps {
takenBy: string;
sharedBy: string | null;
medications: SharedMedicationOverviewItem[];
showTitle?: boolean;
onMedicationImageClick?: (imageUrl: string, name: string) => void;
}
export function SharedMedicationOverviewSection({
takenBy,
medications,
showTitle = true,
onMedicationImageClick,
}: SharedMedicationOverviewSectionProps) {
const { t } = useTranslation();
const renderMedicationAvatar = (name: string, imageUrl: string | null) => {
const isClickable = Boolean(imageUrl && onMedicationImageClick);
return (
<div
className={isClickable ? "med-avatar clickable" : undefined}
onClick={() => {
if (imageUrl && onMedicationImageClick) onMedicationImageClick(imageUrl, name);
}}
onKeyDown={(e) => {
if ((e.key === "Enter" || e.key === " ") && imageUrl && onMedicationImageClick) {
onMedicationImageClick(imageUrl, name);
}
}}
>
<MedicationAvatar name={name} imageUrl={imageUrl} size="sm" />
</div>
);
};
return (
<section className="shared-overview-inline-section" aria-label={t("sharedOverview.title", { person: takenBy })}>
{showTitle ? (
<div className="shared-overview-section-header">
<h2>{t("sharedOverview.title", { person: takenBy })}</h2>
</div>
) : null}
{medications.length === 0 ? (
<p className="shared-schedule-empty">{t("sharedOverview.noMedications")}</p>
) : (
<>
<div className="shared-overview-table-wrap">
<table className="shared-overview-table">
<thead>
<tr>
<th>{t("sharedOverview.columns.name")}</th>
<th>{t("sharedOverview.columns.package")}</th>
<th>{t("sharedOverview.columns.stock")}</th>
<th>{t("sharedOverview.columns.daysLeft")}</th>
<th>{t("sharedOverview.columns.depletion")}</th>
<th>{t("sharedOverview.columns.priority")}</th>
</tr>
</thead>
<tbody>
{medications.map((medication) => {
const overviewStatus = getOverviewStatus(medication.priority);
return (
<tr key={`${medication.name}-${medication.medicationStartDate ?? "no-start"}`}>
<td>
<div className="shared-overview-medication-cell">
{renderMedicationAvatar(medication.name, medication.imageUrl)}
<div className="shared-overview-medication-text">
<div className="shared-overview-med-name">
<strong>{medication.name}</strong>
{medication.genericName ? (
<span className="shared-overview-med-generic">{medication.genericName}</span>
) : null}
</div>
</div>
</div>
</td>
<td>{formatPackageInfo(medication)}</td>
<td>
<span className="shared-overview-stock-value">
{medication.currentStock === null || medication.capacity === null
? "-"
: t("sharedOverview.stock.of", {
current: medication.currentStock,
capacity: medication.capacity,
})}
</span>
</td>
<td>{medication.daysLeft === null ? "-" : medication.daysLeft}</td>
<td>
<span className="shared-overview-date-value">{formatDate(medication.depletionDate)}</span>
</td>
<td>
{overviewStatus === null ? (
"-"
) : (
<span className={`shared-overview-priority ${overviewStatus.className}`}>
{t(overviewStatus.labelKey)}
</span>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<div className="shared-overview-cards">
{medications.map((medication) => {
const overviewStatus = getOverviewStatus(medication.priority);
return (
<article
className="shared-overview-card"
key={`${medication.name}-${medication.medicationStartDate ?? "no-start"}`}
>
<div className="shared-overview-card-title">
{renderMedicationAvatar(medication.name, medication.imageUrl)}
<div className="shared-overview-medication-text">
<div className="shared-overview-med-name">
<strong>{medication.name}</strong>
{medication.genericName ? (
<span className="shared-overview-med-generic">{medication.genericName}</span>
) : null}
</div>
</div>
</div>
<div className="shared-overview-card-grid">
<span>{t("sharedOverview.columns.package")}</span>
<strong>{formatPackageInfo(medication)}</strong>
<span>{t("sharedOverview.columns.stock")}</span>
<strong>
<span className="shared-overview-stock-value">
{medication.currentStock === null || medication.capacity === null
? "-"
: t("sharedOverview.stock.of", {
current: medication.currentStock,
capacity: medication.capacity,
})}
</span>
</strong>
<span>{t("sharedOverview.columns.daysLeft")}</span>
<strong>{medication.daysLeft === null ? "-" : medication.daysLeft}</strong>
<span>{t("sharedOverview.columns.depletion")}</span>
<strong>
<span className="shared-overview-date-value">{formatDate(medication.depletionDate)}</span>
</strong>
</div>
{overviewStatus ? (
<span className={`shared-overview-priority ${overviewStatus.className}`}>
{t(overviewStatus.labelKey)}
</span>
) : null}
</article>
);
})}
</div>
</>
)}
</section>
);
}
File diff suppressed because it is too large Load Diff
+1
View File
@@ -21,6 +21,7 @@ export { default as ProfileModal } from "./ProfileModal";
export { default as ReportModal } from "./ReportModal";
export type { ShareDialogProps } from "./ShareDialog";
export { ShareDialog } from "./ShareDialog";
export { SharedMedicationOverviewSection } from "./SharedMedicationOverviewSection";
export { SharedSchedule } from "./SharedSchedule";
export type { TagInputProps } from "./TagInput";
export { TagInput } from "./TagInput";
+127 -51
View File
@@ -47,6 +47,7 @@ export interface Settings {
shoutrrrPrescriptionReminders: boolean;
stockCalculationMode: "automatic" | "manual";
shareStockStatus: boolean;
shareMedicationOverview: boolean;
upcomingTodayOnly: boolean;
shareScheduleTodayOnly: boolean;
swapDashboardMainSections: boolean;
@@ -98,6 +99,7 @@ const defaultSettings: Settings = {
shoutrrrPrescriptionReminders: true,
stockCalculationMode: "automatic",
shareStockStatus: true,
shareMedicationOverview: false,
upcomingTodayOnly: false,
shareScheduleTodayOnly: false,
swapDashboardMainSections: false,
@@ -145,6 +147,16 @@ export function useSettings(): UseSettingsReturn {
// loadSettings captures the current generation; if it changes before
// the fetch completes, the stale response is silently discarded.
const loadGenerationRef = useRef(0);
const latestSettingsRef = useRef(settings);
const latestSavedSettingsRef = useRef(savedSettings);
useEffect(() => {
latestSettingsRef.current = settings;
}, [settings]);
useEffect(() => {
latestSavedSettingsRef.current = savedSettings;
}, [savedSettings]);
const resetSettingsState = useCallback(() => {
loadGenerationRef.current += 1; // Invalidate any in-flight loadSettings
@@ -214,6 +226,69 @@ export function useSettings(): UseSettingsReturn {
return response;
}, []);
const buildSettingsPayload = useCallback(
(settingsToSave: Settings) => {
const effectiveEmailEnabled = settingsToSave.emailEnabled && !!settingsToSave.notificationEmail?.trim();
const effectiveShoutrrrEnabled = settingsToSave.shoutrrrEnabled && !!settingsToSave.shoutrrrUrl?.trim();
const hasEmailStock =
effectiveEmailEnabled && settingsToSave.emailStockReminders && !!settingsToSave.notificationEmail?.trim();
const hasShoutrrrStock =
effectiveShoutrrrEnabled && settingsToSave.shoutrrrStockReminders && !!settingsToSave.shoutrrrUrl?.trim();
const hasAnyStockReminder = hasEmailStock || hasShoutrrrStock;
const repeatDailyReminders = hasAnyStockReminder ? settingsToSave.repeatDailyReminders : false;
return {
emailEnabled: effectiveEmailEnabled,
notificationEmail: settingsToSave.notificationEmail,
reminderDaysBefore: settingsToSave.reminderDaysBefore,
repeatDailyReminders,
skipRemindersForTakenDoses: settingsToSave.skipRemindersForTakenDoses,
repeatRemindersEnabled: settingsToSave.repeatRemindersEnabled,
reminderRepeatIntervalMinutes: settingsToSave.reminderRepeatIntervalMinutes,
maxNaggingReminders: settingsToSave.maxNaggingReminders ?? 5,
lowStockDays: settingsToSave.lowStockDays,
normalStockDays: settingsToSave.normalStockDays,
highStockDays: settingsToSave.highStockDays,
shoutrrrEnabled: effectiveShoutrrrEnabled,
shoutrrrUrl: settingsToSave.shoutrrrUrl,
emailStockReminders: settingsToSave.emailStockReminders,
emailIntakeReminders: settingsToSave.emailIntakeReminders,
emailPrescriptionReminders: settingsToSave.emailPrescriptionReminders,
shoutrrrStockReminders: settingsToSave.shoutrrrStockReminders,
shoutrrrIntakeReminders: settingsToSave.shoutrrrIntakeReminders,
shoutrrrPrescriptionReminders: settingsToSave.shoutrrrPrescriptionReminders,
stockCalculationMode: settingsToSave.stockCalculationMode,
shareStockStatus: settingsToSave.shareStockStatus,
shareMedicationOverview: settingsToSave.shareMedicationOverview,
upcomingTodayOnly: settingsToSave.upcomingTodayOnly,
shareScheduleTodayOnly: settingsToSave.shareScheduleTodayOnly,
swapDashboardMainSections: settingsToSave.swapDashboardMainSections,
language: i18n.language,
smtpHost: settingsToSave.smtpHost,
smtpPort: settingsToSave.smtpPort,
smtpUser: settingsToSave.smtpUser,
smtpPass: settingsToSave.smtpPass || undefined,
smtpFrom: settingsToSave.smtpFrom,
smtpSecure: settingsToSave.smtpSecure,
};
},
[i18n.language]
);
const flushSettingsWithKeepalive = useCallback(
(settingsToSave: Settings) => {
const payload = buildSettingsPayload(settingsToSave);
void fetch("/api/settings", {
method: "PUT",
headers: { "Content-Type": "application/json" },
credentials: "include",
keepalive: true,
body: JSON.stringify(payload),
}).catch(() => {});
},
[buildSettingsPayload]
);
// Load settings function - exposed for manual refresh (e.g., after auth)
const loadSettings = useCallback(() => {
setSettingsLoading(true);
@@ -331,52 +406,19 @@ export function useSettings(): UseSettingsReturn {
// Internal save function (no event needed)
const performSave = useCallback(
async (settingsToSave: Settings) => {
// Auto-disable email if no recipient is set
const effectiveEmailEnabled = settingsToSave.emailEnabled && !!settingsToSave.notificationEmail?.trim();
// Auto-disable push if no URL is set
const effectiveShoutrrrEnabled = settingsToSave.shoutrrrEnabled && !!settingsToSave.shoutrrrUrl?.trim();
async (settingsToSave: Settings, options?: { syncState?: boolean }) => {
const syncState = options?.syncState ?? true;
const payload = buildSettingsPayload(settingsToSave);
setSettingsSaving(true);
const payload = {
emailEnabled: effectiveEmailEnabled,
notificationEmail: settingsToSave.notificationEmail,
reminderDaysBefore: settingsToSave.reminderDaysBefore,
repeatDailyReminders: settingsToSave.repeatDailyReminders,
skipRemindersForTakenDoses: settingsToSave.skipRemindersForTakenDoses,
repeatRemindersEnabled: settingsToSave.repeatRemindersEnabled,
reminderRepeatIntervalMinutes: settingsToSave.reminderRepeatIntervalMinutes,
maxNaggingReminders: settingsToSave.maxNaggingReminders ?? 5,
lowStockDays: settingsToSave.lowStockDays,
normalStockDays: settingsToSave.normalStockDays,
highStockDays: settingsToSave.highStockDays,
shoutrrrEnabled: effectiveShoutrrrEnabled,
shoutrrrUrl: settingsToSave.shoutrrrUrl,
emailStockReminders: settingsToSave.emailStockReminders,
emailIntakeReminders: settingsToSave.emailIntakeReminders,
emailPrescriptionReminders: settingsToSave.emailPrescriptionReminders,
shoutrrrStockReminders: settingsToSave.shoutrrrStockReminders,
shoutrrrIntakeReminders: settingsToSave.shoutrrrIntakeReminders,
shoutrrrPrescriptionReminders: settingsToSave.shoutrrrPrescriptionReminders,
stockCalculationMode: settingsToSave.stockCalculationMode,
shareStockStatus: settingsToSave.shareStockStatus,
upcomingTodayOnly: settingsToSave.upcomingTodayOnly,
shareScheduleTodayOnly: settingsToSave.shareScheduleTodayOnly,
swapDashboardMainSections: settingsToSave.swapDashboardMainSections,
language: i18n.language,
smtpHost: settingsToSave.smtpHost,
smtpPort: settingsToSave.smtpPort,
smtpUser: settingsToSave.smtpUser,
smtpPass: settingsToSave.smtpPass || undefined,
smtpFrom: settingsToSave.smtpFrom,
smtpSecure: settingsToSave.smtpSecure,
};
if (syncState) {
setSettingsSaving(true);
}
try {
const response = await fetchWithRefresh("/api/settings", {
method: "PUT",
headers: { "Content-Type": "application/json" },
keepalive: true,
body: JSON.stringify(payload),
});
@@ -384,19 +426,27 @@ export function useSettings(): UseSettingsReturn {
throw new Error(`SETTINGS_SAVE_FAILED_${response.status}`);
}
const updatedSettings = { ...settingsToSave };
setSettings(updatedSettings);
setSavedSettings(updatedSettings);
setSettingsSaved(true);
if (syncState) {
const updatedSettings = { ...settingsToSave };
setSettings(updatedSettings);
setSavedSettings(updatedSettings);
setSettingsSaved(true);
} else {
latestSavedSettingsRef.current = { ...settingsToSave };
}
} catch {
setSettingsSaved(false);
// Keep UI aligned with backend truth if save failed (auth/session/network/server error).
loadSettings();
if (syncState) {
setSettingsSaved(false);
// Keep UI aligned with backend truth if save failed (auth/session/network/server error).
loadSettings();
}
} finally {
setSettingsSaving(false);
if (syncState) {
setSettingsSaving(false);
}
}
},
[fetchWithRefresh, i18n.language, loadSettings]
[buildSettingsPayload, fetchWithRefresh, loadSettings]
);
// Debounced auto-save: fires whenever settings change
@@ -424,8 +474,8 @@ export function useSettings(): UseSettingsReturn {
}
debounceRef.current = setTimeout(() => {
performSave(settings);
}, 600);
void performSave(settings);
}, 50);
return () => {
if (debounceRef.current) {
@@ -434,6 +484,32 @@ export function useSettings(): UseSettingsReturn {
};
}, [settings, savedSettings, performSave]);
useEffect(() => {
const flushPendingSettings = () => {
if (JSON.stringify(latestSettingsRef.current) === JSON.stringify(latestSavedSettingsRef.current)) {
return;
}
flushSettingsWithKeepalive(latestSettingsRef.current);
};
window.addEventListener("pagehide", flushPendingSettings);
return () => {
window.removeEventListener("pagehide", flushPendingSettings);
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
if (JSON.stringify(latestSettingsRef.current) === JSON.stringify(latestSavedSettingsRef.current)) {
return;
}
flushSettingsWithKeepalive(latestSettingsRef.current);
};
}, [flushSettingsWithKeepalive]);
// Mark initial load as done after first settings load completes
useEffect(() => {
if (!settingsLoading && !initialLoadDone.current) {
+16 -4
View File
@@ -7,6 +7,8 @@ import type { Medication } from "../types";
import { withCorrelation } from "../utils/correlation";
import { log } from "../utils/logger";
const SHARE_ALL_VALUE = "all";
export interface UseShareReturn {
showShareDialog: boolean;
sharePeople: string[];
@@ -43,10 +45,20 @@ export function useShare(): UseShareReturn {
setShareSelectedPerson("");
setShareSelectedDays(30);
// Get unique takenBy people from all medications (flatten arrays)
const allPeople = meds.flatMap((m) => m.takenBy || []);
const uniquePeople = [...new Set(allPeople)].filter(Boolean).sort();
setSharePeople(uniquePeople);
// Include both per-intake assignments and legacy medication-level assignments.
const uniquePeople = [
...new Set(
meds.flatMap((medication) => [
...(medication.intakes
?.map((intake) => intake.takenBy)
.filter((person): person is string => Boolean(person)) ?? []),
...(medication.takenBy || []),
])
),
]
.filter(Boolean)
.sort();
setSharePeople(uniquePeople.length > 0 ? [SHARE_ALL_VALUE, ...uniquePeople] : []);
log.info("[ShareDialog] Opened", { medicationCount: meds.length, personCount: uniquePeople.length });
if (uniquePeople.length > 0) {
setShareSelectedPerson(uniquePeople[0]);
+5 -3
View File
@@ -364,8 +364,6 @@
"highStockDays": "Hoch (Tage)",
"highStockTooltip": "Bestand über diesem Wert bedeutet, dass du gut versorgt bist",
"thresholdValidation": "Werte müssen wie folgt sein: Kritisch < Niedrig < Hoch",
"shareStockStatus": "Bestand auf geteilten Links anzeigen",
"shareStockStatusDesc": "Bestandsstatus (Normal/Niedrig/Kritisch) und farbige Rahmen auf geteilten Zeitplan-Links für Einnahme-Nutzer anzeigen",
"packageTypesNote": "Hinweis: Tubenmedikamente sind von Bestands-Erinnerungen ausgeschlossen. Flüssigbehälter-Medikamente verwenden einen einzelnen Reminder-Basiswert (Niedrig und Kritisch werden automatisch von diesem Wert abgeleitet)."
},
"timeline": {
@@ -377,6 +375,8 @@
"swapDashboardSections": "Bevorstehenden Zeitplan vor Medikamentenübersicht anzeigen",
"swapDashboardSectionsDesc": "Wenn aktiviert, wird der Bereich mit bevorstehenden Einnahmen über der Medikamentenübersicht angezeigt.",
"sharedSection": "Geteilter Zeitplan",
"shareMedicationOverview": "Medikamentenübersicht auf geteilten Links anzeigen",
"shareMedicationOverviewDesc": "Die Medikamentenübersicht im normalen geteilten Zeitplan für Einnahme-Nutzer einbetten.",
"shareScheduleTodayOnly": "Geteilte Links zeigen nur heute",
"shareScheduleTodayOnlyDesc": "Vergangene und zukünftige Tage in geteilten Zeitplänen ausblenden und nur heutige Einträge zeigen."
},
@@ -497,6 +497,7 @@
"saveFailed": "Speichern fehlgeschlagen",
"networkError": "Netzwerkfehler",
"saving": "Wird gespeichert...",
"outOfStockTakeBlocked": "Dieses Medikament ist leer. Die Einnahme kann nicht als eingenommen markiert werden.",
"unsavedChanges": {
"title": "Ungespeicherte Änderungen",
"message": "Du hast ungespeicherte Änderungen. Möchtest du die Seite wirklich verlassen?",
@@ -551,6 +552,7 @@
"button": "Teilen",
"title": "Zeitplan teilen",
"description": "Generiere einen geheimen Link, um den Medikamentenplan für eine bestimmte Person zu teilen. Jeder mit diesem Link kann den Zeitplan sehen. Wenn die Person eine Dosis als eingenommen markiert, wird sie auch in deiner App als eingenommen angezeigt.",
"allPeople": "Alle",
"selectPerson": "Person auswählen",
"selectPeriod": "Zeitraum auswählen",
"generateLink": "Link generieren",
@@ -577,7 +579,7 @@
}
},
"sharedOverview": {
"title": "Medikamentenübersicht für {{person}}",
"title": "Medikamente für {{person}}",
"sharedBy": "Geteilt von {{user}}",
"expiredOn": "Abgelaufen am: {{date}}",
"noMedications": "Für diesen Teilen-Link sind keine Medikamente verfügbar.",
+5 -3
View File
@@ -364,8 +364,6 @@
"highStockDays": "High (days)",
"highStockTooltip": "Stock above this value means you are well supplied",
"thresholdValidation": "Values must be: Critical < Low < High",
"shareStockStatus": "Show Stock on Shared Links",
"shareStockStatusDesc": "Show stock status (Normal/Low/Critical) and colored borders on shared schedule links for intake users",
"packageTypesNote": "Note: Tube medications are excluded from stock reminders. Liquid container medications use a single reminder baseline (Low and Critical are automatically derived from this value)."
},
"timeline": {
@@ -377,6 +375,8 @@
"swapDashboardSections": "Show Upcoming Schedules before Medication Overview",
"swapDashboardSectionsDesc": "When enabled, the dashboard prioritizes the upcoming schedule section above the medication overview section.",
"sharedSection": "Shared Schedule",
"shareMedicationOverview": "Show medication overview on shared links",
"shareMedicationOverviewDesc": "Embed the medication overview section on the normal shared page for intake users.",
"shareScheduleTodayOnly": "Shared links show only today",
"shareScheduleTodayOnlyDesc": "Hide past and future days on shared schedule links and show only today's entries."
},
@@ -497,6 +497,7 @@
"saveFailed": "Failed to save",
"networkError": "Network error",
"saving": "Saving...",
"outOfStockTakeBlocked": "This medication is empty. Intake cannot be marked as taken.",
"unsavedChanges": {
"title": "Unsaved Changes",
"message": "You have unsaved changes. Are you sure you want to leave?",
@@ -551,6 +552,7 @@
"button": "Share",
"title": "Share Schedule",
"description": "Generate a secret link to share the medication schedule for a specific person. Anyone with this link can view the schedule. When the person marks a dose as taken, it will also be marked as taken in your app.",
"allPeople": "Everyone",
"selectPerson": "Select person",
"selectPeriod": "Select time period",
"generateLink": "Generate Link",
@@ -577,7 +579,7 @@
}
},
"sharedOverview": {
"title": "Medication Overview for {{person}}",
"title": "Medication for {{person}}",
"sharedBy": "Shared by {{user}}",
"expiredOn": "Expired on: {{date}}",
"noMedications": "No medications available for this share link.",
+8 -5
View File
@@ -870,18 +870,21 @@ export function SettingsPage() {
<div className="section-header">
<h3>{t("settings.timeline.sharedSection")}</h3>
</div>
<div className="setting-row compact">
<div className="setting-row compact" style={{ marginTop: "10px" }}>
<div className="setting-label">
<span>{t("settings.stock.shareStockStatus")}</span>
<span className="info-tooltip small" data-tooltip={t("settings.stock.shareStockStatusDesc")}>
<span>{t("settings.timeline.shareMedicationOverview")}</span>
<span
className="info-tooltip small"
data-tooltip={t("settings.timeline.shareMedicationOverviewDesc")}
>
</span>
</div>
<label className="toggle-switch small">
<input
type="checkbox"
checked={settings.shareStockStatus}
onChange={(e) => setSettings({ ...settings, shareStockStatus: e.target.checked })}
checked={settings.shareMedicationOverview}
onChange={(e) => setSettings({ ...settings, shareMedicationOverview: e.target.checked })}
/>
<span className="toggle-slider"></span>
</label>
+4 -349
View File
@@ -1,356 +1,11 @@
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useParams } from "react-router-dom";
import { MedicationAvatar } from "../components";
import type { SharedMedicationOverviewItem, SharedMedicationOverviewResponse } from "../types";
import { getSystemLocale } from "../utils/formatters";
type ThemePreference = "light" | "dark" | "system";
function getSystemTheme(): "light" | "dark" {
if (typeof window !== "undefined" && window.matchMedia?.("(prefers-color-scheme: light)").matches) {
return "light";
}
return "dark";
}
function formatPackageInfo(medication: SharedMedicationOverviewItem): string {
if (medication.packageType === "blister") {
return `${medication.packCount} x ${medication.blistersPerPack} x ${medication.pillsPerBlister}`;
}
if (medication.totalPills !== null) {
return `${medication.packCount} x ${medication.totalPills}`;
}
return `${medication.packCount}`;
}
function formatDate(dateValue: string | null, locale: string): string {
if (!dateValue) return "-";
const parsed = new Date(`${dateValue}T00:00:00`);
if (Number.isNaN(parsed.getTime())) return dateValue;
return parsed.toLocaleDateString(locale);
}
import { Navigate, useParams } from "react-router-dom";
export function SharedOverviewPage() {
const { token } = useParams<{ token: string }>();
const { t, i18n } = useTranslation();
const [data, setData] = useState<SharedMedicationOverviewResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [expiredAt, setExpiredAt] = useState<string | null>(null);
const [themePreference, setThemePreference] = useState<ThemePreference>(() => {
if (typeof window !== "undefined") {
const stored = localStorage.getItem("theme") as ThemePreference | null;
if (stored === "light" || stored === "dark" || stored === "system") return stored;
}
return "dark";
});
const [themeMenuOpen, setThemeMenuOpen] = useState(false);
const themeMenuRef = useRef<HTMLDivElement>(null);
const resolvedTheme = themePreference === "system" ? getSystemTheme() : themePreference;
useEffect(() => {
document.documentElement.setAttribute("data-theme", resolvedTheme);
localStorage.setItem("theme", themePreference);
}, [themePreference, resolvedTheme]);
useEffect(() => {
if (themePreference !== "system") return;
const mq = window.matchMedia?.("(prefers-color-scheme: light)");
if (!mq) return;
const handler = () => {
const resolved = mq.matches ? "light" : "dark";
document.documentElement.setAttribute("data-theme", resolved);
};
mq.addEventListener("change", handler);
return () => mq.removeEventListener("change", handler);
}, [themePreference]);
useEffect(() => {
if (!themeMenuOpen) return;
const handleClickOutside = (event: MouseEvent) => {
if (themeMenuRef.current && !themeMenuRef.current.contains(event.target as Node)) {
setThemeMenuOpen(false);
}
};
document.addEventListener("click", handleClickOutside);
return () => document.removeEventListener("click", handleClickOutside);
}, [themeMenuOpen]);
useEffect(() => {
let isCancelled = false;
async function loadOverview() {
if (!token) {
if (!isCancelled) {
setLoading(false);
setError(t("sharedOverview.error.notFound"));
}
return;
}
setLoading(true);
setError(null);
setExpiredAt(null);
try {
const response = await fetch(`/api/share/${token}/overview`);
const responseData = await response.json().catch(() => ({}));
if (!response.ok) {
if (response.status === 404) {
throw new Error("not_found");
}
if (response.status === 410) {
setExpiredAt(responseData.expiredAt ?? null);
throw new Error("expired");
}
if (response.status === 429) {
throw new Error("rate_limited");
}
throw new Error("load_failed");
}
if (!isCancelled) {
setData(responseData as SharedMedicationOverviewResponse);
}
} catch (loadError) {
if (isCancelled) return;
const message = loadError instanceof Error ? loadError.message : "load_failed";
if (message === "not_found") {
setError(t("sharedOverview.error.notFound"));
return;
}
if (message === "expired") {
setError(t("sharedOverview.error.expired"));
return;
}
if (message === "rate_limited") {
setError(t("sharedOverview.error.rateLimit"));
return;
}
setError(t("sharedOverview.error.generic"));
} finally {
if (!isCancelled) {
setLoading(false);
}
}
}
void loadOverview();
return () => {
isCancelled = true;
};
}, [token, t]);
if (loading) {
return (
<div className="shared-schedule-page">
<div className="shared-schedule-loading shared-schedule-loading-skeleton" aria-busy="true">
<h1>💊 MedAssist-ng</h1>
<span className="screen-reader-only">{t("common.loading")}</span>
<div className="skeleton-card">
<span className="skeleton-line skeleton-line-long" />
<span className="skeleton-line skeleton-line-medium" />
<span className="skeleton-line skeleton-line-short" />
</div>
</div>
</div>
);
if (!token) {
return <Navigate to="/" replace />;
}
if (error || !data) {
return (
<div className="shared-schedule-page">
<div className={`shared-schedule-error${expiredAt ? " expired" : ""}`}>
<h1>💊 MedAssist-ng</h1>
{expiredAt ? <div className="expired-icon"></div> : null}
<h2>{t("sharedOverview.title")}</h2>
<p className="error-message">{error ?? t("sharedOverview.error.generic")}</p>
{expiredAt ? (
<p className="expired-date">
{t("sharedOverview.expiredOn", {
date: new Date(expiredAt).toLocaleDateString(getSystemLocale(i18n.language)),
})}
</p>
) : null}
</div>
</div>
);
}
const locale = getSystemLocale(i18n.language);
return (
<div className="shared-schedule-page">
<div className="shared-schedule-container shared-overview-container">
<header className="shared-schedule-header">
<h1>{t("sharedOverview.title", { person: data.takenBy })}</h1>
<p className="shared-schedule-period">{t("sharedOverview.sharedBy", { user: data.sharedBy ?? "-" })}</p>
<div className="shared-schedule-header-actions">
<div className={`theme-menu ${themeMenuOpen ? "open" : ""}`} ref={themeMenuRef}>
<button className="icon-btn" onClick={() => setThemeMenuOpen(!themeMenuOpen)} title={t("theme.title")}>
{resolvedTheme === "dark" ? "🌙" : "☀️"}
</button>
<div className="theme-dropdown">
<button
className={`theme-dropdown-item${themePreference === "light" ? " active" : ""}`}
onClick={() => {
setThemePreference("light");
setThemeMenuOpen(false);
}}
>
{t("theme.light")}
</button>
<button
className={`theme-dropdown-item${themePreference === "dark" ? " active" : ""}`}
onClick={() => {
setThemePreference("dark");
setThemeMenuOpen(false);
}}
>
{t("theme.dark")}
</button>
<button
className={`theme-dropdown-item${themePreference === "system" ? " active" : ""}`}
onClick={() => {
setThemePreference("system");
setThemeMenuOpen(false);
}}
>
{t("theme.system")}
</button>
</div>
</div>
</div>
</header>
{data.medications.length === 0 ? (
<p className="shared-schedule-empty">{t("sharedOverview.noMedications")}</p>
) : (
<>
<div className="shared-overview-table-wrap">
<table className="shared-overview-table">
<thead>
<tr>
<th>{t("sharedOverview.columns.name")}</th>
<th>{t("sharedOverview.columns.package")}</th>
<th>{t("sharedOverview.columns.stock")}</th>
<th>{t("sharedOverview.columns.daysLeft")}</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>
<tbody>
{data.medications.map((medication) => {
const priorityKey =
medication.priority === "high"
? "sharedOverview.priority.high"
: "sharedOverview.priority.normal";
return (
<tr key={`${medication.name}-${medication.medicationStartDate ?? "no-start"}`}>
<td>
<div className="shared-overview-medication-cell">
<MedicationAvatar name={medication.name} imageUrl={medication.imageUrl} size="sm" />
<div className="shared-overview-medication-text">
<strong>{medication.name}</strong>
{medication.genericName ? <span>{medication.genericName}</span> : null}
</div>
</div>
</td>
<td>{formatPackageInfo(medication)}</td>
<td>
{medication.currentStock === null || medication.capacity === null
? "-"
: t("sharedOverview.stock.of", {
current: medication.currentStock,
capacity: medication.capacity,
})}
</td>
<td>{medication.daysLeft === null ? "-" : medication.daysLeft}</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 ? (
"-"
) : (
<span className={`shared-overview-priority ${medication.priority}`}>{t(priorityKey)}</span>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<div className="shared-overview-cards">
{data.medications.map((medication) => {
const priorityKey =
medication.priority === "high" ? "sharedOverview.priority.high" : "sharedOverview.priority.normal";
return (
<article
className="shared-overview-card"
key={`${medication.name}-${medication.medicationStartDate ?? "no-start"}`}
>
<div className="shared-overview-card-title">
<MedicationAvatar name={medication.name} imageUrl={medication.imageUrl} size="sm" />
<div>
<strong>{medication.name}</strong>
{medication.genericName ? <p>{medication.genericName}</p> : null}
</div>
</div>
<div className="shared-overview-card-grid">
<span>{t("sharedOverview.columns.package")}</span>
<strong>{formatPackageInfo(medication)}</strong>
<span>{t("sharedOverview.columns.stock")}</span>
<strong>
{medication.currentStock === null || medication.capacity === null
? "-"
: t("sharedOverview.stock.of", {
current: medication.currentStock,
capacity: medication.capacity,
})}
</strong>
<span>{t("sharedOverview.columns.daysLeft")}</span>
<strong>{medication.daysLeft === null ? "-" : medication.daysLeft}</strong>
<span>{t("sharedOverview.columns.nextIntake")}</span>
<strong>{formatDate(medication.nextIntakeDate, locale)}</strong>
<span>{t("sharedOverview.columns.depletion")}</span>
<strong>{formatDate(medication.depletionDate, locale)}</strong>
</div>
{medication.priority ? (
<span className={`shared-overview-priority ${medication.priority}`}>{t(priorityKey)}</span>
) : null}
</article>
);
})}
</div>
</>
)}
</div>
</div>
);
return <Navigate to={`/share/${token}`} replace />;
}
+79 -30
View File
@@ -40,6 +40,12 @@
margin-bottom: 1rem;
}
.shared-overview-section-header h2 {
margin: 0;
font-size: 1.1rem;
font-weight: 700;
}
.shared-schedule-error.expired h2 {
font-size: 1.5rem;
color: var(--warning);
@@ -94,6 +100,12 @@
font-size: 1rem;
}
.shared-schedule-section {
display: flex;
flex-direction: column;
gap: 0.9rem;
}
.shared-timeline {
background: var(--bg-secondary);
border-radius: 12px;
@@ -143,6 +155,19 @@
max-width: 1080px;
}
.shared-overview-inline-section {
display: flex;
flex-direction: column;
gap: 0.9rem;
margin-bottom: 1.5rem;
}
.shared-overview-section-header {
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.shared-overview-table-wrap {
overflow-x: auto;
border: 1px solid var(--border-primary);
@@ -153,7 +178,7 @@
.shared-overview-table {
width: 100%;
border-collapse: collapse;
min-width: 780px;
min-width: 600px;
}
.shared-overview-table th,
@@ -187,6 +212,15 @@
.shared-overview-medication-text {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.15rem;
min-width: 0;
}
.shared-overview-med-name {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.15rem;
min-width: 0;
}
@@ -196,20 +230,28 @@
word-break: break-word;
}
.shared-overview-medication-text span {
.shared-overview-medication-text .shared-overview-med-generic {
font-size: 0.8rem;
color: var(--text-secondary);
word-break: break-word;
}
.shared-overview-stock-value,
.shared-overview-date-value {
white-space: nowrap;
}
.shared-overview-priority {
display: inline-flex;
align-items: center;
align-self: flex-start;
padding: 0.2rem 0.5rem;
border-radius: 999px;
font-size: 0.8rem;
font-weight: 600;
border: 1px solid transparent;
width: fit-content;
max-width: 100%;
}
.shared-overview-priority.normal {
@@ -224,6 +266,18 @@
border-color: rgba(239, 68, 68, 0.25);
}
.shared-overview-priority.warning {
color: var(--warning);
background: color-mix(in srgb, var(--warning) 12%, transparent);
border-color: color-mix(in srgb, var(--warning) 30%, transparent);
}
.shared-overview-priority.danger {
color: var(--danger);
background: var(--danger-bg);
border-color: rgba(239, 68, 68, 0.25);
}
.shared-overview-cards {
display: none;
gap: 0.75rem;
@@ -268,11 +322,6 @@
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;
@@ -489,10 +538,6 @@
width: 100%;
}
.mobile-edit-form .date-input-display {
font-size: 16px !important;
}
.mobile-edit-form .blister-row {
display: grid;
grid-template-columns: 1fr 1fr;
@@ -520,17 +565,12 @@
grid-column: 1 / -1;
}
.mobile-edit-form .blister-row label.compact.time-label {
width: fit-content;
}
.mobile-edit-form .blister-row label.compact.time-label input[type="time"] {
width: auto;
}
.mobile-edit-form .blister-row .remove-blister-btn {
grid-column: 2 / 3;
justify-self: end;
.blister-inputs .remind-toggle-row {
display: flex;
align-items: center;
gap: 0.5rem;
align-self: end;
padding-bottom: 0.5rem;
}
.mobile-edit-form .blister-row .remind-toggle-row {
@@ -540,14 +580,6 @@
grid-column: 1 / 2;
}
.blister-inputs .remind-toggle-row {
display: flex;
align-items: center;
gap: 0.5rem;
align-self: end;
padding-bottom: 0.5rem;
}
.mobile-edit-form .blister-row .datetime-inputs {
display: flex;
gap: 0.5rem;
@@ -563,6 +595,14 @@
min-width: 0;
}
.mobile-edit-form .blister-row label.compact.time-label {
width: fit-content;
}
.mobile-edit-form .blister-row label.compact.time-label input[type="time"] {
width: auto;
}
/* Remove blister button */
.remove-blister-btn {
padding: 0.4rem !important;
@@ -579,6 +619,11 @@
opacity: 1;
}
.mobile-edit-form .blister-row .remove-blister-btn {
grid-column: 2 / 3;
justify-self: end;
}
.mobile-edit-form .add-blister {
margin-top: 0.5rem;
width: auto;
@@ -653,6 +698,10 @@
z-index: 1;
}
.mobile-edit-form .date-input-display {
font-size: 16px !important;
}
.date-input-native {
color: transparent !important;
caret-color: transparent !important;
@@ -1,4 +1,4 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { fireEvent, render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { ShareDialog } from "../../components/ShareDialog";
@@ -45,6 +45,12 @@ describe("ShareDialog", () => {
expect(screen.getByRole("option", { name: "Bob" })).toBeInTheDocument();
});
it("renders the translated all-people option label", () => {
render(<ShareDialog {...defaultProps} sharePeople={["all", "Alice"]} shareSelectedPerson="all" />);
expect(screen.getByRole("option", { name: "share.allPeople" })).toBeInTheDocument();
expect(screen.getByRole("option", { name: "Alice" })).toBeInTheDocument();
});
it("renders period selection dropdown", () => {
render(<ShareDialog {...defaultProps} />);
// The dropdown renders with 3 options for time periods
@@ -70,7 +76,7 @@ describe("ShareDialog", () => {
render(<ShareDialog {...defaultProps} shareLink="http://example.com/share/abc123" />);
const inputs = screen.getAllByRole("textbox") as HTMLInputElement[];
expect(inputs[0]).toHaveValue("http://example.com/share/abc123");
expect(inputs[1]).toHaveValue("http://example.com/share/abc123/overview");
expect(inputs).toHaveLength(1);
});
it("calls onCopyShareLink when copy button is clicked", () => {
@@ -93,16 +99,6 @@ describe("ShareDialog", () => {
expect(selectMock).toHaveBeenCalled();
});
it("copies overview link when overview copy button is clicked", async () => {
render(<ShareDialog {...defaultProps} shareLink="http://example.com/share/abc123" />);
fireEvent.click(screen.getByRole("button", { name: /share\.copyOverviewLink/i }));
await waitFor(() => {
expect(navigator.clipboard.writeText).toHaveBeenCalledWith("http://example.com/share/abc123/overview");
});
});
it("calls person and period change callbacks", () => {
render(<ShareDialog {...defaultProps} />);
@@ -23,6 +23,74 @@ function createSharedData() {
};
}
function createSharedDataWithEmbeddedOverview() {
return {
...createSharedData(),
takenBy: "all",
shareMedicationOverview: true,
medicationOverview: [
{
name: "Aspirin",
genericName: "Acetylsalicylic Acid",
imageUrl: null,
packageType: "blister",
packCount: 1,
blistersPerPack: 2,
pillsPerBlister: 10,
totalPills: null,
looseTablets: 0,
currentStock: 8,
capacity: 20,
daysLeft: 8,
nextIntakeDate: null,
depletionDate: "2026-01-20",
priority: "high",
expiryDate: null,
medicationStartDate: null,
prescriptionEnabled: false,
prescriptionRemainingRefills: null,
},
],
};
}
function createSharedDataWithTodayDose() {
const now = new Date();
now.setHours(10, 0, 0, 0);
const dateOnlyMs = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
return {
sharedBy: "Owner",
takenBy: "Max",
scheduleDays: 30,
automaticDoseId: `1-0-${dateOnlyMs}`,
shareStockStatus: true,
medications: [
{
id: 1,
name: "Ibuprofen",
genericName: null,
takenBy: [],
packageType: "blister",
packCount: 2,
blistersPerPack: 1,
pillsPerBlister: 10,
looseTablets: 0,
pillWeightMg: null,
doseUnit: "mg",
expiryDate: null,
notes: null,
intakeRemindersEnabled: false,
blisters: [{ usage: 1, every: 1, start: now.toISOString() }],
intakes: [{ usage: 1, every: 1, start: now.toISOString(), takenBy: null, intakeRemindersEnabled: false }],
updatedAt: null,
dismissedUntil: null,
lastStockCorrectionAt: null,
},
],
};
}
describe("SharedSchedule", () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -117,4 +185,54 @@ describe("SharedSchedule", () => {
expect(screen.getByText("share.error")).toBeInTheDocument();
});
});
it("shows the robot marker for automatically taken shared doses", async () => {
const sharedData = createSharedDataWithTodayDose();
(globalThis.fetch as ReturnType<typeof vi.fn>).mockImplementation((url: string, init?: RequestInit) => {
if (url === "/api/share/token-123/doses" && (!init || !init.method || init.method === "GET")) {
return Promise.resolve({
ok: true,
json: () =>
Promise.resolve({
doses: [{ doseId: sharedData.automaticDoseId, dismissed: false, takenSource: "automatic" }],
}),
});
}
if (url === "/api/share/token-123") {
return Promise.resolve({ ok: true, json: () => Promise.resolve(sharedData) });
}
return Promise.reject(new Error(`Unexpected URL: ${url}`));
});
renderSharedSchedule("/share/token-123");
await waitFor(() => {
expect(screen.getByText("🤖")).toBeInTheDocument();
});
});
it("renders the embedded medication overview on the shared page when enabled", async () => {
const sharedData = createSharedDataWithEmbeddedOverview();
(globalThis.fetch as ReturnType<typeof vi.fn>).mockImplementation((url: string, init?: RequestInit) => {
if (url === "/api/share/token-123/doses" && (!init || !init.method || init.method === "GET")) {
return Promise.resolve({ ok: true, json: () => Promise.resolve({ doses: [] }) });
}
if (url === "/api/share/token-123") {
return Promise.resolve({ ok: true, json: () => Promise.resolve(sharedData) });
}
return Promise.reject(new Error(`Unexpected URL: ${url}`));
});
renderSharedSchedule("/share/token-123");
await waitFor(() => {
expect(screen.getAllByText("Aspirin").length).toBeGreaterThan(0);
expect(screen.getAllByText("Acetylsalicylic Acid").length).toBeGreaterThan(0);
});
expect(screen.getByText("sharedOverview.columns.priority")).toBeInTheDocument();
expect(screen.getByText("share.noSchedule")).toBeInTheDocument();
});
});
@@ -23,6 +23,7 @@ function createSharedData(overrides: Record<string, unknown> = {}) {
takenBy: "Max",
scheduleDays: 30,
shareStockStatus: true,
upcomingTodayOnly: false,
shareScheduleTodayOnly: true,
stockCalculationMode: "automatic",
stockThresholds: {
@@ -73,7 +74,7 @@ describe("SharedSchedule today-only", () => {
vi.restoreAllMocks();
});
it("hides past and future sections when shareScheduleTodayOnly is enabled", async () => {
it("hides past and future sections when shareScheduleTodayOnly is enabled even if dashboard today-only is off", async () => {
const sharedData = createSharedData();
(globalThis.fetch as ReturnType<typeof vi.fn>).mockImplementation((url: string, init?: RequestInit) => {
+49 -1
View File
@@ -17,13 +17,17 @@ describe("useSettings", () => {
vi.restoreAllMocks();
});
it("initializes with default settings", () => {
it("initializes with default settings", async () => {
const { result } = renderHook(() => useSettings());
expect(result.current.settings.emailEnabled).toBe(false);
expect(result.current.settings.lowStockDays).toBe(30);
expect(result.current.settings.reminderDaysBefore).toBe(7);
expect(result.current.settingsLoading).toBe(true);
await waitFor(() => {
expect(result.current.settingsLoading).toBe(false);
});
});
it("loads settings from API on mount", async () => {
@@ -137,6 +141,50 @@ describe("useSettings", () => {
expect(result.current.settingsSaved).toBe(true);
});
it("flushes unsaved settings on pagehide with keepalive including shareMedicationOverview", async () => {
const fetchMock = global.fetch as ReturnType<typeof vi.fn>;
fetchMock.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ shareMedicationOverview: false }),
});
const { result } = renderHook(() => useSettings());
await waitFor(() => {
expect(result.current.settingsLoading).toBe(false);
});
vi.useFakeTimers();
act(() => {
result.current.setSettings((settings) => ({
...settings,
shareMedicationOverview: true,
}));
});
act(() => {
window.dispatchEvent(new Event("pagehide"));
});
const keepaliveCall = fetchMock.mock.calls.find(
([url, init]) => url === "/api/settings" && (init as RequestInit | undefined)?.keepalive === true
);
expect(keepaliveCall).toBeDefined();
expect(keepaliveCall?.[1]).toEqual(
expect.objectContaining({
method: "PUT",
keepalive: true,
})
);
const payload = JSON.parse(((keepaliveCall?.[1] as RequestInit).body as string) ?? "{}");
expect(payload.shareMedicationOverview).toBe(true);
vi.useRealTimers();
});
it("keeps email channel enabled when recipient is non-empty", async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
+1 -1
View File
@@ -86,7 +86,7 @@ describe("useShare", () => {
});
expect(result.current.showShareDialog).toBe(true);
expect(result.current.sharePeople).toEqual(["Alice", "Bob", "Charlie"]);
expect(result.current.sharePeople).toEqual(["all", "Alice", "Bob", "Charlie"]);
expect(result.current.shareSelectedPerson).toBe("Alice");
expect(window.history.pushState).toHaveBeenCalled();
});
+5 -21
View File
@@ -200,46 +200,30 @@ describe("SettingsPage", () => {
const swapRow = screen.getByText("settings.timeline.swapDashboardSections").closest(".setting-row");
const upcomingRow = screen.getByText("settings.timeline.upcomingTodayOnly").closest(".setting-row");
const overviewRow = screen.getByText("settings.timeline.shareMedicationOverview").closest(".setting-row");
const sharedRow = screen.getByText("settings.timeline.shareScheduleTodayOnly").closest(".setting-row");
const swapToggle = swapRow?.querySelector('input[type="checkbox"]') as HTMLInputElement;
const upcomingToggle = upcomingRow?.querySelector('input[type="checkbox"]') as HTMLInputElement;
const overviewToggle = overviewRow?.querySelector('input[type="checkbox"]') as HTMLInputElement;
const sharedToggle = sharedRow?.querySelector('input[type="checkbox"]') as HTMLInputElement;
expect(swapToggle).toBeInTheDocument();
expect(upcomingToggle).toBeInTheDocument();
expect(overviewToggle).toBeInTheDocument();
expect(sharedToggle).toBeInTheDocument();
fireEvent.click(swapToggle);
fireEvent.click(upcomingToggle);
fireEvent.click(overviewToggle);
fireEvent.click(sharedToggle);
expect(setSettings).toHaveBeenCalledWith(expect.objectContaining({ swapDashboardMainSections: true }));
expect(setSettings).toHaveBeenCalledWith(expect.objectContaining({ upcomingTodayOnly: true }));
expect(setSettings).toHaveBeenCalledWith(expect.objectContaining({ shareMedicationOverview: true }));
expect(setSettings).toHaveBeenCalledWith(expect.objectContaining({ shareScheduleTodayOnly: true }));
});
it("updates share stock status toggle through setSettings", () => {
const setSettings = vi.fn();
mockContextValue = createMockContext({
setSettings,
settings: {
...createMockContext().settings,
shareStockStatus: false,
},
});
renderPage();
const shareStockRow = screen.getByText("settings.stock.shareStockStatus").closest(".setting-row");
const shareStockToggle = shareStockRow?.querySelector('input[type="checkbox"]') as HTMLInputElement;
expect(shareStockToggle).toBeInTheDocument();
fireEvent.click(shareStockToggle);
expect(setSettings).toHaveBeenCalledWith(expect.objectContaining({ shareStockStatus: true }));
});
it("opens export modal when export action is clicked", () => {
const setShowExportModal = vi.fn();
mockContextValue = createMockContext({ setShowExportModal });
@@ -10,6 +10,7 @@ function renderSharedOverview(path: string) {
<MemoryRouter initialEntries={[path]}>
<Routes>
<Route path="/share/:token/overview" element={<SharedOverviewPage />} />
<Route path="/share/:token" element={<div>shared schedule target</div>} />
</Routes>
</MemoryRouter>
);
@@ -21,66 +22,13 @@ describe("SharedOverviewPage", () => {
window.localStorage.clear();
});
it("renders medication overview for valid token", async () => {
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: true,
status: 200,
json: () =>
Promise.resolve({
takenBy: "Max",
sharedBy: "Owner",
generatedAt: "2026-03-06T10:00:00.000Z",
medications: [
{
name: "Aspirin",
genericName: "Acetylsalicylic Acid",
imageUrl: null,
packageType: "blister",
packCount: 1,
blistersPerPack: 2,
pillsPerBlister: 10,
totalPills: null,
looseTablets: 0,
currentStock: 18,
capacity: 20,
daysLeft: 18,
nextIntakeDate: "2026-03-07",
depletionDate: "2026-03-24",
priority: "normal",
expiryDate: null,
medicationStartDate: null,
prescriptionEnabled: false,
prescriptionRemainingRefills: null,
},
],
}),
});
it("redirects the legacy overview route to the normal shared schedule route", async () => {
renderSharedOverview("/share/abcdef0123456789/overview");
await waitFor(() => {
expect(screen.getByText("sharedOverview.title")).toBeInTheDocument();
expect(screen.getAllByText("Aspirin").length).toBeGreaterThan(0);
expect(screen.getAllByText("Acetylsalicylic Acid").length).toBeGreaterThan(0);
expect(screen.getByText("shared schedule target")).toBeInTheDocument();
});
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");
expect(globalThis.fetch).not.toHaveBeenCalled();
});
it("keeps the updated shared overview stock wording in English and German", () => {
@@ -95,46 +43,12 @@ describe("SharedOverviewPage", () => {
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,
status: 404,
json: () => Promise.resolve({ error: "not_found" }),
});
it("redirects even when the token would previously have been loaded from the overview route", async () => {
renderSharedOverview("/share/abcdef0123456789/overview");
await waitFor(() => {
expect(screen.getByText("sharedOverview.error.notFound")).toBeInTheDocument();
});
});
it("renders expired state for expired token", async () => {
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: false,
status: 410,
json: () => Promise.resolve({ error: "expired", expiredAt: "2026-03-01T10:00:00.000Z" }),
});
renderSharedOverview("/share/abcdef0123456789/overview");
await waitFor(() => {
expect(screen.getByText("sharedOverview.error.expired")).toBeInTheDocument();
expect(screen.getByText("sharedOverview.expiredOn")).toBeInTheDocument();
});
});
it("renders rate-limit error state", async () => {
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: false,
status: 429,
json: () => Promise.resolve({ error: "rate_limited" }),
});
renderSharedOverview("/share/abcdef0123456789/overview");
await waitFor(() => {
expect(screen.getByText("sharedOverview.error.rateLimit")).toBeInTheDocument();
expect(screen.getByText("shared schedule target")).toBeInTheDocument();
});
expect(globalThis.fetch).not.toHaveBeenCalled();
});
});
+3 -1
View File
@@ -254,6 +254,8 @@ export type SharedScheduleData = {
};
stockCalculationMode?: "automatic" | "manual";
shareStockStatus?: boolean;
shareMedicationOverview?: boolean;
medicationOverview?: SharedMedicationOverviewItem[] | null;
upcomingTodayOnly?: boolean;
shareScheduleTodayOnly?: boolean;
};
@@ -279,7 +281,7 @@ export type SharedMedicationOverviewItem = {
daysLeft: number | null;
nextIntakeDate: string | null;
depletionDate: string | null;
priority: "normal" | "high" | null;
priority: "normal" | "high" | "out-of-stock" | null;
expiryDate: string | null;
medicationStartDate: string | null;
prescriptionEnabled: boolean;