Compare commits

...

4 Commits

Author SHA1 Message Date
github-actions[bot] 812b14df03 chore: update test count badges [skip ci] 2026-05-24 12:04:50 +00:00
Daniel Volz c78fc43083 feat(frontend): add intake journal and shared note flows (#648)
* feat(backend): add intake journal APIs and share note support

* feat(frontend): add intake journal and shared note flows
2026-05-24 14:00:30 +02:00
Daniel Volz e4a1b449c6 feat(backend): add intake journal APIs and share note support 2026-05-24 13:36:25 +02:00
Daniel Volz 767ae23843 docs: clarify dev hosts and deployment guidance 2026-05-24 13:36:01 +02:00
102 changed files with 11871 additions and 890 deletions
+5
View File
@@ -10,6 +10,8 @@ PUID=1000
PGID=1000
PORT=3000
# Docker Compose quickstart serves the frontend on http://localhost:4174.
# Local Vite development usually uses http://localhost:5173 or http://localhost:4173 instead.
CORS_ORIGINS=http://localhost:4174
# Server default timezone for scheduled reminders.
@@ -18,8 +20,11 @@ TZ=Europe/Berlin
# Public base URL used for notification action links.
# Required for intake reminder action buttons.
# Use an externally reachable HTTPS URL for remote/self-hosted access.
# PUBLIC_APP_URL=https://medassist.example.com
# If this uses a non-local host, include that origin in CORS_ORIGINS.
# Local Vite development automatically allows this hostname; set
# VITE_ALLOWED_HOSTS only when you need additional development hostnames.
# Log level: debug, info, warn, error, silent
LOG_LEVEL=info
+6 -19
View File
@@ -1,26 +1,13 @@
# MedAssist-ng - Copilot Entry Point
## VERY IMPORTANT - Prioritized Constraints
This file is intentionally thin. `AGENTS.md` is the canonical governance file for this repository.
**First: Update Memory and Reports**
- Always keep agent work memory updated in `doku/memory_notes.md` so progress and decisions remain recoverable across context loss.
- If `doku/memory_notes.md` is missing, create it immediately.
- Always keep a user-facing work report updated in `doku/report.md` so completed work is easy to review.
- If `doku/report.md` is missing, create it immediately.
- This memory/report rule replaces the previous `doku/APP_BEHAVIOR.md` persistence requirement.
**Second: Follow Governance Rules**
- Consult `AGENTS.md` for governance, workflow, and skill rules when that file exists in the workspace.
When `AGENTS.md` exists in the workspace, use it as the single source of truth for governance, workflow, and skill rules.
If rules differ between files, follow `AGENTS.md`.
## Required Startup Steps
1. Read `AGENTS.md` first when it exists in the workspace.
2. If `AGENTS.md` exists, identify triggered skills from it and read each referenced `SKILL.md` before making changes.
3. Follow delegation boundaries exactly (`@testing-manager` for testing, `@release-manager` for release orchestration).
4. When work moves into a different thematic area, create or switch to a dedicated local branch or worktree before editing code, and reuse the same branch/worktree for follow-up work inside that same theme.
## Scope
This file intentionally stays minimal to prevent duplicated or conflicting instructions.
2. Ensure `doku/memory_notes.md` and `doku/report.md` exist and keep them updated during meaningful work. These files are local-only and must not be staged or committed unless explicitly requested.
3. Identify triggered skills from `AGENTS.md` and read only the matching `SKILL.md` files before making changes.
4. Follow delegation boundaries from `AGENTS.md`: `@testing-manager` for testing work and `@release-manager` for release orchestration, including the documented fallback protocol when a required specialist is unavailable.
5. Keep all non-canonical instruction files brief and aligned with `AGENTS.md`; do not duplicate full governance here.
+2
View File
@@ -24,6 +24,8 @@ on:
concurrency:
group: docker-build-${{ github.ref }}
# Cancel older runs on the same ref so the shared branch tag stays aligned
# with the newest commit instead of racing older builds against newer ones.
cancel-in-progress: true
# Default minimal permissions
+17 -4
View File
@@ -18,8 +18,8 @@
</p>
<p align="center">
<img src="https://img.shields.io/badge/Backend_Tests-697%2F697-brightgreen?logo=vitest" alt="Backend Tests 454/454" />
<img src="https://img.shields.io/badge/Frontend_Tests-919%2F919-brightgreen?logo=vitest" alt="Frontend Tests 611/611" />
<img src="https://img.shields.io/badge/Backend_Tests-715%2F715-brightgreen?logo=vitest" alt="Backend Tests 454/454" />
<img src="https://img.shields.io/badge/Frontend_Tests-949%2F949-brightgreen?logo=vitest" alt="Frontend Tests 611/611" />
</p>
### 🤖 AI-Generated Code
@@ -157,10 +157,13 @@ 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 allow shared links to view and edit intake journal notes for their visible schedule window
- 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
- Export all your data (medications, dose history, intake journal notes, settings) as JSON
- Review validated import contents before replacing current data
- Optionally download a fresh backup before confirming import
- Import previously exported data with automatic ID remapping
- Choose whether to include sensitive data in exports
@@ -188,6 +191,16 @@ docker compose -p medassist-ng up -d
Open `http://localhost:4174` and start tracking your medications.
### Verify Deployment
After the containers start, confirm the stack is actually healthy:
1. Run `docker compose ps` and confirm the `backend` service is `healthy` and the `frontend` service is running.
2. Open `http://localhost:3000/health` and confirm the backend responds with JSON that includes `"status":"ok"`.
3. Open `http://localhost:4174` and confirm the app shell loads and can reach the API.
If the frontend loads but API requests fail, check the backend health endpoint first and confirm `CORS_ORIGINS` includes the frontend origin you are using. If you plan to open reminder or share links from another device, set `PUBLIC_APP_URL` to the externally reachable app URL instead of relying on `localhost`.
# Configuration
Configure the application with environment variables in `.env`. Keep the basic container settings in the README and use the dedicated docs for the full reference.
@@ -206,7 +219,7 @@ Optional but commonly needed:
| Variable | Default | Description |
|----------|---------|-------------|
| `PUBLIC_APP_URL` | — | Public base URL for notification action links |
| `PUBLIC_APP_URL` | — | Public base URL for notification action and share links |
Detailed configuration references:
@@ -0,0 +1,15 @@
CREATE TABLE `intake_journal` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`user_id` integer NOT NULL,
`dose_tracking_id` integer NOT NULL,
`medication_id` integer NOT NULL,
`scheduled_for` integer NOT NULL,
`note` text NOT NULL,
`created_at` integer DEFAULT CURRENT_TIMESTAMP NOT NULL,
`updated_at` integer DEFAULT CURRENT_TIMESTAMP NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`dose_tracking_id`) REFERENCES `dose_tracking`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`medication_id`) REFERENCES `medications`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `intake_journal_dose_tracking_id_unique` ON `intake_journal` (`dose_tracking_id`);
@@ -0,0 +1 @@
ALTER TABLE `share_tokens` ADD `allow_journal_notes` integer DEFAULT false NOT NULL;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+14
View File
@@ -106,6 +106,20 @@
"when": 1775849300000,
"tag": "0014_add_user_settings_timezone",
"breakpoints": true
},
{
"idx": 15,
"version": "6",
"when": 1778962021119,
"tag": "0015_add_intake_journal",
"breakpoints": true
},
{
"idx": 16,
"version": "6",
"when": 1779044316043,
"tag": "0016_add_share_allow_journal_notes",
"breakpoints": true
}
]
}
+12
View File
@@ -76,6 +76,7 @@ export async function runAlterMigrations(client: Client): Promise<{ success: boo
`ALTER TABLE user_settings ADD COLUMN last_prescription_reminder_channel text`,
`ALTER TABLE user_settings ADD COLUMN last_prescription_reminder_med_names text`,
`ALTER TABLE refill_history ADD COLUMN used_prescription integer NOT NULL DEFAULT 0`,
`ALTER TABLE share_tokens ADD COLUMN allow_journal_notes integer NOT NULL DEFAULT 0`,
];
for (const sql of alterMigrations) {
@@ -97,6 +98,16 @@ export async function runAlterMigrations(client: Client): Promise<{ success: boo
loose_pills_added INTEGER NOT NULL DEFAULT 0,
refill_date INTEGER NOT NULL DEFAULT (strftime('%s','now'))
)`,
`CREATE TABLE IF NOT EXISTS intake_journal (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
dose_tracking_id INTEGER NOT NULL REFERENCES dose_tracking(id) ON DELETE CASCADE,
medication_id INTEGER NOT NULL REFERENCES medications(id) ON DELETE CASCADE,
scheduled_for INTEGER NOT NULL,
note TEXT NOT NULL,
created_at INTEGER NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at INTEGER NOT NULL DEFAULT CURRENT_TIMESTAMP
)`,
`CREATE TABLE IF NOT EXISTS notification_action_groups (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
@@ -164,6 +175,7 @@ export async function runAlterMigrations(client: Client): Promise<{ success: boo
const createIndexMigrations = [
`CREATE UNIQUE INDEX IF NOT EXISTS users_username_lower_unique ON users(lower(username))`,
`CREATE UNIQUE INDEX IF NOT EXISTS api_keys_key_hash_unique ON api_keys(key_hash)`,
`CREATE UNIQUE INDEX IF NOT EXISTS intake_journal_dose_tracking_id_unique ON intake_journal(dose_tracking_id)`,
`CREATE UNIQUE INDEX IF NOT EXISTS notification_action_groups_group_key_unique ON notification_action_groups(group_key)`,
`CREATE UNIQUE INDEX IF NOT EXISTS notification_action_tokens_token_hash_unique ON notification_action_tokens(token_hash)`,
`CREATE INDEX IF NOT EXISTS api_keys_user_id_idx ON api_keys(user_id)`,
+1
View File
@@ -100,6 +100,7 @@ export function getTableCreationSQL(): string[] {
token text NOT NULL UNIQUE,
taken_by text NOT NULL,
schedule_days integer NOT NULL DEFAULT 30,
allow_journal_notes integer NOT NULL DEFAULT 0,
created_at integer NOT NULL DEFAULT (strftime('%s','now')),
expires_at integer,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
+22
View File
@@ -180,6 +180,7 @@ export const shareTokens = sqliteTable("share_tokens", {
token: text("token", { length: 64 }).notNull().unique(),
takenBy: text("taken_by", { length: 100 }).notNull(),
scheduleDays: integer("schedule_days").notNull().default(30),
allowJournalNotes: integer("allow_journal_notes", { mode: "boolean" }).notNull().default(false),
createdAt: integer("created_at", { mode: "timestamp" }).notNull().default(sql`CURRENT_TIMESTAMP`),
expiresAt: integer("expires_at", { mode: "timestamp" }), // NULL = never expires
});
@@ -236,6 +237,27 @@ export const doseTracking = sqliteTable("dose_tracking", {
dismissed: integer("dismissed", { mode: "boolean" }).notNull().default(false), // legacy column: true = intake skipped without stock deduction
});
// =============================================================================
// Intake Journal - Optional owner-scoped note for a tracked dose event
// =============================================================================
export const intakeJournal = sqliteTable("intake_journal", {
id: integer("id").primaryKey({ autoIncrement: true }),
userId: integer("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
doseTrackingId: integer("dose_tracking_id")
.notNull()
.unique()
.references(() => doseTracking.id, { onDelete: "cascade" }),
medicationId: integer("medication_id")
.notNull()
.references(() => medications.id, { onDelete: "cascade" }),
scheduledFor: integer("scheduled_for", { mode: "timestamp" }).notNull(),
note: text("note").notNull(),
createdAt: integer("created_at", { mode: "timestamp" }).notNull().default(sql`CURRENT_TIMESTAMP`),
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull().default(sql`CURRENT_TIMESTAMP`),
});
// =============================================================================
// Refill History - Tracks when medication stock was refilled
// =============================================================================
+4
View File
@@ -21,6 +21,7 @@ import { authRoutes } from "./routes/auth.js";
import { doseRoutes } from "./routes/doses.js";
import { exportRoutes } from "./routes/export.js";
import { healthRoutes } from "./routes/health.js";
import { intakeJournalRoutes } from "./routes/intake-journal.js";
import { medicationEnrichmentRoutes } from "./routes/medication-enrichment.js";
import { medicationRoutes } from "./routes/medications.js";
import { notificationActionRoutes } from "./routes/notification-actions.js";
@@ -109,6 +110,7 @@ async function registerApiDocs(app: FastifyInstance, enabled: boolean) {
{ name: "health", description: "Service health endpoints" },
{ name: "auth", description: "Authentication and profile endpoints" },
{ name: "api-keys", description: "Programmatic API key management" },
{ name: "intake-journal", description: "Owner-only intake journal CRUD and history endpoints" },
{ name: "medication-enrichment", description: "Medication search and enrichment endpoints" },
{ name: "settings", description: "User settings and notification test endpoints" },
],
@@ -248,6 +250,7 @@ export async function createApp(options?: {
await app.register(notificationActionRoutes);
await app.register(shareRoutes);
await app.register(doseRoutes);
await app.register(intakeJournalRoutes);
await app.register(exportRoutes);
await app.register(refillRoutes);
await app.register(reportRoutes);
@@ -349,6 +352,7 @@ await app.register(plannerRoutes);
await app.register(notificationActionRoutes);
await app.register(shareRoutes);
await app.register(doseRoutes);
await app.register(intakeJournalRoutes);
await app.register(exportRoutes);
await app.register(refillRoutes);
await app.register(reportRoutes);
+566 -36
View File
@@ -1,19 +1,26 @@
import { and, eq } from "drizzle-orm";
import { and, eq, inArray } from "drizzle-orm";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { z } from "zod";
import { db } from "../db/client.js";
import { doseTracking, medications, shareTokens, userSettings } from "../db/schema.js";
import { doseTracking, intakeJournal, medications, shareTokens, userSettings } from "../db/schema.js";
import { getAnonymousUserId, requireAuth } from "../plugins/auth.js";
import { env } from "../plugins/env.js";
import { computeMedicationCurrentStock } from "../services/current-stock.js";
import { markDoseTakenForUser } from "../services/dose-tracking-service.js";
import {
getIntakeJournalForDoseEvent,
resolveTrackedDoseEventForUser,
upsertIntakeJournalForDoseEvent,
} from "../services/intake-journal-service.js";
import type { AuthUser } from "../types/fastify.js";
import { toLocalDateTimeOffsetString } from "../utils/local-date-time.js";
import {
applyOpenApiRouteStandards,
genericErrorSchema,
tokenParamsSchema,
validationErrorSchema,
} from "../utils/openapi-route-standards.js";
import { redactTokenForLog } from "../utils/redaction.js";
import {
parseIntakesJson,
parseLocalDateTime,
@@ -32,6 +39,10 @@ const shareDoseSchema = z.object({
doseId: z.string().min(1, "doseId is required"),
});
const shareJournalUpsertSchema = z.object({
note: z.string().max(4000),
});
const dismissDosesSchema = z.object({
doseIds: z.array(z.string().min(1)).min(1, "At least one doseId is required"),
});
@@ -56,12 +67,52 @@ const doseReadResponseSchema = {
markedBy: { type: ["string", "null"] },
takenSource: { type: "string" },
dismissed: { type: "boolean" },
hasJournalNote: { type: "boolean" },
},
},
},
},
} as const;
const shareJournalEntrySchema = {
type: "object",
required: [
"doseTrackingId",
"doseId",
"medicationId",
"medicationName",
"scheduledFor",
"dismissed",
"takenSource",
"note",
"updatedAt",
],
properties: {
doseTrackingId: { type: "integer" },
doseId: { type: "string" },
medicationId: { type: "integer" },
medicationName: { type: "string" },
scheduledFor: { type: "string", format: "date-time" },
takenAt: { type: ["string", "null"], format: "date-time" },
dismissed: { type: "boolean" },
takenSource: { type: "string", enum: ["manual", "automatic"] },
markedBy: { type: ["string", "null"] },
note: { type: ["string", "null"] },
updatedAt: { type: ["string", "null"], format: "date-time" },
createdAt: { type: ["string", "null"], format: "date-time" },
},
additionalProperties: false,
} as const;
const shareJournalResponseSchema = {
type: "object",
required: ["entry"],
properties: {
entry: shareJournalEntrySchema,
},
additionalProperties: false,
} as const;
function getValidationErrorMessage(error: z.ZodError): string {
const firstIssue = error.issues[0];
if (!firstIssue) {
@@ -71,6 +122,18 @@ function getValidationErrorMessage(error: z.ZodError): string {
return firstIssue.code === "invalid_type" && firstIssue.input === undefined ? "Required" : firstIssue.message;
}
function serializeJournalTakenAt(value: Date | null, dismissed: boolean): string | null {
if (!(value instanceof Date) || Number.isNaN(value.getTime())) {
return null;
}
if (dismissed && value.getTime() <= 0) {
return null;
}
return value.toISOString();
}
// Helper to get user ID from request
// Returns anonymous user ID when auth is disabled
async function getUserId(request: FastifyRequest, reply: FastifyReply): Promise<number> {
@@ -135,6 +198,10 @@ async function validateShareDoseId(share: typeof shareTokens.$inferSelect, doseI
return false;
}
if (!isDoseInsideShareScheduleWindow(share, parsedDose)) {
return false;
}
const [medication] = await db
.select()
.from(medications)
@@ -172,6 +239,24 @@ async function validateShareDoseId(share: typeof shareTokens.$inferSelect, doseI
return expectedPersons.includes(parsedDose.personSuffix);
}
function getLocalDayStartMs(value: Date | number): number {
const date = typeof value === "number" ? new Date(value) : new Date(value.getTime());
date.setHours(0, 0, 0, 0);
return date.getTime();
}
function isDoseInsideShareScheduleWindow(share: typeof shareTokens.$inferSelect, parsedDose: ParsedDoseId): boolean {
const scheduleDays = Math.max(1, share.scheduleDays ?? 30);
const todayStart = getLocalDayStartMs(new Date());
const earliestVisible = new Date(todayStart);
earliestVisible.setDate(earliestVisible.getDate() - (scheduleDays - 1));
const latestVisibleExclusive = new Date(todayStart);
latestVisibleExclusive.setDate(latestVisibleExclusive.getDate() + scheduleDays);
const doseDayStart = getLocalDayStartMs(parsedDose.timestampMs);
return doseDayStart >= earliestVisible.getTime() && doseDayStart < latestVisibleExclusive.getTime();
}
async function isDoseOutOfStock(options: {
userId: number;
doseId: string;
@@ -226,6 +311,81 @@ async function isDoseOutOfStock(options: {
);
}
async function markDoseSkippedForUser(input: {
userId: number;
doseId: string;
}): Promise<"created" | "updated" | "already_skipped"> {
const [existing] = await db
.select()
.from(doseTracking)
.where(and(eq(doseTracking.userId, input.userId), eq(doseTracking.doseId, input.doseId)));
if (existing) {
if (existing.dismissed) {
return "already_skipped";
}
await db
.update(doseTracking)
.set({ dismissed: true })
.where(and(eq(doseTracking.userId, input.userId), eq(doseTracking.doseId, input.doseId)));
return "updated";
}
await db.insert(doseTracking).values({
userId: input.userId,
doseId: input.doseId,
markedBy: null,
takenAt: new Date(0),
dismissed: true,
});
return "created";
}
async function undoDoseSkippedForUser(input: { userId: number; doseId: string }): Promise<boolean> {
const [existing] = await db
.select()
.from(doseTracking)
.where(and(eq(doseTracking.userId, input.userId), eq(doseTracking.doseId, input.doseId)));
if (!existing?.dismissed) {
return false;
}
const hasRealTakenTimestamp =
existing.takenAt instanceof Date ? existing.takenAt.getTime() > 0 : Boolean(existing.takenAt);
if (existing.markedBy !== null || hasRealTakenTimestamp) {
await db.update(doseTracking).set({ dismissed: false }).where(eq(doseTracking.id, existing.id));
return true;
}
await db.delete(doseTracking).where(eq(doseTracking.id, existing.id));
return true;
}
function buildSharedJournalEntryDto(input: {
event: NonNullable<Awaited<ReturnType<typeof resolveTrackedDoseEventForUser>>>;
journalEntry: Awaited<ReturnType<typeof getIntakeJournalForDoseEvent>>;
}) {
const { event, journalEntry } = input;
return {
doseTrackingId: event.doseTrackingId,
doseId: event.doseId,
medicationId: event.medicationId,
medicationName: event.medicationName,
scheduledFor: toLocalDateTimeOffsetString(journalEntry?.scheduledFor ?? event.scheduledFor),
takenAt: serializeJournalTakenAt(event.takenAt, event.dismissed),
dismissed: event.dismissed,
takenSource: event.takenSource,
markedBy: event.markedBy,
note: journalEntry?.note ?? null,
updatedAt: journalEntry?.updatedAt?.toISOString() ?? null,
createdAt: journalEntry?.createdAt?.toISOString() ?? null,
};
}
// =============================================================================
// Dose Tracking Routes
// =============================================================================
@@ -233,7 +393,13 @@ export async function doseRoutes(app: FastifyInstance) {
applyOpenApiRouteStandards(app, {
tag: "doses",
protectedByDefault: false,
protectedPaths: [/^\/doses\/taken$/, /^\/doses\/taken\/:doseId$/, /^\/doses\/dismiss$/],
protectedPaths: [
/^\/doses\/taken$/,
/^\/doses\/taken\/:doseId$/,
/^\/doses\/dismiss$/,
/^\/doses\/skip$/,
/^\/doses\/skip\/:doseId$/,
],
});
// ---------------------------------------------------------------------------
@@ -383,6 +549,83 @@ export async function doseRoutes(app: FastifyInstance) {
}
);
// ---------------------------------------------------------------------------
// POST /doses/skip - PROTECTED: Mark a single dose as skipped
// ---------------------------------------------------------------------------
app.post<{ Body: z.infer<typeof markDoseSchema> }>(
"/doses/skip",
{
preHandler: requireAuth,
schema: {
tags: ["doses"],
security: protectedEndpointSecurity,
body: {
type: "object",
required: ["doseId"],
properties: {
doseId: { type: "string", minLength: 1 },
},
},
response: {
200: {
type: "object",
properties: {
success: { type: "boolean" },
message: { type: "string" },
},
},
400: { anyOf: [genericErrorSchema, validationErrorSchema] },
401: genericErrorSchema,
},
},
},
async (request, reply) => {
const userId = await getUserId(request, reply);
const parsed = markDoseSchema.safeParse(request.body);
if (!parsed.success) {
return reply.status(400).send({ error: getValidationErrorMessage(parsed.error) });
}
const status = await markDoseSkippedForUser({ userId, doseId: parsed.data.doseId });
if (status === "already_skipped") {
return { success: true, message: "Already skipped" };
}
return { success: true };
}
);
// ---------------------------------------------------------------------------
// DELETE /doses/skip/:doseId - PROTECTED: Undo a single skipped dose
// ---------------------------------------------------------------------------
app.delete<{ Params: { doseId: string } }>(
"/doses/skip/:doseId",
{
preHandler: requireAuth,
schema: {
tags: ["doses"],
security: protectedEndpointSecurity,
params: {
type: "object",
required: ["doseId"],
properties: {
doseId: { type: "string", minLength: 1 },
},
},
response: {
200: { type: "object", properties: { success: { type: "boolean" } } },
401: genericErrorSchema,
},
},
},
async (request, reply) => {
const userId = await getUserId(request, reply);
await undoDoseSkippedForUser({ userId, doseId: request.params.doseId });
return { success: true };
}
);
// ---------------------------------------------------------------------------
// POST /doses/dismiss - PROTECTED: Dismiss missed doses without deducting stock
// ---------------------------------------------------------------------------
@@ -431,27 +674,8 @@ export async function doseRoutes(app: FastifyInstance) {
// becomes dismissed, regardless of whether it already has a taken timestamp.
let dismissedCount = 0;
for (const doseId of doseIds) {
const [existing] = await db
.select()
.from(doseTracking)
.where(and(eq(doseTracking.userId, userId), eq(doseTracking.doseId, doseId)));
if (existing) {
if (!existing.dismissed) {
await db
.update(doseTracking)
.set({ dismissed: true })
.where(and(eq(doseTracking.userId, userId), eq(doseTracking.doseId, doseId)));
dismissedCount++;
}
} else {
await db.insert(doseTracking).values({
userId,
doseId,
markedBy: null,
takenAt: new Date(0),
dismissed: true,
});
const status = await markDoseSkippedForUser({ userId, doseId });
if (status !== "already_skipped") {
dismissedCount++;
}
}
@@ -533,28 +757,332 @@ export async function doseRoutes(app: FastifyInstance) {
},
async (request, reply) => {
const { token } = request.params;
const tokenRef = redactTokenForLog(token);
const { share, reason } = await getActiveShareToken(token);
if (!share) {
request.log.warn(`[ShareDose] Rejected read: token=${token}, reason=${reason}`);
request.log.warn(`[ShareDose] Rejected read: tokenRef=${tokenRef}, reason=${reason}`);
return reply.notFound("Share link not found");
}
// Get all taken doses for this user (no time limit)
// Keep public dose reads scoped to the selected share person and visible schedule window.
const doses = await db.select().from(doseTracking).where(eq(doseTracking.userId, share.userId));
const visibleDoses: (typeof doseTracking.$inferSelect)[] = [];
for (const dose of doses) {
if (await validateShareDoseId(share, dose.doseId)) {
visibleDoses.push(dose);
}
}
const journalDoseTrackingIds = new Set<number>();
if ((share.allowJournalNotes ?? false) && visibleDoses.length > 0) {
const journalRows = await db
.select({ doseTrackingId: intakeJournal.doseTrackingId })
.from(intakeJournal)
.where(
and(
eq(intakeJournal.userId, share.userId),
inArray(
intakeJournal.doseTrackingId,
visibleDoses.map((dose) => dose.id)
)
)
);
for (const row of journalRows) {
journalDoseTrackingIds.add(row.doseTrackingId);
}
}
return {
doses: doses.map((d) => ({
doses: visibleDoses.map((d) => ({
doseId: d.doseId,
takenAt: d.takenAt?.getTime() ?? Date.now(),
markedBy: d.markedBy,
takenSource: d.takenSource ?? "manual",
dismissed: d.dismissed ?? false,
hasJournalNote: journalDoseTrackingIds.has(d.id),
})),
};
}
);
app.get<{ Params: { token: string; doseId: string } }>(
"/share/:token/journal/event/:doseId",
{
schema: {
params: {
type: "object",
required: ["token", "doseId"],
properties: {
token: tokenParamsSchema.properties.token,
doseId: { type: "string", minLength: 1 },
},
},
response: {
200: shareJournalResponseSchema,
400: { anyOf: [genericErrorSchema, validationErrorSchema] },
403: genericErrorSchema,
404: genericErrorSchema,
},
},
},
async (request, reply) => {
const { token, doseId } = request.params;
const tokenRef = redactTokenForLog(token);
const { share, reason } = await getActiveShareToken(token);
if (!share) {
request.log.warn(`[ShareJournal] Rejected read: tokenRef=${tokenRef}, doseId=${doseId}, reason=${reason}`);
return reply.notFound("Share link not found");
}
if (!(share.allowJournalNotes ?? false)) {
return reply
.status(403)
.send({ error: "Journal notes are not enabled for this share link", code: "NOT_ENABLED" });
}
const isValidShareDoseId = await validateShareDoseId(share, doseId);
if (!isValidShareDoseId) {
return reply.status(400).send({ error: "Invalid or unauthorized doseId", code: "INVALID_DOSE" });
}
const event = await resolveTrackedDoseEventForUser({ userId: share.userId, doseId });
if (!event) {
return reply
.status(404)
.send({ error: "Tracked dose event not found for this share link", code: "DOSE_NOT_FOUND" });
}
const journalEntry = await getIntakeJournalForDoseEvent({ userId: share.userId, doseId });
return { entry: buildSharedJournalEntryDto({ event, journalEntry }) };
}
);
app.put<{ Params: { token: string; doseId: string }; Body: z.infer<typeof shareJournalUpsertSchema> }>(
"/share/:token/journal/event/:doseId",
{
schema: {
params: {
type: "object",
required: ["token", "doseId"],
properties: {
token: tokenParamsSchema.properties.token,
doseId: { type: "string", minLength: 1 },
},
},
body: {
type: "object",
required: ["note"],
properties: {
note: { type: "string", maxLength: 4000 },
},
additionalProperties: false,
},
response: {
200: shareJournalResponseSchema,
400: { anyOf: [genericErrorSchema, validationErrorSchema] },
403: genericErrorSchema,
404: genericErrorSchema,
},
},
},
async (request, reply) => {
const { token, doseId } = request.params;
const tokenRef = redactTokenForLog(token);
const parsed = shareJournalUpsertSchema.safeParse(request.body);
if (!parsed.success) {
return reply.status(400).send({ error: getValidationErrorMessage(parsed.error), code: "VALIDATION_ERROR" });
}
const normalizedNote = parsed.data.note.trim();
if (normalizedNote.length === 0) {
return reply.status(400).send({ error: "Journal note cannot be empty", code: "EMPTY_NOTE" });
}
const { share, reason } = await getActiveShareToken(token);
if (!share) {
request.log.warn(`[ShareJournal] Rejected save: tokenRef=${tokenRef}, doseId=${doseId}, reason=${reason}`);
return reply.notFound("Share link not found");
}
if (!(share.allowJournalNotes ?? false)) {
return reply
.status(403)
.send({ error: "Journal notes are not enabled for this share link", code: "NOT_ENABLED" });
}
const isValidShareDoseId = await validateShareDoseId(share, doseId);
if (!isValidShareDoseId) {
return reply.status(400).send({ error: "Invalid or unauthorized doseId", code: "INVALID_DOSE" });
}
const event = await resolveTrackedDoseEventForUser({ userId: share.userId, doseId });
if (!event) {
return reply
.status(404)
.send({ error: "Tracked dose event not found for this share link", code: "DOSE_NOT_FOUND" });
}
const journalEntry = await upsertIntakeJournalForDoseEvent({
userId: share.userId,
doseId,
note: normalizedNote,
});
return { entry: buildSharedJournalEntryDto({ event, journalEntry }) };
}
);
app.delete<{ Params: { token: string; doseId: string } }>(
"/share/:token/journal/event/:doseId",
{
schema: {
params: {
type: "object",
required: ["token", "doseId"],
properties: {
token: tokenParamsSchema.properties.token,
doseId: { type: "string", minLength: 1 },
},
},
response: {
403: genericErrorSchema,
404: genericErrorSchema,
},
},
},
async (request, reply) => {
const { token, doseId } = request.params;
const tokenRef = redactTokenForLog(token);
const { share, reason } = await getActiveShareToken(token);
if (!share) {
request.log.warn(`[ShareJournal] Rejected delete: tokenRef=${tokenRef}, doseId=${doseId}, reason=${reason}`);
return reply.notFound("Share link not found");
}
if (!(share.allowJournalNotes ?? false)) {
return reply
.status(403)
.send({ error: "Journal notes are not enabled for this share link", code: "NOT_ENABLED" });
}
return reply.status(403).send({ error: "Shared links cannot delete journal notes", code: "DELETE_NOT_ALLOWED" });
}
);
// ---------------------------------------------------------------------------
// POST /share/:token/doses/skip - PUBLIC: Mark a dose as skipped via share link
// ---------------------------------------------------------------------------
app.post<{ Params: { token: string }; Body: z.infer<typeof shareDoseSchema> }>(
"/share/:token/doses/skip",
{
schema: {
params: tokenParamsSchema,
body: {
type: "object",
required: ["doseId"],
properties: {
doseId: { type: "string", minLength: 1 },
},
},
response: {
200: {
type: "object",
properties: {
success: { type: "boolean" },
message: { type: "string" },
},
},
400: { anyOf: [genericErrorSchema, validationErrorSchema] },
404: genericErrorSchema,
},
},
},
async (request, reply) => {
const { token } = request.params;
const tokenRef = redactTokenForLog(token);
const parsed = shareDoseSchema.safeParse(request.body);
if (!parsed.success) {
return reply.status(400).send({ error: getValidationErrorMessage(parsed.error) });
}
const { doseId } = parsed.data;
const { share, reason } = await getActiveShareToken(token);
if (!share) {
request.log.warn(`[ShareDose] Rejected skip: tokenRef=${tokenRef}, doseId=${doseId}, reason=${reason}`);
return reply.notFound("Share link not found");
}
const isValidShareDoseId = await validateShareDoseId(share, doseId);
if (!isValidShareDoseId) {
request.log.warn(
`[ShareDose] Rejected invalid doseId in skip request: tokenRef=${tokenRef}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}`
);
return reply.status(400).send({ error: "Invalid or unauthorized doseId" });
}
const status = await markDoseSkippedForUser({ userId: share.userId, doseId });
if (status === "already_skipped") {
return { success: true, message: "Already skipped" };
}
request.log.info(
`[ShareDose] Dose skipped via share link: tokenRef=${tokenRef}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}`
);
return { success: true };
}
);
// ---------------------------------------------------------------------------
// DELETE /share/:token/doses/skip/:doseId - PUBLIC: Undo a skipped dose via share link
// ---------------------------------------------------------------------------
app.delete<{ Params: { token: string; doseId: string } }>(
"/share/:token/doses/skip/:doseId",
{
schema: {
params: {
type: "object",
required: ["token", "doseId"],
properties: {
token: tokenParamsSchema.properties.token,
doseId: { type: "string", minLength: 1 },
},
},
response: {
200: { type: "object", properties: { success: { type: "boolean" } } },
400: genericErrorSchema,
404: genericErrorSchema,
},
},
},
async (request, reply) => {
const { token, doseId } = request.params;
const tokenRef = redactTokenForLog(token);
const { share, reason } = await getActiveShareToken(token);
if (!share) {
request.log.warn(`[ShareDose] Rejected undo skip: tokenRef=${tokenRef}, doseId=${doseId}, reason=${reason}`);
return reply.notFound("Share link not found");
}
const isValidShareDoseId = await validateShareDoseId(share, doseId);
if (!isValidShareDoseId) {
request.log.warn(
`[ShareDose] Rejected invalid doseId in undo skip request: tokenRef=${tokenRef}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}`
);
return reply.status(400).send({ error: "Invalid or unauthorized doseId" });
}
await undoDoseSkippedForUser({ userId: share.userId, doseId });
return { success: true };
}
);
// ---------------------------------------------------------------------------
// POST /share/:token/doses - PUBLIC: Mark a dose as taken via share link
// ---------------------------------------------------------------------------
@@ -582,6 +1110,7 @@ export async function doseRoutes(app: FastifyInstance) {
},
async (request, reply) => {
const { token } = request.params;
const tokenRef = redactTokenForLog(token);
const parsed = shareDoseSchema.safeParse(request.body);
if (!parsed.success) {
@@ -594,14 +1123,14 @@ export async function doseRoutes(app: FastifyInstance) {
const { share, reason } = await getActiveShareToken(token);
if (!share) {
request.log.warn(`[ShareDose] Rejected mark: token=${token}, doseId=${doseId}, reason=${reason}`);
request.log.warn(`[ShareDose] Rejected mark: tokenRef=${tokenRef}, doseId=${doseId}, reason=${reason}`);
return reply.notFound("Share link not found");
}
const isValidShareDoseId = await validateShareDoseId(share, doseId);
if (!isValidShareDoseId) {
request.log.warn(
`[ShareDose] Rejected invalid doseId in mark request: token=${token}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}`
`[ShareDose] Rejected invalid doseId in mark request: tokenRef=${tokenRef}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}`
);
return reply.status(400).send({ error: "Invalid or unauthorized doseId" });
}
@@ -614,7 +1143,7 @@ export async function doseRoutes(app: FastifyInstance) {
if (existing) {
request.log.debug(
`[ShareDose] Duplicate mark ignored: token=${token}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}`
`[ShareDose] Duplicate mark ignored: tokenRef=${tokenRef}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}`
);
return { success: true, message: "Already marked" };
}
@@ -627,7 +1156,7 @@ export async function doseRoutes(app: FastifyInstance) {
});
if (outOfStock) {
request.log.info(
`[ShareDose] Rejected out-of-stock mark request: token=${token}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}`
`[ShareDose] Rejected out-of-stock mark request: tokenRef=${tokenRef}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}`
);
return reply.status(409).send({ error: "Medication is out of stock", code: "OUT_OF_STOCK" });
}
@@ -644,7 +1173,7 @@ export async function doseRoutes(app: FastifyInstance) {
});
request.log.info(
`[ShareDose] Dose marked via share link: token=${token}, ownerUserId=${share.userId}, shareTakenBy=${share.takenBy}, markedBy=${markedBy}, doseId=${doseId}`
`[ShareDose] Dose marked via share link: tokenRef=${tokenRef}, ownerUserId=${share.userId}, shareTakenBy=${share.takenBy}, markedBy=${markedBy}, doseId=${doseId}`
);
return { success: true };
@@ -675,17 +1204,18 @@ export async function doseRoutes(app: FastifyInstance) {
},
async (request, reply) => {
const { token, doseId } = request.params;
const tokenRef = redactTokenForLog(token);
const { share, reason } = await getActiveShareToken(token);
if (!share) {
request.log.warn(`[ShareDose] Rejected unmark: token=${token}, doseId=${doseId}, reason=${reason}`);
request.log.warn(`[ShareDose] Rejected unmark: tokenRef=${tokenRef}, doseId=${doseId}, reason=${reason}`);
return reply.notFound("Share link not found");
}
const isValidShareDoseId = await validateShareDoseId(share, doseId);
if (!isValidShareDoseId) {
request.log.warn(
`[ShareDose] Rejected invalid doseId in unmark request: token=${token}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}`
`[ShareDose] Rejected invalid doseId in unmark request: tokenRef=${tokenRef}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}`
);
return reply.status(400).send({ error: "Invalid or unauthorized doseId" });
}
@@ -699,7 +1229,7 @@ export async function doseRoutes(app: FastifyInstance) {
if (existing?.dismissed) {
// Already dismissed - keep the record as-is
request.log.debug(
`[ShareDose] Unmark ignored for dismissed dose: token=${token}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}`
`[ShareDose] Unmark ignored for dismissed dose: tokenRef=${tokenRef}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}`
);
} else {
// Not dismissed - delete the record entirely
@@ -707,7 +1237,7 @@ export async function doseRoutes(app: FastifyInstance) {
.delete(doseTracking)
.where(and(eq(doseTracking.userId, share.userId), eq(doseTracking.doseId, doseId)));
request.log.info(
`[ShareDose] Dose unmarked via share link: token=${token}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}`
`[ShareDose] Dose unmarked via share link: tokenRef=${tokenRef}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}`
);
}
+372 -176
View File
@@ -6,9 +6,13 @@ import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { z } from "zod";
import { db } from "../db/client.js";
import { getDataDir } from "../db/path-utils.js";
import { doseTracking, medications, refillHistory, shareTokens, userSettings } from "../db/schema.js";
import { doseTracking, intakeJournal, medications, refillHistory, shareTokens, userSettings } from "../db/schema.js";
import { getAnonymousUserId, requireAuth } from "../plugins/auth.js";
import { env } from "../plugins/env.js";
import {
listIntakeJournalExportPayloadsForUser,
restoreIntakeJournalForImportedDose,
} from "../services/intake-journal-export.js";
import type { AuthUser } from "../types/fastify.js";
import {
applyOpenApiRouteStandards,
@@ -23,7 +27,7 @@ const IMAGES_DIR = resolve(getDataDir(), "images");
// =============================================================================
// Export Format Version (bump this when format changes)
// =============================================================================
const EXPORT_VERSION = "1.5";
const EXPORT_VERSION = "1.6";
// =============================================================================
// Zod Schemas for Import Validation
@@ -91,6 +95,9 @@ const doseHistorySchema = z.object({
takenSource: z.enum(["manual", "automatic"]).default("manual"),
dismissed: z.boolean().default(false),
takenByPerson: z.string().nullable().optional(), // Person suffix from dose ID (e.g., "Daniel")
journalNote: z.string().nullable().optional(),
journalCreatedAt: z.string().nullable().optional(),
journalUpdatedAt: z.string().nullable().optional(),
});
const refillHistoryExportSchema = z.object({
@@ -105,6 +112,7 @@ const refillHistoryExportSchema = z.object({
const shareLinkSchema = z.object({
takenBy: z.string().min(1),
scheduleDays: z.number().int().min(1).default(30),
allowJournalNotes: z.boolean().default(false),
expiresAt: z.string().nullable().optional(), // ISO datetime
regenerateToken: z.boolean().default(true),
});
@@ -195,7 +203,7 @@ const importBodyOpenApiSchema = {
shareLinks: { type: "array", items: { type: "object", additionalProperties: true } },
},
example: {
version: "1.8.0",
version: "1.6",
exportedAt: "2026-03-11T10:15:00.000Z",
includeSensitiveData: true,
medications: [
@@ -215,13 +223,72 @@ const importBodyOpenApiSchema = {
],
},
],
doseHistory: [{ doseId: "1:2026-03-11T08:00:00.000Z:Daniel", takenAt: 1773216000000 }],
doseHistory: [
{
medicationRef: "med-1",
scheduleIndex: 0,
scheduledTime: "2026-03-11T08:00:00.000Z",
takenAt: "2026-03-11T08:03:00.000Z",
markedBy: "Daniel",
takenSource: "manual",
dismissed: false,
takenByPerson: "Daniel",
journalNote: "Took after breakfast.",
journalUpdatedAt: "2026-03-11T08:05:00.000Z",
},
],
refillHistory: [{ packsAdded: 1, loosePillsAdded: 4, quantityAdded: 34, refillDate: "2026-03-10T12:00:00.000Z" }],
settings: { language: "en", stockCalculationMode: "automatic" },
shareLinks: [{ takenBy: "Daniel", scheduleDays: 14 }],
},
} as const;
const importPreviewResponseSchema = {
type: "object",
properties: {
success: { type: "boolean" },
preview: {
type: "object",
properties: {
version: { type: "string" },
exportedAt: { type: "string", format: "date-time" },
includeSensitiveData: { type: "boolean" },
incoming: {
type: "object",
properties: {
medications: { type: "integer" },
doseHistory: { type: "integer" },
refillHistory: { type: "integer" },
shareLinks: { type: "integer" },
journalEntries: { type: "integer" },
imageCount: { type: "integer" },
hasSettings: { type: "boolean" },
},
},
current: {
type: "object",
properties: {
medications: { type: "integer" },
doseHistory: { type: "integer" },
refillHistory: { type: "integer" },
shareLinks: { type: "integer" },
hasSettings: { type: "boolean" },
},
},
warnings: {
type: "object",
properties: {
replacesExistingData: { type: "boolean" },
regeneratesShareLinks: { type: "boolean" },
containsImages: { type: "boolean" },
containsSensitiveData: { type: "boolean" },
},
},
},
},
},
} as const;
// =============================================================================
// Helper Functions
// =============================================================================
@@ -321,6 +388,64 @@ function base64ToImage(base64: string, medicationId: number): string | null {
}
}
function removeFileIfPresent(filePath: string): string | null {
if (!existsSync(filePath)) {
return null;
}
try {
unlinkSync(filePath);
return null;
} catch (error) {
return error instanceof Error ? error.message : "Unknown file removal error";
}
}
function buildImportPreview(
importData: z.infer<typeof importDataSchema>,
currentData: {
medications: number;
doseHistory: number;
refillHistory: number;
shareLinks: number;
hasSettings: boolean;
}
) {
const journalEntries = importData.doseHistory.filter(
(dose) => typeof dose.journalNote === "string" && dose.journalNote.trim()
).length;
const imageCount = importData.medications.filter(
(med) => typeof med.image === "string" && med.image.startsWith("data:")
).length;
return {
version: importData.version,
exportedAt: importData.exportedAt,
includeSensitiveData: importData.includeSensitiveData,
incoming: {
medications: importData.medications.length,
doseHistory: importData.doseHistory.length,
refillHistory: importData.refillHistory.length,
shareLinks: importData.shareLinks.length,
journalEntries,
imageCount,
hasSettings: Boolean(importData.settings),
},
current: currentData,
warnings: {
replacesExistingData:
currentData.medications > 0 ||
currentData.doseHistory > 0 ||
currentData.refillHistory > 0 ||
currentData.shareLinks > 0 ||
currentData.hasSettings,
regeneratesShareLinks: importData.shareLinks.length > 0,
containsImages: imageCount > 0,
containsSensitiveData: importData.includeSensitiveData,
},
};
}
// Parse dose ID to extract medication ID and timestamp
// Format: "{medicationId}-{blisterIndex}-{timestampMs}" or "{medicationId}-{blisterIndex}-{timestampMs}-{person}"
function parseDoseId(
@@ -442,6 +567,7 @@ export async function exportRoutes(app: FastifyInstance) {
// 2. Load all dose tracking entries
const doses = await db.select().from(doseTracking).where(eq(doseTracking.userId, userId));
const journalPayloadsByDoseTrackingId = await listIntakeJournalExportPayloadsForUser(userId);
const exportDoseHistory = doses
.map((dose) => {
@@ -484,6 +610,7 @@ export async function exportRoutes(app: FastifyInstance) {
takenSource: dose.takenSource === "automatic" ? "automatic" : "manual",
dismissed: dose.dismissed ?? false,
takenByPerson: parsed.person,
...journalPayloadsByDoseTrackingId.get(dose.id),
};
})
.filter((d): d is NonNullable<typeof d> => d !== null);
@@ -542,6 +669,7 @@ export async function exportRoutes(app: FastifyInstance) {
return {
takenBy: share.takenBy,
scheduleDays: share.scheduleDays,
allowJournalNotes: share.allowJournalNotes ?? false,
expiresAt: expiresAtIso,
regenerateToken: true, // Always regenerate tokens on import for security
};
@@ -617,6 +745,58 @@ export async function exportRoutes(app: FastifyInstance) {
}
);
// ---------------------------------------------------------------------------
// POST /import/preview - Validate and summarize import data without writing
// ---------------------------------------------------------------------------
app.post(
"/import/preview",
{
config: {
rawBody: true,
},
bodyLimit: 50 * 1024 * 1024,
schema: {
body: importBodyOpenApiSchema,
response: {
200: importPreviewResponseSchema,
400: { anyOf: [genericErrorSchema, validationErrorSchema] },
401: genericErrorSchema,
},
},
},
async (request, reply) => {
const userId = await getUserId(request, reply);
const parsed = importDataSchema.safeParse(request.body);
if (!parsed.success) {
return reply.status(400).send({
error: "Invalid import data format",
details: parsed.error.format(),
});
}
const [existingMeds, existingDoseHistory, existingRefillHistory, existingShareLinks, existingSettings] =
await Promise.all([
db.select({ id: medications.id }).from(medications).where(eq(medications.userId, userId)),
db.select({ id: doseTracking.id }).from(doseTracking).where(eq(doseTracking.userId, userId)),
db.select({ id: refillHistory.id }).from(refillHistory).where(eq(refillHistory.userId, userId)),
db.select({ id: shareTokens.id }).from(shareTokens).where(eq(shareTokens.userId, userId)),
db.select({ id: userSettings.id }).from(userSettings).where(eq(userSettings.userId, userId)),
]);
return {
success: true,
preview: buildImportPreview(parsed.data, {
medications: existingMeds.length,
doseHistory: existingDoseHistory.length,
refillHistory: existingRefillHistory.length,
shareLinks: existingShareLinks.length,
hasSettings: existingSettings.length > 0,
}),
};
}
);
// ---------------------------------------------------------------------------
// POST /import - Import user data (replaces all existing data!)
// ---------------------------------------------------------------------------
@@ -649,6 +829,7 @@ export async function exportRoutes(app: FastifyInstance) {
},
400: { anyOf: [genericErrorSchema, validationErrorSchema] },
401: genericErrorSchema,
500: genericErrorSchema,
},
},
},
@@ -666,193 +847,208 @@ export async function exportRoutes(app: FastifyInstance) {
const importData = parsed.data;
// 2. Delete all existing user data (in correct order to respect foreign keys)
// Note: CASCADE delete should handle this, but let's be explicit
// First, delete images for existing medications
// Existing image files are removed only after the DB import commits.
const existingMeds = await db.select().from(medications).where(eq(medications.userId, userId));
for (const med of existingMeds) {
if (med.imageUrl) {
const imagePath = resolve(IMAGES_DIR, med.imageUrl);
if (existsSync(imagePath)) {
try {
unlinkSync(imagePath);
} catch {
/* ignore */
const oldImagePaths = existingMeds
.map((med) => (med.imageUrl ? resolve(IMAGES_DIR, med.imageUrl) : null))
.filter((path): path is string => path !== null);
const newImagePaths: string[] = [];
try {
await db.transaction(async (tx) => {
// Delete in order: journal entries, refill history, doses, share tokens, medications, settings.
await tx.delete(intakeJournal).where(eq(intakeJournal.userId, userId));
await tx.delete(refillHistory).where(eq(refillHistory.userId, userId));
await tx.delete(doseTracking).where(eq(doseTracking.userId, userId));
await tx.delete(shareTokens).where(eq(shareTokens.userId, userId));
await tx.delete(medications).where(eq(medications.userId, userId));
await tx.delete(userSettings).where(eq(userSettings.userId, userId));
const exportIdToNewId = new Map<string, number>();
for (const med of importData.medications) {
const normalizedSchedules = med.schedules.map((schedule) =>
normalizeIntake({
usage: schedule.usage,
every: schedule.every,
start: schedule.start,
scheduleMode: schedule.scheduleMode,
weekdays: schedule.weekdays,
intakeUnit: schedule.intakeUnit ?? null,
takenBy: schedule.takenBy || null,
intakeRemindersEnabled: schedule.remind ?? false,
})
);
const usageJson = JSON.stringify(normalizedSchedules.map((schedule) => schedule.usage));
const everyJson = JSON.stringify(normalizedSchedules.map((schedule) => schedule.every));
const startJson = JSON.stringify(normalizedSchedules.map((schedule) => schedule.start));
const takenByJson = JSON.stringify(med.takenBy);
const intakesJson = JSON.stringify(normalizedSchedules);
const intakeRemindersEnabled =
normalizedSchedules.some((schedule) => schedule.intakeRemindersEnabled) || med.intakeRemindersEnabled;
const [inserted] = await tx
.insert(medications)
.values({
userId,
name: med.name,
genericName: med.genericName || null,
takenByJson,
medicationForm: med.medicationForm ?? "tablet",
pillForm: med.pillForm || null,
lifecycleCategory: med.lifecycleCategory ?? "refill_when_empty",
packageType: normalizePackageType(med.inventory.packageType),
packageAmountValue: med.inventory.packageAmountValue ?? 0,
packageAmountUnit: med.inventory.packageAmountUnit ?? "ml",
packCount: med.inventory.packCount,
blistersPerPack: med.inventory.blistersPerPack,
pillsPerBlister: med.inventory.pillsPerBlister,
looseTablets: med.inventory.looseTablets,
totalPills: med.inventory.totalPills ?? null,
stockAdjustment: med.inventory.stockAdjustment ?? 0,
lastStockCorrectionAt: med.lastStockCorrectionAt ? new Date(med.lastStockCorrectionAt) : null,
pillWeightMg: med.pillWeightMg || null,
doseUnit: med.doseUnit ?? "mg",
medicationStartDate: med.medicationStartDate || "",
medicationEndDate: med.medicationEndDate || null,
autoMarkObsoleteAfterEndDate: med.autoMarkObsoleteAfterEndDate ?? true,
intakesJson,
usageJson,
everyJson,
startJson,
expiryDate: med.expiryDate || null,
notes: med.notes || null,
intakeRemindersEnabled,
isObsolete: med.isObsolete ?? false,
obsoleteAt: med.obsoleteAt ? new Date(med.obsoleteAt) : null,
prescriptionEnabled: med.prescriptionEnabled ?? false,
prescriptionAuthorizedRefills: med.prescriptionEnabled
? (med.prescriptionAuthorizedRefills ?? null)
: null,
prescriptionRemainingRefills: med.prescriptionEnabled
? (med.prescriptionRemainingRefills ?? null)
: null,
prescriptionLowRefillThreshold: med.prescriptionLowRefillThreshold ?? 1,
prescriptionExpiryDate: med.prescriptionExpiryDate || null,
dismissedUntil: med.dismissedUntil || null,
imageUrl: null,
})
.returning();
exportIdToNewId.set(med._exportId, inserted.id);
if (med.image) {
const imageUrl = base64ToImage(med.image, inserted.id);
if (imageUrl) {
newImagePaths.push(resolve(IMAGES_DIR, imageUrl));
await tx.update(medications).set({ imageUrl }).where(eq(medications.id, inserted.id));
}
}
}
}
}
// Delete in order: refill history, doses, share tokens, medications, settings
await db.delete(refillHistory).where(eq(refillHistory.userId, userId));
await db.delete(doseTracking).where(eq(doseTracking.userId, userId));
await db.delete(shareTokens).where(eq(shareTokens.userId, userId));
await db.delete(medications).where(eq(medications.userId, userId));
await db.delete(userSettings).where(eq(userSettings.userId, userId));
for (const dose of importData.doseHistory) {
const newMedId = exportIdToNewId.get(dose.medicationRef);
if (!newMedId) continue;
// 3. Import medications and build ID mapping
const exportIdToNewId = new Map<string, number>();
const scheduledFor = new Date(dose.scheduledTime);
const timestampMs = scheduledFor.getTime();
const doseId = buildDoseId(newMedId, dose.scheduleIndex, timestampMs, dose.takenByPerson);
for (const med of importData.medications) {
const normalizedSchedules = med.schedules.map((schedule) =>
normalizeIntake({
usage: schedule.usage,
every: schedule.every,
start: schedule.start,
scheduleMode: schedule.scheduleMode,
weekdays: schedule.weekdays,
intakeUnit: schedule.intakeUnit ?? null,
takenBy: schedule.takenBy || null,
intakeRemindersEnabled: schedule.remind ?? false,
})
);
const usageJson = JSON.stringify(normalizedSchedules.map((schedule) => schedule.usage));
const everyJson = JSON.stringify(normalizedSchedules.map((schedule) => schedule.every));
const startJson = JSON.stringify(normalizedSchedules.map((schedule) => schedule.start));
const takenByJson = JSON.stringify(med.takenBy);
const [insertedDose] = await tx
.insert(doseTracking)
.values({
userId,
doseId,
takenAt: new Date(dose.takenAt),
markedBy: dose.markedBy || null,
takenSource: dose.takenSource ?? "manual",
dismissed: dose.dismissed ?? false,
})
.returning({ id: doseTracking.id });
const intakesJson = JSON.stringify(normalizedSchedules);
await restoreIntakeJournalForImportedDose({
userId,
doseTrackingId: insertedDose.id,
medicationId: newMedId,
scheduledFor,
journalNote: dose.journalNote,
journalCreatedAt: dose.journalCreatedAt,
journalUpdatedAt: dose.journalUpdatedAt,
database: tx,
});
}
// Check if any schedule has remind enabled
const intakeRemindersEnabled =
normalizedSchedules.some((schedule) => schedule.intakeRemindersEnabled) || med.intakeRemindersEnabled;
if (importData.settings) {
await tx.insert(userSettings).values({
userId,
emailEnabled: importData.settings.emailEnabled ?? false,
notificationEmail: importData.settings.notificationEmail || null,
emailStockReminders: importData.settings.emailStockReminders ?? true,
emailIntakeReminders: importData.settings.emailIntakeReminders ?? true,
emailPrescriptionReminders: importData.settings.emailPrescriptionReminders ?? true,
shoutrrrEnabled: importData.settings.shoutrrrEnabled ?? false,
shoutrrrUrl: importData.settings.shoutrrrUrl || null,
shoutrrrStockReminders: importData.settings.shoutrrrStockReminders ?? true,
shoutrrrIntakeReminders: importData.settings.shoutrrrIntakeReminders ?? true,
shoutrrrPrescriptionReminders: importData.settings.shoutrrrPrescriptionReminders ?? true,
reminderDaysBefore: importData.settings.reminderDaysBefore ?? 7,
repeatDailyReminders: importData.settings.repeatDailyReminders ?? false,
skipRemindersForTakenDoses: importData.settings.skipRemindersForTakenDoses ?? false,
repeatRemindersEnabled: importData.settings.repeatRemindersEnabled ?? false,
reminderRepeatIntervalMinutes: importData.settings.reminderRepeatIntervalMinutes ?? 30,
maxNaggingReminders: importData.settings.maxNaggingReminders ?? 5,
lowStockDays: importData.settings.lowStockDays ?? 30,
normalStockDays: importData.settings.normalStockDays ?? 90,
highStockDays: importData.settings.highStockDays ?? 180,
expiryWarningDays: importData.settings.expiryWarningDays ?? 90,
language: importData.settings.language ?? "en",
stockCalculationMode: importData.settings.stockCalculationMode ?? "automatic",
shareMedicationOverview: importData.settings.shareMedicationOverview ?? false,
});
}
const [inserted] = await db
.insert(medications)
.values({
userId,
name: med.name,
genericName: med.genericName || null,
takenByJson,
medicationForm: med.medicationForm ?? "tablet",
pillForm: med.pillForm || null,
lifecycleCategory: med.lifecycleCategory ?? "refill_when_empty",
packageType: normalizePackageType(med.inventory.packageType),
packageAmountValue: med.inventory.packageAmountValue ?? 0,
packageAmountUnit: med.inventory.packageAmountUnit ?? "ml",
packCount: med.inventory.packCount,
blistersPerPack: med.inventory.blistersPerPack,
pillsPerBlister: med.inventory.pillsPerBlister,
looseTablets: med.inventory.looseTablets,
totalPills: med.inventory.totalPills ?? null,
stockAdjustment: med.inventory.stockAdjustment ?? 0,
lastStockCorrectionAt: med.lastStockCorrectionAt ? new Date(med.lastStockCorrectionAt) : null,
pillWeightMg: med.pillWeightMg || null,
doseUnit: med.doseUnit ?? "mg",
medicationStartDate: med.medicationStartDate || "",
medicationEndDate: med.medicationEndDate || null,
autoMarkObsoleteAfterEndDate: med.autoMarkObsoleteAfterEndDate ?? true,
intakesJson,
usageJson,
everyJson,
startJson,
expiryDate: med.expiryDate || null,
notes: med.notes || null,
intakeRemindersEnabled,
isObsolete: med.isObsolete ?? false,
obsoleteAt: med.obsoleteAt ? new Date(med.obsoleteAt) : null,
prescriptionEnabled: med.prescriptionEnabled ?? false,
prescriptionAuthorizedRefills: med.prescriptionEnabled ? (med.prescriptionAuthorizedRefills ?? null) : null,
prescriptionRemainingRefills: med.prescriptionEnabled ? (med.prescriptionRemainingRefills ?? null) : null,
prescriptionLowRefillThreshold: med.prescriptionLowRefillThreshold ?? 1,
prescriptionExpiryDate: med.prescriptionExpiryDate || null,
dismissedUntil: med.dismissedUntil || null,
imageUrl: null, // Will be set after image is saved
})
.returning();
for (const share of importData.shareLinks) {
await tx.insert(shareTokens).values({
userId,
token: randomBytes(8).toString("hex"),
takenBy: share.takenBy,
scheduleDays: share.scheduleDays,
allowJournalNotes: share.allowJournalNotes ?? false,
expiresAt: share.expiresAt ? new Date(share.expiresAt) : null,
});
}
// Save mapping
exportIdToNewId.set(med._exportId, inserted.id);
for (const refill of importData.refillHistory) {
const newMedId = exportIdToNewId.get(refill.medicationRef);
if (!newMedId) continue;
// Save image if present
if (med.image) {
const imageUrl = base64ToImage(med.image, inserted.id);
if (imageUrl) {
await db.update(medications).set({ imageUrl }).where(eq(medications.id, inserted.id));
await tx.insert(refillHistory).values({
medicationId: newMedId,
userId,
packsAdded: refill.packsAdded ?? 0,
loosePillsAdded: refill.loosePillsAdded ?? refill.quantityAdded ?? 0,
usedPrescription: refill.usedPrescription ?? false,
refillDate: new Date(refill.refillDate),
});
}
});
} catch (error) {
for (const imagePath of newImagePaths) {
const removalError = removeFileIfPresent(imagePath);
if (removalError) {
request.log.warn(`[Import] Failed to remove rolled-back image path=${imagePath}: ${removalError}`);
}
}
request.log.error({ err: error }, "[Import] Failed to import data");
return reply.status(500).send({ error: "Import failed" });
}
// 4. Import dose history with remapped medication IDs
for (const dose of importData.doseHistory) {
const newMedId = exportIdToNewId.get(dose.medicationRef);
if (!newMedId) continue; // Skip orphaned doses
// Convert ISO timestamp back to milliseconds for dose ID
const timestampMs = new Date(dose.scheduledTime).getTime();
// Rebuild dose ID with optional person suffix
const doseId = buildDoseId(newMedId, dose.scheduleIndex, timestampMs, dose.takenByPerson);
await db.insert(doseTracking).values({
userId,
doseId,
takenAt: new Date(dose.takenAt),
markedBy: dose.markedBy || null,
takenSource: dose.takenSource ?? "manual",
dismissed: dose.dismissed ?? false,
});
}
// 5. Import settings
if (importData.settings) {
// Legacy exports may still contain shareStockStatus. The current app no longer
// uses that setting, so imports accept it for compatibility and then ignore it.
await db.insert(userSettings).values({
userId,
emailEnabled: importData.settings.emailEnabled ?? false,
notificationEmail: importData.settings.notificationEmail || null,
emailStockReminders: importData.settings.emailStockReminders ?? true,
emailIntakeReminders: importData.settings.emailIntakeReminders ?? true,
emailPrescriptionReminders: importData.settings.emailPrescriptionReminders ?? true,
shoutrrrEnabled: importData.settings.shoutrrrEnabled ?? false,
shoutrrrUrl: importData.settings.shoutrrrUrl || null,
shoutrrrStockReminders: importData.settings.shoutrrrStockReminders ?? true,
shoutrrrIntakeReminders: importData.settings.shoutrrrIntakeReminders ?? true,
shoutrrrPrescriptionReminders: importData.settings.shoutrrrPrescriptionReminders ?? true,
reminderDaysBefore: importData.settings.reminderDaysBefore ?? 7,
repeatDailyReminders: importData.settings.repeatDailyReminders ?? false,
skipRemindersForTakenDoses: importData.settings.skipRemindersForTakenDoses ?? false,
repeatRemindersEnabled: importData.settings.repeatRemindersEnabled ?? false,
reminderRepeatIntervalMinutes: importData.settings.reminderRepeatIntervalMinutes ?? 30,
maxNaggingReminders: importData.settings.maxNaggingReminders ?? 5,
lowStockDays: importData.settings.lowStockDays ?? 30,
normalStockDays: importData.settings.normalStockDays ?? 90,
highStockDays: importData.settings.highStockDays ?? 180,
expiryWarningDays: importData.settings.expiryWarningDays ?? 90,
language: importData.settings.language ?? "en",
stockCalculationMode: importData.settings.stockCalculationMode ?? "automatic",
shareMedicationOverview: importData.settings.shareMedicationOverview ?? false,
});
}
// 6. Import share links (with new tokens)
for (const share of importData.shareLinks) {
// Always generate new token for security
const token = randomBytes(8).toString("hex");
await db.insert(shareTokens).values({
userId,
token,
takenBy: share.takenBy,
scheduleDays: share.scheduleDays,
expiresAt: share.expiresAt ? new Date(share.expiresAt) : null,
});
}
// 7. Import refill history with remapped medication IDs
for (const refill of importData.refillHistory) {
const newMedId = exportIdToNewId.get(refill.medicationRef);
if (!newMedId) continue; // Skip orphaned refill records
await db.insert(refillHistory).values({
medicationId: newMedId,
userId,
packsAdded: refill.packsAdded ?? 0,
loosePillsAdded: refill.loosePillsAdded ?? refill.quantityAdded ?? 0,
usedPrescription: refill.usedPrescription ?? false,
refillDate: new Date(refill.refillDate),
});
for (const imagePath of oldImagePaths) {
const removalError = removeFileIfPresent(imagePath);
if (removalError) {
request.log.warn(`[Import] Failed to remove replaced image path=${imagePath}: ${removalError}`);
}
}
return {
+373
View File
@@ -0,0 +1,373 @@
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { z } from "zod";
import { getAnonymousUserId, requireAuth } from "../plugins/auth.js";
import { env } from "../plugins/env.js";
import {
deleteIntakeJournalForDoseEvent,
getIntakeJournalForDoseEvent,
isTrackedDoseIdFormat,
listIntakeJournalEntriesForUser,
resolveTrackedDoseEventForUser,
upsertIntakeJournalForDoseEvent,
} from "../services/intake-journal-service.js";
import type { AuthUser } from "../types/fastify.js";
import { toLocalDateTimeOffsetString } from "../utils/local-date-time.js";
import {
applyOpenApiRouteStandards,
genericErrorSchema,
validationErrorSchema,
} from "../utils/openapi-route-standards.js";
const intakeJournalEndpointSecurity: ReadonlyArray<Record<string, readonly string[]>> = [
{ bearerAuth: [] },
{ cookieAuth: [] },
];
const doseIdParamsSchema = {
type: "object",
required: ["doseId"],
properties: {
doseId: { type: "string", minLength: 1 },
},
} as const;
const intakeJournalEntrySchema = {
type: "object",
required: [
"doseTrackingId",
"doseId",
"medicationId",
"medicationName",
"scheduledFor",
"dismissed",
"takenSource",
"note",
"updatedAt",
],
properties: {
doseTrackingId: { type: "integer" },
doseId: { type: "string" },
medicationId: { type: "integer" },
medicationName: { type: "string" },
scheduledFor: { type: "string", format: "date-time" },
takenAt: { type: ["string", "null"], format: "date-time" },
dismissed: { type: "boolean" },
takenSource: { type: "string", enum: ["manual", "automatic"] },
markedBy: { type: ["string", "null"] },
note: { type: ["string", "null"] },
updatedAt: { type: ["string", "null"], format: "date-time" },
createdAt: { type: ["string", "null"], format: "date-time" },
},
additionalProperties: false,
} as const;
const intakeJournalEventResponseSchema = {
type: "object",
required: ["entry"],
properties: {
entry: intakeJournalEntrySchema,
},
additionalProperties: false,
} as const;
const intakeJournalHistoryResponseSchema = {
type: "object",
required: ["entries"],
properties: {
entries: {
type: "array",
items: intakeJournalEntrySchema,
},
},
additionalProperties: false,
} as const;
const intakeJournalHistoryQuerySchema = z.object({
medicationId: z.coerce.number().int().positive().optional(),
from: z.string().trim().min(1).optional(),
to: z.string().trim().min(1).optional(),
limit: z.coerce.number().int().min(1).max(200).optional().default(100),
});
const intakeJournalUpsertSchema = z.object({
note: z.string().max(4000),
});
function getValidationErrorMessage(error: z.ZodError): string {
const issue = error.issues[0];
if (!issue) {
return "Invalid request payload";
}
return issue.message;
}
function parseOptionalDate(value: string | undefined): Date | null {
if (!value) {
return null;
}
const parsed = new Date(value);
return Number.isNaN(parsed.getTime()) ? null : parsed;
}
function serializeTakenAt(value: Date | null, dismissed: boolean): string | null {
if (!(value instanceof Date) || Number.isNaN(value.getTime())) {
return null;
}
if (dismissed && value.getTime() <= 0) {
return null;
}
return value.toISOString();
}
function buildJournalEntryDto(input: {
event: Awaited<ReturnType<typeof resolveTrackedDoseEventForUser>> extends infer T
? T extends null
? never
: T
: never;
journalEntry: Awaited<ReturnType<typeof getIntakeJournalForDoseEvent>>;
}) {
const { event, journalEntry } = input;
return {
doseTrackingId: event.doseTrackingId,
doseId: event.doseId,
medicationId: event.medicationId,
medicationName: event.medicationName,
scheduledFor: toLocalDateTimeOffsetString(journalEntry?.scheduledFor ?? event.scheduledFor),
takenAt: serializeTakenAt(event.takenAt, event.dismissed),
dismissed: event.dismissed,
takenSource: event.takenSource,
markedBy: event.markedBy,
note: journalEntry?.note ?? null,
updatedAt: journalEntry?.updatedAt?.toISOString() ?? null,
createdAt: journalEntry?.createdAt?.toISOString() ?? null,
};
}
async function getUserId(request: FastifyRequest, reply: FastifyReply): Promise<number> {
if (!env.AUTH_ENABLED) {
return getAnonymousUserId();
}
const authUser = request.user as AuthUser | null;
if (!authUser) {
reply.status(401).send({ error: "Not authenticated" });
throw new Error("AUTH_REQUIRED");
}
return authUser.id;
}
export async function intakeJournalRoutes(app: FastifyInstance) {
app.addHook("preHandler", requireAuth);
applyOpenApiRouteStandards(app, { tag: "intake-journal", protectedByDefault: true });
app.get<{ Querystring: z.infer<typeof intakeJournalHistoryQuerySchema> }>(
"/intake-journal",
{
schema: {
tags: ["intake-journal"],
summary: "List intake journal history for the current owner",
security: intakeJournalEndpointSecurity,
querystring: {
type: "object",
properties: {
medicationId: { type: "integer", minimum: 1 },
from: { type: "string", format: "date-time" },
to: { type: "string", format: "date-time" },
limit: { type: "integer", minimum: 1, maximum: 200 },
},
},
response: {
200: intakeJournalHistoryResponseSchema,
400: { anyOf: [genericErrorSchema, validationErrorSchema] },
401: genericErrorSchema,
},
},
},
async (request, reply) => {
const userId = await getUserId(request, reply);
const parsed = intakeJournalHistoryQuerySchema.safeParse(request.query);
if (!parsed.success) {
return reply.status(400).send({ error: getValidationErrorMessage(parsed.error) });
}
const from = parseOptionalDate(parsed.data.from);
if (parsed.data.from && !from) {
return reply.status(400).send({ error: "Invalid 'from' date-time filter", code: "INVALID_FROM" });
}
const to = parseOptionalDate(parsed.data.to);
if (parsed.data.to && !to) {
return reply.status(400).send({ error: "Invalid 'to' date-time filter", code: "INVALID_TO" });
}
if (from && to && from.getTime() > to.getTime()) {
return reply.status(400).send({ error: "'from' must be before or equal to 'to'", code: "INVALID_RANGE" });
}
const entries = await listIntakeJournalEntriesForUser({
userId,
medicationId: parsed.data.medicationId,
from: from ?? undefined,
to: to ?? undefined,
limit: parsed.data.limit,
});
return {
entries: entries.map((entry) => ({
doseTrackingId: entry.doseTrackingId,
doseId: entry.doseId,
medicationId: entry.medicationId,
medicationName: entry.medicationName,
scheduledFor: toLocalDateTimeOffsetString(entry.scheduledFor),
takenAt: serializeTakenAt(entry.takenAt, entry.dismissed),
dismissed: entry.dismissed,
takenSource: entry.takenSource,
markedBy: entry.markedBy,
note: entry.note,
updatedAt: entry.updatedAt.toISOString(),
createdAt: entry.createdAt.toISOString(),
})),
};
}
);
app.get<{ Params: { doseId: string } }>(
"/intake-journal/event/:doseId",
{
schema: {
tags: ["intake-journal"],
summary: "Get intake journal context for a tracked dose event",
security: intakeJournalEndpointSecurity,
params: doseIdParamsSchema,
response: {
200: intakeJournalEventResponseSchema,
400: { anyOf: [genericErrorSchema, validationErrorSchema] },
401: genericErrorSchema,
404: genericErrorSchema,
},
},
},
async (request, reply) => {
const userId = await getUserId(request, reply);
const { doseId } = request.params;
if (!isTrackedDoseIdFormat(doseId)) {
return reply.status(400).send({ error: "Invalid doseId format", code: "INVALID_DOSE" });
}
const event = await resolveTrackedDoseEventForUser({ userId, doseId });
if (!event) {
return reply
.status(404)
.send({ error: "Tracked dose event not found for the current owner", code: "DOSE_NOT_FOUND" });
}
const journalEntry = await getIntakeJournalForDoseEvent({ userId, doseId });
return { entry: buildJournalEntryDto({ event, journalEntry }) };
}
);
app.put<{ Body: z.infer<typeof intakeJournalUpsertSchema>; Params: { doseId: string } }>(
"/intake-journal/event/:doseId",
{
schema: {
tags: ["intake-journal"],
summary: "Create or update an intake journal note for a tracked dose event",
security: intakeJournalEndpointSecurity,
params: doseIdParamsSchema,
body: {
type: "object",
required: ["note"],
properties: {
note: { type: "string", maxLength: 4000 },
},
additionalProperties: false,
},
response: {
200: intakeJournalEventResponseSchema,
400: { anyOf: [genericErrorSchema, validationErrorSchema] },
401: genericErrorSchema,
404: genericErrorSchema,
},
},
},
async (request, reply) => {
const userId = await getUserId(request, reply);
const { doseId } = request.params;
if (!isTrackedDoseIdFormat(doseId)) {
return reply.status(400).send({ error: "Invalid doseId format", code: "INVALID_DOSE" });
}
const parsed = intakeJournalUpsertSchema.safeParse(request.body);
if (!parsed.success) {
return reply.status(400).send({ error: getValidationErrorMessage(parsed.error) });
}
const event = await resolveTrackedDoseEventForUser({ userId, doseId });
if (!event) {
return reply
.status(404)
.send({ error: "Tracked dose event not found for the current owner", code: "DOSE_NOT_FOUND" });
}
const journalEntry = await upsertIntakeJournalForDoseEvent({
userId,
doseId,
note: parsed.data.note,
});
return { entry: buildJournalEntryDto({ event, journalEntry }) };
}
);
app.delete<{ Params: { doseId: string } }>(
"/intake-journal/event/:doseId",
{
schema: {
tags: ["intake-journal"],
summary: "Delete an intake journal note for a tracked dose event",
security: intakeJournalEndpointSecurity,
params: doseIdParamsSchema,
response: {
200: {
type: "object",
required: ["success"],
properties: {
success: { type: "boolean" },
},
additionalProperties: false,
},
400: { anyOf: [genericErrorSchema, validationErrorSchema] },
401: genericErrorSchema,
404: genericErrorSchema,
},
},
},
async (request, reply) => {
const userId = await getUserId(request, reply);
const { doseId } = request.params;
if (!isTrackedDoseIdFormat(doseId)) {
return reply.status(400).send({ error: "Invalid doseId format", code: "INVALID_DOSE" });
}
const deleted = await deleteIntakeJournalForDoseEvent({ userId, doseId });
if (!deleted) {
return reply
.status(404)
.send({ error: "Tracked dose event not found for the current owner", code: "DOSE_NOT_FOUND" });
}
return { success: true };
}
);
}
+28 -5
View File
@@ -45,12 +45,24 @@ type PlannerRow = {
type SendEmailBody = {
email: string;
from: string;
until: string;
from?: string;
until?: string;
startDate?: string;
endDate?: string;
rows: PlannerRow[];
language?: Language; // Optional: passed from frontend for unauthenticated requests
};
function resolvePlannerDateRange(body: SendEmailBody): { startDate: string; endDate: string } | null {
const startDate = body.startDate ?? body.from;
const endDate = body.endDate ?? body.until;
if (!startDate || !endDate) {
return null;
}
return { startDate, endDate };
}
type LowStockItem = {
name: string;
medsLeft: number;
@@ -165,11 +177,15 @@ export async function plannerRoutes(app: FastifyInstance) {
email: { type: "string" },
from: { type: "string" },
until: { type: "string" },
startDate: { type: "string", format: "date-time" },
endDate: { type: "string", format: "date-time" },
language: { type: "string" },
rows: { type: "array", items: plannerRowSchema },
},
example: {
email: "daniel@example.com",
startDate: "2026-03-11T00:00:00.000Z",
endDate: "2026-04-11T00:00:00.000Z",
from: "2026-03-11",
until: "2026-04-11",
language: "en",
@@ -198,13 +214,20 @@ export async function plannerRoutes(app: FastifyInstance) {
},
},
async (request, reply) => {
const { email, from, until, rows, language: bodyLanguage } = request.body;
const { email, rows, language: bodyLanguage } = request.body;
const resolvedDateRange = resolvePlannerDateRange(request.body);
request.log.info({ email, rowCount: rows?.length ?? 0 }, "[Planner] Demand notification request received");
if (!rows || rows.length === 0) {
return reply.status(400).send({ error: "Missing planner data" });
}
if (!resolvedDateRange) {
return reply.status(400).send({ error: "Missing planner date range" });
}
const { startDate, endDate } = resolvedDateRange;
// Load user settings for notification channels
const userId = await getUserId(request);
const activeMeds = await db
@@ -246,14 +269,14 @@ export async function plannerRoutes(app: FastifyInstance) {
// Format dates for display - escape to prevent XSS even though toLocaleDateString should be safe
const fromDate = escapeHtml(
new Date(from).toLocaleDateString(locale, {
new Date(startDate).toLocaleDateString(locale, {
year: "numeric",
month: "long",
day: "numeric",
})
);
const untilDate = escapeHtml(
new Date(until).toLocaleDateString(locale, {
new Date(endDate).toLocaleDateString(locale, {
year: "numeric",
month: "long",
day: "numeric",
+92 -9
View File
@@ -1,4 +1,4 @@
import { and, eq } from "drizzle-orm";
import { and, eq, gte, lt } from "drizzle-orm";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { z } from "zod";
import { db } from "../db/client.js";
@@ -12,10 +12,42 @@ import {
validationErrorSchema,
} from "../utils/openapi-route-standards.js";
const reportDataSchema = z.object({
medicationIds: z.array(z.number().int().positive()).min(1).max(100),
takenByFilter: z.array(z.string().trim().min(1).max(100)).max(50).optional(),
});
const reportDataSchema = z
.object({
medicationIds: z.array(z.number().int().positive()).min(1).max(100),
startDate: z.string().datetime().optional(),
endDate: z.string().datetime().optional(),
takenByFilter: z.array(z.string().trim().min(1).max(100)).max(50).optional(),
})
.superRefine((value, ctx) => {
const hasStartDate = typeof value.startDate === "string";
const hasEndDate = typeof value.endDate === "string";
if (hasStartDate !== hasEndDate) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "startDate and endDate must be provided together",
path: hasStartDate ? ["endDate"] : ["startDate"],
});
return;
}
if (!hasStartDate || !hasEndDate) {
return;
}
const startDateValue = value.startDate!;
const endDateValue = value.endDate!;
const startDate = new Date(startDateValue);
const endDate = new Date(endDateValue);
if (Number.isNaN(startDate.getTime()) || Number.isNaN(endDate.getTime()) || endDate <= startDate) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Invalid date range",
path: ["endDate"],
});
}
});
const reportDataBodyOpenApiSchema = {
type: "object",
@@ -27,6 +59,14 @@ const reportDataBodyOpenApiSchema = {
maxItems: 100,
items: { type: "integer", minimum: 1 },
},
startDate: {
type: "string",
format: "date-time",
},
endDate: {
type: "string",
format: "date-time",
},
takenByFilter: {
type: "array",
maxItems: 50,
@@ -35,17 +75,47 @@ const reportDataBodyOpenApiSchema = {
},
example: {
medicationIds: [1, 3, 5],
startDate: "2026-05-01T00:00:00.000Z",
endDate: "2026-06-01T00:00:00.000Z",
takenByFilter: ["Daniel"],
},
} as const;
const trackedDoseIdPattern = /^(\d+)-(\d+)-(\d+)(?:-(.+))?$/;
function getPersonTagKey(value: string): string {
return value.trim().toLocaleLowerCase();
}
function matchesTakenByFilter(doseId: string, takenByFilter: Set<string> | null): boolean {
if (!takenByFilter) return true;
const parts = doseId.split("-");
if (parts.length < 4) return false;
const takenBy = parts.at(-1)?.trim();
if (!takenBy) return false;
return takenByFilter.has(takenBy);
return takenByFilter.has(getPersonTagKey(takenBy));
}
function getDoseScheduledAtMs(doseId: string): number | null {
const match = trackedDoseIdPattern.exec(doseId);
if (!match) {
return null;
}
const scheduledAtMs = Number.parseInt(match[3], 10);
return Number.isNaN(scheduledAtMs) ? null : scheduledAtMs;
}
function isWithinDateRange(timestampMs: number | null, range: { startMs: number; endMs: number } | null): boolean {
if (!range) {
return true;
}
if (timestampMs === null) {
return false;
}
return timestampMs >= range.startMs && timestampMs < range.endMs;
}
const reportDataResponseSchema = {
@@ -110,10 +180,17 @@ export async function reportRoutes(app: FastifyInstance) {
if (!parsed.success) return reply.status(400).send(parsed.error.format());
const userId = await getUserId(req, reply);
const { medicationIds, takenByFilter } = parsed.data;
const { medicationIds, startDate, endDate, takenByFilter } = parsed.data;
const normalizedTakenByFilter = takenByFilter?.length
? new Set(takenByFilter.map((value) => value.trim()))
? new Set(takenByFilter.map((value) => getPersonTagKey(value)))
: null;
const dateRange =
startDate && endDate
? {
startMs: new Date(startDate).getTime(),
endMs: new Date(endDate).getTime(),
}
: null;
// Verify all medications belong to this user
const userMeds = await db
@@ -152,6 +229,7 @@ export async function reportRoutes(app: FastifyInstance) {
const medId = Number.parseInt(dose.doseId.split("-")[0], 10);
if (Number.isNaN(medId) || !medicationIds.includes(medId)) continue;
if (!matchesTakenByFilter(dose.doseId, normalizedTakenByFilter)) continue;
if (!isWithinDateRange(getDoseScheduledAtMs(dose.doseId), dateRange)) continue;
if (!dosesByMed.has(medId)) dosesByMed.set(medId, []);
dosesByMed.get(medId)!.push({
takenAt: dose.takenAt,
@@ -191,10 +269,15 @@ export async function reportRoutes(app: FastifyInstance) {
const isAmountBased = medication?.packageType === "liquid_container" || medication?.packageType === "tube";
// Get refills for this medication scoped to the authenticated user.
const refillFilters = [eq(refillHistory.medicationId, medId), eq(refillHistory.userId, userId)];
if (dateRange) {
refillFilters.push(gte(refillHistory.refillDate, new Date(dateRange.startMs)));
refillFilters.push(lt(refillHistory.refillDate, new Date(dateRange.endMs)));
}
const refills = await db
.select()
.from(refillHistory)
.where(and(eq(refillHistory.medicationId, medId), eq(refillHistory.userId, userId)));
.where(and(...refillFilters));
result[medId] = {
dosesTaken: takenDoses.length,
+193 -13
View File
@@ -1,5 +1,5 @@
import { randomBytes } from "node:crypto";
import { and, eq } from "drizzle-orm";
import { and, desc, eq } from "drizzle-orm";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { z } from "zod";
import { db } from "../db/client.js";
@@ -15,6 +15,7 @@ import {
validationErrorSchema,
} from "../utils/openapi-route-standards.js";
import { isAmountBasedPackageType, normalizePackageType } from "../utils/package-profiles.js";
import { redactTokenForLog } from "../utils/redaction.js";
import {
getAllTakenByForMedication,
parseIntakesJson,
@@ -28,6 +29,11 @@ import {
const createShareSchema = z.object({
takenBy: z.string().min(1, "takenBy is required"),
scheduleDays: z.number().int().min(1).max(365).default(30),
expiryDays: z
.union([z.number().int().min(1).max(365), z.null()])
.optional()
.default(null),
allowJournalNotes: z.boolean().optional().default(false),
});
const protectedEndpointSecurity: ReadonlyArray<Record<string, readonly string[]>> = [
@@ -37,15 +43,59 @@ const protectedEndpointSecurity: ReadonlyArray<Record<string, readonly string[]>
const shareTokenPattern = /^[a-f0-9]{16}$/;
function toIsoTimestamp(value: Date | string | number | null | undefined): string | null {
if (value == null) {
return null;
}
try {
if (value instanceof Date) {
return Number.isNaN(value.getTime()) ? null : value.toISOString();
}
if (typeof value === "number" || (typeof value === "string" && /^\d+$/.test(value))) {
const numericValue = typeof value === "number" ? value : Number(value);
const timestampMs = numericValue < 1_000_000_000_000 ? numericValue * 1000 : numericValue;
const date = new Date(timestampMs);
return Number.isNaN(date.getTime()) ? null : date.toISOString();
}
const date = new Date(value);
return Number.isNaN(date.getTime()) ? null : date.toISOString();
} catch {
return null;
}
}
function resolveExpiryDate(expiryDays: number | null | undefined): Date | null {
if (expiryDays == null) {
return null;
}
return new Date(Date.now() + expiryDays * 24 * 60 * 60 * 1000);
}
function isExpiredTimestamp(value: Date | string | number | null | undefined): boolean {
const isoValue = toIsoTimestamp(value);
return isoValue != null && new Date(isoValue).getTime() < Date.now();
}
const createShareBodyOpenApiSchema = {
type: "object",
properties: {
takenBy: { type: "string" },
scheduleDays: { type: "integer", minimum: 1, maximum: 365, default: 30 },
allowJournalNotes: { type: "boolean", default: false },
expiryDays: {
anyOf: [{ type: "integer", minimum: 1, maximum: 365 }, { type: "null" }],
default: null,
},
},
example: {
takenBy: "Daniel",
scheduleDays: 14,
allowJournalNotes: true,
expiryDays: 30,
},
} as const;
@@ -64,6 +114,7 @@ const shareReadResponseSchema = {
stockCalculationMode: { type: "string", enum: ["automatic", "manual"] },
upcomingTodayOnly: { type: "boolean" },
shareScheduleTodayOnly: { type: "boolean" },
allowJournalNotes: { type: "boolean" },
},
} as const;
@@ -96,6 +147,37 @@ const shareOverviewResponseSchema = {
},
} as const;
const shareListResponseSchema = {
type: "object",
properties: {
shareLinks: {
type: "array",
items: {
type: "object",
properties: {
token: { type: "string" },
takenBy: { type: "string" },
scheduleDays: { type: "integer" },
createdAt: { type: "string", format: "date-time" },
expiresAt: { type: ["string", "null"], format: "date-time" },
allowJournalNotes: { type: "boolean" },
shareUrl: { type: "string" },
},
required: ["token", "takenBy", "scheduleDays", "createdAt", "expiresAt", "allowJournalNotes", "shareUrl"],
},
},
},
required: ["shareLinks"],
} as const;
const ownerTokenParamsSchema = {
type: "object",
properties: {
token: { type: "string" },
},
required: ["token"],
} as const;
// Helper to get user ID from request
// Returns anonymous user ID when auth is disabled
async function getUserId(request: FastifyRequest, reply: FastifyReply): Promise<number> {
@@ -146,11 +228,12 @@ export async function shareRoutes(app: FastifyInstance) {
},
async (request, reply) => {
const { token } = request.params;
const tokenRef = redactTokenForLog(token);
// Find share token
const [share] = await db.select().from(shareTokens).where(eq(shareTokens.token, token));
if (!share) {
request.log.warn(`[Share] Invalid share token requested: token=${token}`);
request.log.warn(`[Share] Invalid share token requested: tokenRef=${tokenRef}`);
return reply.status(404).send({
error: "Share link not found",
code: "NOT_FOUND",
@@ -160,7 +243,7 @@ export async function shareRoutes(app: FastifyInstance) {
// Check if token has expired
if (share.expiresAt && share.expiresAt.getTime() < Date.now()) {
request.log.warn(
`[Share] Expired token requested: token=${token}, ownerUserId=${share.userId}, takenBy=${share.takenBy}`
`[Share] Expired token requested: tokenRef=${tokenRef}, ownerUserId=${share.userId}, takenBy=${share.takenBy}`
);
// Get the username of the owner to show in the expired message
const [owner] = await db.select({ username: users.username }).from(users).where(eq(users.id, share.userId));
@@ -255,6 +338,7 @@ export async function shareRoutes(app: FastifyInstance) {
takenBy: share.takenBy,
sharedBy: owner?.username ?? null,
scheduleDays: share.scheduleDays,
allowJournalNotes: share.allowJournalNotes ?? false,
medications: medicationsWithBlisters,
shareMedicationOverview,
medicationOverview,
@@ -298,20 +382,21 @@ export async function shareRoutes(app: FastifyInstance) {
reply.header("Cache-Control", "no-store");
const { token } = request.params;
const tokenRef = redactTokenForLog(token);
if (!shareTokenPattern.test(token)) {
request.log.warn(`[ShareOverview] Rejected invalid token format: token=${token}`);
request.log.warn(`[ShareOverview] Rejected invalid token format: tokenRef=${tokenRef}`);
return reply.status(404).send({ error: "not_found" });
}
const [share] = await db.select().from(shareTokens).where(eq(shareTokens.token, token));
if (!share) {
request.log.warn(`[ShareOverview] Unknown token requested: token=${token}`);
request.log.warn(`[ShareOverview] Unknown token requested: tokenRef=${tokenRef}`);
return reply.status(404).send({ error: "not_found" });
}
if (share.expiresAt && share.expiresAt.getTime() < Date.now()) {
request.log.warn(
`[ShareOverview] Expired token requested: token=${token}, ownerUserId=${share.userId}, takenBy=${share.takenBy}`
`[ShareOverview] Expired token requested: tokenRef=${tokenRef}, ownerUserId=${share.userId}, takenBy=${share.takenBy}`
);
return reply.status(410).send({
error: "expired",
@@ -371,6 +456,7 @@ export async function shareRoutes(app: FastifyInstance) {
reused: { type: "boolean" },
token: { type: "string" },
shareUrl: { type: "string" },
allowJournalNotes: { type: "boolean" },
expiresAt: { type: ["string", "null"] },
},
},
@@ -390,7 +476,8 @@ export async function shareRoutes(app: FastifyInstance) {
});
}
const { takenBy, scheduleDays } = parsed.data;
const { takenBy, scheduleDays, expiryDays, allowJournalNotes } = parsed.data;
const expiresAt = resolveExpiryDate(expiryDays);
// Check if user has medications for this takenBy (search in both medication-level and intake-level)
const allMeds = await db
@@ -422,43 +509,136 @@ export async function shareRoutes(app: FastifyInstance) {
.where(and(eq(shareTokens.userId, userId), eq(shareTokens.takenBy, takenBy)));
if (existingShare) {
await db.update(shareTokens).set({ scheduleDays, expiresAt: null }).where(eq(shareTokens.id, existingShare.id));
const existingTokenRef = redactTokenForLog(existingShare.token);
await db
.update(shareTokens)
.set({ scheduleDays, expiresAt, allowJournalNotes })
.where(eq(shareTokens.id, existingShare.id));
request.log.info(
`[Share] Reused existing share token: token=${existingShare.token}, ownerUserId=${userId}, takenBy=${takenBy}, scheduleDays=${scheduleDays}`
`[Share] Reused existing share token: tokenRef=${existingTokenRef}, ownerUserId=${userId}, takenBy=${takenBy}, scheduleDays=${scheduleDays}, allowJournalNotes=${allowJournalNotes}, expiresAt=${expiresAt?.toISOString() ?? "never"}`
);
return {
reused: true,
token: existingShare.token,
shareUrl: `/share/${existingShare.token}`,
expiresAt: null,
allowJournalNotes,
expiresAt: toIsoTimestamp(expiresAt),
};
}
const token = randomBytes(8).toString("hex");
const tokenRef = redactTokenForLog(token);
await db.insert(shareTokens).values({
userId,
token,
takenBy,
scheduleDays,
expiresAt: null,
allowJournalNotes,
expiresAt,
});
request.log.info(
`[Share] Created new share token: token=${token}, ownerUserId=${userId}, takenBy=${takenBy}, scheduleDays=${scheduleDays}`
`[Share] Created new share token: tokenRef=${tokenRef}, ownerUserId=${userId}, takenBy=${takenBy}, scheduleDays=${scheduleDays}, allowJournalNotes=${allowJournalNotes}, expiresAt=${expiresAt?.toISOString() ?? "never"}`
);
return {
reused: false,
token,
shareUrl: `/share/${token}`,
expiresAt: null,
allowJournalNotes,
expiresAt: toIsoTimestamp(expiresAt),
};
}
);
// ---------------------------------------------------------------------------
// GET /share - PROTECTED: List active share links for current owner
// ---------------------------------------------------------------------------
app.get(
"/share",
{
preHandler: requireAuth,
schema: {
tags: ["share"],
security: protectedEndpointSecurity,
response: {
200: shareListResponseSchema,
401: genericErrorSchema,
},
},
},
async (request, reply) => {
const userId = await getUserId(request, reply);
const shares = await db
.select()
.from(shareTokens)
.where(eq(shareTokens.userId, userId))
.orderBy(desc(shareTokens.createdAt));
return {
shareLinks: shares
.filter((share) => !isExpiredTimestamp(share.expiresAt))
.map((share) => ({
token: share.token,
takenBy: share.takenBy,
scheduleDays: share.scheduleDays,
createdAt: toIsoTimestamp(share.createdAt) ?? new Date().toISOString(),
expiresAt: toIsoTimestamp(share.expiresAt),
allowJournalNotes: share.allowJournalNotes ?? false,
shareUrl: `/share/${share.token}`,
})),
};
}
);
// ---------------------------------------------------------------------------
// DELETE /share/:token - PROTECTED: Revoke an existing share link
// ---------------------------------------------------------------------------
app.delete<{ Params: { token: string } }>(
"/share/:token",
{
preHandler: requireAuth,
schema: {
tags: ["share"],
security: protectedEndpointSecurity,
params: ownerTokenParamsSchema,
response: {
204: { type: "null" },
401: genericErrorSchema,
404: genericErrorSchema,
},
},
},
async (request, reply) => {
const userId = await getUserId(request, reply);
const { token } = request.params;
const tokenRef = redactTokenForLog(token);
const [share] = await db
.select()
.from(shareTokens)
.where(and(eq(shareTokens.userId, userId), eq(shareTokens.token, token)));
if (!share) {
return reply.status(404).send({
error: "Share link not found",
code: "NOT_FOUND",
});
}
await db.delete(shareTokens).where(eq(shareTokens.id, share.id));
request.log.info(
`[Share] Revoked share token: tokenRef=${tokenRef}, ownerUserId=${userId}, takenBy=${share.takenBy}`
);
return reply.status(204).send();
}
);
// ---------------------------------------------------------------------------
// GET /share/people - PROTECTED: Get list of unique takenBy values
// ---------------------------------------------------------------------------
@@ -0,0 +1,90 @@
import { eq } from "drizzle-orm";
import { db } from "../db/client.js";
import { intakeJournal } from "../db/schema.js";
type IntakeJournalWriteDatabase = Pick<typeof db, "insert">;
export type IntakeJournalExportPayload = {
journalNote: string;
journalCreatedAt?: string | null;
journalUpdatedAt?: string | null;
};
function toIsoStringOrNull(value: Date | string | number | null | undefined): string | null {
if (!value) {
return null;
}
try {
if (value instanceof Date) {
return Number.isNaN(value.getTime()) ? null : value.toISOString();
}
const parsed = new Date(value);
return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString();
} catch {
return null;
}
}
function toDateOrFallback(value: string | null | undefined, fallback: Date): Date {
if (!value) {
return fallback;
}
try {
const parsed = new Date(value);
return Number.isNaN(parsed.getTime()) ? fallback : parsed;
} catch {
return fallback;
}
}
export async function listIntakeJournalExportPayloadsForUser(
userId: number
): Promise<Map<number, IntakeJournalExportPayload>> {
const rows = await db.select().from(intakeJournal).where(eq(intakeJournal.userId, userId));
return new Map(
rows.map((row) => [
row.doseTrackingId,
{
journalNote: row.note,
journalCreatedAt: toIsoStringOrNull(row.createdAt),
journalUpdatedAt: toIsoStringOrNull(row.updatedAt),
},
])
);
}
export async function restoreIntakeJournalForImportedDose(input: {
userId: number;
doseTrackingId: number;
medicationId: number;
scheduledFor: Date;
journalNote?: string | null;
journalCreatedAt?: string | null;
journalUpdatedAt?: string | null;
database?: IntakeJournalWriteDatabase;
}): Promise<boolean> {
const normalizedNote = input.journalNote?.trim() ?? "";
if (normalizedNote.length === 0) {
return false;
}
const createdAt = toDateOrFallback(input.journalCreatedAt, input.scheduledFor);
const updatedAt = toDateOrFallback(input.journalUpdatedAt, createdAt);
const database = input.database ?? db;
await database.insert(intakeJournal).values({
userId: input.userId,
doseTrackingId: input.doseTrackingId,
medicationId: input.medicationId,
scheduledFor: input.scheduledFor,
note: normalizedNote,
createdAt,
updatedAt,
});
return true;
}
@@ -0,0 +1,332 @@
import { and, desc, eq, gte, lte } from "drizzle-orm";
import { db } from "../db/client.js";
import { doseTracking, intakeJournal, medications } from "../db/schema.js";
import { parseIntakesJson, parseLocalDateTime } from "../utils/scheduler-utils.js";
import type { DoseTrackingSource } from "./dose-tracking-service.js";
const doseIdPattern = /^(\d+)-(\d+)-(\d+)(?:-(.+))?$/;
type ParsedDoseId = {
medicationId: number;
intakeIndex: number;
timestampMs: number;
personSuffix: string | null;
};
type MedicationTimingRow = {
id: number;
name: string | null;
genericName: string | null;
intakesJson: string;
usageJson: string;
everyJson: string;
startJson: string;
intakeRemindersEnabled: boolean;
};
export type ResolvedTrackedDoseEvent = {
doseTrackingId: number;
userId: number;
doseId: string;
medicationId: number;
medicationName: string;
scheduledFor: Date;
takenAt: Date;
markedBy: string | null;
takenSource: DoseTrackingSource;
dismissed: boolean;
personSuffix: string | null;
};
export type IntakeJournalEntry = typeof intakeJournal.$inferSelect;
export type IntakeJournalHistoryEntry = {
id: number;
doseTrackingId: number;
doseId: string;
medicationId: number;
medicationName: string;
scheduledFor: Date;
takenAt: Date;
markedBy: string | null;
takenSource: DoseTrackingSource;
dismissed: boolean;
note: string;
createdAt: Date;
updatedAt: Date;
};
function parseDoseId(doseId: string): ParsedDoseId | null {
const match = doseIdPattern.exec(doseId);
if (!match) {
return null;
}
const medicationId = Number.parseInt(match[1], 10);
const intakeIndex = Number.parseInt(match[2], 10);
const timestampMs = Number.parseInt(match[3], 10);
const personSuffix = match[4] ? match[4].trim() : null;
if (Number.isNaN(medicationId) || Number.isNaN(intakeIndex) || Number.isNaN(timestampMs) || intakeIndex < 0) {
return null;
}
return {
medicationId,
intakeIndex,
timestampMs,
personSuffix,
};
}
export function isTrackedDoseIdFormat(doseId: string): boolean {
return parseDoseId(doseId) !== null;
}
function getMedicationDisplayName(medication: Pick<MedicationTimingRow, "id" | "name" | "genericName">): string {
const commercialName = medication.name?.trim() ?? "";
if (commercialName.length > 0) {
return commercialName;
}
const genericName = medication.genericName?.trim() ?? "";
if (genericName.length > 0) {
return genericName;
}
return `Medication #${medication.id}`;
}
function resolveScheduledFor(parsedDose: ParsedDoseId, medication: MedicationTimingRow): Date {
const intakes = parseIntakesJson(
medication.intakesJson,
{
usageJson: medication.usageJson,
everyJson: medication.everyJson,
startJson: medication.startJson,
},
medication.intakeRemindersEnabled
);
const intake = intakes[parsedDose.intakeIndex];
if (!intake) {
return new Date(parsedDose.timestampMs);
}
const doseDate = new Date(parsedDose.timestampMs);
const intakeStart = parseLocalDateTime(intake.start);
return new Date(
doseDate.getFullYear(),
doseDate.getMonth(),
doseDate.getDate(),
intakeStart.getHours(),
intakeStart.getMinutes(),
intakeStart.getSeconds(),
intakeStart.getMilliseconds()
);
}
export async function resolveTrackedDoseEventForUser(input: {
userId: number;
doseId: string;
}): Promise<ResolvedTrackedDoseEvent | null> {
const parsedDose = parseDoseId(input.doseId);
if (!parsedDose) {
return null;
}
const [event] = await db
.select({
doseTrackingId: doseTracking.id,
userId: doseTracking.userId,
doseId: doseTracking.doseId,
takenAt: doseTracking.takenAt,
markedBy: doseTracking.markedBy,
takenSource: doseTracking.takenSource,
dismissed: doseTracking.dismissed,
medicationId: medications.id,
medicationName: medications.name,
medicationGenericName: medications.genericName,
intakesJson: medications.intakesJson,
usageJson: medications.usageJson,
everyJson: medications.everyJson,
startJson: medications.startJson,
intakeRemindersEnabled: medications.intakeRemindersEnabled,
})
.from(doseTracking)
.innerJoin(medications, and(eq(medications.id, parsedDose.medicationId), eq(medications.userId, input.userId)))
.where(and(eq(doseTracking.userId, input.userId), eq(doseTracking.doseId, input.doseId)))
.limit(1);
if (!event) {
return null;
}
const scheduledFor = resolveScheduledFor(parsedDose, {
id: event.medicationId,
name: event.medicationName,
genericName: event.medicationGenericName,
intakesJson: event.intakesJson,
usageJson: event.usageJson,
everyJson: event.everyJson,
startJson: event.startJson,
intakeRemindersEnabled: event.intakeRemindersEnabled ?? false,
});
return {
doseTrackingId: event.doseTrackingId,
userId: event.userId,
doseId: event.doseId,
medicationId: event.medicationId,
medicationName: getMedicationDisplayName({
id: event.medicationId,
name: event.medicationName,
genericName: event.medicationGenericName,
}),
scheduledFor,
takenAt: event.takenAt,
markedBy: event.markedBy,
takenSource: event.takenSource as DoseTrackingSource,
dismissed: event.dismissed ?? false,
personSuffix: parsedDose.personSuffix,
};
}
export async function getIntakeJournalForDoseEvent(input: {
userId: number;
doseId: string;
}): Promise<IntakeJournalEntry | null> {
const event = await resolveTrackedDoseEventForUser(input);
if (!event) {
return null;
}
const [journalEntry] = await db
.select()
.from(intakeJournal)
.where(and(eq(intakeJournal.userId, input.userId), eq(intakeJournal.doseTrackingId, event.doseTrackingId)))
.limit(1);
return journalEntry ?? null;
}
export async function upsertIntakeJournalForDoseEvent(input: {
userId: number;
doseId: string;
note: string;
}): Promise<IntakeJournalEntry | null> {
const normalizedNote = input.note.trim();
if (normalizedNote.length === 0) {
await deleteIntakeJournalForDoseEvent({ userId: input.userId, doseId: input.doseId });
return null;
}
const event = await resolveTrackedDoseEventForUser({ userId: input.userId, doseId: input.doseId });
if (!event) {
return null;
}
const now = new Date();
await db
.insert(intakeJournal)
.values({
userId: input.userId,
doseTrackingId: event.doseTrackingId,
medicationId: event.medicationId,
scheduledFor: event.scheduledFor,
note: normalizedNote,
createdAt: now,
updatedAt: now,
})
.onConflictDoUpdate({
target: intakeJournal.doseTrackingId,
set: {
userId: input.userId,
medicationId: event.medicationId,
note: normalizedNote,
updatedAt: now,
},
});
return getIntakeJournalForDoseEvent({ userId: input.userId, doseId: input.doseId });
}
export async function deleteIntakeJournalForDoseEvent(input: { userId: number; doseId: string }): Promise<boolean> {
const event = await resolveTrackedDoseEventForUser(input);
if (!event) {
return false;
}
await db
.delete(intakeJournal)
.where(and(eq(intakeJournal.userId, input.userId), eq(intakeJournal.doseTrackingId, event.doseTrackingId)));
return true;
}
export async function listIntakeJournalEntriesForUser(input: {
userId: number;
medicationId?: number;
from?: Date;
to?: Date;
limit?: number;
}): Promise<IntakeJournalHistoryEntry[]> {
const filters = [eq(intakeJournal.userId, input.userId)];
if (typeof input.medicationId === "number") {
filters.push(eq(intakeJournal.medicationId, input.medicationId));
}
if (input.from) {
filters.push(gte(intakeJournal.scheduledFor, input.from));
}
if (input.to) {
filters.push(lte(intakeJournal.scheduledFor, input.to));
}
const rows = await db
.select({
id: intakeJournal.id,
doseTrackingId: intakeJournal.doseTrackingId,
doseId: doseTracking.doseId,
medicationId: intakeJournal.medicationId,
medicationName: medications.name,
medicationGenericName: medications.genericName,
scheduledFor: intakeJournal.scheduledFor,
takenAt: doseTracking.takenAt,
markedBy: doseTracking.markedBy,
takenSource: doseTracking.takenSource,
dismissed: doseTracking.dismissed,
note: intakeJournal.note,
createdAt: intakeJournal.createdAt,
updatedAt: intakeJournal.updatedAt,
})
.from(intakeJournal)
.innerJoin(doseTracking, eq(doseTracking.id, intakeJournal.doseTrackingId))
.innerJoin(medications, eq(medications.id, intakeJournal.medicationId))
.where(and(...filters))
.orderBy(desc(intakeJournal.scheduledFor), desc(intakeJournal.updatedAt))
.limit(input.limit ?? 100);
return rows.map((row) => ({
id: row.id,
doseTrackingId: row.doseTrackingId,
doseId: row.doseId,
medicationId: row.medicationId,
medicationName: getMedicationDisplayName({
id: row.medicationId,
name: row.medicationName,
genericName: row.medicationGenericName,
}),
scheduledFor: row.scheduledFor,
takenAt: row.takenAt,
markedBy: row.markedBy,
takenSource: row.takenSource as DoseTrackingSource,
dismissed: row.dismissed ?? false,
note: row.note,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
}));
}
+240 -6
View File
@@ -51,6 +51,7 @@ const __dirname = dirname(__filename);
const migrationsFolder = resolve(__dirname, "../../drizzle");
async function clearTables() {
await testClient.execute("DELETE FROM intake_journal");
await testClient.execute("DELETE FROM dose_tracking");
await testClient.execute("DELETE FROM share_tokens");
await testClient.execute("DELETE FROM api_keys");
@@ -78,20 +79,30 @@ async function insertMedication(options: {
start?: string;
}) {
const intakeStart = options.start ?? "2025-01-01T08:00:00.000Z";
const takenBy = options.takenBy ?? [];
const intakeTakenBy = takenBy[0] ?? null;
await testClient.execute({
sql: `INSERT INTO medications (
id, user_id, name, taken_by_json, medication_form, package_type,
pack_count, blisters_per_pack, pills_per_blister, loose_tablets, stock_adjustment,
usage_json, every_json, start_json, intakes_json, intake_reminders_enabled
) VALUES (?, ?, 'Test Medication', ?, 'tablet', 'blister', ?, 1, 10, ?, 0, '[1]', '[1]', ?, '[]', 0)`,
) VALUES (?, ?, 'Test Medication', ?, 'tablet', 'blister', ?, 1, 10, ?, 0, '[1]', '[1]', ?, ?, 0)`,
args: [
options.id,
options.userId,
JSON.stringify(options.takenBy ?? []),
JSON.stringify(takenBy),
options.packCount ?? 1,
options.looseTablets ?? 0,
intakeStart,
"[]",
JSON.stringify([
{
usage: 1,
every: 1,
start: intakeStart,
takenBy: intakeTakenBy,
intakeRemindersEnabled: false,
},
]),
],
});
}
@@ -103,13 +114,24 @@ async function insertUserSettings(userId: number, stockCalculationMode: "automat
});
}
async function _insertShareToken(userId: number, token: string, takenBy: string) {
async function _insertShareToken(userId: number, token: string, takenBy: string, allowJournalNotes = false) {
await testClient.execute({
sql: "INSERT INTO share_tokens (user_id, token, taken_by, schedule_days) VALUES (?, ?, ?, 30)",
args: [userId, token, takenBy],
sql: "INSERT INTO share_tokens (user_id, token, taken_by, schedule_days, allow_journal_notes) VALUES (?, ?, ?, 30, ?)",
args: [userId, token, takenBy, allowJournalNotes ? 1 : 0],
});
}
function buildLocalDoseStart(hours = 8): string {
const start = new Date();
start.setHours(hours, 0, 0, 0);
const year = start.getFullYear();
const month = String(start.getMonth() + 1).padStart(2, "0");
const day = String(start.getDate()).padStart(2, "0");
const hour = String(start.getHours()).padStart(2, "0");
return `${year}-${month}-${day}T${hour}:00:00.000`;
}
async function buildSessionCookie(app: FastifyInstance, userId: number, username: string) {
const token = await app.jwt.sign({ sub: userId, username });
return `access_token=${token}`;
@@ -458,6 +480,48 @@ describe("Dose Tracking API", () => {
});
});
describe("single-dose skip routes", () => {
it("marks a single owner dose as skipped through the frontend route", async () => {
const doseId = "1-0-1735344000000";
const response = await app.inject({
method: "POST",
url: "/doses/skip",
headers: { cookie: cookieHeader },
payload: { doseId },
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ success: true });
const result = await testClient.execute({
sql: "SELECT dose_id, marked_by, dismissed FROM dose_tracking WHERE user_id = ? AND dose_id = ?",
args: [userId, doseId],
});
expect(result.rows).toEqual([expect.objectContaining({ dose_id: doseId, marked_by: null, dismissed: 1 })]);
});
it("undoes a skipped-only owner dose through the frontend route", async () => {
const doseId = "1-0-1735344000000";
await insertDose({ userId, doseId, dismissed: true, takenAt: null });
const response = await app.inject({
method: "DELETE",
url: `/doses/skip/${encodeURIComponent(doseId)}`,
headers: { cookie: cookieHeader },
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ success: true });
const result = await testClient.execute({
sql: "SELECT COUNT(*) AS count FROM dose_tracking WHERE user_id = ? AND dose_id = ?",
args: [userId, doseId],
});
expect(Number(result.rows[0].count)).toBe(0);
});
});
describe("DELETE /doses/dismiss", () => {
it("clears dismissed-only records and removes the dismissed flag from taken doses", async () => {
await insertDose({ userId, doseId: "1-0-1735344000000", dismissed: true, takenAt: null });
@@ -481,4 +545,174 @@ describe("Dose Tracking API", () => {
]);
});
});
describe("shared single-dose skip routes", () => {
it("marks and undoes a visible shared dose as skipped", async () => {
const start = buildLocalDoseStart();
await insertMedication({
id: 6,
userId,
takenBy: ["Max"],
start,
});
await _insertShareToken(userId, "share-skip-token", "Max", false);
const doseId = `6-0-${new Date(start).getTime()}-Max`;
const skipResponse = await app.inject({
method: "POST",
url: "/share/share-skip-token/doses/skip",
payload: { doseId },
});
expect(skipResponse.statusCode).toBe(200);
expect(skipResponse.json()).toEqual({ success: true });
const skippedRows = await testClient.execute({
sql: "SELECT dose_id, marked_by, dismissed FROM dose_tracking WHERE user_id = ? AND dose_id = ?",
args: [userId, doseId],
});
expect(skippedRows.rows).toEqual([expect.objectContaining({ dose_id: doseId, marked_by: null, dismissed: 1 })]);
const undoResponse = await app.inject({
method: "DELETE",
url: `/share/share-skip-token/doses/skip/${encodeURIComponent(doseId)}`,
});
expect(undoResponse.statusCode).toBe(200);
expect(undoResponse.json()).toEqual({ success: true });
const remainingRows = await testClient.execute({
sql: "SELECT COUNT(*) AS count FROM dose_tracking WHERE user_id = ? AND dose_id = ?",
args: [userId, doseId],
});
expect(Number(remainingRows.rows[0].count)).toBe(0);
});
});
describe("Shared journal notes", () => {
it("rejects shared journal access when the share link does not allow notes", async () => {
const start = buildLocalDoseStart();
await insertMedication({
id: 7,
userId,
takenBy: ["Max"],
start,
});
await _insertShareToken(userId, "token-no-notes", "Max", false);
const doseId = `7-0-${new Date(start).getTime()}-Max`;
await insertDose({ userId, doseId, markedBy: "Max" });
const response = await app.inject({
method: "GET",
url: `/share/token-no-notes/journal/event/${encodeURIComponent(doseId)}`,
});
expect(response.statusCode).toBe(403);
expect(response.json()).toEqual({
error: "Journal notes are not enabled for this share link",
code: "NOT_ENABLED",
});
});
it("supports shared journal note read and save, but not implicit or explicit delete", async () => {
const start = buildLocalDoseStart();
await insertMedication({
id: 8,
userId,
takenBy: ["Max"],
start,
});
await _insertShareToken(userId, "token-with-notes", "Max", true);
const doseId = `8-0-${new Date(start).getTime()}-Max`;
await insertDose({ userId, doseId, markedBy: "Max" });
const initialResponse = await app.inject({
method: "GET",
url: `/share/token-with-notes/journal/event/${encodeURIComponent(doseId)}`,
});
expect(initialResponse.statusCode).toBe(200);
expect(initialResponse.json().entry).toEqual(
expect.objectContaining({
doseId,
markedBy: "Max",
note: null,
})
);
const initialDosesResponse = await app.inject({
method: "GET",
url: "/share/token-with-notes/doses",
});
expect(initialDosesResponse.statusCode).toBe(200);
expect(initialDosesResponse.json().doses).toEqual([
expect.objectContaining({
doseId,
hasJournalNote: false,
}),
]);
const saveResponse = await app.inject({
method: "PUT",
url: `/share/token-with-notes/journal/event/${encodeURIComponent(doseId)}`,
payload: { note: "Shared note from Max" },
});
expect(saveResponse.statusCode).toBe(200);
expect(saveResponse.json().entry).toEqual(
expect.objectContaining({
doseId,
note: "Shared note from Max",
})
);
const savedDosesResponse = await app.inject({
method: "GET",
url: "/share/token-with-notes/doses",
});
expect(savedDosesResponse.statusCode).toBe(200);
expect(savedDosesResponse.json().doses).toEqual([
expect.objectContaining({
doseId,
hasJournalNote: true,
}),
]);
const blankSaveResponse = await app.inject({
method: "PUT",
url: `/share/token-with-notes/journal/event/${encodeURIComponent(doseId)}`,
payload: { note: " " },
});
expect(blankSaveResponse.statusCode).toBe(400);
expect(blankSaveResponse.json()).toEqual({
error: "Journal note cannot be empty",
code: "EMPTY_NOTE",
});
const deleteResponse = await app.inject({
method: "DELETE",
url: `/share/token-with-notes/journal/event/${encodeURIComponent(doseId)}`,
});
expect(deleteResponse.statusCode).toBe(403);
expect(deleteResponse.json()).toEqual({
error: "Shared links cannot delete journal notes",
code: "DELETE_NOT_ALLOWED",
});
const journalRows = await testClient.execute({
sql: "SELECT note FROM intake_journal WHERE user_id = ? AND medication_id = ?",
args: [userId, 8],
});
expect(journalRows.rows).toHaveLength(1);
expect(journalRows.rows[0].note).toBe("Shared note from Max");
});
});
});
+199 -14
View File
@@ -3,6 +3,7 @@
* These tests import the actual route handlers for real coverage.
*/
import { existsSync, unlinkSync } from "node:fs";
import cookie from "@fastify/cookie";
import fastifyMultipart from "@fastify/multipart";
import sensible from "@fastify/sensible";
@@ -13,13 +14,16 @@ import { jwtPlugin } from "../plugins/jwt.js";
import { documentationSchemaAjv } from "../utils/documentation-schema-keywords.js";
// Use vi.hoisted to create the db BEFORE mocks are set up
const { testClient, testDb } = vi.hoisted(() => {
const { testClient, testDb, testDbPath } = vi.hoisted(() => {
// Dynamic import inside hoisted block
const { createClient } = require("@libsql/client");
const { drizzle } = require("drizzle-orm/libsql");
const client = createClient({ url: ":memory:" });
const { tmpdir } = require("node:os");
const { join } = require("node:path");
const dbPath = join(tmpdir(), `medassist-e2e-routes-${process.pid}-${Date.now()}.db`);
const client = createClient({ url: `file:${dbPath}` });
const db = drizzle(client);
return { testClient: client, testDb: db };
return { testClient: client, testDb: db, testDbPath: dbPath };
});
// Mock modules using the hoisted db
@@ -171,6 +175,7 @@ async function createSchema(client: Client) {
token text NOT NULL UNIQUE,
taken_by text NOT NULL,
schedule_days integer NOT NULL DEFAULT 30,
allow_journal_notes integer NOT NULL DEFAULT 0,
created_at integer NOT NULL DEFAULT (strftime('%s','now')),
expires_at integer,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
@@ -184,6 +189,19 @@ async function createSchema(client: Client) {
taken_source text NOT NULL DEFAULT 'manual',
dismissed integer NOT NULL DEFAULT 0,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)`,
`CREATE TABLE IF NOT EXISTS intake_journal (
id integer PRIMARY KEY AUTOINCREMENT,
user_id integer NOT NULL,
dose_tracking_id integer NOT NULL UNIQUE,
medication_id integer NOT NULL,
scheduled_for integer NOT NULL,
note text NOT NULL,
created_at integer NOT NULL DEFAULT (strftime('%s','now')),
updated_at integer NOT NULL DEFAULT (strftime('%s','now')),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (dose_tracking_id) REFERENCES dose_tracking(id) ON DELETE CASCADE,
FOREIGN KEY (medication_id) REFERENCES medications(id) ON DELETE CASCADE
)`,
`CREATE TABLE IF NOT EXISTS refill_history (
id integer PRIMARY KEY AUTOINCREMENT,
@@ -204,6 +222,7 @@ async function createSchema(client: Client) {
}
async function clearData(client: Client) {
await client.execute("DELETE FROM intake_journal");
await client.execute("DELETE FROM refill_history");
await client.execute("DELETE FROM dose_tracking");
await client.execute("DELETE FROM share_tokens");
@@ -222,10 +241,11 @@ async function _createUser(client: Client, username: string): Promise<number> {
}
async function createMedication(client: Client, userId: number, name: string, takenBy: string[]): Promise<number> {
const start = new Date(visibleDoseTimestampMs()).toISOString();
const result = await client.execute({
sql: `INSERT INTO medications (user_id, name, taken_by_json, usage_json, every_json, start_json)
VALUES (?, ?, ?, '[1]', '[1]', '["2025-01-01T08:00:00.000Z"]') RETURNING id`,
args: [userId, name, JSON.stringify(takenBy)],
VALUES (?, ?, ?, '[1]', '[1]', ?) RETURNING id`,
args: [userId, name, JSON.stringify(takenBy), JSON.stringify([start])],
});
return result.rows[0].id as number;
}
@@ -237,6 +257,12 @@ async function createShareToken(client: Client, userId: number, takenBy: string,
});
}
function visibleDoseTimestampMs(): number {
const doseDate = new Date();
doseDate.setHours(8, 0, 0, 0);
return doseDate.getTime();
}
// =============================================================================
// E2E Tests with Real Routes
// =============================================================================
@@ -386,6 +412,11 @@ describe("E2E Tests with Real Routes", () => {
afterAll(async () => {
await app.close();
testClient.close();
for (const path of [testDbPath, `${testDbPath}-shm`, `${testDbPath}-wal`]) {
if (existsSync(path)) {
unlinkSync(path);
}
}
});
beforeEach(async () => {
@@ -508,12 +539,12 @@ describe("E2E Tests with Real Routes", () => {
});
it("should mark dose via share link using real route", async () => {
await createMedication(testClient, userId, "Aspirin", ["Daniel"]);
const medId = await createMedication(testClient, userId, "Aspirin", ["Daniel"]);
const token = "test_share_token_456";
await createShareToken(testClient, userId, "Daniel", token);
const doseId = "1-0-1735344000000";
const doseId = `${medId}-0-${visibleDoseTimestampMs()}`;
const response = await app.inject({
method: "POST",
url: `/share/${token}/doses`,
@@ -1039,13 +1070,13 @@ describe("E2E Tests with Real Routes", () => {
});
it("should unmark dose via share link", async () => {
await createMedication(testClient, userId, "Aspirin", ["Daniel"]);
const medId = await createMedication(testClient, userId, "Aspirin", ["Daniel"]);
const token = "test_delete_dose_token";
await createShareToken(testClient, userId, "Daniel", token);
// First mark the dose
const doseId = "1-0-1735344000000";
const doseId = `${medId}-0-${visibleDoseTimestampMs()}`;
await testClient.execute({
sql: `INSERT INTO dose_tracking (user_id, dose_id, marked_by) VALUES (?, ?, ?)`,
args: [userId, doseId, "Daniel"],
@@ -1089,12 +1120,12 @@ describe("E2E Tests with Real Routes", () => {
});
it("should return already marked message for duplicate dose", async () => {
await createMedication(testClient, userId, "Aspirin", ["Daniel"]);
const medId = await createMedication(testClient, userId, "Aspirin", ["Daniel"]);
const token = "test_duplicate_token";
await createShareToken(testClient, userId, "Daniel", token);
const doseId = "1-0-1735344000000";
const doseId = `${medId}-0-${visibleDoseTimestampMs()}`;
// Mark the dose first time
await app.inject({
@@ -1530,6 +1561,59 @@ describe("E2E Tests with Real Routes", () => {
// ---------------------------------------------------------------------------
describe("Share token management", () => {
it("should list active share links for the owner", async () => {
await createMedication(testClient, userId, "Med1", ["Daniel"]);
const createResponse = await app.inject({
method: "POST",
url: "/share",
payload: {
takenBy: "Daniel",
scheduleDays: 90,
},
});
expect(createResponse.statusCode).toBe(200);
const listResponse = await app.inject({
method: "GET",
url: "/share",
});
expect(listResponse.statusCode).toBe(200);
const data = listResponse.json();
expect(data.shareLinks).toHaveLength(1);
expect(data.shareLinks[0].takenBy).toBe("Daniel");
});
it("should revoke an active share link", async () => {
await createMedication(testClient, userId, "Med1", ["Daniel"]);
const createResponse = await app.inject({
method: "POST",
url: "/share",
payload: {
takenBy: "Daniel",
scheduleDays: 30,
},
});
const { token } = createResponse.json();
const revokeResponse = await app.inject({
method: "DELETE",
url: `/share/${token}`,
});
expect(revokeResponse.statusCode).toBe(204);
const publicResponse = await app.inject({
method: "GET",
url: `/share/${token}`,
});
expect(publicResponse.statusCode).toBe(404);
});
it("should create share token with custom scheduleDays", async () => {
await createMedication(testClient, userId, "Med1", ["Daniel"]);
@@ -1548,6 +1632,34 @@ describe("E2E Tests with Real Routes", () => {
expect(data.expiresAt).toBeDefined();
});
it("should create a share token with an expiry and keep it in the active owner list", async () => {
await createMedication(testClient, userId, "Med1", ["Daniel"]);
const createResponse = await app.inject({
method: "POST",
url: "/share",
payload: {
takenBy: "Daniel",
scheduleDays: 30,
expiryDays: 7,
},
});
expect(createResponse.statusCode).toBe(200);
const created = createResponse.json();
expect(created.expiresAt).toBeTruthy();
const listResponse = await app.inject({
method: "GET",
url: "/share",
});
expect(listResponse.statusCode).toBe(200);
const listData = listResponse.json();
expect(listData.shareLinks).toHaveLength(1);
expect(listData.shareLinks[0].expiresAt).toBeTruthy();
});
it("should return validation error for invalid scheduleDays", async () => {
await createMedication(testClient, userId, "Med1", ["Daniel"]);
@@ -1685,14 +1797,15 @@ describe("E2E Tests with Real Routes", () => {
describe("Share token dose routes", () => {
it("should get taken doses via share link", async () => {
await createMedication(testClient, userId, "Aspirin", ["Daniel"]);
const medId = await createMedication(testClient, userId, "Aspirin", ["Daniel"]);
const token = "get-doses-token";
await createShareToken(testClient, userId, "Daniel", token);
// Insert a dose directly
const doseId = `${medId}-0-${visibleDoseTimestampMs()}`;
await testClient.execute({
sql: `INSERT INTO dose_tracking (user_id, dose_id, marked_by) VALUES (?, ?, ?)`,
args: [userId, "1-0-1735344000000", "Daniel"],
args: [userId, doseId, "Daniel"],
});
const response = await app.inject({
@@ -1703,7 +1816,7 @@ describe("E2E Tests with Real Routes", () => {
expect(response.statusCode).toBe(200);
const data = response.json();
expect(data.doses).toHaveLength(1);
expect(data.doses[0].doseId).toBe("1-0-1735344000000");
expect(data.doses[0].doseId).toBe(doseId);
expect(data.doses[0].markedBy).toBe("Daniel");
});
@@ -3000,6 +3113,78 @@ describe("E2E Tests with Real Routes", () => {
});
describe("Real /import routes", () => {
it("should preview import data without mutating existing user data", async () => {
await app.inject({
method: "POST",
url: "/medications",
payload: {
name: "Existing Med",
packCount: 2,
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
},
});
const previewPayload = {
version: "1.6",
exportedAt: new Date().toISOString(),
includeSensitiveData: true,
medications: [
{
_exportId: "med-1",
name: "Imported Med",
inventory: { packCount: 1, blistersPerPack: 2, pillsPerBlister: 14, looseTablets: 0 },
schedules: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
},
],
settings: { language: "en", stockCalculationMode: "automatic" },
shareLinks: [{ takenBy: "Person A", scheduleDays: 14 }],
doseHistory: [
{
medicationRef: "med-1",
scheduleIndex: 0,
scheduledTime: "2025-01-01T08:00:00.000Z",
takenAt: "2025-01-01T08:03:00.000Z",
journalNote: "after breakfast",
},
],
};
const previewResponse = await app.inject({
method: "POST",
url: "/import/preview",
payload: previewPayload,
});
expect(previewResponse.statusCode).toBe(200);
expect(previewResponse.json()).toMatchObject({
success: true,
preview: {
version: "1.6",
includeSensitiveData: true,
incoming: {
medications: 1,
doseHistory: 1,
shareLinks: 1,
journalEntries: 1,
hasSettings: true,
},
current: {
medications: 1,
hasSettings: false,
},
warnings: {
replacesExistingData: true,
regeneratesShareLinks: true,
containsSensitiveData: true,
},
},
});
const medsResponse = await app.inject({ method: "GET", url: "/medications" });
expect(medsResponse.json()).toHaveLength(1);
expect(medsResponse.json()[0].name).toBe("Existing Med");
});
it("should import medications from export format", async () => {
const importData = {
version: "1.0",
@@ -0,0 +1,453 @@
import { existsSync, unlinkSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import cookie from "@fastify/cookie";
import sensible from "@fastify/sensible";
import { migrate } from "drizzle-orm/libsql/migrator";
import Fastify, { type FastifyInstance } from "fastify";
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { runAlterMigrations } from "../db/db-utils.js";
import { jwtPlugin } from "../plugins/jwt.js";
import { documentationSchemaAjv } from "../utils/documentation-schema-keywords.js";
const { testClient, testDb, testDbPath, mockedEnv } = vi.hoisted(() => {
const { createClient } = require("@libsql/client");
const { drizzle } = require("drizzle-orm/libsql");
const { tmpdir } = require("node:os");
const { join } = require("node:path");
const dbPath = join(tmpdir(), `medassist-intake-journal-routes-${process.pid}-${Date.now()}.db`);
const client = createClient({ url: `file:${dbPath}` });
const db = drizzle(client);
return {
testClient: client,
testDb: db,
testDbPath: dbPath,
mockedEnv: {
AUTH_ENABLED: true,
REGISTRATION_ENABLED: true,
FORM_LOGIN_ENABLED: true,
OIDC_ENABLED: false,
OIDC_PROVIDER_NAME: "SSO",
NODE_ENV: "test",
LOG_LEVEL: "silent",
PORT: 3000,
CORS_ORIGINS: "*",
JWT_SECRET: "test-jwt-secret",
REFRESH_SECRET: "test-refresh-secret",
COOKIE_SECRET: "test-cookie-secret",
ACCESS_TOKEN_TTL_MINUTES: 15,
REFRESH_TOKEN_TTL_DAYS: 7,
OPENAPI_DOCS_ENABLED: false,
PUBLIC_APP_URL: "https://app.example.com",
},
};
});
vi.mock("../db/client.js", () => ({
db: testDb,
migrationsReady: Promise.resolve(),
}));
vi.mock("../plugins/env.js", () => ({ env: mockedEnv }));
const { exportRoutes } = await import("../routes/export.js");
const { intakeJournalRoutes } = await import("../routes/intake-journal.js");
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const migrationsFolder = resolve(__dirname, "../../drizzle");
async function clearTables() {
await testClient.execute("DELETE FROM intake_journal");
await testClient.execute("DELETE FROM refill_history");
await testClient.execute("DELETE FROM dose_tracking");
await testClient.execute("DELETE FROM share_tokens");
await testClient.execute("DELETE FROM user_settings");
await testClient.execute("DELETE FROM medications");
await testClient.execute("DELETE FROM api_keys");
await testClient.execute("DELETE FROM refresh_tokens");
await testClient.execute("DELETE FROM users");
}
async function createUser(username: string) {
const result = await testClient.execute({
sql: "INSERT INTO users (username, auth_provider, is_active) VALUES (?, 'local', 1) RETURNING id",
args: [username],
});
return Number(result.rows[0].id);
}
async function buildSessionCookie(app: FastifyInstance, userId: number, username: string) {
const token = await app.jwt.sign({ sub: userId, username });
return `access_token=${token}`;
}
async function seedMedication(options: { userId: number; name: string; start?: string; takenBy?: string[] }) {
const start = options.start ?? "2026-02-01T08:00:00.000Z";
const takenBy = options.takenBy ?? ["Daniel"];
const result = await testClient.execute({
sql: `INSERT INTO medications (
user_id, name, generic_name, taken_by_json, medication_form, package_type,
pack_count, blisters_per_pack, pills_per_blister, loose_tablets,
usage_json, every_json, start_json, intakes_json,
stock_adjustment, intake_reminders_enabled
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id`,
args: [
options.userId,
options.name,
`${options.name} Generic`,
JSON.stringify(takenBy),
"tablet",
"blister",
1,
1,
10,
0,
JSON.stringify([1]),
JSON.stringify([1]),
JSON.stringify([start]),
JSON.stringify([
{
usage: 1,
every: 1,
start,
takenBy: takenBy[0] ?? null,
intakeRemindersEnabled: true,
},
]),
0,
1,
],
});
return Number(result.rows[0].id);
}
async function seedTrackedDose(options: {
userId: number;
doseId: string;
takenAt: Date;
markedBy?: string | null;
dismissed?: boolean;
}) {
const result = await testClient.execute({
sql: `INSERT INTO dose_tracking (user_id, dose_id, taken_at, marked_by, taken_source, dismissed)
VALUES (?, ?, ?, ?, ?, ?) RETURNING id`,
args: [
options.userId,
options.doseId,
Math.floor(options.takenAt.getTime() / 1000),
options.markedBy ?? null,
"manual",
options.dismissed ? 1 : 0,
],
});
return Number(result.rows[0].id);
}
describe("Intake journal routes", () => {
let app: FastifyInstance;
beforeAll(async () => {
await migrate(testDb, { migrationsFolder });
await runAlterMigrations(testClient);
app = Fastify({ logger: false, ajv: documentationSchemaAjv });
await app.register(sensible);
await app.register(cookie, { secret: "test-cookie-secret" });
await app.register(jwtPlugin, {
secret: "test-jwt-secret",
cookie: { cookieName: "access_token", signed: false },
});
await app.register(intakeJournalRoutes);
await app.register(exportRoutes);
await app.ready();
});
afterAll(async () => {
await app.close();
testClient.close();
for (const path of [testDbPath, `${testDbPath}-shm`, `${testDbPath}-wal`]) {
if (existsSync(path)) {
unlinkSync(path);
}
}
});
beforeEach(async () => {
vi.clearAllMocks();
await clearTables();
});
it("keeps journal CRUD/history owner-scoped across route access", async () => {
const ownerId = await createUser("journal-owner");
const otherId = await createUser("journal-other");
const ownerCookie = await buildSessionCookie(app, ownerId, "journal-owner");
const otherCookie = await buildSessionCookie(app, otherId, "journal-other");
const ownerStart = "2026-02-01T08:00:00.000Z";
const otherStart = "2026-02-02T09:00:00.000Z";
const ownerMedicationId = await seedMedication({ userId: ownerId, name: "Owner Med", start: ownerStart });
const otherMedicationId = await seedMedication({ userId: otherId, name: "Other Med", start: otherStart });
const ownerDoseId = `${ownerMedicationId}-0-${new Date(ownerStart).getTime()}-Daniel`;
const otherDoseId = `${otherMedicationId}-0-${new Date(otherStart).getTime()}-Maria`;
await seedTrackedDose({
userId: ownerId,
doseId: ownerDoseId,
takenAt: new Date("2026-02-01T08:05:00.000Z"),
markedBy: "Daniel",
});
await seedTrackedDose({
userId: otherId,
doseId: otherDoseId,
takenAt: new Date("2026-02-02T09:05:00.000Z"),
markedBy: "Maria",
});
const ownerPutResponse = await app.inject({
method: "PUT",
url: `/intake-journal/event/${encodeURIComponent(ownerDoseId)}`,
headers: { cookie: ownerCookie },
payload: { note: "Took after breakfast." },
});
expect(ownerPutResponse.statusCode).toBe(200);
expect(ownerPutResponse.json().entry).toEqual(
expect.objectContaining({
doseId: ownerDoseId,
medicationId: ownerMedicationId,
scheduledFor: expect.stringContaining("T08:00:00"),
note: "Took after breakfast.",
})
);
const otherPutResponse = await app.inject({
method: "PUT",
url: `/intake-journal/event/${encodeURIComponent(otherDoseId)}`,
headers: { cookie: otherCookie },
payload: { note: "Different owner note." },
});
expect(otherPutResponse.statusCode).toBe(200);
const ownerHistoryResponse = await app.inject({
method: "GET",
url: `/intake-journal?medicationId=${ownerMedicationId}&limit=25`,
headers: { cookie: ownerCookie },
});
expect(ownerHistoryResponse.statusCode).toBe(200);
expect(ownerHistoryResponse.json().entries).toEqual([
expect.objectContaining({
doseId: ownerDoseId,
medicationId: ownerMedicationId,
note: "Took after breakfast.",
markedBy: "Daniel",
}),
]);
const otherEventResponse = await app.inject({
method: "GET",
url: `/intake-journal/event/${encodeURIComponent(otherDoseId)}`,
headers: { cookie: ownerCookie },
});
expect(otherEventResponse.statusCode).toBe(404);
expect(otherEventResponse.json()).toMatchObject({ code: "DOSE_NOT_FOUND" });
const deleteResponse = await app.inject({
method: "DELETE",
url: `/intake-journal/event/${encodeURIComponent(ownerDoseId)}`,
headers: { cookie: ownerCookie },
});
expect(deleteResponse.statusCode).toBe(200);
expect(deleteResponse.json()).toEqual({ success: true });
const emptyHistoryResponse = await app.inject({
method: "GET",
url: "/intake-journal",
headers: { cookie: ownerCookie },
});
expect(emptyHistoryResponse.statusCode).toBe(200);
expect(emptyHistoryResponse.json().entries).toEqual([]);
});
it("preserves journal metadata through authenticated export and import", async () => {
const userId = await createUser("journal-roundtrip");
const sessionCookie = await buildSessionCookie(app, userId, "journal-roundtrip");
const start = "2026-02-03T07:30:00.000Z";
const medicationId = await seedMedication({ userId, name: "Roundtrip Journal Med", start });
const doseId = `${medicationId}-0-${new Date(start).getTime()}-Daniel`;
const doseTrackingId = await seedTrackedDose({
userId,
doseId,
takenAt: new Date("2026-02-03T07:33:00.000Z"),
markedBy: "Daniel",
});
const createdAt = new Date("2026-02-03T07:40:00.000Z");
const updatedAt = new Date("2026-02-03T07:50:00.000Z");
await testClient.execute({
sql: `INSERT INTO intake_journal (
user_id, dose_tracking_id, medication_id, scheduled_for, note, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?)`,
args: [
userId,
doseTrackingId,
medicationId,
Math.floor(new Date(start).getTime() / 1000),
"Roundtrip journal note",
Math.floor(createdAt.getTime() / 1000),
Math.floor(updatedAt.getTime() / 1000),
],
});
const exportResponse = await app.inject({
method: "GET",
url: "/export",
headers: { cookie: sessionCookie },
});
expect(exportResponse.statusCode).toBe(200);
const exportBody = exportResponse.json();
expect(exportBody.doseHistory).toHaveLength(1);
expect(exportBody.doseHistory[0]).toEqual(
expect.objectContaining({
journalNote: "Roundtrip journal note",
journalCreatedAt: createdAt.toISOString(),
journalUpdatedAt: updatedAt.toISOString(),
})
);
const importResponse = await app.inject({
method: "POST",
url: "/import",
headers: { cookie: sessionCookie },
payload: exportBody,
});
expect(importResponse.statusCode).toBe(200);
const reExportResponse = await app.inject({
method: "GET",
url: "/export",
headers: { cookie: sessionCookie },
});
expect(reExportResponse.statusCode).toBe(200);
expect(reExportResponse.json().doseHistory).toEqual([
expect.objectContaining({
journalNote: "Roundtrip journal note",
journalCreatedAt: createdAt.toISOString(),
journalUpdatedAt: updatedAt.toISOString(),
}),
]);
const restoredJournalRows = await testClient.execute({
sql: "SELECT note FROM intake_journal WHERE user_id = ?",
args: [userId],
});
expect(restoredJournalRows.rows).toHaveLength(1);
expect(restoredJournalRows.rows[0].note).toBe("Roundtrip journal note");
});
it("preserves the shared journal-note permission through authenticated export and import", async () => {
const userId = await createUser("share-journal-roundtrip");
const sessionCookie = await buildSessionCookie(app, userId, "share-journal-roundtrip");
await testClient.execute({
sql: `INSERT INTO share_tokens (user_id, token, taken_by, schedule_days, allow_journal_notes, expires_at)
VALUES (?, ?, ?, ?, ?, ?)`,
args: [userId, "share-journal-token", "Daniel", 14, 1, null],
});
const exportResponse = await app.inject({
method: "GET",
url: "/export",
headers: { cookie: sessionCookie },
});
expect(exportResponse.statusCode).toBe(200);
const exportBody = exportResponse.json();
expect(exportBody.shareLinks).toEqual([
expect.objectContaining({
takenBy: "Daniel",
scheduleDays: 14,
allowJournalNotes: true,
regenerateToken: true,
}),
]);
const importResponse = await app.inject({
method: "POST",
url: "/import",
headers: { cookie: sessionCookie },
payload: exportBody,
});
expect(importResponse.statusCode).toBe(200);
const shareRows = await testClient.execute({
sql: "SELECT token, taken_by, schedule_days, allow_journal_notes FROM share_tokens WHERE user_id = ?",
args: [userId],
});
expect(shareRows.rows).toHaveLength(1);
expect(shareRows.rows[0]).toEqual(
expect.objectContaining({
taken_by: "Daniel",
schedule_days: 14,
allow_journal_notes: 1,
})
);
expect(shareRows.rows[0].token).not.toBe("share-journal-token");
});
it("keeps existing data when import fails inside the replacement transaction", async () => {
const userId = await createUser("import-rollback");
const sessionCookie = await buildSessionCookie(app, userId, "import-rollback");
await seedMedication({ userId, name: "Existing Rollback Med" });
const importResponse = await app.inject({
method: "POST",
url: "/import",
headers: { cookie: sessionCookie },
payload: {
version: "1.6",
exportedAt: new Date().toISOString(),
medications: [
{
_exportId: "med-1",
name: "Imported Rollback Med",
inventory: { packCount: 1, blistersPerPack: 1, pillsPerBlister: 10, looseTablets: 0 },
schedules: [{ usage: 1, every: 1, start: "2026-02-04T08:00:00.000Z" }],
},
],
doseHistory: [
{
medicationRef: "med-1",
scheduleIndex: 0,
scheduledTime: "2026-02-04T08:00:00.000Z",
takenAt: "not-a-date",
},
],
},
});
expect(importResponse.statusCode).toBe(500);
const medicationRows = await testClient.execute({
sql: "SELECT name FROM medications WHERE user_id = ? ORDER BY name",
args: [userId],
});
expect(medicationRows.rows).toEqual([expect.objectContaining({ name: "Existing Rollback Med" })]);
});
});
+30 -10
View File
@@ -165,6 +165,7 @@ async function createSchema(client: Client) {
token text NOT NULL UNIQUE,
taken_by text NOT NULL,
schedule_days integer NOT NULL DEFAULT 30,
allow_journal_notes integer NOT NULL DEFAULT 0,
created_at integer NOT NULL DEFAULT (strftime('%s','now')),
expires_at integer,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
@@ -195,6 +196,16 @@ async function clearData(client: Client) {
await client.execute("DELETE FROM sqlite_sequence");
}
function visibleDoseTimestampMs(): number {
const doseDate = new Date();
doseDate.setHours(8, 0, 0, 0);
return doseDate.getTime();
}
function visibleDoseStartIso(): string {
return new Date(visibleDoseTimestampMs()).toISOString();
}
// =============================================================================
// Tests
// =============================================================================
@@ -259,9 +270,11 @@ describe("Integration Tests", () => {
packCount: 1,
blistersPerPack: 1,
pillsPerBlister: 10,
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
looseTablets: 10,
blisters: [{ usage: 1, every: 1, start: visibleDoseStartIso() }],
},
});
expect(createRes.statusCode, createRes.body).toBe(200);
const medId = createRes.json().id;
// Mark some doses (Jan 1, Jan 2, Jan 5, Jan 10)
@@ -617,9 +630,10 @@ describe("Integration Tests", () => {
packCount: 1,
blistersPerPack: 1,
pillsPerBlister: 10,
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
blisters: [{ usage: 1, every: 1, start: visibleDoseStartIso() }],
},
});
expect(createRes.statusCode, createRes.body).toBe(200);
const medId = createRes.json().id;
// Create share token for Daniel
@@ -628,15 +642,17 @@ describe("Integration Tests", () => {
url: "/share",
payload: { takenBy: "Daniel", scheduleDays: 30 },
});
expect(shareRes.statusCode, shareRes.body).toBe(200);
const token = shareRes.json().token;
// Mark dose via share link
const doseId = `${medId}-0-${new Date("2025-01-01T08:00:00.000Z").getTime()}`;
await app.inject({
const doseId = `${medId}-0-${visibleDoseTimestampMs()}`;
const markRes = await app.inject({
method: "POST",
url: `/share/${token}/doses`,
payload: { doseId },
});
expect(markRes.statusCode, markRes.body).toBe(200);
// Verify markedBy is "Daniel"
const result = await testClient.execute({
@@ -667,9 +683,10 @@ describe("Integration Tests", () => {
payload: {
name: "Vitamin D",
takenBy: ["Anna"],
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
blisters: [{ usage: 1, every: 1, start: visibleDoseStartIso() }],
},
});
expect(createRes.statusCode, createRes.body).toBe(200);
const medId = createRes.json().id;
// Create share token
@@ -678,21 +695,24 @@ describe("Integration Tests", () => {
url: "/share",
payload: { takenBy: "Anna", scheduleDays: 30 },
});
expect(shareRes.statusCode, shareRes.body).toBe(200);
const token = shareRes.json().token;
// Mark a dose
const doseId = `${medId}-0-${new Date("2025-01-05T08:00:00.000Z").getTime()}`;
await app.inject({
const doseId = `${medId}-0-${visibleDoseTimestampMs()}`;
const markRes = await app.inject({
method: "POST",
url: `/share/${token}/doses`,
payload: { doseId },
});
expect(markRes.statusCode, markRes.body).toBe(200);
// Get shared schedule
const scheduleRes = await app.inject({
method: "GET",
url: `/share/${token}`,
});
expect(scheduleRes.statusCode, scheduleRes.body).toBe(200);
const data = scheduleRes.json();
expect(data.takenBy).toBe("Anna");
@@ -781,7 +801,7 @@ describe("Integration Tests", () => {
payload: {
name: "Family Vitamins",
takenBy: ["Daniel", "Anna", "Max"],
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
blisters: [{ usage: 1, every: 1, start: visibleDoseStartIso() }],
},
});
@@ -799,8 +819,8 @@ describe("Integration Tests", () => {
});
// Both should succeed with different tokens
expect(danielShare.statusCode).toBe(200);
expect(annaShare.statusCode).toBe(200);
expect(danielShare.statusCode, danielShare.body).toBe(200);
expect(annaShare.statusCode, annaShare.body).toBe(200);
expect(danielShare.json().token).not.toBe(annaShare.json().token);
// Each share link should show correct person
+71
View File
@@ -248,6 +248,32 @@ describe("Planner Routes", () => {
expect(response.json()).toEqual({ error: "Missing planner data" });
});
it("should reject request when no planner date range can be resolved", async () => {
const response = await app.inject({
method: "POST",
url: "/planner/send-email",
payload: {
email: "test@example.com",
rows: [
{
medicationId: 1,
medicationName: "Aspirin",
totalPills: 30,
plannerUsage: 10,
blisterSize: 10,
blistersNeeded: 1,
fullBlisters: 3,
loosePills: 0,
enough: true,
},
],
},
});
expect(response.statusCode).toBe(400);
expect(response.json()).toEqual({ error: "Missing planner date range" });
});
it("should return error when no notification channels configured", async () => {
// User settings exist but email/shoutrrr disabled
await testClient.execute({
@@ -282,6 +308,51 @@ describe("Planner Routes", () => {
expect(response.json()).toEqual({ error: "No notification channels configured" });
});
it("should accept startDate and endDate aliases for planner range", async () => {
process.env.SMTP_HOST = "smtp.test.com";
process.env.SMTP_USER = "user@test.com";
process.env.SMTP_PASS = "password";
await testClient.execute({
sql: `INSERT INTO user_settings (user_id, email_enabled, shoutrrr_enabled, language) VALUES (?, 1, 0, 'en')`,
args: [999999999],
});
mockSendMail.mockResolvedValueOnce({ messageId: "123", accepted: ["test.com"], rejected: [] });
const response = await app.inject({
method: "POST",
url: "/planner/send-email",
payload: {
email: "test@example.com",
startDate: "2025-01-01T00:00:00.000Z",
endDate: "2025-01-31T00:00:00.000Z",
language: "en",
rows: [
{
medicationId: 1,
medicationName: "Aspirin",
totalPills: 30,
plannerUsage: 10,
blisterSize: 10,
blistersNeeded: 1,
fullBlisters: 3,
loosePills: 0,
enough: true,
},
],
},
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ success: true, message: "Notification sent via email" });
expect(mockSendMail).toHaveBeenCalledTimes(1);
delete process.env.SMTP_HOST;
delete process.env.SMTP_USER;
delete process.env.SMTP_PASS;
});
it("should send email successfully when SMTP is configured", async () => {
// Set SMTP env vars
process.env.SMTP_HOST = "smtp.test.com";
+20
View File
@@ -0,0 +1,20 @@
import { describe, expect, it } from "vitest";
import { redactTokenForLog } from "../utils/redaction.js";
describe("redactTokenForLog", () => {
it("returns a stable short hash reference without exposing the raw token", () => {
const rawToken = "share-token-secret-value";
const tokenRef = redactTokenForLog(rawToken);
expect(tokenRef).toMatch(/^sha256:[a-f0-9]{12}$/);
expect(tokenRef).toBe(redactTokenForLog(rawToken));
expect(tokenRef).not.toContain(rawToken);
});
it("normalizes empty tokens to a non-sensitive placeholder", () => {
expect(redactTokenForLog("")).toBe("missing");
expect(redactTokenForLog(" ")).toBe("missing");
expect(redactTokenForLog(null)).toBe("missing");
expect(redactTokenForLog(undefined)).toBe("missing");
});
});
+71 -3
View File
@@ -1,3 +1,4 @@
import { existsSync, unlinkSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { migrate } from "drizzle-orm/libsql/migrator";
@@ -6,10 +7,13 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vites
import { runAlterMigrations } from "../db/db-utils.js";
import { documentationSchemaAjv } from "../utils/documentation-schema-keywords.js";
const { testClient, testDb, mockedEnv, nodemailerSendMail, fetchMock } = vi.hoisted(() => {
const { testClient, testDb, testDbPath, mockedEnv, nodemailerSendMail, fetchMock } = vi.hoisted(() => {
const { createClient } = require("@libsql/client");
const { drizzle } = require("drizzle-orm/libsql");
const client = createClient({ url: ":memory:" });
const { tmpdir } = require("node:os");
const { join } = require("node:path");
const dbPath = join(tmpdir(), `medassist-routes-real-${process.pid}-${Date.now()}.db`);
const client = createClient({ url: `file:${dbPath}` });
const db = drizzle(client);
const env = {
AUTH_ENABLED: false,
@@ -22,6 +26,7 @@ const { testClient, testDb, mockedEnv, nodemailerSendMail, fetchMock } = vi.hois
return {
testClient: client,
testDb: db,
testDbPath: dbPath,
mockedEnv: env,
nodemailerSendMail: vi.fn(),
fetchMock: vi.fn(),
@@ -121,6 +126,9 @@ describe("Real route coverage: settings/export/report", () => {
afterAll(async () => {
await app.close();
testClient.close();
if (existsSync(testDbPath)) {
unlinkSync(testDbPath);
}
});
beforeEach(async () => {
@@ -647,7 +655,7 @@ describe("Real route coverage: settings/export/report", () => {
});
await testClient.execute({
sql: "INSERT INTO dose_tracking (user_id, dose_id, taken_at, dismissed) VALUES (?, ?, ?, ?)",
args: [1, `${medId}-0-1700000600000-Alice`, 1700000600, 1],
args: [1, `${medId}-0-1700000600000-alice`, 1700000600, 1],
});
await testClient.execute({
sql: "INSERT INTO dose_tracking (user_id, dose_id, taken_at, dismissed) VALUES (?, ?, ?, ?)",
@@ -665,6 +673,66 @@ describe("Real route coverage: settings/export/report", () => {
expect(body[medId].dosesSkipped).toBe(1);
});
it("POST /medications/report-data filters doses by scheduled doseId timestamp and refills by the same date window", async () => {
const medId = await seedMedication("Report Date Range Med");
const windowStart = "2026-01-10T00:00:00.000Z";
const windowEnd = "2026-01-20T00:00:00.000Z";
await testClient.execute({
sql: "INSERT INTO dose_tracking (user_id, dose_id, taken_at, dismissed) VALUES (?, ?, ?, ?)",
args: [
1,
`${medId}-0-${Date.parse("2026-01-05T09:00:00.000Z")}-Daniel`,
Math.floor(Date.parse("2026-01-12T09:00:00.000Z") / 1000),
0,
],
});
await testClient.execute({
sql: "INSERT INTO dose_tracking (user_id, dose_id, taken_at, dismissed) VALUES (?, ?, ?, ?)",
args: [
1,
`${medId}-0-${Date.parse("2026-01-15T09:00:00.000Z")}-Daniel`,
Math.floor(Date.parse("2026-01-25T09:00:00.000Z") / 1000),
0,
],
});
await testClient.execute({
sql: "INSERT INTO dose_tracking (user_id, dose_id, taken_at, dismissed) VALUES (?, ?, ?, ?)",
args: [
1,
`${medId}-0-${Date.parse("2026-01-18T09:00:00.000Z")}-Daniel`,
Math.floor(Date.parse("2026-01-18T09:30:00.000Z") / 1000),
1,
],
});
await testClient.execute({
sql: "INSERT INTO refill_history (medication_id, user_id, packs_added, loose_pills_added, used_prescription, refill_date) VALUES (?, ?, ?, ?, ?, ?)",
args: [medId, 1, 1, 0, 0, Math.floor(Date.parse("2026-01-12T08:00:00.000Z") / 1000)],
});
await testClient.execute({
sql: "INSERT INTO refill_history (medication_id, user_id, packs_added, loose_pills_added, used_prescription, refill_date) VALUES (?, ?, ?, ?, ?, ?)",
args: [medId, 1, 9, 0, 1, Math.floor(Date.parse("2026-01-22T08:00:00.000Z") / 1000)],
});
const response = await app.inject({
method: "POST",
url: "/medications/report-data",
payload: { medicationIds: [medId], startDate: windowStart, endDate: windowEnd },
});
expect(response.statusCode).toBe(200);
const body = response.json();
expect(body[medId]).toMatchObject({
dosesTaken: 1,
dosesSkipped: 1,
});
expect(body[medId].refills).toHaveLength(1);
expect(body[medId].refills[0]).toMatchObject({
packsAdded: 1,
usedPrescription: false,
});
});
it("GET /export includes medications, settings, doseHistory and refillHistory", async () => {
const medId = await seedMedication("Export Med");
await testClient.execute({
+12 -4
View File
@@ -177,18 +177,26 @@ export interface CreateShareTokenOptions {
token?: string;
scheduleDays?: number;
expiresAt?: number | null;
allowJournalNotes?: boolean;
}
/**
* Create a test share token and return the token string
*/
export async function createTestShareToken(client: Client, options: CreateShareTokenOptions): Promise<string> {
const { userId, takenBy, token = `test_token_${Date.now()}`, scheduleDays = 30, expiresAt = null } = options;
const {
userId,
takenBy,
token = `test_token_${Date.now()}`,
scheduleDays = 30,
expiresAt = null,
allowJournalNotes = false,
} = options;
await client.execute({
sql: `INSERT INTO share_tokens (user_id, token, taken_by, schedule_days, expires_at)
VALUES (?, ?, ?, ?, ?)`,
args: [userId, token, takenBy, scheduleDays, expiresAt],
sql: `INSERT INTO share_tokens (user_id, token, taken_by, schedule_days, expires_at, allow_journal_notes)
VALUES (?, ?, ?, ?, ?, ?)`,
args: [userId, token, takenBy, scheduleDays, expiresAt, allowJournalNotes ? 1 : 0],
});
return token;
+17
View File
@@ -0,0 +1,17 @@
function pad(value: number, size = 2): string {
return String(value).padStart(size, "0");
}
export function toLocalDateTimeOffsetString(value: Date): string {
const offsetMinutes = -value.getTimezoneOffset();
const sign = offsetMinutes >= 0 ? "+" : "-";
const absoluteOffsetMinutes = Math.abs(offsetMinutes);
const offsetHours = Math.floor(absoluteOffsetMinutes / 60);
const offsetRemainderMinutes = absoluteOffsetMinutes % 60;
return [
`${value.getFullYear()}-${pad(value.getMonth() + 1)}-${pad(value.getDate())}`,
`T${pad(value.getHours())}:${pad(value.getMinutes())}:${pad(value.getSeconds())}.${pad(value.getMilliseconds(), 3)}`,
`${sign}${pad(offsetHours)}:${pad(offsetRemainderMinutes)}`,
].join("");
}
+10
View File
@@ -0,0 +1,10 @@
import { createHash } from "node:crypto";
export function redactTokenForLog(token: string | null | undefined): string {
const normalizedToken = token?.trim();
if (!normalizedToken) {
return "missing";
}
return `sha256:${createHash("sha256").update(normalizedToken, "utf8").digest("hex").slice(0, 12)}`;
}
+2 -5
View File
@@ -7,11 +7,8 @@ export default defineConfig({
include: ["src/**/*.test.ts"],
setupFiles: ["src/test/setup.ts"],
// Run tests sequentially to avoid DB conflicts
poolOptions: {
threads: {
singleThread: true,
},
},
fileParallelism: false,
maxWorkers: 1,
// Timeout for longer integration tests
testTimeout: 10000,
coverage: {
+13 -3
View File
@@ -9,9 +9,9 @@ Configure MedAssist with environment variables in `.env`. Start from `.env.examp
| `PUID` | `1000` | User ID for container file permissions |
| `PGID` | `1000` | Group ID for container file permissions |
| `PORT` | `3000` | Backend API port |
| `CORS_ORIGINS` | `http://localhost:4174` | Allowed origins for CORS |
| `CORS_ORIGINS` | `http://localhost:4174` | Allowed origins for CORS in the Docker Compose quickstart; local Vite development commonly uses `http://localhost:5173` or `http://localhost:4173` |
| `TZ` | `Europe/Berlin` | Server default timezone for scheduled reminders |
| `PUBLIC_APP_URL` | — | Public base URL for notification action links. Must be reachable by phones, browsers, and notification providers; do not point this to `localhost` or an internal Docker hostname. |
| `PUBLIC_APP_URL` | — | Public base URL for notification action and share links. Strongly recommended for any deployment used from another device; do not point this to `localhost` or an internal Docker hostname. Local Vite development also allows this hostname automatically. |
| `LOG_LEVEL` | `info` | Log level: `debug`, `info`, `warn`, `error`, or `silent` |
| `RATE_LIMIT_MAX` | `100` | Maximum requests per minute per IP |
| `OPENAPI_DOCS_ENABLED` | `auto` | Explicitly enable or disable `/docs` and `/docs/json` |
@@ -22,6 +22,12 @@ API docs behavior:
- `OPENAPI_DOCS_ENABLED=true` enables `/docs` and `/docs/json`.
- `OPENAPI_DOCS_ENABLED=false` disables the docs only.
`CORS_ORIGINS` note:
- The `.env.example` file is optimized for the Docker Compose quickstart, where the frontend runs on `http://localhost:4174`.
- Local frontend development uses the Vite dev server instead, so the backend schema defaults cover `http://localhost:5173` and `http://localhost:4173`.
- If you use a custom hostname or reverse proxy, include that origin in `CORS_ORIGINS`.
## Authentication
| Variable | Default | Description |
@@ -102,13 +108,17 @@ API reference:
Reminder timing uses IANA timezones. `TZ` is the server default. Users can override it in Settings.
These values are runtime defaults. User-specific settings can override reminder behavior after first save.
## Push Notifications
Push notification setup, provider support, and URL examples are documented in [PUSH_NOTIFICATIONS.md](PUSH_NOTIFICATIONS.md).
Recommended provider: `ntfy`, especially for intake reminders with direct actions.
Notification action links use `PUBLIC_APP_URL` as their base URL. For self-hosted setups, this should normally be your externally reachable HTTPS address, for example `https://med.example.com`.
Notification action and share links should use `PUBLIC_APP_URL` as their reachable base URL. For self-hosted setups, this should normally be your externally reachable HTTPS address, for example `https://med.example.com`.
If `PUBLIC_APP_URL` is missing in a remote deployment, reminder links can still be generated from local origins that are unreachable from phones or external browsers.
## Default User Settings
+11 -2
View File
@@ -19,8 +19,17 @@ If the frontend dev server runs behind a reverse proxy or on a remote host, set
These development overrides are documented here intentionally and are not part of the standard operator-focused `.env.example` surface.
## API Proxy Contract
- Frontend browser code should call `/api/*`, not hardcoded backend hostnames.
- Vite rewrites `/api/*` to the backend target configured by `BACKEND_URL` or the built-in default for the current environment.
- Default backend target:
- local dev outside Docker: `http://localhost:3000`
- dev stack inside Docker: `http://backend-dev:3000`
- If your backend runs on a different host or service name, set `BACKEND_URL` explicitly before starting Vite.
- `BACKEND_URL`: backend target used by the Vite `/api` proxy; default `http://localhost:3000` outside Docker and `http://backend-dev:3000` in Docker
- `VITE_ALLOWED_HOSTS`: comma-separated hostnames allowed to connect to the dev server; default `localhost,127.0.0.1`
- `VITE_ALLOWED_HOSTS`: comma-separated hostnames allowed to connect to the dev server; default `localhost,127.0.0.1` plus the hostname from `PUBLIC_APP_URL` when configured
- `VITE_HMR_HOST`: public hostname for HMR websocket connections
- `VITE_HMR_PROTOCOL`: websocket protocol override (`ws` or `wss`)
- `VITE_HMR_CLIENT_PORT`: public websocket port exposed to the browser
@@ -43,4 +52,4 @@ npm run check
npm run build
```
Use the root-level commands for full-stack validation when a change spans backend and frontend. Keep using the package-local commands when you are validating only one slice.
Use the root-level commands for full-stack validation when a change spans backend and frontend. Keep using the package-local commands when you are validating only one slice.
+12 -2
View File
@@ -289,6 +289,7 @@ export interface TestShareToken {
token: string;
takenBy: string;
scheduleDays: number;
allowJournalNotes?: boolean;
expiresAt: string;
}
@@ -460,7 +461,11 @@ export async function deleteAllMedicationsViaAPI(): Promise<void> {
* Create a share token via the backend API.
* Requires a medication with takenBy to exist first.
*/
export async function createShareTokenViaAPI(takenBy: string, scheduleDays = 30): Promise<TestShareToken> {
export async function createShareTokenViaAPI(
takenBy: string,
scheduleDays = 30,
options: { allowJournalNotes?: boolean; expiryDays?: number | null } = {}
): Promise<TestShareToken> {
let token = await ensureAuthCookie();
const apiBase = await getRuntimeApiBase();
for (let attempt = 0; attempt < 5; attempt++) {
@@ -470,7 +475,12 @@ export async function createShareTokenViaAPI(takenBy: string, scheduleDays = 30)
"Content-Type": "application/json",
...(token ? { Cookie: `access_token=${token}` } : {}),
},
body: JSON.stringify({ takenBy, scheduleDays }),
body: JSON.stringify({
takenBy,
scheduleDays,
expiryDays: options.expiryDays ?? null,
allowJournalNotes: options.allowJournalNotes ?? false,
}),
});
if (res.status === 401) {
token = await refreshAuthCookieViaLogin();
+94
View File
@@ -0,0 +1,94 @@
import {
authFile,
createMedicationViaAPI,
createShareTokenViaAPI,
deleteAllMedicationsViaAPI,
expect,
navigateTo,
test,
} from "./fixtures";
test.describe("Mobile modal browser back", () => {
test.use({
storageState: authFile,
viewport: { width: 412, height: 915 },
isMobile: true,
hasTouch: true,
});
test("closes owner-side modals with browser back on a Pixel-width viewport", async ({ page }) => {
await navigateTo(page, "/dashboard");
const journalHistoryButton = page.locator(".journal-history-button").first();
await expect(journalHistoryButton).toBeVisible({ timeout: 10000 });
await journalHistoryButton.click();
const journalHistoryModal = page.locator(".journal-history-modal");
await expect(journalHistoryModal).toBeVisible({ timeout: 10000 });
await page.goBack();
await expect(journalHistoryModal).toBeHidden({ timeout: 10000 });
await navigateTo(page, "/settings");
const exportButton = page
.locator("button.secondary")
.filter({ hasText: /Export|Exportieren/i })
.first();
await expect(exportButton).toBeVisible({ timeout: 10000 });
await exportButton.click();
const exportModal = page.locator(".modal-content").filter({ hasText: /Export Options|Export-Optionen/i });
await expect(exportModal).toBeVisible({ timeout: 10000 });
await page.goBack();
await expect(exportModal).toBeHidden({ timeout: 10000 });
});
test("closes the shared intake journal modal with browser back on mobile", async ({ page }) => {
const uniqueSuffix = Date.now().toString(36);
const person = `Mobile Journal ${uniqueSuffix}`;
const medicationName = `Mobile Shared Journal ${uniqueSuffix}`;
const start = new Date();
start.setHours(8, 0, 0, 0);
const pad = (value: number) => value.toString().padStart(2, "0");
const startTime = `${start.getFullYear()}-${pad(start.getMonth() + 1)}-${pad(start.getDate())}T${pad(start.getHours())}:${pad(start.getMinutes())}`;
await deleteAllMedicationsViaAPI();
await createMedicationViaAPI({
name: medicationName,
takenBy: [person],
packageType: "blister",
packCount: 1,
blistersPerPack: 1,
pillsPerBlister: 10,
intakes: [{ usage: 1, every: 1, start: startTime, intakeRemindersEnabled: false, takenBy: person }],
});
const shareToken = await createShareTokenViaAPI(person, 30, { allowJournalNotes: true });
await page.goto(`/share/${shareToken.token}`);
await page.waitForLoadState("networkidle");
await expect(page.locator(".shared-schedule-loading-skeleton")).toBeHidden({ timeout: 10000 });
await expect(page.locator(".med-name-text").filter({ hasText: medicationName }).first()).toBeVisible({
timeout: 15000,
});
const doseItem = page.locator(".dose-item").first();
await expect(doseItem).toBeVisible({ timeout: 15000 });
await doseItem.locator(".dose-btn.take").click();
const collapsedTodayDivider = page.locator(".day-block.today.collapsed .day-divider.clickable").first();
if (await collapsedTodayDivider.isVisible().catch(() => false)) {
await collapsedTodayDivider.click();
}
const noteButton = page.locator(".dose-item").first().locator(".dose-btn.journal");
await expect(noteButton).toBeEnabled({ timeout: 10000 });
await noteButton.click();
const journalModal = page.locator(".journal-modal");
await expect(journalModal).toBeVisible({ timeout: 10000 });
await page.goBack();
await expect(journalModal).toBeHidden({ timeout: 10000 });
await expect(page.locator(".shared-schedule-container")).toBeVisible();
});
});
+56 -1
View File
@@ -18,7 +18,7 @@ import {
*/
test.describe("Share Schedule", () => {
test.use({ storageState: authFile });
test.describe.configure({ timeout: 90000 });
test.describe.configure({ mode: "serial", timeout: 90000 });
const MED_ALICE = "ShareTest AliceMed";
const MED_BOB = "ShareTest BobMed";
@@ -300,4 +300,59 @@ test.describe("Share Schedule", () => {
await page.locator("button.modal-close").click();
});
test("should let a shared recipient add and reopen a journal note", async ({ page }) => {
const uniqueSuffix = Date.now().toString(36);
const person = `Journal E2E ${uniqueSuffix}`;
const medicationName = `Share Journal E2E ${uniqueSuffix}`;
const journalNote = `Shared E2E note ${uniqueSuffix}`;
await createMedicationViaAPI({
name: medicationName,
takenBy: [person],
packageType: "blister",
packCount: 1,
blistersPerPack: 1,
pillsPerBlister: 10,
intakes: [{ usage: 1, every: 1, start: todayMorning, intakeRemindersEnabled: false, takenBy: person }],
});
const shareToken = await createShareTokenViaAPI(person, 30, { allowJournalNotes: true });
await page.goto(`/share/${shareToken.token}`);
await page.waitForLoadState("networkidle");
await expect(page.locator(".shared-schedule-loading-skeleton")).toBeHidden({ timeout: 10000 });
await expect(page.locator(".med-name-text").filter({ hasText: medicationName }).first()).toBeVisible({
timeout: 15000,
});
const doseItem = page.locator(".dose-item").first();
await expect(doseItem).toBeVisible({ timeout: 15000 });
await expect(doseItem.locator(".dose-btn.journal")).toBeDisabled();
await doseItem.locator(".dose-btn.take").click();
const collapsedTodayDivider = page.locator(".day-block.today.collapsed .day-divider.clickable").first();
if (await collapsedTodayDivider.isVisible().catch(() => false)) {
await collapsedTodayDivider.click();
}
const updatedDoseItem = page.locator(".dose-item").first();
const noteButton = updatedDoseItem.locator(".dose-btn.journal");
await expect(noteButton).toBeEnabled({ timeout: 10000 });
await noteButton.click();
const noteInput = page.locator("#journal-note-input");
await expect(noteInput).toBeVisible({ timeout: 10000 });
await expect(noteInput).toHaveValue("");
await noteInput.fill(journalNote);
await page.locator(".journal-modal-footer button.primary").click();
await expect(page.locator(".journal-modal")).toBeHidden({ timeout: 10000 });
await noteButton.click();
await expect(noteInput).toBeVisible({ timeout: 10000 });
await expect(noteInput).toHaveValue(journalNote, { timeout: 10000 });
});
});
+94 -78
View File
@@ -12,7 +12,7 @@ import {
} from "./components";
import { AppHeader } from "./components/AppHeader";
import { AuthPage, AuthProvider, useAuth } from "./components/Auth";
import { AppProvider, UnsavedChangesProvider, useAppContext, useShareContext } from "./context";
import { AppProvider, FeedbackProvider, UnsavedChangesProvider, useAppContext, useShareContext } from "./context";
import { useScrollLock } from "./hooks/useScrollLock";
const DashboardPage = lazy(() => import("./pages/DashboardPage").then((module) => ({ default: module.DashboardPage })));
@@ -38,21 +38,72 @@ function RouteLoadingFallback() {
return <div style={{ padding: "1rem", textAlign: "center" }}>{t("common.loading")}</div>;
}
function AuthStatusCard({ theme, children }: { theme: "light" | "dark"; children: React.ReactNode }) {
return (
<div className="auth-container" data-theme={theme}>
<div className="auth-card" style={{ textAlign: "center" }}>
<h1 className="auth-title">💊 MedAssist-ng</h1>
{children}
</div>
</div>
);
}
// =============================================================================
// Main App Wrapper with Auth
// =============================================================================
export default function App() {
// Close tooltips on scroll/touch (for mobile). Keep this in the public
// wrapper too so shared links get the same tooltip behavior as the app.
useEffect(() => {
const closeAllTooltips = () => {
document.querySelectorAll(".info-tooltip.tooltip-active, .tooltip-trigger.tooltip-active").forEach((el) => {
el.classList.remove("tooltip-active");
});
};
const handleTooltipClick = (e: Event) => {
const target = e.target as HTMLElement;
const tooltipTrigger = target.closest(".info-tooltip, .tooltip-trigger") as HTMLElement | null;
if (tooltipTrigger) {
closeAllTooltips();
tooltipTrigger.classList.add("tooltip-active");
if (window.innerWidth <= 640) {
const rect = tooltipTrigger.getBoundingClientRect();
tooltipTrigger.style.setProperty("--tooltip-bottom", `${window.innerHeight - rect.top + 8}px`);
}
} else {
closeAllTooltips();
}
};
const handleTouchMove = () => {
closeAllTooltips();
};
document.addEventListener("click", handleTooltipClick, { capture: true });
document.addEventListener("touchmove", handleTouchMove, { passive: true });
document.addEventListener("scroll", handleTouchMove, { passive: true });
return () => {
document.removeEventListener("click", handleTooltipClick, { capture: true });
document.removeEventListener("touchmove", handleTouchMove);
document.removeEventListener("scroll", handleTouchMove);
};
}, []);
return (
<AuthProvider>
<Suspense fallback={<RouteLoadingFallback />}>
<Routes>
{/* Public share route - accessible without auth */}
<Route path="/share/:token/overview" element={<SharedOverviewPage />} />
<Route path="/share/:token" element={<SharedSchedule />} />
{/* All other routes go through AppRouter */}
<Route path="*" element={<AppRouter />} />
</Routes>
</Suspense>
<FeedbackProvider>
<Suspense fallback={<RouteLoadingFallback />}>
<Routes>
{/* Public share route - accessible without auth */}
<Route path="/share/:token/overview" element={<SharedOverviewPage />} />
<Route path="/share/:token" element={<SharedSchedule />} />
{/* All other routes go through AppRouter */}
<Route path="*" element={<AppRouter />} />
</Routes>
</Suspense>
</FeedbackProvider>
</AuthProvider>
);
}
@@ -73,52 +124,42 @@ function getInitialAuthTheme(): "light" | "dark" {
}
function AppRouter() {
const { t } = useTranslation();
const { user, authState, loading, authError } = useAuth();
const authTheme = getInitialAuthTheme();
// Show loading while checking auth state
if (loading) {
return (
<div className="auth-container" data-theme={authTheme}>
<div className="auth-card" style={{ textAlign: "center" }}>
<h1 className="auth-title">💊 MedAssist-ng</h1>
<p>Loading...</p>
</div>
</div>
<AuthStatusCard theme={authTheme}>
<p>{t("common.loading")}</p>
</AuthStatusCard>
);
}
// Show error if we couldn't connect to the server
if (authError) {
return (
<div className="auth-container" data-theme={authTheme}>
<div className="auth-card" style={{ textAlign: "center" }}>
<h1 className="auth-title">💊 MedAssist-ng</h1>
<div className="auth-error" style={{ marginBottom: "1rem" }}>
<strong>Connection Error</strong>
<br />
{authError}
</div>
<p style={{ fontSize: "0.9rem", color: "var(--text-muted)" }}>
Please check if the server is running and try again.
</p>
<button className="btn btn-primary" onClick={() => window.location.reload()} style={{ marginTop: "1rem" }}>
Retry
</button>
<AuthStatusCard theme={authTheme}>
<div className="auth-error" style={{ marginBottom: "1rem" }}>
<strong>{t("auth.connectionErrorTitle")}</strong>
<br />
{authError}
</div>
</div>
<p style={{ fontSize: "0.9rem", color: "var(--text-muted)" }}>{t("auth.connectionErrorHelp")}</p>
<button className="btn btn-primary" onClick={() => window.location.reload()} style={{ marginTop: "1rem" }}>
{t("common.retry")}
</button>
</AuthStatusCard>
);
}
// If auth state is null (shouldn't happen after loading, but be safe)
if (!authState) {
return (
<div className="auth-container" data-theme={authTheme}>
<div className="auth-card" style={{ textAlign: "center" }}>
<h1 className="auth-title">💊 MedAssist-ng</h1>
<p>Initializing...</p>
</div>
</div>
<AuthStatusCard theme={authTheme}>
<p>{t("common.initializing")}</p>
</AuthStatusCard>
);
}
@@ -212,12 +253,20 @@ function AppContent() {
setShareSelectedPerson,
shareSelectedDays,
setShareSelectedDays,
shareSelectedExpiryDays,
setShareSelectedExpiryDays,
shareAllowJournalNotes,
setShareAllowJournalNotes,
shareGenerating,
shareLink,
setShareLink,
shareCopied,
setShareCopied,
activeShareLinks,
activeSharesLoading,
revokingShareToken,
generateShareLink,
revokeShareLink,
copyShareLink,
closeShareDialog,
resetShareDialogState,
@@ -291,47 +340,6 @@ function AppContent() {
setShowRefillModal,
]);
// Close tooltips on scroll/touch (for mobile)
useEffect(() => {
const closeAllTooltips = () => {
document.querySelectorAll(".info-tooltip.tooltip-active, .tooltip-trigger.tooltip-active").forEach((el) => {
el.classList.remove("tooltip-active");
});
};
const handleTooltipClick = (e: Event) => {
const target = e.target as HTMLElement;
const tooltipTrigger = target.closest(".info-tooltip, .tooltip-trigger") as HTMLElement | null;
if (tooltipTrigger) {
// Close other tooltips first
closeAllTooltips();
// Toggle this one
tooltipTrigger.classList.add("tooltip-active");
// Position tooltip above the icon on mobile
if (window.innerWidth <= 640) {
const rect = tooltipTrigger.getBoundingClientRect();
// Place tooltip bottom edge just above the icon
tooltipTrigger.style.setProperty("--tooltip-bottom", `${window.innerHeight - rect.top + 8}px`);
}
} else {
closeAllTooltips();
}
};
const handleTouchMove = () => {
closeAllTooltips();
};
document.addEventListener("click", handleTooltipClick, { capture: true });
document.addEventListener("touchmove", handleTouchMove, { passive: true });
document.addEventListener("scroll", handleTouchMove, { passive: true });
return () => {
document.removeEventListener("click", handleTooltipClick, { capture: true });
document.removeEventListener("touchmove", handleTouchMove);
document.removeEventListener("scroll", handleTouchMove);
};
}, []);
// Global Escape handling in priority order.
// This keeps behavior consistent even when child modals are mocked in tests.
useEffect(() => {
@@ -602,13 +610,21 @@ function AppContent() {
onShareSelectedPersonChange={setShareSelectedPerson}
shareSelectedDays={shareSelectedDays}
onShareSelectedDaysChange={setShareSelectedDays}
shareSelectedExpiryDays={shareSelectedExpiryDays}
onShareSelectedExpiryDaysChange={setShareSelectedExpiryDays}
shareAllowJournalNotes={shareAllowJournalNotes}
onShareAllowJournalNotesChange={setShareAllowJournalNotes}
shareGenerating={shareGenerating}
shareLink={shareLink}
onShareLinkChange={setShareLink}
shareCopied={shareCopied}
onShareCopiedChange={setShareCopied}
activeShareLinks={activeShareLinks}
activeSharesLoading={activeSharesLoading}
revokingShareToken={revokingShareToken}
onClose={closeShareDialog}
onGenerateShareLink={generateShareLink}
onRevokeShareLink={revokeShareLink}
onCopyShareLink={copyShareLink}
/>
+32 -2
View File
@@ -2,6 +2,7 @@
import { createContext, type ReactNode, useCallback, useContext, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useEscapeKey } from "../hooks/useEscapeKey";
import { useModalHistory } from "../hooks/useModalHistory";
import { withCorrelation } from "../utils/correlation";
import { MAX_IMAGE_UPLOAD_BYTES, resolveImageUploadError } from "../utils/image-upload";
import { log } from "../utils/logger";
@@ -32,6 +33,7 @@ interface AuthContextType {
authState: AuthState | null;
loading: boolean;
authError: string | null;
sessionExpired: boolean;
login: (username: string, password: string, rememberMe?: boolean) => Promise<void>;
register: (username: string, password: string) => Promise<void>;
logout: () => Promise<void>;
@@ -64,6 +66,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const [authState, setAuthState] = useState<AuthState | null>(null);
const [loading, setLoading] = useState(true);
const [authError, setAuthError] = useState<string | null>(null);
const [sessionExpired, setSessionExpired] = useState(false);
// Track if initial fetch has been done to prevent duplicate calls
const initialFetchDone = useRef(false);
@@ -113,6 +116,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
// If auth is enabled and we might be logged in, check session
if (state.authEnabled) {
await refreshUser();
} else {
setSessionExpired(false);
}
setLoading(false);
} catch (err) {
@@ -138,6 +143,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
if (res.ok) {
const userData = await res.json();
setUser(userData);
setSessionExpired(false);
log.debug("[Auth] Session user loaded", { userId: userData.id, correlationId });
} else if (res.status === 401) {
// Access token expired - try to refresh it
@@ -150,6 +156,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
if (retryRes.ok) {
const userData = await retryRes.json();
setUser(userData);
setSessionExpired(false);
log.info("[Auth] Session restored after token refresh", {
userId: userData.id,
correlationId: retry.correlationId,
@@ -159,6 +166,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}
log.debug("[Auth] Session refresh unavailable, clearing local user state", { correlationId });
setUser(null);
setSessionExpired(true);
} else {
log.warn("[Auth] Unexpected /auth/me response", { status: res.status, correlationId });
setUser(null);
@@ -215,6 +223,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const data = await res.json();
setUser(data.user);
setSessionExpired(false);
log.info("[Auth] Login successful", { userId: data.user?.id, username: data.user?.username, correlationId });
}
@@ -233,6 +242,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
// Auto-login after registration
await login(username, password);
setSessionExpired(false);
// Refresh auth state (registration might disable further registrations)
await fetchAuthState();
@@ -249,6 +259,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
log.info("[Auth] Logout requested", { userId: user?.id ?? null, correlationId });
await fetch("/api/auth/logout", init);
setUser(null);
setSessionExpired(false);
log.info("[Auth] Logout completed", { correlationId });
}
@@ -341,9 +352,13 @@ export function AuthProvider({ children }: { children: ReactNode }) {
if (refreshed) {
// Retry the original request with new token
res = await fetch(input, options);
if (res.ok) {
setSessionExpired(false);
}
} else {
// Refresh failed - user needs to login again
setUser(null);
setSessionExpired(true);
}
}
@@ -359,6 +374,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
authState,
loading,
authError,
sessionExpired,
login,
register,
logout,
@@ -386,7 +402,7 @@ export function LoginForm({
onSwitchToRegister?: () => void;
}) {
const { t } = useTranslation();
const { login, authState } = useAuth();
const { login, authState, sessionExpired } = useAuth();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [rememberMe, setRememberMe] = useState(false);
@@ -440,6 +456,13 @@ export function LoginForm({
{/* Local login form - only show if form login is enabled */}
{authState?.formLoginEnabled && (
<form onSubmit={handleSubmit} className="auth-form">
{sessionExpired && (
<div className="auth-error">
<strong>{t("auth.sessionExpiredTitle")}</strong>
<br />
{t("auth.sessionExpiredHelp")}
</div>
)}
{error && <div className="auth-error">{error}</div>}
<div className="form-group">
@@ -633,7 +656,14 @@ export function UserProfile({ onClose }: { onClose?: () => void }) {
const [deleteLoading, setDeleteLoading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const closeDeleteConfirm = useCallback(() => {
if (!deleteLoading) {
setShowDeleteConfirm(false);
}
}, [deleteLoading]);
useEscapeKey(!!onClose, onClose ?? (() => {}));
useModalHistory(showDeleteConfirm, "profile-delete-account", closeDeleteConfirm);
async function handleAvatarUpload(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
@@ -842,7 +872,7 @@ export function UserProfile({ onClose }: { onClose?: () => void }) {
confirmLabel={t("auth.deleteAccountButton", "Yes, delete my account")}
cancelLabel={t("common.cancel", "Cancel")}
onConfirm={handleDeleteAccount}
onCancel={() => setShowDeleteConfirm(false)}
onCancel={closeDeleteConfirm}
isLoading={deleteLoading}
confirmVariant="danger"
/>
@@ -0,0 +1,161 @@
import { X } from "lucide-react";
import { useTranslation } from "react-i18next";
import type { ImportPreview } from "../context/AppContext";
import { useEscapeKey } from "../hooks/useEscapeKey";
import { useScrollLock } from "../hooks/useScrollLock";
interface ImportReviewModalProps {
isOpen: boolean;
importPreview: ImportPreview | null;
formattedExportedAt: string;
importing: boolean;
exporting: boolean;
onClose: () => void;
onBackup: () => void;
onConfirm: () => void;
}
export function ImportReviewModal({
isOpen,
importPreview,
formattedExportedAt,
importing,
exporting,
onClose,
onBackup,
onConfirm,
}: ImportReviewModalProps) {
const { t } = useTranslation();
const titleId = "import-review-modal-title";
const hasExistingData = importPreview?.warnings.replacesExistingData ?? false;
const hasWarnings = Boolean(
importPreview?.warnings.replacesExistingData ||
importPreview?.warnings.regeneratesShareLinks ||
importPreview?.warnings.containsImages ||
importPreview?.warnings.containsSensitiveData
);
useScrollLock(isOpen);
useEscapeKey(isOpen, onClose);
if (!isOpen || !importPreview) {
return null;
}
return (
<div
className="modal-overlay"
onClick={onClose}
onKeyDown={(event) => {
if (event.key !== "Escape") {
event.stopPropagation();
}
}}
>
<div
className="modal-content confirm-modal import-review-modal"
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => {
if (event.key !== "Escape") {
event.stopPropagation();
}
}}
>
<button className="modal-close" onClick={onClose} type="button" aria-label={t("common.close")}>
<X size={20} aria-hidden="true" />
</button>
<h2 id={titleId}>{t(hasExistingData ? "exportImport.confirmImport" : "exportImport.confirmImportEmpty")}</h2>
<div className="import-review-body">
<p>{t(hasExistingData ? "exportImport.reviewDescription" : "exportImport.reviewDescriptionEmpty")}</p>
<div className="import-review-summary">
<div className="action-card">
<div className="action-card-content">
<span className="action-card-title">{t("exportImport.incomingData")}</span>
<span className="action-card-desc">
{t("exportImport.summaryCounts", {
medications: importPreview.incoming.medications,
doses: importPreview.incoming.doseHistory,
refills: importPreview.incoming.refillHistory,
shares: importPreview.incoming.shareLinks,
})}
</span>
</div>
<div className="import-review-meta">
<span>{t("exportImport.formatVersion", { version: importPreview.version })}</span>
<span>{t("exportImport.exportedAt", { date: formattedExportedAt })}</span>
{importPreview.incoming.hasSettings && <span>{t("exportImport.settingsIncluded")}</span>}
{importPreview.incoming.journalEntries > 0 && (
<span>{t("exportImport.journalEntries", { count: importPreview.incoming.journalEntries })}</span>
)}
{importPreview.incoming.imageCount > 0 && (
<span>{t("exportImport.imageCount", { count: importPreview.incoming.imageCount })}</span>
)}
</div>
</div>
<div className="action-card">
<div className="action-card-content">
<span className="action-card-title">{t("exportImport.currentData")}</span>
<span className="action-card-desc">
{t("exportImport.summaryCounts", {
medications: importPreview.current.medications,
doses: importPreview.current.doseHistory,
refills: importPreview.current.refillHistory,
shares: importPreview.current.shareLinks,
})}
</span>
</div>
{importPreview.current.hasSettings && (
<span className="import-review-meta">{t("exportImport.settingsConfigured")}</span>
)}
</div>
</div>
{hasWarnings && (
<div className="import-review-warnings">
<strong>{t("exportImport.warningListTitle")}</strong>
<ul>
{importPreview.warnings.replacesExistingData && <li>{t("exportImport.warningReplaceData")}</li>}
{importPreview.warnings.regeneratesShareLinks && <li>{t("exportImport.warningShareLinks")}</li>}
{importPreview.warnings.containsImages && <li>{t("exportImport.warningImages")}</li>}
{importPreview.warnings.containsSensitiveData && <li>{t("exportImport.warningSensitive")}</li>}
</ul>
</div>
)}
{hasExistingData ? (
<p className="warning-text">{t("exportImport.confirmImportWarning")}</p>
) : (
<p className="hint-text">{t("exportImport.confirmImportEmptyMessage")}</p>
)}
<p className="hint-text">{t("exportImport.backupHint")}</p>
</div>
<div className="modal-footer import-review-footer">
<button type="button" className="ghost" onClick={onClose} disabled={importing || exporting}>
{t("exportImport.cancelButton")}
</button>
<div className="import-review-actions">
{hasExistingData && (
<button type="button" className="secondary" onClick={onBackup} disabled={exporting || importing}>
{exporting ? t("exportImport.exporting") : t("exportImport.backupFirst")}
</button>
)}
<button
type="button"
className={hasExistingData ? "danger" : "primary"}
onClick={onConfirm}
disabled={importing}
>
{importing
? t("exportImport.importing")
: t(hasExistingData ? "exportImport.confirmButton" : "exportImport.confirmButtonEmpty")}
</button>
</div>
</div>
</div>
</div>
);
}
@@ -298,7 +298,12 @@ export function MedicationEnrichmentSection({
}
const animationFrameId = window.requestAnimationFrame(() => {
resultRefs.current.get(expandedResultCode)?.scrollIntoView({
const expandedResultElement = resultRefs.current.get(expandedResultCode);
if (typeof expandedResultElement?.scrollIntoView !== "function") {
return;
}
expandedResultElement.scrollIntoView({
block: "nearest",
inline: "nearest",
behavior: "smooth",
+131 -25
View File
@@ -11,8 +11,11 @@ import {
isLiquidContainerPackageType,
isTubePackageType,
} from "../types";
import { formatDate, formatDateTime } from "../utils/formatters";
import { formatDate, formatDateTime, toInputValue } from "../utils/formatters";
import { getIntakeFrequencyText, getMedicationIntakes } from "../utils/intake-schedule";
import { mergePersonTags, personTagsMatch } from "../utils/person-tags";
import { useAuth } from "./Auth";
import { DateTimeInput } from "./DateTimeInput";
import { MedicationAvatar } from "./MedicationAvatar";
type ReportFormat = "txt" | "md" | "pdf";
@@ -41,31 +44,53 @@ type ReportData = Record<
}
>;
type ReportDateRange = {
startDate: string;
endDate: string;
};
type ReportPreview = {
format: "txt" | "md";
content: string;
};
function getDefaultDateRange(): ReportDateRange {
const endDate = new Date();
const startDate = new Date(endDate);
startDate.setDate(startDate.getDate() - 30);
return {
startDate: toInputValue(startDate),
endDate: toInputValue(endDate),
};
}
export function ReportModal({ isOpen, onClose, medications }: ReportModalProps) {
const { t } = useTranslation();
const { authFetch } = useAuth();
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
const [format, setFormat] = useState<ReportFormat>("pdf");
const [generating, setGenerating] = useState(false);
const [takenByFilter, setTakenByFilter] = useState<Set<string>>(new Set());
const [dateRange, setDateRange] = useState<ReportDateRange>(() => getDefaultDateRange());
const [preview, setPreview] = useState<ReportPreview | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
useScrollLock(isOpen);
useEscapeKey(isOpen, onClose);
// Collect all unique "taken by" people across all medications
const allPeople = useMemo(() => {
const people = new Set<string>();
for (const med of medications) {
if (med.takenBy) {
for (const p of med.takenBy) people.add(p);
}
}
return Array.from(people).sort();
return mergePersonTags(medications.flatMap((medication) => medication.takenBy || []));
}, [medications]);
// Filtered medications based on takenBy filter
const filteredMeds = useMemo(() => {
if (takenByFilter.size === 0) return medications;
return medications.filter((m) => m.takenBy?.some((p) => takenByFilter.has(p)));
return medications.filter((medication) =>
medication.takenBy?.some((person) =>
Array.from(takenByFilter).some((filterValue) => personTagsMatch(person, filterValue))
)
);
}, [medications, takenByFilter]);
const activeMeds = useMemo(() => filteredMeds.filter((m) => !m.isObsolete), [filteredMeds]);
@@ -97,9 +122,22 @@ export function ReportModal({ isOpen, onClose, medications }: ReportModalProps)
setTakenByFilter(new Set());
setFormat("pdf");
setGenerating(false);
setDateRange(getDefaultDateRange());
setPreview(null);
setErrorMessage(null);
}
}, [isOpen]);
// biome-ignore lint/correctness/useExhaustiveDependencies: preview should reset when any report input changes while the modal is open
useEffect(() => {
if (!isOpen) {
return;
}
setPreview(null);
setErrorMessage(null);
}, [isOpen, selectedIds, takenByFilter, format, dateRange.startDate, dateRange.endDate]);
const toggleMed = useCallback((id: number) => {
setSelectedIds((prev) => {
const next = new Set(prev);
@@ -118,37 +156,59 @@ export function ReportModal({ isOpen, onClose, medications }: ReportModalProps)
}, []);
const selectedMeds = useMemo(() => filteredMeds.filter((m) => selectedIds.has(m.id)), [filteredMeds, selectedIds]);
let generateButtonLabel = t("report.generate");
if (generating) {
generateButtonLabel = t("report.generating");
} else if (preview) {
generateButtonLabel = t("report.regenerate");
}
async function handleGenerate() {
if (selectedIds.size === 0) return;
const startDate = new Date(dateRange.startDate);
const endDate = new Date(dateRange.endDate);
if (Number.isNaN(startDate.getTime()) || Number.isNaN(endDate.getTime()) || endDate <= startDate) {
setErrorMessage(t("report.invalidDateRange"));
return;
}
setGenerating(true);
setErrorMessage(null);
try {
const resolvedDateRange = {
startDate: startDate.toISOString(),
endDate: endDate.toISOString(),
};
const filterArr = takenByFilter.size > 0 ? Array.from(takenByFilter) : null;
// Fetch report data from backend
const res = await fetch("/api/medications/report-data", {
const res = await authFetch("/api/medications/report-data", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
medicationIds: Array.from(selectedIds),
startDate: resolvedDateRange.startDate,
endDate: resolvedDateRange.endDate,
takenByFilter: takenByFilter.size > 0 ? Array.from(takenByFilter) : undefined,
}),
credentials: "include",
});
if (!res.ok) throw new Error("Failed to fetch report data");
const reportData = (await res.json()) as ReportData;
if (format === "pdf") {
const imageMap = await fetchMedImages(selectedMeds);
const filterArr = takenByFilter.size > 0 ? Array.from(takenByFilter) : null;
openPrintView(selectedMeds, reportData, t, imageMap, filterArr);
const imageMap = await fetchMedImages(selectedMeds, authFetch);
openPrintView(selectedMeds, reportData, t, imageMap, filterArr, resolvedDateRange);
setPreview(null);
setErrorMessage(null);
onClose();
} else {
const filterArr = takenByFilter.size > 0 ? Array.from(takenByFilter) : null;
const content = generateTextReport(selectedMeds, reportData, format, t, filterArr);
downloadFile(content, format);
const content = generateTextReport(selectedMeds, reportData, format, t, filterArr, resolvedDateRange);
setPreview({ format, content });
}
onClose();
} catch {
// Stay open on error so user can retry
setErrorMessage(t("report.error"));
} finally {
setGenerating(false);
}
@@ -177,6 +237,28 @@ export function ReportModal({ isOpen, onClose, medications }: ReportModalProps)
<h2 className="report-modal-title">{t("report.title")}</h2>
<p className="report-modal-desc">{t("report.description")}</p>
<div className="report-range">
<h4>{t("report.dateRange")}</h4>
<div className="report-range-grid">
<div className="report-range-field">
<span>{t("report.from")}</span>
<DateTimeInput
step="60"
value={dateRange.startDate}
onChange={(e) => setDateRange((prev) => ({ ...prev, startDate: e.target.value }))}
/>
</div>
<div className="report-range-field">
<span>{t("report.until")}</span>
<DateTimeInput
step="60"
value={dateRange.endDate}
onChange={(e) => setDateRange((prev) => ({ ...prev, endDate: e.target.value }))}
/>
</div>
</div>
</div>
{/* Person filter */}
{allPeople.length > 1 && (
<div className="report-person-filter">
@@ -279,6 +361,25 @@ export function ReportModal({ isOpen, onClose, medications }: ReportModalProps)
</div>
</div>
{errorMessage && <p className="report-error">{errorMessage}</p>}
{preview && (
<div className="report-preview">
<div className="report-preview-header">
<h4>{t("report.preview")}</h4>
<button
type="button"
className="ghost small"
onClick={() => downloadFile(preview.content, preview.format)}
>
{t("report.download")}
</button>
</div>
<p className="report-preview-desc">{t("report.previewDescription")}</p>
<pre className="report-preview-content">{preview.content}</pre>
</div>
)}
{/* Actions */}
<div className="report-actions">
<button type="button" className="ghost" onClick={onClose}>
@@ -290,7 +391,7 @@ export function ReportModal({ isOpen, onClose, medications }: ReportModalProps)
onClick={handleGenerate}
disabled={selectedIds.size === 0 || generating}
>
{generating ? t("report.generating") : t("report.generate")}
{generateButtonLabel}
</button>
</div>
</div>
@@ -348,7 +449,8 @@ function generateTextReport(
reportData: ReportData,
fmt: "txt" | "md",
t: TFn,
personFilter: string[] | null
personFilter: string[] | null,
dateRange: { startDate: string; endDate: string }
): string {
const lines: string[] = [];
const sep = fmt === "md" ? "---" : "═".repeat(60);
@@ -360,6 +462,7 @@ function generateTextReport(
lines.push(h1(t("report.docTitle")));
lines.push(`${t("report.docGenerated")}: ${formatDate(new Date().toISOString())}`);
lines.push(`${t("report.docRange")}: ${formatDateTime(dateRange.startDate)} - ${formatDateTime(dateRange.endDate)}`);
lines.push("");
for (const med of meds) {
@@ -483,13 +586,13 @@ function downloadFile(content: string, format: "txt" | "md") {
type ImageMap = Record<number, string>;
async function fetchMedImages(meds: Medication[]): Promise<ImageMap> {
async function fetchMedImages(meds: Medication[], authFetch: typeof fetch): Promise<ImageMap> {
const map: ImageMap = {};
const fetches = meds
.filter((m) => m.imageUrl)
.map(async (m) => {
try {
const res = await fetch(`/api/images/${m.imageUrl}`, { credentials: "include" });
const res = await authFetch(`/api/images/${m.imageUrl}`);
if (!res.ok) return;
const blob = await res.blob();
const dataUrl = await new Promise<string>((resolve) => {
@@ -511,12 +614,13 @@ function openPrintView(
reportData: ReportData,
t: TFn,
imageMap: ImageMap,
personFilter: string[] | null
personFilter: string[] | null,
dateRange: { startDate: string; endDate: string }
) {
const w = window.open("", "_blank");
if (!w) return;
const html = buildPrintHtml(meds, reportData, t, imageMap, personFilter);
const html = buildPrintHtml(meds, reportData, t, imageMap, personFilter, dateRange);
w.document.write(html);
w.document.close();
w.onload = () => setTimeout(() => w.print(), 300);
@@ -531,7 +635,8 @@ function buildPrintHtml(
reportData: ReportData,
t: TFn,
imageMap: ImageMap,
personFilter: string[] | null
personFilter: string[] | null,
dateRange: { startDate: string; endDate: string }
): string {
const sections: string[] = [];
@@ -721,6 +826,7 @@ function buildPrintHtml(
<div class="no-print print-hint">${escHtml(t("report.docPrintInstruction"))}</div>
<h1>${escHtml(t("report.docTitle"))}</h1>
<p class="subtitle">${escHtml(t("report.docGenerated"))}: ${formatDate(new Date().toISOString())}</p>
<p class="subtitle">${escHtml(t("report.docRange"))}: ${formatDateTime(dateRange.startDate)} - ${formatDateTime(dateRange.endDate)}</p>
${sections.join("\n")}
</body>
</html>`;
+152
View File
@@ -4,7 +4,11 @@
*/
import { Check, Copy, Link2, X } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useModalHistory } from "../hooks";
import type { ActiveShareLink } from "../hooks/useShare";
import { ConfirmModal } from "./ConfirmModal";
export interface ShareDialogProps {
show: boolean;
@@ -13,13 +17,21 @@ export interface ShareDialogProps {
onShareSelectedPersonChange: (person: string) => void;
shareSelectedDays: number;
onShareSelectedDaysChange: (days: number) => void;
shareSelectedExpiryDays: number | null;
onShareSelectedExpiryDaysChange: (days: number | null) => void;
shareAllowJournalNotes: boolean;
onShareAllowJournalNotesChange: (enabled: boolean) => void;
shareGenerating: boolean;
shareLink: string | null;
onShareLinkChange: (link: string | null) => void;
shareCopied: boolean;
onShareCopiedChange: (copied: boolean) => void;
activeShareLinks: ActiveShareLink[];
activeSharesLoading: boolean;
revokingShareToken: string | null;
onClose: () => void;
onGenerateShareLink: () => Promise<void>;
onRevokeShareLink: (token: string) => Promise<boolean>;
onCopyShareLink: () => void;
}
@@ -30,24 +42,116 @@ export function ShareDialog({
onShareSelectedPersonChange,
shareSelectedDays,
onShareSelectedDaysChange,
shareSelectedExpiryDays,
onShareSelectedExpiryDaysChange,
shareAllowJournalNotes,
onShareAllowJournalNotesChange,
shareGenerating,
shareLink,
onShareLinkChange,
shareCopied,
onShareCopiedChange,
activeShareLinks,
activeSharesLoading,
revokingShareToken,
onClose,
onGenerateShareLink,
onRevokeShareLink,
onCopyShareLink,
}: ShareDialogProps) {
const { t } = useTranslation();
const [manageLinksOpen, setManageLinksOpen] = useState(false);
const [shareToRevoke, setShareToRevoke] = useState<ActiveShareLink | null>(null);
const closeLabel = t("common.close");
const copyLabel = shareCopied ? t("share.copied") : t("share.copyLink");
const getPersonLabel = (person: string) => (person === "all" ? t("share.allPeople") : person);
const closeRevokeConfirm = useCallback(() => {
if (shareToRevoke && revokingShareToken !== shareToRevoke.token) {
setShareToRevoke(null);
}
}, [revokingShareToken, shareToRevoke]);
useModalHistory(show && Boolean(shareToRevoke), "share-revoke", closeRevokeConfirm);
useEffect(() => {
if (!show) {
setShareToRevoke(null);
}
}, [show]);
// ESC is handled by the global handler in App.tsx to avoid double history.back()
if (!show) return null;
const renderActiveShares = () => {
if (activeSharesLoading) {
return <p>{t("share.loadingActiveLinks")}</p>;
}
if (activeShareLinks.length === 0) {
return <p>{t("share.noActiveLinks")}</p>;
}
return (
<ul className="share-active-list">
{activeShareLinks.map((share) => {
const personLabel = getPersonLabel(share.takenBy);
const createdAtLabel = new Date(share.createdAt).toLocaleDateString();
const expiresAtLabel = share.expiresAt ? new Date(share.expiresAt).toLocaleDateString() : null;
return (
<li key={share.token} className="share-active-item">
<div className="share-active-copy">
<a href={`${window.location.origin}${share.shareUrl}`} className="share-link-inline">
{personLabel}
</a>
<span className="hint-text">
{expiresAtLabel
? t("share.activeLinkMetaWithExpiry", {
person: personLabel,
days: share.scheduleDays,
createdAt: createdAtLabel,
expiresAt: expiresAtLabel,
})
: t("share.activeLinkMeta", {
person: personLabel,
days: share.scheduleDays,
createdAt: createdAtLabel,
})}
{share.allowJournalNotes ? ` · ${t("share.journalNotesEnabled")}` : ""}
</span>
</div>
<button
type="button"
className="ghost"
disabled={revokingShareToken === share.token}
onClick={() => setShareToRevoke(share)}
>
{revokingShareToken === share.token ? t("share.revoking") : t("share.revoke")}
</button>
</li>
);
})}
</ul>
);
};
const renderManageLinks = () => (
<div className="share-dialog-manage">
<button
type="button"
className="share-dialog-manage-summary"
onClick={() => setManageLinksOpen((current) => !current)}
aria-expanded={manageLinksOpen}
>
<span>{t("share.manageLinksSummary", { count: activeShareLinks.length })}</span>
<span className="share-dialog-manage-count">
{manageLinksOpen ? t("common.hide") : activeShareLinks.length}
</span>
</button>
{manageLinksOpen ? <div className="share-dialog-manage-content">{renderActiveShares()}</div> : null}
</div>
);
return (
<div
className="modal-overlay"
@@ -85,6 +189,7 @@ export function ShareDialog({
return (
<div className="share-dialog-empty">
<p>{t("share.noPeople")}</p>
<div className="share-dialog-active-links">{renderManageLinks()}</div>
</div>
);
}
@@ -124,6 +229,7 @@ export function ShareDialog({
</button>
<button onClick={onClose}>{t("common.close")}</button>
</div>
<div className="share-dialog-active-links">{renderManageLinks()}</div>
</div>
);
}
@@ -159,6 +265,33 @@ export function ShareDialog({
</select>
</div>
<div className="form-group">
<label htmlFor="share-expiry-select">{t("share.selectExpiry")}</label>
<select
id="share-expiry-select"
className="select-field"
value={shareSelectedExpiryDays == null ? "never" : String(shareSelectedExpiryDays)}
onChange={(e) =>
onShareSelectedExpiryDaysChange(e.target.value === "never" ? null : Number(e.target.value))
}
>
<option value="never">{t("share.expiryNever")}</option>
<option value="7">{t("share.expiry7Days")}</option>
<option value="30">{t("share.expiry30Days")}</option>
<option value="90">{t("share.expiry90Days")}</option>
</select>
</div>
<label className="inline-checkbox" htmlFor="share-journal-notes-toggle">
<input
id="share-journal-notes-toggle"
type="checkbox"
checked={shareAllowJournalNotes}
onChange={(event) => onShareAllowJournalNotesChange(event.target.checked)}
/>
<span>{t("share.allowJournalNotes")}</span>
</label>
<div className="share-dialog-footer">
<button className="ghost" onClick={onClose}>
{t("common.close")}
@@ -167,9 +300,28 @@ export function ShareDialog({
{shareGenerating ? t("share.generating") : t("share.generateLink")}
</button>
</div>
<div className="share-dialog-active-links">{renderManageLinks()}</div>
</div>
);
})()}
{shareToRevoke && (
<ConfirmModal
title={t("share.revoke")}
message={t("share.revokeConfirm", { person: getPersonLabel(shareToRevoke.takenBy) })}
confirmLabel={revokingShareToken === shareToRevoke.token ? t("share.revoking") : t("share.revoke")}
cancelLabel={t("common.cancel")}
onConfirm={async () => {
const revoked = await onRevokeShareLink(shareToRevoke.token);
if (revoked) {
setShareToRevoke(null);
}
}}
onCancel={closeRevokeConfirm}
isLoading={revokingShareToken === shareToRevoke.token}
confirmVariant="danger"
overlayClassName="nested-confirm"
/>
)}
</div>
</div>
);
+182 -4
View File
@@ -4,14 +4,17 @@
/* biome-ignore-all lint/style/noNestedTernary: rendering branches are intentionally explicit in schedule UI */
/* biome-ignore-all lint/correctness/useExhaustiveDependencies: modal and helper callbacks are stable at runtime */
import { NotebookPen } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useParams } from "react-router-dom";
import { useFeedback } from "../context/FeedbackContext";
import { ScheduleUsageTag } from "../features/schedule/components";
import { formatScheduleDoseUsageLabel, formatScheduleTotalUsageLabel } from "../features/schedule/formatters";
import { toggleDateInSet } from "../features/schedule/interactions";
import { loadScheduleCollapseState, saveCollapsedDaySet } from "../features/schedule/storage";
import { useEscapeKey } from "../hooks";
import { useEscapeKey, useModalHistory } from "../hooks";
import type { IntakeJournalEntry } from "../hooks/useIntakeJournal";
import type { ExpiredLinkData, SharedScheduleData } from "../types";
import {
allowsPillFormSelection,
@@ -26,12 +29,30 @@ import { getSystemLocale } from "../utils/formatters";
import { getIntakeDailyRate, getMedicationIntakes, iterateIntakeOccurrences } from "../utils/intake-schedule";
import { convertLiquidUsageToMl } from "../utils/intake-units";
import { getStockStatus, isDoseDismissed, parseLocalDateTime } from "../utils/schedule";
import { IntakeJournalModal } from "./intake-journal/IntakeJournalModal";
import { MedicationAvatar } from "./MedicationAvatar";
import { SharedMedicationOverviewSection } from "./SharedMedicationOverviewSection";
async function readSharedJournalError(response: Response, fallbackMessage: string): Promise<string> {
try {
const data = (await response.json()) as { error?: string; code?: string };
if (typeof data.error === "string" && data.error.trim().length > 0) {
return data.error;
}
if (typeof data.code === "string" && data.code.trim().length > 0) {
return data.code;
}
} catch {
// Fall back to the supplied message when the response body is not JSON.
}
return fallbackMessage;
}
export function SharedSchedule() {
const { token } = useParams<{ token: string }>();
const { t, i18n } = useTranslation();
const { showFeedback } = useFeedback();
const [data, setData] = useState<SharedScheduleData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@@ -39,8 +60,15 @@ export function SharedSchedule() {
const [takenDoses, setTakenDoses] = useState<Set<string>>(new Set());
const [automaticTakenDoses, setAutomaticTakenDoses] = useState<Set<string>>(new Set());
const [dismissedDoses, setDismissedDoses] = useState<Set<string>>(new Set());
const [sharedJournalDoseIdsWithNotes, setSharedJournalDoseIdsWithNotes] = useState<Set<string>>(new Set());
const mutationInFlightRef = useRef(0);
const [lightboxImage, setLightboxImage] = useState<{ url: string; name: string } | null>(null);
const [sharedJournalOpen, setSharedJournalOpen] = useState(false);
const [sharedJournalDoseId, setSharedJournalDoseId] = useState<string | null>(null);
const [sharedJournalEntry, setSharedJournalEntry] = useState<IntakeJournalEntry | null>(null);
const [sharedJournalLoading, setSharedJournalLoading] = useState(false);
const [sharedJournalSaving, setSharedJournalSaving] = useState(false);
const [sharedJournalError, setSharedJournalError] = useState<string | null>(null);
const [showPastDays, setShowPastDays] = useState(false);
const [showFutureDays, setShowFutureDays] = useState(false);
@@ -169,6 +197,107 @@ export function SharedSchedule() {
// Close lightbox on Escape key
useEscapeKey(!!lightboxImage, closeLightbox);
const closeSharedJournalEditor = useCallback(() => {
setSharedJournalOpen(false);
setSharedJournalDoseId(null);
setSharedJournalEntry(null);
setSharedJournalLoading(false);
setSharedJournalSaving(false);
setSharedJournalError(null);
}, []);
useModalHistory(sharedJournalOpen, "shared-intake-journal", closeSharedJournalEditor);
const openSharedJournalEditor = useCallback(
async (doseId: string) => {
if (!token || !data?.allowJournalNotes) {
return;
}
setSharedJournalOpen(true);
setSharedJournalDoseId(doseId);
setSharedJournalEntry(null);
setSharedJournalLoading(true);
setSharedJournalError(null);
try {
const response = await fetch(`/api/share/${token}/journal/event/${encodeURIComponent(doseId)}`);
if (!response.ok) {
setSharedJournalEntry(null);
setSharedJournalError(await readSharedJournalError(response, t("journal.errors.loadFailed")));
return;
}
const payload = (await response.json()) as { entry: IntakeJournalEntry };
setSharedJournalEntry(payload.entry);
setSharedJournalDoseIdsWithNotes((current) => {
const next = new Set(current);
if (payload.entry.note?.trim()) {
next.add(payload.entry.doseId);
} else {
next.delete(payload.entry.doseId);
}
return next;
});
} catch {
setSharedJournalEntry(null);
setSharedJournalError(t("journal.errors.loadFailed"));
} finally {
setSharedJournalLoading(false);
}
},
[data?.allowJournalNotes, t, token]
);
const saveSharedJournalNote = useCallback(
async (note: string) => {
if (!token || !sharedJournalDoseId) {
setSharedJournalError(t("journal.errors.noEventSelected"));
return false;
}
if (note.trim().length === 0) {
setSharedJournalError(t("journal.errors.emptySharedNote"));
return false;
}
setSharedJournalSaving(true);
setSharedJournalError(null);
try {
const response = await fetch(`/api/share/${token}/journal/event/${encodeURIComponent(sharedJournalDoseId)}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ note }),
});
if (!response.ok) {
setSharedJournalError(await readSharedJournalError(response, t("journal.errors.saveFailed")));
return false;
}
const payload = (await response.json()) as { entry: IntakeJournalEntry };
setSharedJournalEntry(payload.entry);
setSharedJournalDoseIdsWithNotes((current) => {
const next = new Set(current);
if (payload.entry.note?.trim()) {
next.add(payload.entry.doseId);
} else {
next.delete(payload.entry.doseId);
}
return next;
});
return true;
} catch {
setSharedJournalError(t("journal.errors.saveFailed"));
return false;
} finally {
setSharedJournalSaving(false);
}
},
[sharedJournalDoseId, t, token]
);
// Handle browser back button to close lightbox
useEffect(() => {
function handlePopState() {
@@ -194,11 +323,13 @@ export function SharedSchedule() {
const taken = new Set<string>();
const automatic = new Set<string>();
const dismissed = new Set<string>();
const journalDoseIds = new Set<string>();
for (const d of data.doses as Array<{
doseId: string;
dismissed?: boolean;
skipped?: boolean;
takenSource?: string;
hasJournalNote?: boolean;
}>) {
if (d.skipped === true || d.dismissed === true) {
dismissed.add(d.doseId);
@@ -208,10 +339,14 @@ export function SharedSchedule() {
automatic.add(d.doseId);
}
}
if (d.hasJournalNote === true) {
journalDoseIds.add(d.doseId);
}
}
setTakenDoses(taken);
setAutomaticTakenDoses(automatic);
setDismissedDoses(dismissed);
setSharedJournalDoseIdsWithNotes(journalDoseIds);
}
} catch {
// Keep the current optimistic/shared state on transient read errors.
@@ -268,7 +403,7 @@ export function SharedSchedule() {
try {
const data = (await response.json()) as { code?: string };
if (data.code === "OUT_OF_STOCK") {
alert(t("common.outOfStockTakeBlocked"));
showFeedback({ message: t("common.outOfStockTakeBlocked"), tone: "error" });
}
} catch {
// Ignore JSON parsing errors and fall back to the optimistic rollback only.
@@ -448,6 +583,9 @@ export function SharedSchedule() {
isAutomaticallyTaken: boolean;
isEmpty: boolean;
}) => {
const showSharedJournalAction = Boolean(data?.allowJournalNotes);
const canOpenSharedJournal = showSharedJournalAction && (options.isTaken || options.isSkipped);
const hasSharedJournalNote = sharedJournalDoseIdsWithNotes.has(options.doseId);
const takeButton = options.isTaken ? (
<button className="dose-btn undo take" onClick={() => undoDoseTaken(options.doseId)} title={t("common.undo")}>
{options.isAutomaticallyTaken && (
@@ -486,10 +624,33 @@ export function SharedSchedule() {
</button>
);
const journalButton = showSharedJournalAction ? (
<span
className={!canOpenSharedJournal ? "tooltip-trigger" : undefined}
data-tooltip={!canOpenSharedJournal ? t("journal.actions.noteTakenOnly") : undefined}
>
<button
type="button"
className={`dose-btn journal${hasSharedJournalNote ? " has-note" : ""}`}
onClick={() => {
if (canOpenSharedJournal) {
void openSharedJournalEditor(options.doseId);
}
}}
disabled={!canOpenSharedJournal}
title={canOpenSharedJournal ? t("journal.actions.note") : undefined}
>
<NotebookPen size={14} aria-hidden="true" />
<span className="dose-btn-label">{t("journal.actions.note")}</span>
</button>
</span>
) : null;
return (
<>
{takeButton}
{skipButton}
{journalButton}
</>
);
};
@@ -641,7 +802,10 @@ export function SharedSchedule() {
}, [data, i18n.language]);
// Split into past, today, and future - matches main app logic
const pastDays = useMemo(() => schedule.filter((d) => d.isPast), [schedule]);
const pastDays = useMemo(() => {
const visiblePastDays = Math.max(1, data?.scheduleDays ?? 30);
return schedule.filter((d) => d.isPast).slice(-visiblePastDays);
}, [schedule, data?.scheduleDays]);
// Separate today from future days
const { todayDay, futureDays } = useMemo(() => {
@@ -901,6 +1065,7 @@ export function SharedSchedule() {
<div className="shared-schedule-container">
<header className="shared-schedule-header">
<h1>{pageTitle}</h1>
<p className="shared-schedule-boundary">{t("share.publicAccessHelp")}</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")}>
@@ -1226,7 +1391,7 @@ export function SharedSchedule() {
const hasAutomaticTakenDose = allDoseIds.some((id) => isDoseTakenAutomatically(id));
// Today: only collapse if manually collapsed or all taken
const isAutoCollapsed = allDayTaken && !hasAutomaticTakenDose;
const isAutoCollapsed = allDayTaken && !hasAutomaticTakenDose && !data.allowJournalNotes;
const isManuallyExpanded = manuallyExpandedDays.has(day.dateStr);
const isManuallyCollapsed = manuallyCollapsedDays.has(day.dateStr);
const isCollapsed = isAutoCollapsed ? !isManuallyExpanded : isManuallyCollapsed;
@@ -1582,6 +1747,19 @@ export function SharedSchedule() {
/>
</div>
)}
<IntakeJournalModal
isOpen={sharedJournalOpen}
entry={sharedJournalEntry}
isLoading={sharedJournalLoading}
isSaving={sharedJournalSaving}
isDeleting={false}
error={sharedJournalError}
onClose={closeSharedJournalEditor}
onSave={saveSharedJournalNote}
onDelete={() => undefined}
allowDelete={false}
/>
</div>
);
}
+6 -2
View File
@@ -12,6 +12,7 @@ import { formatNumber } from "../utils";
import { getSystemLocale } from "../utils/formatters";
import { getIntakeFrequencyText, getMedicationIntakes } from "../utils/intake-schedule";
import { getLiquidCountUnitLabel } from "../utils/intake-units";
import { personTagsMatch } from "../utils/person-tags";
import { getStockStatus } from "../utils/schedule";
export interface UserFilterModalProps {
@@ -72,7 +73,10 @@ export function UserFilterModal({
if (!selectedUser) return null;
const userMeds = meds.filter((m) => !m.isObsolete && (m.takenBy || []).includes(selectedUser));
const userMeds = meds.filter(
(medication) =>
!medication.isObsolete && (medication.takenBy || []).some((person) => personTagsMatch(person, selectedUser))
);
return (
<div
@@ -110,7 +114,7 @@ export function UserFilterModal({
// Get intakes relevant to this person
const personIntakes = getMedicationIntakes(med).filter(
(intake) => intake.takenBy === null || intake.takenBy === selectedUser
(intake) => intake.takenBy === null || personTagsMatch(intake.takenBy, selectedUser)
);
return (
+3 -1
View File
@@ -7,8 +7,10 @@ export { DateInput } from "./DateInput";
export { DateTimeInput } from "./DateTimeInput";
export { default as ExportModal } from "./ExportModal";
export { FormNumberStepper } from "./FormNumberStepper";
export { ImportReviewModal } from "./ImportReviewModal";
export { IntakeJournalHistoryModal } from "./intake-journal/IntakeJournalHistoryModal";
export { IntakeJournalModal } from "./intake-journal/IntakeJournalModal";
export type { LightboxProps } from "./Lightbox";
export { Lightbox } from "./Lightbox";
export type { MedDetailModalProps } from "./MedDetailModal";
export { MedDetailModal } from "./MedDetailModal";
@@ -0,0 +1,191 @@
import { useTranslation } from "react-i18next";
import { useEscapeKey } from "../../hooks/useEscapeKey";
import type { IntakeJournalEntry, IntakeJournalHistoryFilters } from "../../hooks/useIntakeJournal";
import { useScrollLock } from "../../hooks/useScrollLock";
import type { Medication } from "../../types";
import { formatDateTime, getNumericLocale } from "../../utils/formatters";
import { DateTimeInput } from "../DateTimeInput";
import { MedicationAvatar } from "../MedicationAvatar";
interface IntakeJournalHistoryModalProps {
isOpen: boolean;
entries: IntakeJournalEntry[];
filters: IntakeJournalHistoryFilters;
medications: Medication[];
isLoading: boolean;
error: string | null;
onClose: () => void;
onFilterChange: (patch: Partial<IntakeJournalHistoryFilters>) => void;
onReload: () => Promise<void> | void;
onResetFilters: () => void;
onReopen: (doseId: string) => Promise<void> | void;
}
function formatDisplayDateTime(value: string | null): string | null {
if (!value) {
return null;
}
return formatDateTime(value, getNumericLocale());
}
function getJournalSourceLabel(entry: IntakeJournalEntry, t: ReturnType<typeof useTranslation>["t"]): string {
if (entry.takenSource === "automatic") {
return t("journal.context.sourceAutomaticReminder");
}
return entry.markedBy ? t("journal.context.sourceSharedLink") : t("journal.context.sourceOwnerApp");
}
export function IntakeJournalHistoryModal({
isOpen,
entries,
filters,
medications,
isLoading,
error,
onClose,
onFilterChange,
onReload,
onResetFilters,
onReopen,
}: IntakeJournalHistoryModalProps) {
const { t } = useTranslation();
useScrollLock(isOpen);
useEscapeKey(isOpen, onClose);
if (!isOpen) {
return null;
}
let listContent: React.ReactNode;
if (isLoading) {
listContent = <div className="journal-modal-state">{t("journal.history.loading")}</div>;
} else if (entries.length === 0) {
listContent = <div className="journal-modal-state">{t("journal.history.empty")}</div>;
} else {
listContent = entries.map((entry) => (
<article key={entry.doseTrackingId} className="journal-history-entry">
<div className="journal-history-entry-main">
<div className="journal-history-entry-header">
<MedicationAvatar name={entry.medicationName} size="sm" />
<div>
<strong>{entry.medicationName}</strong>
<p>{formatDisplayDateTime(entry.scheduledFor) ?? t("common.notAvailable")}</p>
</div>
</div>
<p className="journal-history-note">{entry.note ?? t("journal.history.noNote")}</p>
<div className="journal-history-meta">
<span>{t(entry.dismissed ? "journal.context.statusSkipped" : "journal.context.statusTaken")}</span>
<span>{getJournalSourceLabel(entry, t)}</span>
{entry.updatedAt && (
<span>
{t("journal.history.updatedAt", {
date: formatDisplayDateTime(entry.updatedAt) ?? entry.updatedAt,
})}
</span>
)}
</div>
</div>
<button type="button" className="primary small" onClick={() => void onReopen(entry.doseId)}>
{t("journal.history.reopen")}
</button>
</article>
));
}
return (
<div
className="modal-overlay"
onClick={onClose}
onKeyDown={(event) => {
if (event.key !== "Escape") {
event.stopPropagation();
}
}}
>
<div
className="modal-content journal-history-modal"
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => {
if (event.key !== "Escape") {
event.stopPropagation();
}
}}
>
<button type="button" className="modal-close" onClick={onClose} aria-label={t("common.close")}>
×
</button>
<div className="journal-modal-header">
<h2>{t("journal.history.title")}</h2>
<p>{t("journal.history.description")}</p>
</div>
<div className="journal-history-filters">
<label className="journal-field" htmlFor="journal-history-medication">
<span>{t("journal.history.filters.medication")}</span>
<select
id="journal-history-medication"
className="select-field"
value={filters.medicationId ?? "all"}
onChange={(event) => {
const value = event.target.value;
onFilterChange({ medicationId: value === "all" ? null : Number(value) });
}}
>
<option value="all">{t("journal.history.filters.allMedications")}</option>
{medications.map((medication) => (
<option key={medication.id} value={medication.id}>
{medication.name}
</option>
))}
</select>
</label>
<div className="journal-field journal-date-filter">
<span>{t("journal.history.filters.from")}</span>
<DateTimeInput
value={filters.from}
onChange={(event) => onFilterChange({ from: event.target.value })}
step="60"
aria-label={t("journal.history.filters.from")}
placeholder={t("journal.history.filters.fromPlaceholder")}
/>
</div>
<div className="journal-field journal-date-filter">
<span>{t("journal.history.filters.to")}</span>
<DateTimeInput
value={filters.to}
onChange={(event) => onFilterChange({ to: event.target.value })}
step="60"
aria-label={t("journal.history.filters.to")}
placeholder={t("journal.history.filters.toPlaceholder")}
/>
</div>
</div>
<div className="journal-history-toolbar">
<button type="button" className="ghost small" onClick={onResetFilters}>
{t("journal.history.resetFilters")}
</button>
<button type="button" className="ghost small" onClick={() => void onReload()} disabled={isLoading}>
{t("journal.history.reload")}
</button>
</div>
{error && <div className="journal-inline-error">{error}</div>}
<div className="journal-history-list">{listContent}</div>
<div className="modal-footer journal-modal-footer">
<div className="footer-right">
<button type="button" className="ghost" onClick={onClose}>
{t("common.close")}
</button>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,231 @@
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useEscapeKey } from "../../hooks/useEscapeKey";
import type { IntakeJournalEntry } from "../../hooks/useIntakeJournal";
import { useScrollLock } from "../../hooks/useScrollLock";
import { formatDateTime, getNumericLocale } from "../../utils/formatters";
import { MedicationAvatar } from "../MedicationAvatar";
interface IntakeJournalModalProps {
isOpen: boolean;
entry: IntakeJournalEntry | null;
isLoading: boolean;
isSaving: boolean;
isDeleting: boolean;
error: string | null;
onClose: () => void;
onSave: (note: string) => Promise<boolean> | boolean;
onDelete: () => Promise<void> | void;
allowDelete?: boolean;
}
function formatDisplayDateTime(value: string | null): string | null {
if (!value) {
return null;
}
return formatDateTime(value, getNumericLocale());
}
function getJournalSourceLabel(entry: IntakeJournalEntry, t: ReturnType<typeof useTranslation>["t"]): string {
if (entry.takenSource === "automatic") {
return t("journal.context.sourceAutomaticReminder");
}
return entry.markedBy ? t("journal.context.sourceSharedLink") : t("journal.context.sourceOwnerApp");
}
export function IntakeJournalModal({
isOpen,
entry,
isLoading,
isSaving,
isDeleting,
error,
onClose,
onSave,
onDelete,
allowDelete = true,
}: IntakeJournalModalProps) {
const { t } = useTranslation();
const [note, setNote] = useState("");
const [showSavedState, setShowSavedState] = useState(false);
const activeDoseTrackingIdRef = useRef<number | null>(null);
const wasSavingRef = useRef(false);
useScrollLock(isOpen);
useEscapeKey(isOpen, onClose);
useEffect(() => {
if (!isOpen) {
setNote("");
setShowSavedState(false);
activeDoseTrackingIdRef.current = null;
wasSavingRef.current = false;
return;
}
if (!entry) {
return;
}
setNote(entry.note ?? "");
if (activeDoseTrackingIdRef.current !== entry.doseTrackingId) {
activeDoseTrackingIdRef.current = entry.doseTrackingId;
setShowSavedState(false);
}
}, [entry, isOpen]);
useEffect(() => {
if (!isOpen) {
wasSavingRef.current = false;
return;
}
if (isSaving) {
setShowSavedState(false);
wasSavingRef.current = true;
return;
}
if (wasSavingRef.current) {
wasSavingRef.current = false;
if (entry && !error && note === (entry.note ?? "")) {
setShowSavedState(true);
}
}
}, [entry, error, isOpen, isSaving, note]);
if (!isOpen) {
return null;
}
const handleSave = async () => {
const saved = await onSave(note);
if (saved) {
onClose();
}
};
const scheduledForLabel = formatDisplayDateTime(entry?.scheduledFor ?? null);
const takenAtLabel = formatDisplayDateTime(entry?.takenAt ?? null);
const title = entry?.note ? t("journal.editor.editTitle") : t("journal.editor.addTitle");
const saveLabel = showSavedState ? t("common.saved") : t("common.save");
let bodyContent: React.ReactNode;
if (isLoading) {
bodyContent = <div className="journal-modal-state">{t("journal.editor.loading")}</div>;
} else if (entry) {
bodyContent = (
<>
<div className="journal-event-card">
<div className="journal-event-medication">
<MedicationAvatar name={entry.medicationName} size="sm" />
<div>
<strong>{entry.medicationName}</strong>
<p>{entry.dismissed ? t("journal.context.statusSkipped") : t("journal.context.statusTaken")}</p>
</div>
</div>
<div className="journal-event-grid">
<div>
<span>{t("journal.context.scheduledFor")}</span>
<strong>{scheduledForLabel ?? t("common.notAvailable")}</strong>
</div>
<div>
<span>{t("journal.context.takenAt")}</span>
<strong>{takenAtLabel ?? t("journal.context.notRecorded")}</strong>
</div>
<div>
<span>{t("journal.context.markedBy")}</span>
<strong>{entry.markedBy ?? t("journal.context.self")}</strong>
</div>
<div>
<span>{t("journal.context.source")}</span>
<strong>{getJournalSourceLabel(entry, t)}</strong>
</div>
</div>
</div>
<label className="journal-field" htmlFor="journal-note-input">
<span>{t("journal.editor.noteLabel")}</span>
<textarea
id="journal-note-input"
className="journal-note-input"
rows={7}
value={note}
onChange={(event) => {
setNote(event.target.value);
setShowSavedState(false);
}}
placeholder={t("journal.editor.notePlaceholder")}
maxLength={4000}
/>
</label>
{error && <div className="journal-inline-error">{error}</div>}
</>
);
} else {
bodyContent = <div className="journal-modal-state">{error ?? t("journal.errors.loadFailed")}</div>;
}
return (
<div
className="modal-overlay"
onClick={onClose}
onKeyDown={(event) => {
if (event.key !== "Escape") {
event.stopPropagation();
}
}}
>
<div
className="modal-content journal-modal"
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => {
if (event.key !== "Escape") {
event.stopPropagation();
}
}}
>
<button type="button" className="modal-close" onClick={onClose} aria-label={t("common.close")}>
×
</button>
<div className="journal-modal-header">
<h2>{title}</h2>
<p>{t("journal.editor.description")}</p>
</div>
{bodyContent}
<div className="modal-footer journal-modal-footer">
<div className="footer-left">
{allowDelete && (
<button
type="button"
className="ghost"
onClick={() => void onDelete()}
disabled={isLoading || isSaving || isDeleting || !entry?.note}
>
{isDeleting ? t("journal.editor.deleting") : t("common.delete")}
</button>
)}
</div>
<div className="footer-right">
<button type="button" className="ghost" onClick={onClose} disabled={isSaving || isDeleting}>
{t("common.cancel")}
</button>
<button
type="button"
className="primary"
onClick={() => void handleSave()}
disabled={isLoading || isSaving || isDeleting || !entry}
>
{saveLabel}
</button>
</div>
</div>
</div>
</div>
);
}
+183 -21
View File
@@ -2,7 +2,15 @@ import type React from "react";
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useAuth } from "../components/Auth";
import { useCollapsedDays, useDoses, useMedications, useRefill, useSettings, useShare } from "../hooks";
import {
useCollapsedDays,
useDoses,
useIntakeJournal,
useMedications,
useRefill,
useSettings,
useShare,
} from "../hooks";
import {
type Coverage,
type FormState,
@@ -13,7 +21,9 @@ import {
} from "../types";
import { getSystemLocale, setDefaultFormattingTimezone } from "../utils/formatters";
import { log } from "../utils/logger";
import { mergePersonTags } from "../utils/person-tags";
import { buildSchedulePreview, calculateCoverage, computeMissedPastDoseIds, getStockStatus } from "../utils/schedule";
import { useFeedback } from "./FeedbackContext";
import { ShareContextProvider } from "./ShareContext";
// =============================================================================
@@ -44,6 +54,34 @@ export type GroupedDay = {
meds: DayMedEntry[];
};
export type ImportPreview = {
version: string;
exportedAt: string;
includeSensitiveData: boolean;
incoming: {
medications: number;
doseHistory: number;
refillHistory: number;
shareLinks: number;
journalEntries: number;
imageCount: number;
hasSettings: boolean;
};
current: {
medications: number;
doseHistory: number;
refillHistory: number;
shareLinks: number;
hasSettings: boolean;
};
warnings: {
replacesExistingData: boolean;
regeneratesShareLinks: boolean;
containsImages: boolean;
containsSensitiveData: boolean;
};
};
export interface AppContextValue {
// From useMedications
meds: Medication[];
@@ -87,6 +125,29 @@ export interface AppContextValue {
undoDoseTaken: (doseId: string) => Promise<void>;
undoDoseSkipped: (doseId: string) => Promise<void>;
// From useIntakeJournal
journalEditorOpen: boolean;
journalHistoryOpen: boolean;
journalTargetDoseId: string | null;
journalEvent: ReturnType<typeof useIntakeJournal>["journalEvent"];
journalEventLoading: boolean;
journalEventSaving: boolean;
journalEventDeleting: boolean;
journalEventError: string | null;
journalHistoryEntries: ReturnType<typeof useIntakeJournal>["journalHistoryEntries"];
journalHistoryFilters: ReturnType<typeof useIntakeJournal>["journalHistoryFilters"];
journalHistoryLoading: boolean;
journalHistoryError: string | null;
openJournalEditor: (doseId: string) => Promise<void>;
closeJournalEditor: () => void;
saveJournalNote: (note: string) => Promise<boolean>;
deleteJournalNote: () => Promise<boolean>;
openJournalHistory: () => void;
closeJournalHistory: () => void;
setJournalHistoryFilters: (patch: Partial<ReturnType<typeof useIntakeJournal>["journalHistoryFilters"]>) => void;
reloadJournalHistory: () => Promise<void>;
reopenJournalHistoryEntry: (doseId: string) => Promise<void>;
// From useCollapsedDays
manuallyCollapsedDays: Set<string>;
manuallyExpandedDays: Set<string>;
@@ -99,13 +160,21 @@ export interface AppContextValue {
setShareSelectedPerson: React.Dispatch<React.SetStateAction<string>>;
shareSelectedDays: number;
setShareSelectedDays: React.Dispatch<React.SetStateAction<number>>;
shareSelectedExpiryDays: number | null;
setShareSelectedExpiryDays: React.Dispatch<React.SetStateAction<number | null>>;
shareAllowJournalNotes: boolean;
setShareAllowJournalNotes: React.Dispatch<React.SetStateAction<boolean>>;
shareGenerating: boolean;
shareLink: string | null;
setShareLink: React.Dispatch<React.SetStateAction<string | null>>;
shareCopied: boolean;
setShareCopied: React.Dispatch<React.SetStateAction<boolean>>;
activeShareLinks: ReturnType<typeof useShare>["activeShareLinks"];
activeSharesLoading: boolean;
revokingShareToken: string | null;
openShareDialog: () => void;
generateShareLink: () => Promise<void>;
revokeShareLink: (token: string) => Promise<boolean>;
copyShareLink: () => void;
closeShareDialog: () => void;
resetShareDialogState: () => void;
@@ -188,6 +257,8 @@ export interface AppContextValue {
setShowImportConfirm: React.Dispatch<React.SetStateAction<boolean>>;
pendingImportData: unknown;
setPendingImportData: React.Dispatch<React.SetStateAction<unknown>>;
importPreview: ImportPreview | null;
setImportPreview: React.Dispatch<React.SetStateAction<ImportPreview | null>>;
importResult: {
medications: number;
doses: number;
@@ -245,12 +316,14 @@ function userStorageKey(userId: number | undefined, key: string): string {
export function AppProvider({ children }: { children: React.ReactNode }) {
const { i18n } = useTranslation();
const { user } = useAuth();
const { user, authFetch } = useAuth();
const { showFeedback } = useFeedback();
// Compose hooks
const medications = useMedications();
const settingsHook = useSettings();
const doses = useDoses();
const intakeJournal = useIntakeJournal();
const collapsed = useCollapsedDays(user?.id);
const share = useShare();
const refill = useRefill();
@@ -295,6 +368,7 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
const [showExportModal, setShowExportModal] = useState(false);
const [showImportConfirm, setShowImportConfirm] = useState(false);
const [pendingImportData, setPendingImportData] = useState<unknown>(null);
const [importPreview, setImportPreview] = useState<ImportPreview | null>(null);
const [importResult, setImportResult] = useState<{
medications: number;
doses: number;
@@ -326,6 +400,7 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
medications.clearMedicationsState();
settingsHook.resetSettingsState();
doses.clearDosesState();
intakeJournal.resetJournalState();
refill.clearRefillState();
share.resetShareDialogState();
@@ -351,6 +426,7 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
settingsHook.loadSettings,
doses.clearDosesState,
doses.loadTakenDoses,
intakeJournal.resetJournalState,
refill.clearRefillState,
share.resetShareDialogState,
]);
@@ -442,8 +518,7 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
);
const existingPeople = useMemo(() => {
const allPeople = medications.meds.flatMap((m) => m.takenBy || []);
return [...new Set(allPeople)].filter(Boolean).sort();
return mergePersonTags(medications.meds.flatMap((medication) => medication.takenBy || []));
}, [medications.meds]);
// Get worst stock status for a day's medications
@@ -658,9 +733,7 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
async (includeImages: boolean = true) => {
setExporting(true);
try {
const res = await fetch(`/api/export?includeSensitive=true&includeImages=${includeImages}`, {
credentials: "include",
});
const res = await authFetch(`/api/export?includeSensitive=true&includeImages=${includeImages}`);
if (!res.ok) throw new Error("Export failed");
const data = await res.json();
@@ -682,7 +755,7 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
}
setExporting(false);
},
[t, user?.username]
[authFetch, t, user?.username]
);
// Handle file selection for import
@@ -692,24 +765,64 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
if (!file) return;
const reader = new FileReader();
reader.onload = (event) => {
reader.onload = async (event) => {
try {
const data = JSON.parse(event.target?.result as string);
if (!data.version || !data.exportedAt) {
alert(t("exportImport.invalidFile"));
setPendingImportData(null);
setImportPreview(null);
showFeedback({ message: t("exportImport.invalidFile"), tone: "error" });
return;
}
const res = await authFetch("/api/import/preview", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
const text = await res.text();
let previewResponse: { error?: string; preview?: ImportPreview } = {};
try {
previewResponse = text ? JSON.parse(text) : {};
} catch {
log.error("Import preview response parse error:", text);
showFeedback({
message: `${t("exportImport.importError")}: Server returned invalid response`,
tone: "error",
});
return;
}
if (!res.ok || !previewResponse.preview) {
setPendingImportData(null);
setImportPreview(null);
if (previewResponse.error === "Invalid import data format") {
showFeedback({ message: t("exportImport.invalidFile"), tone: "error" });
return;
}
showFeedback({
message: `${t("exportImport.importError")}: ${previewResponse.error || `HTTP ${res.status}`}`,
tone: "error",
});
return;
}
setImportResult(null);
setPendingImportData(data);
setImportPreview(previewResponse.preview);
setShowImportConfirm(true);
} catch {
alert(t("exportImport.invalidFile"));
setPendingImportData(null);
setImportPreview(null);
showFeedback({ message: t("exportImport.invalidFile"), tone: "error" });
}
};
reader.readAsText(file);
// Reset file input
e.target.value = "";
},
[t]
[authFetch, showFeedback, t]
);
// Confirm and execute import
@@ -719,10 +832,9 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
setShowImportConfirm(false);
try {
const res = await fetch("/api/import", {
const res = await authFetch("/api/import", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify(pendingImportData),
});
@@ -744,12 +856,18 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
data = text ? JSON.parse(text) : {};
} catch {
log.error("Import response parse error:", text);
alert(`${t("exportImport.importError")}: Server returned invalid response`);
showFeedback({
message: `${t("exportImport.importError")}: Server returned invalid response`,
tone: "error",
});
return;
}
if (!res.ok) {
alert(`${t("exportImport.importError")}: ${data.error || `HTTP ${res.status}`}`);
showFeedback({
message: `${t("exportImport.importError")}: ${data.error || `HTTP ${res.status}`}`,
tone: "error",
});
return;
}
@@ -768,12 +886,13 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
doses.loadTakenDoses();
} catch (err) {
log.error("Import error:", err);
alert(t("exportImport.importError"));
showFeedback({ message: t("exportImport.importError"), tone: "error" });
} finally {
setPendingImportData(null);
setImportPreview(null);
setImporting(false);
}
setPendingImportData(null);
setImporting(false);
}, [pendingImportData, t, medications, settingsHook, doses]);
}, [authFetch, pendingImportData, t, medications, settingsHook, doses, showFeedback]);
// Compute settingsChanged
const settingsChanged = useMemo(() => {
@@ -815,13 +934,21 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
setShareSelectedPerson: share.setShareSelectedPerson,
shareSelectedDays: share.shareSelectedDays,
setShareSelectedDays: share.setShareSelectedDays,
shareSelectedExpiryDays: share.shareSelectedExpiryDays,
setShareSelectedExpiryDays: share.setShareSelectedExpiryDays,
shareAllowJournalNotes: share.shareAllowJournalNotes,
setShareAllowJournalNotes: share.setShareAllowJournalNotes,
shareGenerating: share.shareGenerating,
shareLink: share.shareLink,
setShareLink: share.setShareLink,
shareCopied: share.shareCopied,
setShareCopied: share.setShareCopied,
activeShareLinks: share.activeShareLinks,
activeSharesLoading: share.activeSharesLoading,
revokingShareToken: share.revokingShareToken,
openShareDialog,
generateShareLink: share.generateShareLink,
revokeShareLink: share.revokeShareLink,
copyShareLink: share.copyShareLink,
closeShareDialog: share.closeShareDialog,
resetShareDialogState: share.resetShareDialogState,
@@ -865,6 +992,29 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
undoDoseTaken: doses.undoDoseTaken,
undoDoseSkipped: doses.undoDoseSkipped,
// From useIntakeJournal
journalEditorOpen: intakeJournal.journalEditorOpen,
journalHistoryOpen: intakeJournal.journalHistoryOpen,
journalTargetDoseId: intakeJournal.journalTargetDoseId,
journalEvent: intakeJournal.journalEvent,
journalEventLoading: intakeJournal.journalEventLoading,
journalEventSaving: intakeJournal.journalEventSaving,
journalEventDeleting: intakeJournal.journalEventDeleting,
journalEventError: intakeJournal.journalEventError,
journalHistoryEntries: intakeJournal.journalHistoryEntries,
journalHistoryFilters: intakeJournal.journalHistoryFilters,
journalHistoryLoading: intakeJournal.journalHistoryLoading,
journalHistoryError: intakeJournal.journalHistoryError,
openJournalEditor: intakeJournal.openJournalEditor,
closeJournalEditor: intakeJournal.closeJournalEditor,
saveJournalNote: intakeJournal.saveJournalNote,
deleteJournalNote: intakeJournal.deleteJournalNote,
openJournalHistory: intakeJournal.openJournalHistory,
closeJournalHistory: intakeJournal.closeJournalHistory,
setJournalHistoryFilters: intakeJournal.setJournalHistoryFilters,
reloadJournalHistory: intakeJournal.reloadJournalHistory,
reopenJournalHistoryEntry: intakeJournal.reopenJournalHistoryEntry,
// From useCollapsedDays
manuallyCollapsedDays: collapsed.manuallyCollapsedDays,
manuallyExpandedDays: collapsed.manuallyExpandedDays,
@@ -877,13 +1027,21 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
setShareSelectedPerson: share.setShareSelectedPerson,
shareSelectedDays: share.shareSelectedDays,
setShareSelectedDays: share.setShareSelectedDays,
shareSelectedExpiryDays: share.shareSelectedExpiryDays,
setShareSelectedExpiryDays: share.setShareSelectedExpiryDays,
shareAllowJournalNotes: share.shareAllowJournalNotes,
setShareAllowJournalNotes: share.setShareAllowJournalNotes,
shareGenerating: share.shareGenerating,
shareLink: share.shareLink,
setShareLink: share.setShareLink,
shareCopied: share.shareCopied,
setShareCopied: share.setShareCopied,
activeShareLinks: share.activeShareLinks,
activeSharesLoading: share.activeSharesLoading,
revokingShareToken: share.revokingShareToken,
openShareDialog,
generateShareLink: share.generateShareLink,
revokeShareLink: share.revokeShareLink,
copyShareLink: share.copyShareLink,
closeShareDialog: share.closeShareDialog,
resetShareDialogState: share.resetShareDialogState,
@@ -970,6 +1128,8 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
setShowImportConfirm,
pendingImportData,
setPendingImportData,
importPreview,
setImportPreview,
importResult,
setImportResult,
handleExport,
@@ -981,6 +1141,7 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
medications,
settingsHook,
doses,
intakeJournal,
collapsed,
share,
refill,
@@ -1017,6 +1178,7 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
showExportModal,
showImportConfirm,
pendingImportData,
importPreview,
importResult,
handleExport,
handleImportFileSelect,
+103
View File
@@ -0,0 +1,103 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
export type FeedbackTone = "info" | "success" | "warning" | "error";
type FeedbackNotice = {
id: number;
message: string;
tone: FeedbackTone;
durationMs: number;
};
type FeedbackContextValue = {
showFeedback: (options: { message: string; tone?: FeedbackTone; durationMs?: number }) => void;
dismissFeedback: (id: number) => void;
clearFeedback: () => void;
};
const noop = () => {};
const defaultValue: FeedbackContextValue = {
showFeedback: noop,
dismissFeedback: noop,
clearFeedback: noop,
};
const FeedbackContext = createContext<FeedbackContextValue>(defaultValue);
export function FeedbackProvider({ children }: { children: React.ReactNode }) {
const { t } = useTranslation();
const [notices, setNotices] = useState<FeedbackNotice[]>([]);
const nextIdRef = useRef(1);
const timeoutMapRef = useRef<Map<number, number>>(new Map());
const dismissFeedback = useCallback((id: number) => {
const timeoutId = timeoutMapRef.current.get(id);
if (typeof timeoutId === "number") {
window.clearTimeout(timeoutId);
timeoutMapRef.current.delete(id);
}
setNotices((current) => current.filter((notice) => notice.id !== id));
}, []);
const clearFeedback = useCallback(() => {
for (const timeoutId of timeoutMapRef.current.values()) {
window.clearTimeout(timeoutId);
}
timeoutMapRef.current.clear();
setNotices([]);
}, []);
const showFeedback = useCallback(
({ message, tone = "info", durationMs = 5000 }: { message: string; tone?: FeedbackTone; durationMs?: number }) => {
const id = nextIdRef.current++;
setNotices((current) => [...current, { id, message, tone, durationMs }].slice(-3));
const timeoutId = window.setTimeout(() => {
dismissFeedback(id);
}, durationMs);
timeoutMapRef.current.set(id, timeoutId);
},
[dismissFeedback]
);
useEffect(() => () => clearFeedback(), [clearFeedback]);
const value = useMemo(
() => ({
showFeedback,
dismissFeedback,
clearFeedback,
}),
[showFeedback, dismissFeedback, clearFeedback]
);
return (
<FeedbackContext.Provider value={value}>
{children}
<div className="app-feedback-stack" aria-live="polite" aria-atomic="false">
{notices.map((notice) => (
<div
key={notice.id}
className={`app-feedback app-feedback-${notice.tone}`}
role={notice.tone === "error" ? "alert" : "status"}
>
<div className="app-feedback-message">{notice.message}</div>
<button
type="button"
className="app-feedback-close"
onClick={() => dismissFeedback(notice.id)}
aria-label={t("common.close")}
>
×
</button>
</div>
))}
</div>
</FeedbackContext.Provider>
);
}
export function useFeedback() {
return useContext(FeedbackContext);
}
+8
View File
@@ -7,13 +7,21 @@ type ShareContextValue = {
setShareSelectedPerson: React.Dispatch<React.SetStateAction<string>>;
shareSelectedDays: number;
setShareSelectedDays: React.Dispatch<React.SetStateAction<number>>;
shareSelectedExpiryDays: number | null;
setShareSelectedExpiryDays: React.Dispatch<React.SetStateAction<number | null>>;
shareAllowJournalNotes: boolean;
setShareAllowJournalNotes: React.Dispatch<React.SetStateAction<boolean>>;
shareGenerating: boolean;
shareLink: string | null;
setShareLink: React.Dispatch<React.SetStateAction<string | null>>;
shareCopied: boolean;
setShareCopied: React.Dispatch<React.SetStateAction<boolean>>;
activeShareLinks: import("../hooks/useShare").ActiveShareLink[];
activeSharesLoading: boolean;
revokingShareToken: string | null;
openShareDialog: () => void;
generateShareLink: () => Promise<void>;
revokeShareLink: (token: string) => Promise<boolean>;
copyShareLink: () => void;
closeShareDialog: () => void;
resetShareDialogState: () => void;
+2
View File
@@ -2,6 +2,8 @@
export type { AppContextValue, DayMedEntry, DoseInfo, GroupedDay } from "./AppContext";
export { AppProvider, useAppContext } from "./AppContext";
export type { FeedbackTone } from "./FeedbackContext";
export { FeedbackProvider, useFeedback } from "./FeedbackContext";
export type { ShareContextValue } from "./ShareContext";
export { ShareContextProvider, useShareContext } from "./ShareContext";
export { UnsavedChangesProvider, useUnsavedChanges } from "./UnsavedChangesContext";
+2
View File
@@ -5,6 +5,8 @@ export { useCollapsedDays } from "./useCollapsedDays";
export type { UseDosesReturn } from "./useDoses";
export { useDoses } from "./useDoses";
export { useEscapeKey } from "./useEscapeKey";
export type { IntakeJournalEntry, IntakeJournalHistoryFilters, UseIntakeJournalReturn } from "./useIntakeJournal";
export { useIntakeJournal } from "./useIntakeJournal";
export {
createMedicationEnrichmentState,
MEDICATION_ENRICHMENT_INITIAL_LIMIT,
+25 -15
View File
@@ -4,6 +4,8 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useAuth } from "../components/Auth";
import { useFeedback } from "../context/FeedbackContext";
export interface UseDosesReturn {
takenDoses: Set<string>;
@@ -25,6 +27,8 @@ export interface UseDosesReturn {
export function useDoses(): UseDosesReturn {
const { t } = useTranslation();
const { authFetch } = useAuth();
const { showFeedback } = useFeedback();
const [takenDoses, setTakenDoses] = useState<Set<string>>(new Set());
const [takenDoseTimestamps, setTakenDoseTimestamps] = useState<Map<string, number>>(new Map());
const [takenDoseSources, setTakenDoseSources] = useState<Map<string, "manual" | "automatic">>(new Map());
@@ -48,7 +52,7 @@ export function useDoses(): UseDosesReturn {
if (mutationInFlightRef.current > 0) return;
try {
const res = await fetch("/api/doses/taken", { credentials: "include" });
const res = await authFetch("/api/doses/taken");
if (res.ok) {
// Double-check no mutation started while we were fetching
if (mutationInFlightRef.current > 0) return;
@@ -79,7 +83,7 @@ export function useDoses(): UseDosesReturn {
} catch {
// Don't reset on error - keep current state
}
}, [clearDosesState]);
}, [authFetch, clearDosesState]);
// Poll for taken doses from server (works with or without auth)
useEffect(() => {
@@ -164,15 +168,14 @@ export function useDoses(): UseDosesReturn {
// Send to server
try {
const response = await fetch("/api/doses/taken", {
const response = await authFetch("/api/doses/taken", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ doseId }),
});
if (!response.ok) {
if ((await getErrorCode(response)) === "OUT_OF_STOCK") {
alert(t("common.outOfStockTakeBlocked"));
showFeedback({ message: t("common.outOfStockTakeBlocked"), tone: "error" });
}
throw new Error("Failed to mark dose as taken");
}
@@ -220,7 +223,17 @@ export function useDoses(): UseDosesReturn {
loadTakenDoses();
}
},
[dismissedDoses, getErrorCode, loadTakenDoses, t, takenDoseSources, takenDoseTimestamps, takenDoses]
[
authFetch,
dismissedDoses,
getErrorCode,
loadTakenDoses,
showFeedback,
t,
takenDoseSources,
takenDoseTimestamps,
takenDoses,
]
);
const markDoseSkipped = useCallback(
@@ -257,10 +270,9 @@ export function useDoses(): UseDosesReturn {
});
try {
const response = await fetch("/api/doses/skip", {
const response = await authFetch("/api/doses/skip", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ doseId }),
});
if (!response.ok) {
@@ -302,7 +314,7 @@ export function useDoses(): UseDosesReturn {
loadTakenDoses();
}
},
[dismissedDoses, loadTakenDoses, takenDoseSources, takenDoseTimestamps, takenDoses]
[authFetch, dismissedDoses, loadTakenDoses, takenDoseSources, takenDoseTimestamps, takenDoses]
);
const undoDoseTaken = useCallback(
@@ -330,9 +342,8 @@ export function useDoses(): UseDosesReturn {
// Send to server
try {
await fetch(`/api/doses/taken/${encodeURIComponent(doseId)}`, {
await authFetch(`/api/doses/taken/${encodeURIComponent(doseId)}`, {
method: "DELETE",
credentials: "include",
});
} catch {
// Revert on error
@@ -361,7 +372,7 @@ export function useDoses(): UseDosesReturn {
loadTakenDoses();
}
},
[loadTakenDoses, takenDoseSources, takenDoseTimestamps]
[authFetch, loadTakenDoses, takenDoseSources, takenDoseTimestamps]
);
const undoDoseSkipped = useCallback(
@@ -376,9 +387,8 @@ export function useDoses(): UseDosesReturn {
});
try {
await fetch(`/api/doses/skip/${encodeURIComponent(doseId)}`, {
await authFetch(`/api/doses/skip/${encodeURIComponent(doseId)}`, {
method: "DELETE",
credentials: "include",
});
} catch {
setDismissedDoses((prev) => {
@@ -393,7 +403,7 @@ export function useDoses(): UseDosesReturn {
loadTakenDoses();
}
},
[dismissedDoses, loadTakenDoses]
[authFetch, dismissedDoses, loadTakenDoses]
);
return {
+339
View File
@@ -0,0 +1,339 @@
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useAuth } from "../components/Auth";
import { useModalHistory } from "./useModalHistory";
export type IntakeJournalEntry = {
doseTrackingId: number;
doseId: string;
medicationId: number;
medicationName: string;
scheduledFor: string;
takenAt: string | null;
dismissed: boolean;
takenSource: "manual" | "automatic";
markedBy: string | null;
note: string | null;
updatedAt: string | null;
createdAt: string | null;
};
export type IntakeJournalHistoryFilters = {
medicationId: number | null;
from: string;
to: string;
limit: number;
};
export interface UseIntakeJournalReturn {
journalEditorOpen: boolean;
journalHistoryOpen: boolean;
journalTargetDoseId: string | null;
journalEvent: IntakeJournalEntry | null;
journalEventLoading: boolean;
journalEventSaving: boolean;
journalEventDeleting: boolean;
journalEventError: string | null;
journalHistoryEntries: IntakeJournalEntry[];
journalHistoryFilters: IntakeJournalHistoryFilters;
journalHistoryLoading: boolean;
journalHistoryError: string | null;
resetJournalState: () => void;
openJournalEditor: (doseId: string) => Promise<void>;
closeJournalEditor: () => void;
saveJournalNote: (note: string) => Promise<boolean>;
deleteJournalNote: () => Promise<boolean>;
openJournalHistory: () => void;
closeJournalHistory: () => void;
setJournalHistoryFilters: (patch: Partial<IntakeJournalHistoryFilters>) => void;
reloadJournalHistory: () => Promise<void>;
reopenJournalHistoryEntry: (doseId: string) => Promise<void>;
}
const DEFAULT_HISTORY_FILTERS: IntakeJournalHistoryFilters = {
medicationId: null,
from: "",
to: "",
limit: 100,
};
async function readErrorMessage(response: Response, fallbackMessage: string): Promise<string> {
try {
const data = (await response.json()) as { error?: string; code?: string };
if (typeof data.error === "string" && data.error.trim().length > 0) {
return data.error;
}
if (typeof data.code === "string" && data.code.trim().length > 0) {
return data.code;
}
} catch {
// Fall back to the supplied message when the response body is not JSON.
}
return fallbackMessage;
}
function buildHistoryQuery(filters: IntakeJournalHistoryFilters): string {
const params = new URLSearchParams();
if (typeof filters.medicationId === "number") {
params.set("medicationId", String(filters.medicationId));
}
if (filters.from.trim().length > 0) {
params.set("from", filters.from.trim());
}
if (filters.to.trim().length > 0) {
params.set("to", filters.to.trim());
}
params.set("limit", String(filters.limit));
const query = params.toString();
return query.length > 0 ? `?${query}` : "";
}
export function useIntakeJournal(): UseIntakeJournalReturn {
const { authFetch } = useAuth();
const { t } = useTranslation();
const [journalEditorOpen, setJournalEditorOpen] = useState(false);
const [journalHistoryOpen, setJournalHistoryOpen] = useState(false);
const [journalTargetDoseId, setJournalTargetDoseId] = useState<string | null>(null);
const [journalEvent, setJournalEvent] = useState<IntakeJournalEntry | null>(null);
const [journalEventLoading, setJournalEventLoading] = useState(false);
const [journalEventSaving, setJournalEventSaving] = useState(false);
const [journalEventDeleting, setJournalEventDeleting] = useState(false);
const [journalEventError, setJournalEventError] = useState<string | null>(null);
const [journalHistoryEntries, setJournalHistoryEntries] = useState<IntakeJournalEntry[]>([]);
const [journalHistoryFilters, setJournalHistoryFiltersState] =
useState<IntakeJournalHistoryFilters>(DEFAULT_HISTORY_FILTERS);
const [journalHistoryLoading, setJournalHistoryLoading] = useState(false);
const [journalHistoryError, setJournalHistoryError] = useState<string | null>(null);
const resetJournalState = useCallback(() => {
setJournalEditorOpen(false);
setJournalHistoryOpen(false);
setJournalTargetDoseId(null);
setJournalEvent(null);
setJournalEventLoading(false);
setJournalEventSaving(false);
setJournalEventDeleting(false);
setJournalEventError(null);
setJournalHistoryEntries([]);
setJournalHistoryFiltersState(DEFAULT_HISTORY_FILTERS);
setJournalHistoryLoading(false);
setJournalHistoryError(null);
}, []);
const loadJournalEvent = useCallback(
async (doseId: string) => {
setJournalEventLoading(true);
setJournalEventError(null);
try {
const response = await authFetch(`/api/intake-journal/event/${encodeURIComponent(doseId)}`);
if (!response.ok) {
const message = await readErrorMessage(response, t("journal.errors.loadFailed"));
setJournalEvent(null);
setJournalEventError(message);
return;
}
const data = (await response.json()) as { entry: IntakeJournalEntry };
setJournalEvent(data.entry);
} catch {
setJournalEvent(null);
setJournalEventError(t("journal.errors.loadFailed"));
} finally {
setJournalEventLoading(false);
}
},
[authFetch, t]
);
const loadJournalHistory = useCallback(
async (filters: IntakeJournalHistoryFilters) => {
setJournalHistoryLoading(true);
setJournalHistoryError(null);
try {
const response = await authFetch(`/api/intake-journal${buildHistoryQuery(filters)}`);
if (!response.ok) {
const message = await readErrorMessage(response, t("journal.errors.historyFailed"));
setJournalHistoryEntries([]);
setJournalHistoryError(message);
return;
}
const data = (await response.json()) as { entries: IntakeJournalEntry[] };
setJournalHistoryEntries(Array.isArray(data.entries) ? data.entries : []);
} catch {
setJournalHistoryEntries([]);
setJournalHistoryError(t("journal.errors.historyFailed"));
} finally {
setJournalHistoryLoading(false);
}
},
[authFetch, t]
);
useEffect(() => {
if (!journalHistoryOpen) {
return;
}
void loadJournalHistory(journalHistoryFilters);
}, [journalHistoryFilters, journalHistoryOpen, loadJournalHistory]);
const openJournalEditor = useCallback(
async (doseId: string) => {
setJournalHistoryOpen(false);
setJournalEditorOpen(true);
setJournalTargetDoseId(doseId);
setJournalEvent(null);
await loadJournalEvent(doseId);
},
[loadJournalEvent]
);
const closeJournalEditor = useCallback(() => {
setJournalEditorOpen(false);
setJournalTargetDoseId(null);
setJournalEvent(null);
setJournalEventError(null);
setJournalEventLoading(false);
setJournalEventSaving(false);
setJournalEventDeleting(false);
}, []);
const saveJournalNote = useCallback(
async (note: string) => {
if (!journalTargetDoseId) {
setJournalEventError(t("journal.errors.noEventSelected"));
return false;
}
setJournalEventSaving(true);
setJournalEventError(null);
try {
const response = await authFetch(`/api/intake-journal/event/${encodeURIComponent(journalTargetDoseId)}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ note }),
});
if (!response.ok) {
const message = await readErrorMessage(response, t("journal.errors.saveFailed"));
setJournalEventError(message);
return false;
}
const data = (await response.json()) as { entry: IntakeJournalEntry };
setJournalEvent(data.entry);
if (journalHistoryOpen) {
void loadJournalHistory(journalHistoryFilters);
}
return true;
} catch {
setJournalEventError(t("journal.errors.saveFailed"));
return false;
} finally {
setJournalEventSaving(false);
}
},
[authFetch, journalHistoryFilters, journalHistoryOpen, journalTargetDoseId, loadJournalHistory, t]
);
const deleteJournalNote = useCallback(async () => {
if (!journalTargetDoseId) {
setJournalEventError(t("journal.errors.noEventSelected"));
return false;
}
setJournalEventDeleting(true);
setJournalEventError(null);
try {
const response = await authFetch(`/api/intake-journal/event/${encodeURIComponent(journalTargetDoseId)}`, {
method: "DELETE",
});
if (!response.ok) {
const message = await readErrorMessage(response, t("journal.errors.deleteFailed"));
setJournalEventError(message);
return false;
}
setJournalEvent((previous) =>
previous ? { ...previous, note: null, updatedAt: null, createdAt: null } : previous
);
if (journalHistoryOpen) {
void loadJournalHistory(journalHistoryFilters);
}
return true;
} catch {
setJournalEventError(t("journal.errors.deleteFailed"));
return false;
} finally {
setJournalEventDeleting(false);
}
}, [authFetch, journalHistoryFilters, journalHistoryOpen, journalTargetDoseId, loadJournalHistory, t]);
const openJournalHistory = useCallback(() => {
setJournalEditorOpen(false);
setJournalHistoryOpen(true);
setJournalHistoryError(null);
}, []);
const closeJournalHistory = useCallback(() => {
setJournalHistoryOpen(false);
setJournalHistoryError(null);
}, []);
useModalHistory(journalEditorOpen, "intake-journal-editor", closeJournalEditor);
useModalHistory(journalHistoryOpen, "intake-journal-history", closeJournalHistory);
const updateJournalHistoryFilters = useCallback((patch: Partial<IntakeJournalHistoryFilters>) => {
setJournalHistoryFiltersState((previous) => ({
...previous,
...patch,
}));
}, []);
const reloadJournalHistory = useCallback(async () => {
await loadJournalHistory(journalHistoryFilters);
}, [journalHistoryFilters, loadJournalHistory]);
const reopenJournalHistoryEntry = useCallback(
async (doseId: string) => {
setJournalHistoryOpen(false);
await openJournalEditor(doseId);
},
[openJournalEditor]
);
return {
journalEditorOpen,
journalHistoryOpen,
journalTargetDoseId,
journalEvent,
journalEventLoading,
journalEventSaving,
journalEventDeleting,
journalEventError,
journalHistoryEntries,
journalHistoryFilters,
journalHistoryLoading,
journalHistoryError,
resetJournalState,
openJournalEditor,
closeJournalEditor,
saveJournalNote,
deleteJournalNote,
openJournalHistory,
closeJournalHistory,
setJournalHistoryFilters: updateJournalHistoryFilters,
reloadJournalHistory,
reopenJournalHistoryEntry,
};
}
+3 -1
View File
@@ -12,6 +12,7 @@ import {
} from "../types";
import { toDateValue, toTimeValue } from "../utils/formatters";
import { normalizeWeekdays } from "../utils/intake-schedule";
import { personTagsMatch } from "../utils/person-tags";
export const defaultBlister = (): FormBlister => {
const now = new Date();
@@ -488,7 +489,8 @@ export function useMedicationForm(): UseMedicationFormReturn {
const addTakenByPerson = useCallback(
(name: string) => {
const trimmed = name.trim();
if (trimmed && trimmed.length <= FIELD_LIMITS.takenBy.max && !form.takenBy.includes(trimmed)) {
const alreadyExists = form.takenBy.some((person) => personTagsMatch(person, trimmed));
if (trimmed && trimmed.length <= FIELD_LIMITS.takenBy.max && !alreadyExists) {
setForm((prev) => ({ ...prev, takenBy: [...prev.takenBy, trimmed] }));
}
setTakenByInput("");
+10 -9
View File
@@ -1,4 +1,5 @@
import { useCallback, useState } from "react";
import { useAuth } from "../components/Auth";
import type { Medication } from "../types";
export interface UseMedicationsReturn {
@@ -16,6 +17,7 @@ export interface UseMedicationsReturn {
}
export function useMedications(): UseMedicationsReturn {
const { authFetch } = useAuth();
const [meds, setMeds] = useState<Medication[]>([]);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
@@ -30,20 +32,20 @@ export function useMedications(): UseMedicationsReturn {
const loadMeds = useCallback(() => {
setLoading(true);
fetch("/api/medications?includeObsolete=true", { credentials: "include" })
authFetch("/api/medications?includeObsolete=true")
.then((res) => res.json())
.then((data) => setMeds(Array.isArray(data) ? data : []))
.catch(() => setMeds([]))
.finally(() => setLoading(false));
}, []);
}, [authFetch]);
const deleteMed = useCallback(
async (id: number, editingId: number | null, resetForm: () => void) => {
await fetch(`/api/medications/${id}`, { method: "DELETE", credentials: "include" }).catch(() => null);
await authFetch(`/api/medications/${id}`, { method: "DELETE" }).catch(() => null);
if (editingId === id) resetForm();
loadMeds();
},
[loadMeds]
[authFetch, loadMeds]
);
const uploadMedImage = useCallback(
@@ -53,10 +55,9 @@ export function useMedications(): UseMedicationsReturn {
formData.append("file", file);
try {
const res = await fetch(`/api/medications/${medId}/image`, {
const res = await authFetch(`/api/medications/${medId}/image`, {
method: "POST",
body: formData,
credentials: "include",
});
if (!res.ok) {
let code = "UNKNOWN";
@@ -86,15 +87,15 @@ export function useMedications(): UseMedicationsReturn {
setUploadingImage(false);
}
},
[loadMeds]
[authFetch, loadMeds]
);
const deleteMedImage = useCallback(
async (medId: number) => {
await fetch(`/api/medications/${medId}/image`, { method: "DELETE", credentials: "include" }).catch(() => null);
await authFetch(`/api/medications/${medId}/image`, { method: "DELETE" }).catch(() => null);
loadMeds();
},
[loadMeds]
[authFetch, loadMeds]
);
return {
+4 -3
View File
@@ -19,14 +19,15 @@ export function useModalHistory(isOpen: boolean, modalKey: string, onClose: () =
useEffect(() => {
if (!isOpen) return;
const handlePopState = () => {
const handlePopState = (event: PopStateEvent) => {
if (pushedRef.current) {
pushedRef.current = false;
onClose();
event.stopImmediatePropagation();
}
};
window.addEventListener("popstate", handlePopState);
return () => window.removeEventListener("popstate", handlePopState);
window.addEventListener("popstate", handlePopState, { capture: true });
return () => window.removeEventListener("popstate", handlePopState, true);
}, [isOpen, onClose]);
}
+20 -17
View File
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useState } from "react";
import { useAuth } from "../components/Auth";
import type { Coverage, FormState, Medication, RefillEntry } from "../types";
import {
getMedTotal,
@@ -55,6 +56,7 @@ export interface UseRefillReturn {
}
export function useRefill(): UseRefillReturn {
const { authFetch } = useAuth();
// Refill state
const [showRefillModal, setShowRefillModal] = useState(false);
const [refillPacks, setRefillPacks] = useState(1);
@@ -93,19 +95,22 @@ export function useRefill(): UseRefillReturn {
}, [resetRefillForm]);
// Load refill history for a medication
const loadRefillHistory = useCallback(async (medId: number) => {
try {
const res = await fetch(`/api/medications/${medId}/refills`, { credentials: "include" });
if (res.ok) {
const data = await res.json();
setRefillHistory(Array.isArray(data) ? data : data.refills || []);
} else {
const loadRefillHistory = useCallback(
async (medId: number) => {
try {
const res = await authFetch(`/api/medications/${medId}/refills`);
if (res.ok) {
const data = await res.json();
setRefillHistory(Array.isArray(data) ? data : data.refills || []);
} else {
setRefillHistory([]);
}
} catch {
setRefillHistory([]);
}
} catch {
setRefillHistory([]);
}
}, []);
},
[authFetch]
);
// Submit a refill
const submitRefill = useCallback(
@@ -119,10 +124,9 @@ export function useRefill(): UseRefillReturn {
if (refillPacks < 1 && refillLoose < 1) return;
setRefillSaving(true);
try {
const res = await fetch(`/api/medications/${medId}/refill`, {
const res = await authFetch(`/api/medications/${medId}/refill`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
packsAdded: refillPacks,
loosePillsAdded: refillLoose,
@@ -162,7 +166,7 @@ export function useRefill(): UseRefillReturn {
}
setRefillSaving(false);
},
[refillPacks, refillLoose, showRefillModal, loadRefillHistory]
[authFetch, refillPacks, refillLoose, showRefillModal, loadRefillHistory]
);
// Submit a stock correction - user says how many pills they have RIGHT NOW
@@ -282,10 +286,9 @@ export function useRefill(): UseRefillReturn {
}
// Use the PATCH endpoint - it sets stockAdjustment, looseTablets, AND lastStockCorrectionAt
const res = await fetch(`/api/medications/${medId}/stock-adjustment`, {
const res = await authFetch(`/api/medications/${medId}/stock-adjustment`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify(patchBody),
});
if (res.ok) {
@@ -301,7 +304,7 @@ export function useRefill(): UseRefillReturn {
}
setEditStockSaving(false);
},
[editStockFullBlisters, editStockPartialBlisterPills, editStockLoosePills, showEditStockModal]
[authFetch, editStockFullBlisters, editStockPartialBlisterPills, editStockLoosePills, showEditStockModal]
);
const openRefillModal = useCallback(() => {
@@ -28,6 +28,27 @@ export function useScheduleController() {
markDoseSkipped: ctx.markDoseSkipped,
undoDoseTaken: ctx.undoDoseTaken,
undoDoseSkipped: ctx.undoDoseSkipped,
journalEditorOpen: ctx.journalEditorOpen,
journalHistoryOpen: ctx.journalHistoryOpen,
journalTargetDoseId: ctx.journalTargetDoseId,
journalEvent: ctx.journalEvent,
journalEventLoading: ctx.journalEventLoading,
journalEventSaving: ctx.journalEventSaving,
journalEventDeleting: ctx.journalEventDeleting,
journalEventError: ctx.journalEventError,
journalHistoryEntries: ctx.journalHistoryEntries,
journalHistoryFilters: ctx.journalHistoryFilters,
journalHistoryLoading: ctx.journalHistoryLoading,
journalHistoryError: ctx.journalHistoryError,
openJournalEditor: ctx.openJournalEditor,
closeJournalEditor: ctx.closeJournalEditor,
saveJournalNote: ctx.saveJournalNote,
deleteJournalNote: ctx.deleteJournalNote,
openJournalHistory: ctx.openJournalHistory,
closeJournalHistory: ctx.closeJournalHistory,
setJournalHistoryFilters: ctx.setJournalHistoryFilters,
reloadJournalHistory: ctx.reloadJournalHistory,
reopenJournalHistoryEntry: ctx.reopenJournalHistoryEntry,
manuallyCollapsedDays: ctx.manuallyCollapsedDays,
manuallyExpandedDays: ctx.manuallyExpandedDays,
toggleDayCollapse: ctx.toggleDayCollapse,
+144 -29
View File
@@ -3,12 +3,25 @@
// =============================================================================
import { useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import { useAuth } from "../components/Auth";
import { useFeedback } from "../context/FeedbackContext";
import type { Medication } from "../types";
import { withCorrelation } from "../utils/correlation";
import { log } from "../utils/logger";
const SHARE_ALL_VALUE = "all";
export interface ActiveShareLink {
token: string;
takenBy: string;
scheduleDays: number;
createdAt: string;
expiresAt: string | null;
allowJournalNotes: boolean;
shareUrl: string;
}
export interface UseShareReturn {
showShareDialog: boolean;
sharePeople: string[];
@@ -16,54 +29,96 @@ export interface UseShareReturn {
setShareSelectedPerson: React.Dispatch<React.SetStateAction<string>>;
shareSelectedDays: number;
setShareSelectedDays: React.Dispatch<React.SetStateAction<number>>;
shareSelectedExpiryDays: number | null;
setShareSelectedExpiryDays: React.Dispatch<React.SetStateAction<number | null>>;
shareAllowJournalNotes: boolean;
setShareAllowJournalNotes: React.Dispatch<React.SetStateAction<boolean>>;
shareGenerating: boolean;
shareLink: string | null;
setShareLink: React.Dispatch<React.SetStateAction<string | null>>;
shareCopied: boolean;
setShareCopied: React.Dispatch<React.SetStateAction<boolean>>;
activeShareLinks: ActiveShareLink[];
activeSharesLoading: boolean;
revokingShareToken: string | null;
openShareDialog: (meds: Medication[]) => void;
generateShareLink: () => Promise<void>;
revokeShareLink: (token: string) => Promise<boolean>;
copyShareLink: () => void;
closeShareDialog: () => void;
resetShareDialogState: () => void;
}
export function useShare(): UseShareReturn {
const { authFetch } = useAuth();
const { t } = useTranslation();
const { showFeedback } = useFeedback();
const [showShareDialog, setShowShareDialog] = useState(false);
const [sharePeople, setSharePeople] = useState<string[]>([]);
const [shareSelectedPerson, setShareSelectedPerson] = useState<string>("");
const [shareSelectedDays, setShareSelectedDays] = useState<number>(30);
const [shareSelectedExpiryDays, setShareSelectedExpiryDays] = useState<number | null>(null);
const [shareAllowJournalNotes, setShareAllowJournalNotes] = useState(false);
const [shareGenerating, setShareGenerating] = useState(false);
const [shareLink, setShareLink] = useState<string | null>(null);
const [shareCopied, setShareCopied] = useState(false);
const [activeShareLinks, setActiveShareLinks] = useState<ActiveShareLink[]>([]);
const [activeSharesLoading, setActiveSharesLoading] = useState(false);
const [revokingShareToken, setRevokingShareToken] = useState<string | null>(null);
const openShareDialog = useCallback((meds: Medication[]) => {
setShowShareDialog(true);
window.history.pushState({ modal: "share" }, "");
setShareLink(null);
setShareCopied(false);
setShareSelectedPerson("");
setShareSelectedDays(30);
const loadActiveShareLinks = useCallback(async () => {
setActiveSharesLoading(true);
try {
const response = await authFetch("/api/share");
const data = await response.json().catch(() => ({}));
if (!response.ok || !Array.isArray(data?.shareLinks)) {
setActiveShareLinks([]);
log.warn("[ShareDialog] Failed to load active share links", { status: response.status });
return;
}
// 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]);
setActiveShareLinks(data.shareLinks);
} catch (error) {
setActiveShareLinks([]);
log.error("[ShareDialog] Active share list request threw error", { error });
} finally {
setActiveSharesLoading(false);
}
}, []);
}, [authFetch]);
const openShareDialog = useCallback(
(meds: Medication[]) => {
setShowShareDialog(true);
window.history.pushState({ modal: "share" }, "");
setShareLink(null);
setShareCopied(false);
setShareSelectedPerson("");
setShareSelectedDays(30);
setShareSelectedExpiryDays(null);
setShareAllowJournalNotes(false);
void loadActiveShareLinks();
// 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]);
}
},
[loadActiveShareLinks]
);
const generateShareLink = useCallback(async () => {
if (!shareSelectedPerson) {
@@ -82,19 +137,24 @@ export function useShare(): UseShareReturn {
body: JSON.stringify({
takenBy: shareSelectedPerson,
scheduleDays: shareSelectedDays,
expiryDays: shareSelectedExpiryDays,
allowJournalNotes: shareAllowJournalNotes,
}),
},
"fe-share"
);
const res = await fetch("/api/share", init);
const res = await authFetch("/api/share", init);
if (res.ok) {
const data = await res.json();
const fullUrl = `${window.location.origin}/share/${data.token}`;
setShareLink(fullUrl);
void loadActiveShareLinks();
log.info("[ShareDialog] Share link ready", {
person: shareSelectedPerson,
days: shareSelectedDays,
expiryDays: shareSelectedExpiryDays,
allowJournalNotes: shareAllowJournalNotes,
reused: Boolean(data.reused),
correlationId,
});
@@ -106,15 +166,57 @@ export function useShare(): UseShareReturn {
error: err.error,
correlationId,
});
alert(err.error || "Failed to generate share link");
showFeedback({
message: err.error || t("share.generateFailed"),
tone: "error",
});
}
} catch (error) {
log.error("[ShareDialog] Share link request threw error", { person: shareSelectedPerson, error });
alert("Failed to generate share link");
showFeedback({ message: t("share.generateFailed"), tone: "error" });
} finally {
setShareGenerating(false);
}
}, [shareSelectedPerson, shareSelectedDays]);
}, [
authFetch,
loadActiveShareLinks,
shareAllowJournalNotes,
shareSelectedExpiryDays,
shareSelectedPerson,
shareSelectedDays,
showFeedback,
t,
]);
const revokeShareLink = useCallback(
async (token: string) => {
setRevokingShareToken(token);
try {
const response = await authFetch(`/api/share/${token}`, { method: "DELETE" });
if (!response.ok) {
const data = await response.json().catch(() => ({}));
showFeedback({
message: data.error || t("share.revokeFailed"),
tone: "error",
});
return false;
}
setActiveShareLinks((current) => current.filter((share) => share.token !== token));
if (shareLink?.endsWith(`/share/${token}`)) {
setShareLink(null);
setShareCopied(false);
}
return true;
} catch {
showFeedback({ message: t("share.revokeFailed"), tone: "error" });
return false;
} finally {
setRevokingShareToken(null);
}
},
[authFetch, shareLink, showFeedback, t]
);
const copyShareLink = useCallback(() => {
if (shareLink) {
@@ -168,6 +270,11 @@ export function useShare(): UseShareReturn {
setShowShareDialog(false);
setShareLink(null);
setShareCopied(false);
setShareSelectedExpiryDays(null);
setShareAllowJournalNotes(false);
setActiveShareLinks([]);
setActiveSharesLoading(false);
setRevokingShareToken(null);
}, []);
return {
@@ -177,13 +284,21 @@ export function useShare(): UseShareReturn {
setShareSelectedPerson,
shareSelectedDays,
setShareSelectedDays,
shareSelectedExpiryDays,
setShareSelectedExpiryDays,
shareAllowJournalNotes,
setShareAllowJournalNotes,
shareGenerating,
shareLink,
setShareLink,
shareCopied,
setShareCopied,
activeShareLinks,
activeSharesLoading,
revokingShareToken,
openShareDialog,
generateShareLink,
revokeShareLink,
copyShareLink,
closeShareDialog,
resetShareDialogState,
+113 -1
View File
@@ -102,6 +102,64 @@
"needsRefill": "Nachfüllen nötig"
}
},
"journal": {
"actions": {
"note": "Notiz",
"noteTakenOnly": "Notizen funktionieren nur für genommene oder uebersprungene Dosen.",
"history": "Journal-Verlauf",
"historyShort": "Journal"
},
"editor": {
"addTitle": "Journal-Notiz hinzufügen",
"editTitle": "Journal-Notiz bearbeiten",
"description": "Halte fest, was bei dieser Einnahme passiert ist, ohne den bestehenden Einnahme- oder Überspringen-Status zu ändern.",
"loading": "Journal-Eintrag wird geladen...",
"noteLabel": "Journal-Notiz",
"notePlaceholder": "Was möchtest du zu dieser Einnahme festhalten?",
"saving": "Speichern...",
"deleting": "Löschen..."
},
"history": {
"title": "Journal-Verlauf",
"description": "Durchsuche gespeicherte Einnahme-Notizen nach Medikament oder Zeitraum und öffne einen Eintrag erneut im Bearbeitungsmodus.",
"loading": "Journal-Verlauf wird geladen...",
"empty": "Keine Journal-Einträge passen zu den aktuellen Filtern.",
"noNote": "Keine Notiz gespeichert.",
"reload": "Neu laden",
"resetFilters": "Filter zurücksetzen",
"reopen": "Notiz erneut öffnen",
"updatedAt": "Aktualisiert {{date}}",
"filters": {
"medication": "Medikament",
"allMedications": "Alle Medikamente",
"from": "Von",
"to": "Bis",
"fromPlaceholder": "Startdatum",
"toPlaceholder": "Enddatum"
}
},
"context": {
"scheduledFor": "Geplant für",
"takenAt": "Eingenommen um",
"markedBy": "Markiert von",
"source": "Markiert ueber",
"sourceOwnerApp": "Haupt-App",
"sourceSharedLink": "Geteilter Einnahme-Link",
"sourceAutomaticReminder": "Automatische Erinnerungslogik",
"statusTaken": "Eingenommen",
"statusSkipped": "Übersprungen",
"notRecorded": "Nicht erfasst",
"self": "Du"
},
"errors": {
"loadFailed": "Der Journal-Eintrag konnte nicht geladen werden.",
"historyFailed": "Der Journal-Verlauf konnte nicht geladen werden.",
"saveFailed": "Die Journal-Notiz konnte nicht gespeichert werden.",
"deleteFailed": "Die Journal-Notiz konnte nicht gelöscht werden.",
"emptySharedNote": "Geteilte Links koennen Journal-Notizen nicht leeren. Gib eine Notiz ein oder schliesse den Dialog.",
"noEventSelected": "Es ist kein Journal-Eintrag ausgewählt."
}
},
"table": {
"name": "Name",
"pills": "Tabletten",
@@ -604,10 +662,16 @@
"deleteAccount": "Konto löschen",
"deleteAccountConfirmTitle": "Konto löschen?",
"deleteAccountConfirmText": "Dadurch werden dein Konto und alle deine Daten (Medikamente, Einstellungen, Verlauf) dauerhaft gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.",
"deleteAccountButton": "Ja, mein Konto löschen"
"deleteAccountButton": "Ja, mein Konto löschen",
"connectionErrorTitle": "Verbindungsfehler",
"connectionErrorHelp": "Bitte prüfe, ob der Server läuft, und versuche es erneut.",
"sessionExpiredTitle": "Sitzung abgelaufen",
"sessionExpiredHelp": "Bitte melde dich erneut an, um mit deiner Besitzersitzung fortzufahren."
},
"common": {
"loading": "Wird geladen...",
"initializing": "Initialisierung...",
"retry": "Erneut versuchen",
"sending": "Wird gesendet...",
"sent": "Gesendet!",
"sendFailed": "Senden fehlgeschlagen",
@@ -632,6 +696,7 @@
"back": "Zurück",
"cancel": "Abbrechen",
"close": "Schließen",
"hide": "Ausblenden",
"edit": "Bearbeiten",
"view": "Ansehen",
"delete": "Löschen",
@@ -676,6 +741,13 @@
"allPeople": "Alle",
"selectPerson": "Person auswählen",
"selectPeriod": "Zeitraum auswählen",
"selectExpiry": "Link-Ablauf",
"allowJournalNotes": "Diesem geteilten Link das Anzeigen und Bearbeiten von Journal-Notizen erlauben",
"journalNotesEnabled": "Journal anzeigen/bearbeiten erlaubt",
"expiryNever": "Laeuft nicht ab",
"expiry7Days": "Laeuft in 7 Tagen ab",
"expiry30Days": "Laeuft in 30 Tagen ab",
"expiry90Days": "Laeuft in 90 Tagen ab",
"generateLink": "Link generieren",
"generating": "Wird generiert...",
"generateAnother": "Weiteren Link generieren",
@@ -685,9 +757,21 @@
"copyLink": "Link kopieren",
"copyOverviewLink": "Übersichts-Link kopieren",
"copied": "In Zwischenablage kopiert!",
"activeLinksTitle": "Aktive Teilen-Links",
"loadingActiveLinks": "Aktive Teilen-Links werden geladen...",
"noActiveLinks": "Noch keine aktiven Teilen-Links.",
"manageLinksSummary": "Aktive Teilen-Links verwalten",
"generateFailed": "Freigabelink konnte nicht erstellt werden",
"revokeFailed": "Freigabelink konnte nicht widerrufen werden",
"activeLinkMeta": "{{days}} Tage, erstellt {{createdAt}}",
"activeLinkMetaWithExpiry": "{{days}} Tage, erstellt {{createdAt}}, Ablauf {{expiresAt}}",
"revoke": "Widerrufen",
"revoking": "Wird widerrufen...",
"revokeConfirm": "Den aktiven Teilen-Link fuer {{person}} widerrufen?",
"noPeople": "Keine Medikamente mit 'Eingenommen von' zugewiesen. Füge zuerst eine Person zu einem Medikament hinzu.",
"scheduleFor": "Zeitplan für",
"period": "Zeitraum",
"publicAccessHelp": "Dieser Teilen-Link zeigt nur den ausgewaehlten Zeitplan und geteilte Dosisaktionen. Einstellungen und voller Kontozugriff bleiben in der Haupt-App.",
"noSchedule": "Keine geplanten Einnahmen gefunden.",
"generatedBy": "Erstellt von",
"notFound": "Teilen-Link nicht gefunden",
@@ -755,6 +839,24 @@
"confirmImportEmpty": "Daten importieren?",
"confirmImportEmptyMessage": "Alle Medikamente, Einnahmehistorie, Einstellungen und Teilen-Links aus der ausgewählten Datei werden importiert.",
"confirmButtonEmpty": "Importieren",
"reviewDescription": "Prüfe den validierten Sicherungsinhalt, bevor deine aktuellen Installationsdaten ersetzt werden.",
"reviewDescriptionEmpty": "Prüfe den validierten Sicherungsinhalt, bevor er in diese Installation importiert wird.",
"incomingData": "Importdatei",
"currentData": "Aktuelle Daten",
"summaryCounts": "{{medications}} Medikamente, {{doses}} Dosen, {{refills}} Nachfüllungen, {{shares}} Teilen-Links",
"formatVersion": "Formatversion: {{version}}",
"exportedAt": "Exportiert am: {{date}}",
"settingsIncluded": "Einstellungen enthalten",
"settingsConfigured": "Einstellungen aktuell konfiguriert",
"journalEntries": "{{count}} Journaleinträge",
"imageCount": "{{count}} eingebettete Bilder",
"warningListTitle": "Warnungen",
"warningReplaceData": "Deine aktuellen Medikamente, die Einnahmehistorie, Einstellungen und Teilen-Links werden ersetzt.",
"warningShareLinks": "Importierte Teilen-Links erhalten beim Wiederherstellen aus Sicherheitsgründen neue Tokens.",
"warningImages": "Eingebettete Bilder vergrößern den Import und können die Wiederherstellung verlängern.",
"warningSensitive": "Diese Sicherung enthält sensible Benachrichtigungsdaten.",
"backupFirst": "Aktuelle Sicherung zuerst herunterladen",
"backupHint": "Empfohlen: exportiere zuerst deine aktuellen Daten, bevor du den Import bestätigst.",
"cancelButton": "Abbrechen",
"exportSuccess": "Daten erfolgreich exportiert",
"importSuccess": "Daten erfolgreich importiert",
@@ -836,6 +938,9 @@
"button": "Bericht",
"title": "Medikamentenbericht",
"description": "Erstelle ein Dokument mit detaillierten Medikamenteninformationen für deinen Arzt oder deine persönlichen Unterlagen.",
"dateRange": "Zeitraum",
"from": "Von",
"until": "Bis",
"selectAll": "Alle auswählen",
"deselectAll": "Alle abwählen",
"activeMeds": "Aktive Medikamente",
@@ -845,12 +950,19 @@
"formatMd": "Markdown (.md)",
"formatPdf": "PDF (Drucken)",
"generate": "Erstellen",
"regenerate": "Vorschau aktualisieren",
"generating": "Wird erstellt...",
"download": "Herunterladen",
"preview": "Vorschau",
"previewDescription": "Prüfe den generierten Bericht vor dem Export.",
"invalidDateRange": "Wähle einen gültigen Zeitraum.",
"error": "Der Bericht konnte nicht erstellt werden. Bitte versuche es erneut.",
"noSelection": "Wähle mindestens ein Medikament aus",
"filterByPerson": "Bericht für",
"allPeople": "Alle Personen",
"docTitle": "Medikamentenbericht",
"docGenerated": "Erstellt am",
"docRange": "Berichtszeitraum",
"docGeneral": "Allgemein",
"docCommercialName": "Handelsname",
"docGenericName": "Wirkstoff",
+113 -1
View File
@@ -102,6 +102,64 @@
"needsRefill": "Needs refill"
}
},
"journal": {
"actions": {
"note": "Note",
"noteTakenOnly": "Notes are only available for taken or skipped doses.",
"history": "Journal history",
"historyShort": "Journal"
},
"editor": {
"addTitle": "Add journal note",
"editTitle": "Edit journal note",
"description": "Capture what happened for this intake without changing the existing take or skip status.",
"loading": "Loading journal entry...",
"noteLabel": "Journal note",
"notePlaceholder": "What should you remember about this intake?",
"saving": "Saving...",
"deleting": "Deleting..."
},
"history": {
"title": "Journal history",
"description": "Browse saved intake notes by medication or date, then reopen an entry in edit mode.",
"loading": "Loading journal history...",
"empty": "No journal entries match the current filters.",
"noNote": "No note saved.",
"reload": "Reload",
"resetFilters": "Reset filters",
"reopen": "Reopen note",
"updatedAt": "Updated {{date}}",
"filters": {
"medication": "Medication",
"allMedications": "All medications",
"from": "From",
"to": "To",
"fromPlaceholder": "Start date",
"toPlaceholder": "End date"
}
},
"context": {
"scheduledFor": "Scheduled for",
"takenAt": "Taken at",
"markedBy": "Marked by",
"source": "Marked via",
"sourceOwnerApp": "Main app",
"sourceSharedLink": "Shared intake link",
"sourceAutomaticReminder": "Automatic reminder logic",
"statusTaken": "Taken",
"statusSkipped": "Skipped",
"notRecorded": "Not recorded",
"self": "You"
},
"errors": {
"loadFailed": "Journal entry could not be loaded.",
"historyFailed": "Journal history could not be loaded.",
"saveFailed": "Journal note could not be saved.",
"deleteFailed": "Journal note could not be deleted.",
"emptySharedNote": "Shared links cannot clear journal notes. Enter a note or close the dialog.",
"noEventSelected": "No journal entry is selected."
}
},
"table": {
"name": "Name",
"pills": "Pills",
@@ -604,10 +662,16 @@
"deleteAccount": "Delete Account",
"deleteAccountConfirmTitle": "Delete Account?",
"deleteAccountConfirmText": "This will permanently delete your account and all your data (medications, settings, history). This action cannot be undone.",
"deleteAccountButton": "Yes, delete my account"
"deleteAccountButton": "Yes, delete my account",
"connectionErrorTitle": "Connection Error",
"connectionErrorHelp": "Please check if the server is running and try again.",
"sessionExpiredTitle": "Session expired",
"sessionExpiredHelp": "Please sign in again to continue your owner session."
},
"common": {
"loading": "Loading...",
"initializing": "Initializing...",
"retry": "Retry",
"sending": "Sending...",
"sent": "Sent!",
"sendFailed": "Failed to send",
@@ -632,6 +696,7 @@
"back": "Back",
"cancel": "Cancel",
"close": "Close",
"hide": "Hide",
"edit": "Edit",
"view": "View",
"delete": "Delete",
@@ -676,6 +741,13 @@
"allPeople": "Everyone",
"selectPerson": "Select person",
"selectPeriod": "Select time period",
"selectExpiry": "Link expiry",
"allowJournalNotes": "Allow this shared link to view and edit journal notes",
"journalNotesEnabled": "Journal view/edit enabled",
"expiryNever": "Never expires",
"expiry7Days": "Expires in 7 days",
"expiry30Days": "Expires in 30 days",
"expiry90Days": "Expires in 90 days",
"generateLink": "Generate Link",
"generating": "Generating...",
"generateAnother": "Generate another link",
@@ -685,9 +757,21 @@
"copyLink": "Copy Link",
"copyOverviewLink": "Copy Overview Link",
"copied": "Copied to clipboard!",
"activeLinksTitle": "Active share links",
"loadingActiveLinks": "Loading active share links...",
"noActiveLinks": "No active share links yet.",
"manageLinksSummary": "Manage active share links",
"generateFailed": "Failed to generate share link",
"revokeFailed": "Failed to revoke share link",
"activeLinkMeta": "{{days}} days, created {{createdAt}}",
"activeLinkMetaWithExpiry": "{{days}} days, created {{createdAt}}, expires {{expiresAt}}",
"revoke": "Revoke",
"revoking": "Revoking...",
"revokeConfirm": "Revoke the active share link for {{person}}?",
"noPeople": "No medications with 'Taken by' assigned. Add a person to a medication first.",
"scheduleFor": "Schedule for",
"period": "Period",
"publicAccessHelp": "This shared link only exposes the selected schedule and shared dose actions. Owner settings and full account access stay in the main app.",
"noSchedule": "No scheduled doses found.",
"generatedBy": "Generated by",
"notFound": "Share link not found",
@@ -755,6 +839,24 @@
"confirmImportEmpty": "Import Data?",
"confirmImportEmptyMessage": "This will import all medications, dose history, settings, and share links from the selected file.",
"confirmButtonEmpty": "Import",
"reviewDescription": "Review the validated backup contents before replacing your current installation data.",
"reviewDescriptionEmpty": "Review the validated backup contents before importing them into this installation.",
"incomingData": "Import file",
"currentData": "Current data",
"summaryCounts": "{{medications}} medications, {{doses}} doses, {{refills}} refills, {{shares}} share links",
"formatVersion": "Format version: {{version}}",
"exportedAt": "Exported at: {{date}}",
"settingsIncluded": "Settings included",
"settingsConfigured": "Settings currently configured",
"journalEntries": "{{count}} journal entries",
"imageCount": "{{count}} embedded images",
"warningListTitle": "Warnings",
"warningReplaceData": "Your current medications, dose history, settings, and share links will be replaced.",
"warningShareLinks": "Imported share links will get new tokens during restore for security.",
"warningImages": "Embedded images increase import size and may take longer to restore.",
"warningSensitive": "This backup includes sensitive notification data.",
"backupFirst": "Download current backup first",
"backupHint": "Recommended: export your current data before confirming the import.",
"cancelButton": "Cancel",
"exportSuccess": "Data exported successfully",
"importSuccess": "Data imported successfully",
@@ -836,6 +938,9 @@
"button": "Report",
"title": "Medication Report",
"description": "Generate a document with detailed medication information for your doctor or personal records.",
"dateRange": "Date range",
"from": "From",
"until": "Until",
"selectAll": "Select all",
"deselectAll": "Deselect all",
"activeMeds": "Active Medications",
@@ -845,12 +950,19 @@
"formatMd": "Markdown (.md)",
"formatPdf": "PDF (Print)",
"generate": "Generate",
"regenerate": "Refresh preview",
"generating": "Generating...",
"download": "Download",
"preview": "Preview",
"previewDescription": "Review the generated report before exporting it.",
"invalidDateRange": "Choose a valid date range.",
"error": "Could not generate the report. Please try again.",
"noSelection": "Select at least one medication",
"filterByPerson": "Report for",
"allPeople": "Everyone",
"docTitle": "Medication Report",
"docGenerated": "Generated on",
"docRange": "Report range",
"docGeneral": "General",
"docCommercialName": "Commercial Name",
"docGenericName": "Generic Name",
+1
View File
@@ -3,6 +3,7 @@ import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import App from "./App";
import "./styles.css";
import "./styles/intake-journal.css";
import "./styles/modals-base.css";
import "./styles/share-dialog.css";
import "./styles/medication-workflows.css";
+137 -19
View File
@@ -3,11 +3,13 @@ import { Archive, Bell, ClipboardList, NotebookPen, Share2 } from "lucide-react"
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useLocation } from "react-router-dom";
import { ConfirmModal, MedicationAvatar } from "../components";
import { ConfirmModal, IntakeJournalHistoryModal, IntakeJournalModal, MedicationAvatar } from "../components";
import { useAuth } from "../components/Auth";
import { DashboardReminderSection } from "../components/dashboard/DashboardReminderSection";
import { DashboardStatusSection } from "../components/dashboard/DashboardStatusSection";
import { useAppContext } from "../context";
import { useFeedback } from "../context/FeedbackContext";
import { useModalHistory } from "../hooks";
import {
allowsPillFormSelection,
getMedDisplayName,
@@ -75,7 +77,8 @@ const EMPTY_DOSE_SET = new Set<string>();
export function DashboardPage() {
const { t, i18n } = useTranslation();
const { user } = useAuth();
const { user, authFetch } = useAuth();
const { showFeedback } = useFeedback();
const location = useLocation();
const {
meds,
@@ -112,6 +115,26 @@ export function DashboardPage() {
openUserFilter,
openShareDialog,
openScheduleLightbox,
journalEditorOpen,
journalHistoryOpen,
journalEvent,
journalEventLoading,
journalEventSaving,
journalEventDeleting,
journalEventError,
journalHistoryEntries,
journalHistoryFilters,
journalHistoryLoading,
journalHistoryError,
openJournalEditor,
closeJournalEditor,
saveJournalNote,
deleteJournalNote,
openJournalHistory,
closeJournalHistory,
setJournalHistoryFilters,
reloadJournalHistory,
reopenJournalHistoryEntry,
stockThresholds,
loadMeds,
loadSettings,
@@ -121,6 +144,21 @@ export function DashboardPage() {
const [showObsoleteConfirm, setShowObsoleteConfirm] = useState(false);
const [obsoleteCandidate, setObsoleteCandidate] = useState<{ id: number; name: string } | null>(null);
const notificationFocusAppliedRef = useRef<string | null>(null);
const closeClearMissedConfirm = useCallback(() => {
if (!clearingMissed) {
setShowClearMissedConfirm(false);
}
}, [clearingMissed]);
const closeObsoleteConfirm = useCallback(() => {
setShowObsoleteConfirm(false);
setObsoleteCandidate(null);
}, []);
useModalHistory(showClearMissedConfirm, "dashboard-clear-missed", closeClearMissedConfirm);
useModalHistory(showObsoleteConfirm, "dashboard-obsolete", closeObsoleteConfirm);
const effectiveSkippedDoses =
skippedDoses instanceof Set ? skippedDoses : dismissedDoses instanceof Set ? dismissedDoses : EMPTY_DOSE_SET;
const canManageSkippedDoses = typeof markDoseSkipped === "function" && typeof undoDoseSkipped === "function";
@@ -333,9 +371,8 @@ export function DashboardPage() {
setClearingMissed(true);
try {
const res = await fetch("/api/medications/dismiss-until", {
const res = await authFetch("/api/medications/dismiss-until", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
@@ -344,14 +381,37 @@ export function DashboardPage() {
}
await loadMeds();
setShowClearMissedConfirm(false);
alert(t("dashboard.schedules.clearMissedSuccess", { count: missedCount }));
showFeedback({
message: t("dashboard.schedules.clearMissedSuccess", { count: missedCount }),
tone: "success",
});
} catch {
alert(t("common.saveFailed"));
showFeedback({ message: t("common.saveFailed"), tone: "error" });
} finally {
setClearingMissed(false);
}
};
const handleSaveJournalNote = async (note: string) => {
return saveJournalNote(note);
};
const handleDeleteJournalNote = async () => {
const deleted = await deleteJournalNote();
if (deleted) {
closeJournalEditor();
}
};
const handleResetJournalFilters = () => {
setJournalHistoryFilters({
medicationId: null,
from: "",
to: "",
limit: 100,
});
};
const renderDoseActionButtons = (options: {
doseId: string;
isTaken: boolean;
@@ -359,6 +419,7 @@ export function DashboardPage() {
isAutomaticallyTaken: boolean;
isEmpty: boolean;
}) => {
const journalUnavailable = !(options.isTaken || options.isSkipped);
const takeButton = options.isTaken ? (
<button className="dose-btn undo take" onClick={() => undoDoseTaken(options.doseId)} title={t("common.undo")}>
{options.isAutomaticallyTaken && (
@@ -381,8 +442,35 @@ export function DashboardPage() {
</button>
);
const journalButton = (
<span
className={journalUnavailable ? "tooltip-trigger" : undefined}
data-tooltip={journalUnavailable ? t("journal.actions.noteTakenOnly") : undefined}
>
<button
type="button"
className="dose-btn journal"
onClick={() => {
if (!journalUnavailable) {
void openJournalEditor(options.doseId);
}
}}
title={!journalUnavailable ? t("journal.actions.note") : undefined}
disabled={journalUnavailable}
>
<NotebookPen size={14} aria-hidden="true" />
<span className="dose-btn-label">{t("journal.actions.note")}</span>
</button>
</span>
);
if (!canManageSkippedDoses) {
return takeButton;
return (
<>
{takeButton}
{journalButton}
</>
);
}
const skipButton = options.isSkipped ? (
@@ -405,6 +493,7 @@ export function DashboardPage() {
<>
{takeButton}
{skipButton}
{journalButton}
</>
);
};
@@ -417,22 +506,20 @@ export function DashboardPage() {
const handleConfirmMarkObsolete = async () => {
if (!obsoleteCandidate) return;
try {
const res = await fetch(`/api/medications/${obsoleteCandidate.id}/obsolete`, {
const res = await authFetch(`/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"));
showFeedback({ message: t("common.saveFailed"), tone: "error" });
}
};
const handleCancelMarkObsolete = () => {
setShowObsoleteConfirm(false);
setObsoleteCandidate(null);
closeObsoleteConfirm();
};
const getDiscreteUnitLabel = (packageType: string | undefined, count: number) => {
@@ -619,10 +706,9 @@ export function DashboardPage() {
};
});
const stockRes = await fetch("/api/reminder/send-email", {
const stockRes = await authFetch("/api/reminder/send-email", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
email: settings.notificationEmail,
lowStock,
@@ -647,10 +733,9 @@ export function DashboardPage() {
};
});
const prescriptionRes = await fetch("/api/reminder/send-prescription", {
const prescriptionRes = await authFetch("/api/reminder/send-prescription", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
email: settings.notificationEmail,
prescriptionLow,
@@ -913,6 +998,17 @@ export function DashboardPage() {
<option value={90}>{t("dashboard.schedules.3months")}</option>
<option value={180}>{t("dashboard.schedules.6months")}</option>
</select>
<button
type="button"
className="ghost journal-history-button"
onClick={openJournalHistory}
aria-label={t("journal.actions.history")}
title={t("journal.actions.history")}
>
<ClipboardList size={16} aria-hidden="true" />
<span className="journal-history-label-full">{t("journal.actions.history")}</span>
<span className="journal-history-label-short">{t("journal.actions.historyShort")}</span>
</button>
{meds.some((m) => m.takenBy && m.takenBy.length > 0) && (
<button
className="ghost share-btn icon-only tooltip-trigger"
@@ -1229,9 +1325,7 @@ export function DashboardPage() {
confirmLabel={t("dashboard.schedules.clearMissedConfirm")}
cancelLabel={t("dashboard.schedules.clearMissedCancel")}
onConfirm={() => void clearMissedDoses(missedPastDoseIds.length)}
onCancel={() => {
if (!clearingMissed) setShowClearMissedConfirm(false);
}}
onCancel={closeClearMissedConfirm}
isLoading={clearingMissed}
confirmVariant="warning"
/>
@@ -1741,6 +1835,30 @@ export function DashboardPage() {
})}
</div>
)}
<IntakeJournalModal
isOpen={journalEditorOpen}
entry={journalEvent}
isLoading={journalEventLoading}
isSaving={journalEventSaving}
isDeleting={journalEventDeleting}
error={journalEventError}
onClose={closeJournalEditor}
onSave={handleSaveJournalNote}
onDelete={handleDeleteJournalNote}
/>
<IntakeJournalHistoryModal
isOpen={journalHistoryOpen}
entries={journalHistoryEntries}
filters={journalHistoryFilters}
medications={meds}
isLoading={journalHistoryLoading}
error={journalHistoryError}
onClose={closeJournalHistory}
onFilterChange={setJournalHistoryFilters}
onReload={reloadJournalHistory}
onResetFilters={handleResetJournalFilters}
onReopen={reopenJournalHistoryEntry}
/>
</article>
</section>
</div>
+19 -14
View File
@@ -11,6 +11,7 @@ import { MedicationDialogs } from "../components/medications/MedicationDialogs";
import { MedicationEditCoordinator } from "../components/medications/MedicationEditCoordinator";
import { MedicationListSection } from "../components/medications/MedicationListSection";
import { useAppContext, useUnsavedChanges } from "../context";
import { useFeedback } from "../context/FeedbackContext";
import {
MEDICATION_ENRICHMENT_INITIAL_LIMIT,
MEDICATION_ENRICHMENT_LIMIT_STEP,
@@ -222,7 +223,8 @@ async function getMedicationEnrichmentErrorMessage(
export function MedicationsPage() {
const [searchParams, setSearchParams] = useSearchParams();
const { t } = useTranslation();
const { user } = useAuth();
const { user, authFetch } = useAuth();
const { showFeedback } = useFeedback();
const {
meds,
saving,
@@ -274,6 +276,7 @@ export function MedicationsPage() {
);
const [viewMode, setViewMode] = useState<"grid" | "form">(pendingEditTransition ? "form" : "grid");
const [lightboxImage, setLightboxImage] = useState<{ src: string; alt: string } | null>(null);
const closeLightbox = useCallback(() => setLightboxImage(null), []);
const [activeTab, setActiveTab] = useState<"general" | "stock" | "prescription" | "schedule">("general");
// Mobile modal state (declared early because it's used in useEffect below)
@@ -394,9 +397,7 @@ export function MedicationsPage() {
try {
const params = new URLSearchParams({ q: trimmedQuery, limit: String(limit) });
const response = await fetch(`/api/medication-enrichment/search?${params.toString()}`, {
credentials: "include",
});
const response = await authFetch(`/api/medication-enrichment/search?${params.toString()}`);
if (!response.ok) {
throw new Error(
@@ -458,7 +459,7 @@ export function MedicationsPage() {
}));
}
},
[medicationEnrichment.query, medicationEnrichment.results, t]
[authFetch, medicationEnrichment.query, medicationEnrichment.results, t]
);
const handlePendingMedicationImageSelection = useCallback(
@@ -489,6 +490,8 @@ export function MedicationsPage() {
const [readOnlyView, setReadOnlyView] = useState(false);
const [showReportModal, setShowReportModal] = useState(false);
useModalHistory(showReportModal, "report", () => setShowReportModal(false));
useModalHistory(!!lightboxImage, "medication-image-lightbox", closeLightbox);
useModalHistory(showUnsavedConfirm, "medication-unsaved-confirm", handleCancelClose);
const [showNameValidation, setShowNameValidation] = useState(false);
useEffect(() => {
@@ -517,13 +520,13 @@ export function MedicationsPage() {
const loadAllMeds = useCallback(async () => {
try {
const res = await fetch("/api/medications?includeObsolete=true", { credentials: "include" });
const res = await authFetch("/api/medications?includeObsolete=true");
const data = (await res.json()) as unknown;
setAllMeds(Array.isArray(data) ? (data as Medication[]) : []);
} catch {
setAllMeds([]);
}
}, []);
}, [authFetch]);
useEffect(() => {
void loadAllMeds();
@@ -617,7 +620,7 @@ export function MedicationsPage() {
}));
try {
const response = await fetch("/api/medication-enrichment/enrich", {
const response = await authFetch("/api/medication-enrichment/enrich", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
@@ -627,7 +630,6 @@ export function MedicationsPage() {
code: result.code,
source: result.source,
}),
credentials: "include",
});
if (!response.ok) {
@@ -699,7 +701,7 @@ export function MedicationsPage() {
}));
}
},
[form, medicationEnrichment.query, setForm, t]
[authFetch, form, medicationEnrichment.query, setForm, t]
);
const handleMedicationEnrichmentStrengthApply = useCallback(
@@ -1018,7 +1020,7 @@ export function MedicationsPage() {
async function markMedicationObsolete(id: number) {
try {
await fetch(`/api/medications/${id}/obsolete`, { method: "POST", credentials: "include" });
await authFetch(`/api/medications/${id}/obsolete`, { method: "POST" });
if (editingId === id) {
handleResetForm();
}
@@ -1031,7 +1033,7 @@ export function MedicationsPage() {
async function reactivateMedication(id: number) {
try {
await fetch(`/api/medications/${id}/reactivate`, { method: "POST", credentials: "include" });
await authFetch(`/api/medications/${id}/reactivate`, { method: "POST" });
loadMeds();
await loadAllMeds();
} catch {
@@ -1229,7 +1231,10 @@ export function MedicationsPage() {
}
} catch (err) {
log.error("Save error:", err);
alert(err instanceof Error && err.message ? err.message : t("common.saveFailed"));
showFeedback({
message: err instanceof Error && err.message ? err.message : t("common.saveFailed"),
tone: "error",
});
}
setSaving(false);
@@ -2314,7 +2319,7 @@ export function MedicationsPage() {
onCancelDelete={handleCancelDelete}
showEditModal={showEditModal}
lightboxImage={lightboxImage}
onCloseLightbox={() => setLightboxImage(null)}
onCloseLightbox={closeLightbox}
showReportModal={showReportModal}
onCloseReportModal={() => setShowReportModal(false)}
medications={allMeds}
+3 -5
View File
@@ -33,7 +33,7 @@ function userStorageKey(userId: number | undefined, key: string): string {
export function PlannerPage() {
const { t } = useTranslation();
const { user } = useAuth();
const { user, authFetch } = useAuth();
const { meds, settings, openMedDetail } = useAppContext();
// Local state for planner
@@ -90,10 +90,9 @@ export function PlannerPage() {
e.preventDefault();
setPlannerLoading(true);
const body = { startDate: toIsoString(range.start), endDate: toIsoString(range.end), includeUntilStart };
const rows = (await fetch("/api/medications/usage", {
const rows = (await authFetch("/api/medications/usage", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify(body),
})
.then((res) => res.json())
@@ -158,10 +157,9 @@ export function PlannerPage() {
setPlannerEmailResult(null);
try {
const res = await fetch("/api/planner/send-email", {
const res = await authFetch("/api/planner/send-email", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
email: settings.notificationEmail,
from: range.start,
+145 -30
View File
@@ -1,12 +1,13 @@
/* biome-ignore-all lint/style/noNestedTernary: schedule timeline branches are intentionally explicit */
import { Archive, Bell } from "lucide-react";
import { useState } from "react";
import { Archive, Bell, ClipboardList, NotebookPen } from "lucide-react";
import { useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import { ConfirmModal, MedicationAvatar } from "../components";
import { ConfirmModal, IntakeJournalHistoryModal, IntakeJournalModal, MedicationAvatar } from "../components";
import { useAuth } from "../components/Auth";
import { useFeedback } from "../context/FeedbackContext";
import { ScheduleUsageTag } from "../features/schedule/components";
import { formatScheduleDoseUsageLabel, formatScheduleTotalUsageLabel } from "../features/schedule/formatters";
import { useScheduleController } from "../hooks";
import { useModalHistory, useScheduleController } from "../hooks";
import type { Coverage, IntakeUnit } from "../types";
import { getMedDisplayName, isLiquidContainerPackageType, isTubePackageType } from "../types";
import { buildClearMissedPayload, isDoseDismissed } from "../utils/schedule";
@@ -71,7 +72,8 @@ function getDoseId(baseId: string, person: string | null): string {
export function SchedulePage() {
const { t } = useTranslation();
const { user } = useAuth();
const { user, authFetch } = useAuth();
const { showFeedback } = useFeedback();
const {
meds,
settings,
@@ -96,12 +98,46 @@ export function SchedulePage() {
openUserFilter,
missedPastDoseIds,
loadMeds,
journalEditorOpen,
journalHistoryOpen,
journalEvent,
journalEventLoading,
journalEventSaving,
journalEventDeleting,
journalEventError,
journalHistoryEntries,
journalHistoryFilters,
journalHistoryLoading,
journalHistoryError,
openJournalEditor,
closeJournalEditor,
saveJournalNote,
deleteJournalNote,
openJournalHistory,
closeJournalHistory,
setJournalHistoryFilters,
reloadJournalHistory,
reopenJournalHistoryEntry,
} = useScheduleController();
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 closeClearMissedConfirm = useCallback(() => {
if (!clearingMissed) {
setShowClearMissedConfirm(false);
}
}, [clearingMissed]);
const closeObsoleteConfirm = useCallback(() => {
setShowObsoleteConfirm(false);
setObsoleteCandidate(null);
}, []);
useModalHistory(showClearMissedConfirm, "schedule-clear-missed", closeClearMissedConfirm);
useModalHistory(showObsoleteConfirm, "schedule-obsolete", closeObsoleteConfirm);
const isDoseTakenForDisplay = (doseId: string) => takenDoses.has(doseId);
const shouldHideNoScheduleStatusForTube = (
@@ -118,9 +154,8 @@ export function SchedulePage() {
setClearingMissed(true);
try {
const res = await fetch("/api/medications/dismiss-until", {
const res = await authFetch("/api/medications/dismiss-until", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
@@ -129,14 +164,37 @@ export function SchedulePage() {
}
await loadMeds();
setShowClearMissedConfirm(false);
alert(t("dashboard.schedules.clearMissedSuccess", { count: missedCount }));
showFeedback({
message: t("dashboard.schedules.clearMissedSuccess", { count: missedCount }),
tone: "success",
});
} catch {
alert(t("common.saveFailed"));
showFeedback({ message: t("common.saveFailed"), tone: "error" });
} finally {
setClearingMissed(false);
}
};
const handleSaveJournalNote = async (note: string) => {
return saveJournalNote(note);
};
const handleDeleteJournalNote = async () => {
const deleted = await deleteJournalNote();
if (deleted) {
closeJournalEditor();
}
};
const handleResetJournalFilters = () => {
setJournalHistoryFilters({
medicationId: null,
from: "",
to: "",
limit: 100,
});
};
const requestMarkObsolete = (med: { id: number; name: string }) => {
setObsoleteCandidate(med);
setShowObsoleteConfirm(true);
@@ -145,22 +203,20 @@ export function SchedulePage() {
const handleConfirmMarkObsolete = async () => {
if (!obsoleteCandidate) return;
try {
const res = await fetch(`/api/medications/${obsoleteCandidate.id}/obsolete`, {
const res = await authFetch(`/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"));
showFeedback({ message: t("common.saveFailed"), tone: "error" });
}
};
const handleCancelMarkObsolete = () => {
setShowObsoleteConfirm(false);
setObsoleteCandidate(null);
closeObsoleteConfirm();
};
const formatDoseUsageLabel = (
@@ -182,6 +238,7 @@ export function SchedulePage() {
isAutomaticallyTaken: boolean;
isEmpty: boolean;
}) => {
const journalUnavailable = !(options.isTaken || options.isSkipped);
const takeButton = options.isTaken ? (
<button className="dose-btn undo take" onClick={() => undoDoseTaken(options.doseId)} title={t("common.undo")}>
{options.isAutomaticallyTaken && (
@@ -220,10 +277,33 @@ export function SchedulePage() {
</button>
);
const journalButton = (
<span
className={journalUnavailable ? "tooltip-trigger" : undefined}
data-tooltip={journalUnavailable ? t("journal.actions.noteTakenOnly") : undefined}
>
<button
type="button"
className="dose-btn journal"
onClick={() => {
if (!journalUnavailable) {
void openJournalEditor(options.doseId);
}
}}
title={!journalUnavailable ? t("journal.actions.note") : undefined}
disabled={journalUnavailable}
>
<NotebookPen size={14} aria-hidden="true" />
<span className="dose-btn-label">{t("journal.actions.note")}</span>
</button>
</span>
);
return (
<>
{takeButton}
{skipButton}
{journalButton}
</>
);
};
@@ -233,19 +313,32 @@ export function SchedulePage() {
<article className="card schedule-full">
<div className="card-head">
<h2>{t("dashboard.schedules.title")}</h2>
<select
className="select-field schedule-days-select"
value={scheduleDays}
onChange={(e) => {
const val = Number(e.target.value);
setScheduleDays(val);
if (user?.id) localStorage.setItem(userStorageKey(user.id, "scheduleDays"), String(val));
}}
>
<option value={30}>{t("dashboard.schedules.1month")}</option>
<option value={90}>{t("dashboard.schedules.3months")}</option>
<option value={180}>{t("dashboard.schedules.6months")}</option>
</select>
<div className="card-head-actions">
<select
className="select-field schedule-days-select"
value={scheduleDays}
onChange={(e) => {
const val = Number(e.target.value);
setScheduleDays(val);
if (user?.id) localStorage.setItem(userStorageKey(user.id, "scheduleDays"), String(val));
}}
>
<option value={30}>{t("dashboard.schedules.1month")}</option>
<option value={90}>{t("dashboard.schedules.3months")}</option>
<option value={180}>{t("dashboard.schedules.6months")}</option>
</select>
<button
type="button"
className="ghost journal-history-button"
onClick={openJournalHistory}
aria-label={t("journal.actions.history")}
title={t("journal.actions.history")}
>
<ClipboardList size={16} aria-hidden="true" />
<span className="journal-history-label-full">{t("journal.actions.history")}</span>
<span className="journal-history-label-short">{t("journal.actions.historyShort")}</span>
</button>
</div>
</div>
<div className="timeline">
{/* Past days (when expanded) — rendered above toggle */}
@@ -482,9 +575,7 @@ export function SchedulePage() {
confirmLabel={t("dashboard.schedules.clearMissedConfirm")}
cancelLabel={t("dashboard.schedules.clearMissedCancel")}
onConfirm={() => void clearMissedDoses(missedPastDoseIds.length)}
onCancel={() => {
if (!clearingMissed) setShowClearMissedConfirm(false);
}}
onCancel={closeClearMissedConfirm}
isLoading={clearingMissed}
confirmVariant="warning"
/>
@@ -630,6 +721,30 @@ export function SchedulePage() {
);
})}
</div>
<IntakeJournalModal
isOpen={journalEditorOpen}
entry={journalEvent}
isLoading={journalEventLoading}
isSaving={journalEventSaving}
isDeleting={journalEventDeleting}
error={journalEventError}
onClose={closeJournalEditor}
onSave={handleSaveJournalNote}
onDelete={handleDeleteJournalNote}
/>
<IntakeJournalHistoryModal
isOpen={journalHistoryOpen}
entries={journalHistoryEntries}
filters={journalHistoryFilters}
medications={meds}
isLoading={journalHistoryLoading}
error={journalHistoryError}
onClose={closeJournalHistory}
onFilterChange={setJournalHistoryFilters}
onReload={reloadJournalHistory}
onResetFilters={handleResetJournalFilters}
onReopen={reopenJournalHistoryEntry}
/>
</article>
</section>
);
+37 -38
View File
@@ -1,12 +1,15 @@
/* biome-ignore-all lint/a11y/noLabelWithoutControl: settings rows use label-styled text with adjacent custom toggle controls */
import { useEffect, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { ConfirmModal, ExportModal } from "../components";
import { ExportModal, ImportReviewModal } from "../components";
import { useAuth } from "../components/Auth";
import { useAppContext } from "../context";
import { useModalHistory } from "../hooks";
import { getSystemLocale, withFormattingTimezone } from "../utils/formatters";
export function SettingsPage() {
const { t, i18n } = useTranslation();
const { authFetch } = useAuth();
const [apiKeyToken, setApiKeyToken] = useState("");
const [apiKeyGenerating, setApiKeyGenerating] = useState(false);
const [apiKeyCopied, setApiKeyCopied] = useState(false);
@@ -37,15 +40,32 @@ export function SettingsPage() {
showImportConfirm,
setShowImportConfirm,
setPendingImportData,
importPreview,
setImportPreview,
handleImportConfirm,
importResult,
setImportResult,
meds,
} = useAppContext();
const [timezoneTouched, setTimezoneTouched] = useState(false);
const [timezoneDraft, setTimezoneDraft] = useState("");
const hasExistingData = meds.length > 0;
const formattedImportPreviewDate = importPreview
? new Date(importPreview.exportedAt).toLocaleString(getSystemLocale(i18n.language))
: "";
const closeExportModal = useCallback(() => {
setShowExportModal(false);
}, [setShowExportModal]);
const closeImportReview = useCallback(() => {
setShowImportConfirm(false);
setPendingImportData(null);
setImportPreview(null);
}, [setImportPreview, setPendingImportData, setShowImportConfirm]);
useModalHistory(showExportModal, "export-options", closeExportModal);
useModalHistory(showImportConfirm, "import-review", closeImportReview);
let emailUnavailableReason: string | null = null;
if (settingsLoadError === "auth") {
emailUnavailableReason = t("settings.email.loadErrorAuth");
@@ -63,10 +83,9 @@ export function SettingsPage() {
setApiKeyCopied(false);
try {
const response = await fetch("/api/auth/api-keys", {
const response = await authFetch("/api/auth/api-keys", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
name: "Default API Key",
scope: "write",
@@ -195,10 +214,9 @@ export function SettingsPage() {
onChange={(e) => {
const lang = e.target.value;
i18n.changeLanguage(lang);
fetch("/api/settings/language", {
authFetch("/api/settings/language", {
method: "PUT",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ language: lang }),
});
}}
@@ -1142,38 +1160,19 @@ export function SettingsPage() {
</div>
)}
{/* Import Confirmation Modal */}
{showImportConfirm && (
<ConfirmModal
title={t(hasExistingData ? "exportImport.confirmImport" : "exportImport.confirmImportEmpty")}
message={
hasExistingData ? (
<>
<p style={{ marginBottom: "12px" }}>{t("exportImport.confirmImportMessage")}</p>
<p className="warning-text"> {t("exportImport.confirmImportWarning")}</p>
</>
) : (
<p>{t("exportImport.confirmImportEmptyMessage")}</p>
)
}
confirmLabel={t(hasExistingData ? "exportImport.confirmButton" : "exportImport.confirmButtonEmpty")}
cancelLabel={t("exportImport.cancelButton")}
onConfirm={handleImportConfirm}
onCancel={() => {
setShowImportConfirm(false);
setPendingImportData(null);
}}
confirmVariant={hasExistingData ? "danger" : "primary"}
/>
)}
<ImportReviewModal
isOpen={showImportConfirm}
importPreview={importPreview}
formattedExportedAt={formattedImportPreviewDate}
importing={importing}
exporting={exporting}
onClose={closeImportReview}
onBackup={() => handleExport(true)}
onConfirm={handleImportConfirm}
/>
{/* Export Options Modal */}
<ExportModal
isOpen={showExportModal}
onClose={() => setShowExportModal(false)}
onExport={handleExport}
exporting={exporting}
/>
<ExportModal isOpen={showExportModal} onClose={closeExportModal} onExport={handleExport} exporting={exporting} />
</section>
);
}
+1
View File
@@ -5,6 +5,7 @@
Add new shared styles to the focused partial that owns the relevant domain.
============================================================================= */
@import url("./styles/foundation.css");
@import url("./styles/feedback.css");
@import url("./styles/app-surfaces.css");
@import url("./styles/settings-surfaces.css");
@import url("./styles/modal-detail.css");
+91
View File
@@ -284,6 +284,37 @@ a.about-version-link:hover {
margin: 0 0 1.25rem;
}
.report-range {
margin-bottom: 1.25rem;
}
.report-range h4 {
font-size: 0.85rem;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--text-muted);
margin: 0 0 0.5rem;
}
.report-range-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.75rem;
}
.report-range-field {
display: flex;
flex-direction: column;
gap: 0.35rem;
font-size: 0.85rem;
color: var(--text-secondary);
}
.report-range-field .date-input-wrapper {
width: 100%;
}
/* Person filter */
.report-person-filter {
margin-bottom: 1.25rem;
@@ -448,6 +479,60 @@ a.about-version-link:hover {
display: none;
}
.report-error {
margin: 0 0 1rem;
padding: 0.75rem 0.9rem;
border-radius: 10px;
background: color-mix(in srgb, var(--danger-bg, #fee2e2) 75%, transparent);
color: var(--danger-text, #b91c1c);
font-size: 0.9rem;
}
.report-preview {
margin-bottom: 1.25rem;
padding: 0.9rem;
border: 1px solid var(--border-primary);
border-radius: 10px;
background: color-mix(in srgb, var(--bg-secondary) 75%, var(--bg-tertiary));
}
.report-preview-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.75rem;
margin-bottom: 0.35rem;
}
.report-preview-header h4 {
font-size: 0.85rem;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--text-muted);
margin: 0;
}
.report-preview-desc {
margin: 0 0 0.75rem;
font-size: 0.85rem;
color: var(--text-secondary);
}
.report-preview-content {
margin: 0;
padding: 0.85rem;
border-radius: 8px;
background: var(--bg-primary);
border: 1px solid var(--border-secondary);
max-height: 280px;
overflow: auto;
font-size: 0.82rem;
line-height: 1.45;
white-space: pre-wrap;
word-break: break-word;
}
/* Actions */
.report-actions {
display: flex;
@@ -456,3 +541,9 @@ a.about-version-link:hover {
padding-top: 0.75rem;
border-top: 1px solid var(--border-primary);
}
@media (max-width: 720px) {
.report-range-grid {
grid-template-columns: 1fr;
}
}
+197 -30
View File
@@ -2919,48 +2919,74 @@ button.has-validation-error {
.time-row {
grid-template-columns: 1fr;
gap: 0.5rem;
min-width: 0;
}
.doses-col {
flex-direction: row;
flex-wrap: wrap;
flex-direction: column;
flex-wrap: nowrap;
width: 100%;
min-width: 0;
}
.dose-item {
flex: 1 1 auto;
min-width: 140px;
gap: 0.35rem;
padding: 0.35rem 0.3rem;
display: grid;
grid-template-columns: minmax(3.75rem, auto) minmax(0, 1fr) auto;
align-items: center;
width: 100%;
min-width: 0;
gap: 0.45rem;
padding: 0.55rem 0.6rem;
}
.dose-time {
min-width: 42px;
padding-left: 0.2rem;
min-width: 0;
padding-left: 0;
white-space: nowrap;
}
.dose-usage {
line-height: 1.15;
min-width: 0;
}
.dose-checks {
gap: 2px;
grid-column: 1 / -1;
width: 100%;
min-width: 0;
margin-left: 0;
gap: 0.3rem;
align-items: stretch;
}
.dose-item .reminder-icon {
justify-self: end;
}
.dose-person {
gap: 4px;
padding: 1px 4px;
width: 100%;
min-width: 0;
justify-content: flex-end;
gap: 0.35rem;
padding: 0.28rem 0.35rem;
}
.dose-person .person-name {
flex: 1 1 auto;
min-width: 0;
max-width: 5.6rem;
margin-right: 0.35rem;
max-width: none;
margin-right: 0;
}
.dose-person > .tooltip-trigger {
display: inline-flex;
flex-shrink: 0;
}
.dose-person .dose-btn {
height: 22px;
min-height: 22px;
padding: 0 5px;
height: 26px;
min-height: 26px;
padding: 0 0.5rem;
font-size: 0.72rem;
}
@@ -2975,31 +3001,172 @@ button.has-validation-error {
.day-block {
padding: 0.75rem;
min-width: 0;
}
/* Use more horizontal space for schedule cards on phones */
.dashboard-schedules-section > .card {
padding-inline: 0.35rem;
overflow: visible;
.timeline,
.time-main,
.time-main .med-name,
.tag-row {
width: 100%;
min-width: 0;
max-width: 100%;
}
/* Keep header controls aligned like other dashboard cards */
.dashboard-schedules-section .card-head {
flex-direction: row;
align-items: center;
justify-content: space-between;
gap: 0.6rem;
padding-inline: 0.65rem;
.time-main .med-name {
overflow-wrap: anywhere;
}
/* Keep schedule controls readable without exceeding phone width. */
.dashboard-schedules-section > .card,
.schedule-full {
width: 100%;
min-width: 0;
overflow: hidden;
}
.dashboard-schedules-section .card-head,
.schedule-full .card-head {
align-items: stretch;
}
.dashboard-schedules-section .card-head h2,
.schedule-full .card-head h2 {
width: 100%;
}
.dashboard-schedules-section .card-head-actions {
margin-left: auto;
display: flex;
flex-wrap: wrap;
width: 100%;
min-width: 0;
margin-left: 0;
gap: 0.5rem;
align-items: stretch;
}
.schedule-full .card-head-actions {
display: flex;
flex-wrap: wrap;
width: 100%;
min-width: 0;
gap: 0.5rem;
align-items: stretch;
}
.dashboard-schedules-section .schedule-days-select,
.schedule-full .schedule-days-select {
flex: 1 1 7.5rem;
width: auto;
min-width: 0;
max-width: none;
}
.dashboard-schedules-section .journal-history-button,
.schedule-full .journal-history-button {
flex: 1 1 7.5rem;
height: 2.75rem;
min-height: 2.75rem;
min-width: 0;
justify-content: center;
padding-block: 0;
}
.dashboard-schedules-section .journal-history-button span,
.schedule-full .journal-history-button span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.dashboard-schedules-section .journal-history-label-full,
.schedule-full .journal-history-label-full {
display: none;
}
.dashboard-schedules-section .journal-history-label-short,
.schedule-full .journal-history-label-short {
display: inline;
}
.dashboard-schedules-section .share-btn.icon-only {
flex: 0 0 2.75rem;
width: 2.75rem;
height: 2.75rem;
min-width: 2.75rem;
min-height: 2.75rem;
padding: 0;
align-self: stretch;
}
@media (max-width: 380px) {
.dashboard-schedules-section .schedule-days-select,
.schedule-full .schedule-days-select {
flex-basis: 100%;
}
.dashboard-schedules-section .journal-history-button {
flex-basis: calc(100% - 3.25rem);
}
}
.dashboard-schedules-section .day-block,
.schedule-full .day-block {
width: 100%;
min-width: 0;
max-width: 100%;
margin-inline: 0;
}
.day-divider {
min-width: 0;
}
.day-date {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.day-summary {
flex-shrink: 0;
white-space: nowrap;
}
.past-days-header,
.future-days-header {
flex-direction: column;
align-items: stretch;
gap: 0.5rem;
min-width: 0;
}
.past-days-toggle,
.future-days-toggle,
.clear-missed-btn {
width: 100%;
min-width: 0;
}
.past-days-label,
.future-days-label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.past-days-warning,
.past-days-complete,
.future-days-progress {
margin-left: auto;
flex-shrink: 0;
}
.dashboard-schedules-section .day-block {
margin-inline: -0.1rem;
.clear-missed-btn {
display: inline-flex;
align-items: center;
justify-content: center;
}
.status-chip {
+75
View File
@@ -0,0 +1,75 @@
.app-feedback-stack {
position: fixed;
right: 1rem;
bottom: 1rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
width: min(24rem, calc(100vw - 2rem));
z-index: 2100;
pointer-events: none;
}
.app-feedback {
pointer-events: auto;
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 0.9rem 1rem;
border-radius: 12px;
border: 1px solid var(--border-primary);
background: var(--bg-secondary);
box-shadow: 0 16px 32px rgba(0, 0, 0, 0.24);
color: var(--text-primary);
backdrop-filter: blur(10px);
}
.app-feedback-info {
border-color: var(--accent);
background: color-mix(in srgb, var(--bg-secondary) 82%, var(--accent-bg));
}
.app-feedback-success {
border-color: var(--success);
background: color-mix(in srgb, var(--bg-secondary) 82%, var(--success-bg));
}
.app-feedback-warning {
border-color: var(--warning);
background: color-mix(in srgb, var(--bg-secondary) 82%, var(--warning-bg));
}
.app-feedback-error {
border-color: var(--danger);
background: color-mix(in srgb, var(--bg-secondary) 82%, var(--danger-bg));
}
.app-feedback-message {
flex: 1;
font-size: 0.95rem;
line-height: 1.45;
}
.app-feedback-close {
padding: 0;
border: none;
background: transparent;
color: inherit;
font-size: 1.2rem;
line-height: 1;
cursor: pointer;
opacity: 0.7;
}
.app-feedback-close:hover {
opacity: 1;
}
@media (max-width: 640px) {
.app-feedback-stack {
right: 0.75rem;
left: 0.75rem;
bottom: 0.75rem;
width: auto;
}
}
+1 -1
View File
@@ -108,7 +108,7 @@ body.modal-open {
}
.page {
max-width: 1200px;
max-width: 1440px;
margin: 0 auto;
padding: 2.5rem 1.5rem 1.5rem;
overflow-x: hidden;
+234
View File
@@ -0,0 +1,234 @@
/* =============================================================================
Intake Journal Modals
Owns the focused owner-only journal editor and history overlays.
============================================================================= */
.journal-modal,
.journal-history-modal {
max-width: 720px;
}
.journal-history-button {
display: inline-flex;
align-items: center;
gap: 0.45rem;
white-space: nowrap;
}
.journal-history-label-short {
display: none;
}
.dose-btn.journal {
display: inline-flex;
align-items: center;
gap: 0.35rem;
border: 1px solid var(--border-primary);
background: #ffffff;
color: #1e293b;
}
.dose-btn.journal:hover:not(:disabled) {
background: #f4f7fb;
}
.dose-btn.journal:disabled {
background: var(--bg-tertiary);
border-color: var(--border-primary);
color: var(--text-secondary);
filter: none;
}
.journal-modal-header {
display: flex;
flex-direction: column;
gap: 0.4rem;
margin-bottom: 1rem;
padding-right: 2.5rem;
}
.journal-modal-header h2 {
margin: 0;
}
.journal-modal-header p {
margin: 0;
color: var(--text-secondary);
font-size: 0.92rem;
line-height: 1.5;
}
.journal-modal-state {
padding: 1rem;
border: 1px dashed var(--border-primary);
border-radius: 10px;
color: var(--text-secondary);
margin-bottom: 1rem;
}
.journal-event-card {
border: 1px solid var(--border-primary);
border-radius: 12px;
padding: 1rem;
background: var(--bg-primary);
margin-bottom: 1rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
.journal-event-medication {
display: flex;
align-items: center;
gap: 0.75rem;
}
.journal-event-medication p {
margin: 0.2rem 0 0;
color: var(--text-secondary);
font-size: 0.85rem;
}
.journal-event-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.75rem;
}
.journal-event-grid span,
.journal-field span {
display: block;
font-size: 0.82rem;
font-weight: 600;
margin-bottom: 0.35rem;
color: var(--text-secondary);
}
.journal-event-grid strong {
display: block;
font-size: 0.92rem;
color: var(--text-primary);
}
.journal-field {
display: block;
margin-bottom: 1rem;
}
.journal-note-input,
.journal-history-modal .select-field {
width: 100%;
border: 1px solid var(--border-primary);
border-radius: 10px;
background: var(--bg-input);
color: var(--text-primary);
padding: 0.8rem 0.9rem;
font: inherit;
}
.journal-note-input {
resize: vertical;
min-height: 10rem;
line-height: 1.5;
}
.journal-inline-error {
margin-bottom: 1rem;
padding: 0.8rem 0.9rem;
border-radius: 10px;
border: 1px solid rgba(248, 113, 113, 0.3);
background: rgba(127, 29, 29, 0.18);
color: var(--danger);
font-size: 0.9rem;
}
.journal-history-filters {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.75rem;
margin-bottom: 0.75rem;
}
.journal-date-filter .date-input-wrapper,
.journal-date-filter .date-input-display {
width: 100%;
}
.journal-history-toolbar {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
margin-bottom: 1rem;
}
.journal-history-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
margin-bottom: 1rem;
}
.journal-history-entry {
display: flex;
justify-content: space-between;
gap: 1rem;
padding: 1rem;
border-radius: 12px;
border: 1px solid var(--border-primary);
background: var(--bg-primary);
align-items: flex-start;
}
.journal-history-entry-main {
min-width: 0;
flex: 1;
display: flex;
flex-direction: column;
gap: 0.55rem;
}
.journal-history-entry-header {
display: flex;
align-items: center;
gap: 0.75rem;
}
.journal-history-entry-header p,
.journal-history-meta {
margin: 0;
color: var(--text-secondary);
font-size: 0.84rem;
}
.journal-history-note {
margin: 0;
white-space: pre-wrap;
line-height: 1.55;
color: var(--text-primary);
}
.journal-history-meta {
display: flex;
flex-wrap: wrap;
gap: 0.5rem 0.85rem;
}
.journal-modal-footer {
padding: 1rem 0 0;
border-top: 1px solid var(--border-primary);
}
@media (max-width: 720px) {
.journal-history-filters,
.journal-event-grid {
grid-template-columns: 1fr;
}
.journal-history-entry {
flex-direction: column;
}
.journal-history-entry > button {
width: 100%;
}
}
+146 -2
View File
@@ -10,7 +10,7 @@
}
.shared-schedule-container {
max-width: 800px;
max-width: 1180px;
margin: 0 auto;
}
@@ -97,6 +97,14 @@
margin-bottom: 0.5rem;
}
.shared-schedule-boundary {
max-width: 34rem;
margin: 0 auto;
color: var(--text-secondary);
font-size: 0.95rem;
line-height: 1.5;
}
.shared-schedule-period {
color: var(--text-secondary);
font-size: 1rem;
@@ -127,6 +135,10 @@
padding: 0.5rem 0;
}
.shared-schedule-page .tooltip-trigger > .dose-btn:disabled {
pointer-events: none;
}
.med-name-stack {
display: flex;
flex-direction: column;
@@ -326,17 +338,127 @@
@media (max-width: 600px) {
.shared-schedule-page {
padding: 1rem;
padding: 0.75rem;
overflow-x: hidden;
}
.shared-schedule-container,
.shared-schedule-section,
.shared-schedule-section .timeline,
.shared-schedule-section .day-block,
.shared-schedule-section .time-row,
.shared-schedule-section .time-main,
.shared-schedule-section .doses-col,
.shared-schedule-section .dose-item,
.shared-schedule-section .dose-checks,
.shared-schedule-section .dose-person {
width: 100%;
min-width: 0;
max-width: 100%;
}
.shared-schedule-header {
margin-bottom: 1.5rem;
padding-top: 0.25rem;
text-align: left;
}
.shared-schedule-header-actions {
top: 0;
right: 0;
}
.shared-schedule-header h1 {
font-size: 1.25rem;
line-height: 1.18;
padding-right: 3rem;
overflow-wrap: anywhere;
}
.shared-schedule-boundary {
margin-inline: 0;
font-size: 0.9rem;
}
.shared-schedule-period {
margin: 0.75rem 0 0;
font-size: 0.9rem;
}
.shared-timeline {
padding: 1rem;
}
.shared-schedule-section .timeline {
gap: 0.85rem;
}
.shared-schedule-section .day-block {
overflow: hidden;
border-radius: 12px;
padding: 0.75rem;
}
.shared-schedule-section .time-row {
gap: 0.65rem;
}
.shared-schedule-section .time-main .med-name {
overflow-wrap: anywhere;
}
.shared-schedule-section .doses-col {
gap: 0.55rem;
overflow: hidden;
}
.shared-schedule-section .dose-item {
grid-template-columns: minmax(3.5rem, auto) minmax(0, 1fr);
gap: 0.45rem 0.6rem;
padding: 0.55rem 0.6rem;
overflow: hidden;
}
.shared-schedule-section .dose-checks {
grid-column: 1 / -1;
overflow: visible;
}
.shared-schedule-section .dose-person {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto auto;
align-items: center;
gap: 0.4rem;
padding: 0.4rem;
overflow: visible;
}
.shared-schedule-section .dose-person .person-name {
grid-column: 1 / -1;
justify-self: stretch;
max-width: 100%;
margin-right: 0;
}
.shared-schedule-section .dose-person > .dose-btn,
.shared-schedule-section .dose-person > .tooltip-trigger {
min-width: 0;
margin-left: 0;
justify-self: end;
}
.shared-schedule-section .dose-person > .tooltip-trigger {
display: inline-flex;
justify-self: end;
max-width: 100%;
}
.shared-schedule-section .dose-person .dose-btn {
height: 28px;
min-height: 28px;
padding-inline: 0.55rem;
}
.shared-overview-table-wrap {
display: none;
}
@@ -346,6 +468,28 @@
}
}
@media (max-width: 640px) {
.shared-schedule-page .tooltip-trigger[data-tooltip]::after,
.shared-schedule-page .tooltip-trigger[data-tooltip]::before {
display: none;
}
.shared-schedule-page .tooltip-trigger.tooltip-active[data-tooltip]::after {
display: block;
position: fixed;
top: auto;
bottom: var(--tooltip-bottom, 50%);
left: 16px;
right: 16px;
transform: none;
width: auto;
max-width: none;
white-space: normal;
text-align: center;
z-index: 10000;
}
}
/* ── Desktop Edit Panel (two-column layout) ── */
.edit-sidebar {
display: none;
+62
View File
@@ -1290,4 +1290,66 @@
border: 1px solid var(--border-primary);
}
.import-review-modal {
max-width: 560px;
}
.import-review-modal h2 {
margin-bottom: 16px;
padding-right: 2rem;
}
.import-review-body {
display: grid;
gap: 16px;
}
.import-review-summary {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.import-review-summary .action-card {
align-items: stretch;
}
.import-review-meta {
display: grid;
gap: 6px;
font-size: 0.9rem;
}
.import-review-warnings {
display: grid;
gap: 8px;
}
.import-review-warnings ul {
display: grid;
gap: 6px;
margin: 0;
padding-left: 1.25rem;
}
.import-review-footer {
justify-content: space-between;
gap: 12px;
padding: 1rem 0 0;
border-top: none;
}
.import-review-actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
}
@media (max-width: 640px) {
.import-review-summary {
grid-template-columns: 1fr;
}
}
/* Modal base styles moved to styles/modals-base.css */
+76
View File
@@ -66,6 +66,82 @@
border-color: var(--accent);
}
.share-dialog-active-links {
margin-top: 1rem;
}
.share-dialog-manage {
border: 1px solid var(--border-primary);
border-radius: 10px;
background: var(--bg-secondary);
overflow: hidden;
}
.share-dialog-manage-summary {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.75rem;
width: 100%;
padding: 0.85rem 1rem;
font-weight: 600;
color: var(--text-primary);
background: transparent;
border: 0;
cursor: pointer;
text-align: left;
}
.share-dialog-manage-count {
font-size: 0.8rem;
font-weight: 500;
color: var(--text-secondary);
}
.share-dialog-manage-content {
padding: 0 1rem 1rem;
border-top: 1px solid var(--border-primary);
}
.share-active-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.share-active-item {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 0.75rem;
padding-top: 0.75rem;
}
.share-active-item + .share-active-item {
border-top: 1px solid var(--border-primary);
}
.share-active-copy {
display: flex;
flex-direction: column;
gap: 0.25rem;
min-width: 0;
}
.share-link-inline {
font-weight: 600;
color: var(--accent);
text-decoration: none;
word-break: break-word;
}
.share-link-inline:hover {
text-decoration: underline;
}
.share-dialog-footer {
display: flex;
gap: 0.75rem;
+33
View File
@@ -3,11 +3,34 @@ import { MemoryRouter, useLocation } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import App from "../App";
const appTranslations: Record<string, string> = {
"auth.connectionErrorTitle": "Connection Error",
"auth.connectionErrorHelp": "Please check if the server is running and try again.",
"common.initializing": "Initializing...",
"common.loading": "Loading...",
"common.retry": "Retry",
};
vi.mock("react-i18next", async () => {
const actual = await vi.importActual<typeof import("react-i18next")>("react-i18next");
return {
...actual,
useTranslation: () => ({
t: (key: string) => appTranslations[key] ?? key,
i18n: {
language: "en",
changeLanguage: vi.fn(),
},
}),
};
});
type AuthStateMock = {
user: { id: number; username: string } | null;
authState: { authEnabled: boolean; needsSetup: boolean } | null;
loading: boolean;
authError: string | null;
sessionExpired?: boolean;
};
let authMock: AuthStateMock = {
@@ -15,6 +38,7 @@ let authMock: AuthStateMock = {
authState: { authEnabled: false, needsSetup: false },
loading: false,
authError: null,
sessionExpired: false,
};
let appContextMock: Record<string, unknown>;
@@ -156,12 +180,20 @@ describe("App", () => {
setShareSelectedPerson: vi.fn(),
shareSelectedDays: 7,
setShareSelectedDays: vi.fn(),
shareSelectedExpiryDays: null,
setShareSelectedExpiryDays: vi.fn(),
shareAllowJournalNotes: false,
setShareAllowJournalNotes: vi.fn(),
shareGenerating: false,
shareLink: null,
setShareLink: vi.fn(),
shareCopied: false,
setShareCopied: vi.fn(),
activeShareLinks: [],
activeSharesLoading: false,
revokingShareToken: null,
generateShareLink: vi.fn(),
revokeShareLink: vi.fn(),
copyShareLink: vi.fn(),
closeShareDialog: vi.fn(),
resetShareDialogState: vi.fn(),
@@ -215,6 +247,7 @@ describe("App", () => {
);
expect(screen.getByText("Connection Error")).toBeInTheDocument();
expect(screen.getByText("Please check if the server is running and try again.")).toBeInTheDocument();
expect(screen.getByText("Backend is unreachable")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Retry" })).toBeInTheDocument();
});
@@ -132,6 +132,7 @@ describe("AuthProvider", () => {
await waitFor(() => {
expect(result.current.user).toBeNull();
expect(result.current.sessionExpired).toBe(true);
});
});
@@ -865,6 +866,28 @@ describe("AuthProvider methods", () => {
});
expect(result.current.user).toBeNull();
expect(result.current.sessionExpired).toBe(false);
});
it("marks the session as expired when refreshUser cannot recover from 401", async () => {
vi.clearAllMocks();
(global.fetch as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ authEnabled: false, formLoginEnabled: true }) })
.mockResolvedValueOnce({ ok: false, status: 401 })
.mockResolvedValueOnce({ ok: false, status: 401 });
const { result } = renderHook(() => useAuth(), { wrapper });
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
await act(async () => {
await result.current.refreshUser();
});
expect(result.current.user).toBeNull();
expect(result.current.sessionExpired).toBe(true);
});
it("updateProfile throws default message when backend has no error field", async () => {
@@ -0,0 +1,96 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { ImportReviewModal } from "../../components/ImportReviewModal";
vi.mock("react-i18next", async () => {
const actual = await vi.importActual<typeof import("react-i18next")>("react-i18next");
return {
...actual,
useTranslation: () => ({
t: (key: string) => key,
}),
};
});
const importPreview = {
version: "1.6",
exportedAt: "2026-05-21T10:00:00.000Z",
includeSensitiveData: true,
incoming: {
medications: 1,
doseHistory: 2,
refillHistory: 3,
shareLinks: 4,
journalEntries: 1,
imageCount: 1,
hasSettings: true,
},
current: {
medications: 5,
doseHistory: 6,
refillHistory: 7,
shareLinks: 8,
hasSettings: true,
},
warnings: {
replacesExistingData: true,
regeneratesShareLinks: true,
containsImages: true,
containsSensitiveData: true,
},
};
describe("ImportReviewModal", () => {
it("stays closed without an open preview", () => {
const { container } = render(
<ImportReviewModal
isOpen={false}
importPreview={importPreview}
formattedExportedAt="May 21, 2026"
importing={false}
exporting={false}
onClose={vi.fn()}
onBackup={vi.fn()}
onConfirm={vi.fn()}
/>
);
expect(container.firstChild).toBeNull();
});
it("supports overlay, Escape, backup, and confirm actions", () => {
const onClose = vi.fn();
const onBackup = vi.fn();
const onConfirm = vi.fn();
const { container } = render(
<ImportReviewModal
isOpen={true}
importPreview={importPreview}
formattedExportedAt="May 21, 2026"
importing={false}
exporting={false}
onClose={onClose}
onBackup={onBackup}
onConfirm={onConfirm}
/>
);
expect(screen.getByRole("dialog")).toBeInTheDocument();
expect(screen.getByText("exportImport.confirmImport")).toBeInTheDocument();
fireEvent.click(container.querySelector(".modal-content") as Element);
expect(onClose).not.toHaveBeenCalled();
fireEvent.click(screen.getByText("exportImport.backupFirst"));
expect(onBackup).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByText("exportImport.confirmButton"));
expect(onConfirm).toHaveBeenCalledTimes(1);
fireEvent.keyDown(document, { key: "Escape" });
expect(onClose).toHaveBeenCalledTimes(1);
fireEvent.click(container.querySelector(".modal-overlay") as Element);
expect(onClose).toHaveBeenCalledTimes(2);
});
});
@@ -0,0 +1,93 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { IntakeJournalModal } from "../../components/intake-journal/IntakeJournalModal";
import type { IntakeJournalEntry } from "../../hooks/useIntakeJournal";
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}));
vi.mock("../../components/MedicationAvatar", () => ({
MedicationAvatar: ({ name }: { name: string }) => <div>{name}</div>,
}));
function buildEntry(overrides: Partial<IntakeJournalEntry> = {}): IntakeJournalEntry {
return {
doseTrackingId: 1,
doseId: "1-0-1760000000000-pillamn",
medicationId: 1,
medicationName: "Liquid Container",
scheduledFor: "2026-05-17T11:55:00.000Z",
takenAt: "2026-05-17T19:23:00.000Z",
dismissed: false,
takenSource: "manual",
markedBy: "pillamn",
note: "",
updatedAt: null,
createdAt: null,
...overrides,
};
}
describe("IntakeJournalModal", () => {
it("closes after a successful save", async () => {
const onSave = vi.fn(async () => true);
const onClose = vi.fn();
const onDelete = vi.fn();
const entry = buildEntry();
render(
<IntakeJournalModal
isOpen
entry={entry}
isLoading={false}
isSaving={false}
isDeleting={false}
error={null}
onClose={onClose}
onSave={onSave}
onDelete={onDelete}
/>
);
fireEvent.change(screen.getByLabelText("journal.editor.noteLabel"), {
target: { value: "Shared note" },
});
fireEvent.click(screen.getByRole("button", { name: "common.save" }));
expect(onSave).toHaveBeenCalledWith("Shared note");
await waitFor(() => {
expect(onClose).toHaveBeenCalled();
});
});
it("keeps the modal open when save fails", async () => {
const onSave = vi.fn(async () => false);
const onClose = vi.fn();
const entry = buildEntry();
render(
<IntakeJournalModal
isOpen
entry={entry}
isLoading={false}
isSaving={false}
isDeleting={false}
error={null}
onClose={onClose}
onSave={onSave}
onDelete={vi.fn()}
/>
);
fireEvent.change(screen.getByLabelText("journal.editor.noteLabel"), {
target: { value: "Shared note" },
});
fireEvent.click(screen.getByRole("button", { name: "common.save" }));
await waitFor(() => {
expect(onSave).toHaveBeenCalledWith("Shared note");
});
expect(onClose).not.toHaveBeenCalled();
});
});
+110 -57
View File
@@ -4,6 +4,28 @@ import ReportModal from "../../components/ReportModal";
import type { Medication } from "../../types";
import { formatDate, formatDateTime } from "../../utils/formatters";
const authFetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => fetch(input, init));
vi.mock("../../components/Auth", () => ({
useAuth: () => ({ authFetch: authFetchMock }),
}));
function getPreviewContent() {
const preview = document.querySelector(".report-preview-content");
if (!(preview instanceof HTMLElement)) {
throw new Error("Expected report preview content to be rendered");
}
return preview.textContent ?? "";
}
function expectPreviewToBeVisible() {
const preview = document.querySelector(".report-preview");
if (!(preview instanceof HTMLElement)) {
throw new Error("Expected report preview to be rendered");
}
expect(preview).toBeInTheDocument();
}
function createMedication(overrides: Partial<Medication> = {}): Medication {
return {
id: 1,
@@ -24,6 +46,7 @@ function createMedication(overrides: Partial<Medication> = {}): Medication {
describe("ReportModal", () => {
beforeEach(() => {
vi.clearAllMocks();
authFetchMock.mockImplementation((input: RequestInfo | URL, init?: RequestInit) => fetch(input, init));
});
it("renders and closes when cancel is clicked", () => {
@@ -35,35 +58,41 @@ describe("ReportModal", () => {
expect(onClose).toHaveBeenCalledTimes(1);
});
it("generates text report and closes modal", async () => {
it("generates txt and md previews in-app without closing the modal", async () => {
const onClose = vi.fn();
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: true,
json: async () => ({
1: {
dosesTaken: 2,
dosesSkipped: 0,
firstDoseAt: "2026-01-01T08:00:00.000Z",
lastDoseAt: "2026-01-02T08:00:00.000Z",
refills: [],
},
}),
});
for (const format of ["txt", "md"] as const) {
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
json: async () => ({
1: {
dosesTaken: 2,
automaticDosesTaken: 0,
dosesSkipped: 0,
firstDoseAt: "2026-01-01T08:00:00.000Z",
lastDoseAt: "2026-01-02T08:00:00.000Z",
refills: [],
},
}),
});
render(<ReportModal isOpen={true} onClose={onClose} medications={[createMedication()]} />);
const view = render(<ReportModal isOpen={true} onClose={onClose} medications={[createMedication()]} />);
fireEvent.click(screen.getByRole("radio", { name: /report\.formatTxt/i }));
fireEvent.click(screen.getByRole("button", { name: /report\.generate/i }));
await waitFor(() => {
expect(global.fetch).toHaveBeenCalledWith(
"/api/medications/report-data",
expect.objectContaining({ method: "POST" })
fireEvent.click(
screen.getByRole("radio", { name: new RegExp(`report\\.format${format === "txt" ? "Txt" : "Md"}`, "i") })
);
});
fireEvent.click(screen.getByRole("button", { name: /report\.generate/i }));
expect(onClose).toHaveBeenCalledTimes(1);
expect(URL.createObjectURL).toHaveBeenCalled();
await waitFor(() => {
expectPreviewToBeVisible();
});
expect(screen.getByRole("button", { name: /report\.download/i })).toBeInTheDocument();
expect(onClose).not.toHaveBeenCalled();
expect(URL.createObjectURL).not.toHaveBeenCalled();
expect(getPreviewContent()).toContain("report.docTitle");
view.unmount();
}
});
it("renders shared formatter output in exported text reports", async () => {
@@ -99,18 +128,15 @@ describe("ReportModal", () => {
fireEvent.click(screen.getByRole("button", { name: /report\.generate/i }));
await waitFor(() => {
expect(URL.createObjectURL).toHaveBeenCalled();
expectPreviewToBeVisible();
});
const [blob] = (URL.createObjectURL as ReturnType<typeof vi.fn>).mock.calls.at(-1) ?? [];
expect(blob).toBeInstanceOf(Blob);
const content = await (blob as Blob).text();
const content = getPreviewContent();
expect(content).toContain(formatDate("2026-02-01"));
expect(content).toContain(formatDateTime("2026-02-02T08:30:00.000Z"));
expect(content).toContain(formatDate("2026-02-03T12:00:00.000Z"));
expect(onClose).toHaveBeenCalledTimes(1);
expect(onClose).not.toHaveBeenCalled();
});
it("exports bottle current stock separately from configured capacity", async () => {
@@ -151,16 +177,15 @@ describe("ReportModal", () => {
fireEvent.click(screen.getByRole("button", { name: /report\.generate/i }));
await waitFor(() => {
expect(URL.createObjectURL).toHaveBeenCalled();
expectPreviewToBeVisible();
});
const [blob] = (URL.createObjectURL as ReturnType<typeof vi.fn>).mock.calls.at(-1) ?? [];
const content = await (blob as Blob).text();
const content = getPreviewContent();
expect(content).toContain("report.docTotalCapacity: 100");
expect(content).toContain("report.docCurrentStock: 70 common.pills");
expect(content).not.toContain("report.docCurrentStock: 100 common.pills");
expect(onClose).toHaveBeenCalledTimes(1);
expect(onClose).not.toHaveBeenCalled();
});
it("exports injection refill history with injection unit wording", async () => {
@@ -205,15 +230,14 @@ describe("ReportModal", () => {
fireEvent.click(screen.getByRole("button", { name: /report\.generate/i }));
await waitFor(() => {
expect(URL.createObjectURL).toHaveBeenCalled();
expectPreviewToBeVisible();
});
const [blob] = (URL.createObjectURL as ReturnType<typeof vi.fn>).mock.calls.at(-1) ?? [];
const content = await (blob as Blob).text();
const content = getPreviewContent();
expect(content).toContain("report.docCurrentStock: 6 common.injections");
expect(content).toContain("+3 common.injections");
expect(onClose).toHaveBeenCalledTimes(1);
expect(onClose).not.toHaveBeenCalled();
});
it("generates printable report when PDF format is selected", async () => {
@@ -288,14 +312,17 @@ describe("ReportModal", () => {
onClose={onClose}
medications={[
createMedication({ id: 1, name: "Alice Med", takenBy: ["Alice"] }),
createMedication({ id: 2, name: "Bob Med", takenBy: ["Bob"] }),
createMedication({ id: 2, name: "Alice Lower", takenBy: ["alice"] }),
createMedication({ id: 3, name: "Bob Med", takenBy: ["Bob"] }),
]}
/>
);
expect(screen.getByText(/report\.filterByPerson/i)).toBeInTheDocument();
expect(screen.getAllByRole("checkbox", { name: "Alice" })).toHaveLength(1);
fireEvent.click(screen.getByRole("checkbox", { name: "Alice" }));
expect(screen.getByText("Alice Med")).toBeInTheDocument();
expect(screen.getByText("Alice Lower")).toBeInTheDocument();
expect(screen.queryByText("Bob Med")).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: /report\.deselectAll/i }));
@@ -335,7 +362,8 @@ describe("ReportModal", () => {
onClose={onClose}
medications={[
createMedication({ id: 1, name: "Alice Med", takenBy: ["Alice"] }),
createMedication({ id: 2, name: "Bob Med", takenBy: ["Bob"] }),
createMedication({ id: 2, name: "Alice Lower", takenBy: ["alice"] }),
createMedication({ id: 3, name: "Bob Med", takenBy: ["Bob"] }),
]}
/>
);
@@ -345,15 +373,14 @@ describe("ReportModal", () => {
fireEvent.click(screen.getByRole("button", { name: /report\.generate/i }));
await waitFor(() => {
expect(global.fetch).toHaveBeenCalledWith(
"/api/medications/report-data",
expect.objectContaining({
method: "POST",
body: JSON.stringify({ medicationIds: [1], takenByFilter: ["Alice"] }),
})
);
const [, requestInit] = authFetchMock.mock.calls[0] ?? [];
const body = JSON.parse((requestInit?.body as string) ?? "{}");
expect(body).toMatchObject({ medicationIds: [1, 2], takenByFilter: ["Alice"] });
expect(typeof body.startDate).toBe("string");
expect(typeof body.endDate).toBe("string");
});
authFetchMock.mockClear();
(global.fetch as ReturnType<typeof vi.fn>).mockClear();
firstRender.unmount();
render(
@@ -362,7 +389,8 @@ describe("ReportModal", () => {
onClose={onClose}
medications={[
createMedication({ id: 1, name: "Alice Med", takenBy: ["Alice"] }),
createMedication({ id: 2, name: "Bob Med", takenBy: ["Bob"] }),
createMedication({ id: 2, name: "Alice Lower", takenBy: ["alice"] }),
createMedication({ id: 3, name: "Bob Med", takenBy: ["Bob"] }),
]}
/>
);
@@ -370,17 +398,16 @@ describe("ReportModal", () => {
fireEvent.click(screen.getByRole("button", { name: /report\.generate/i }));
await waitFor(() => {
expect(global.fetch).toHaveBeenCalledWith(
"/api/medications/report-data",
expect.objectContaining({
method: "POST",
body: JSON.stringify({ medicationIds: [1, 2], takenByFilter: undefined }),
})
);
const [, requestInit] = authFetchMock.mock.calls[0] ?? [];
const body = JSON.parse((requestInit?.body as string) ?? "{}");
expect(body).toMatchObject({ medicationIds: [1, 2, 3] });
expect(body).not.toHaveProperty("takenByFilter");
expect(typeof body.startDate).toBe("string");
expect(typeof body.endDate).toBe("string");
});
});
it("generates markdown report and keeps modal open on fetch error", async () => {
it("shows a localized fetch error and keeps the modal open when preview generation fails", async () => {
const onClose = vi.fn();
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({ ok: false });
@@ -390,9 +417,35 @@ describe("ReportModal", () => {
fireEvent.click(screen.getByRole("button", { name: /report\.generate/i }));
await waitFor(() => {
expect(global.fetch).toHaveBeenCalled();
expect(authFetchMock).toHaveBeenCalledWith(
"/api/medications/report-data",
expect.objectContaining({ method: "POST" })
);
});
expect(onClose).not.toHaveBeenCalled();
expect(screen.getByText(/report\.error/i)).toBeInTheDocument();
expect(screen.queryByText(/report\.preview/i)).not.toBeInTheDocument();
});
it("shows a localized error and skips the request when the date range is invalid", async () => {
const onClose = vi.fn();
render(<ReportModal isOpen={true} onClose={onClose} medications={[createMedication()]} />);
const inputs = screen.getAllByDisplayValue(/\d{2}\.\d{2}\.\d{4}|\d{1,2}\/\d{1,2}\/\d{4}|\d{4}-\d{2}-\d{2}/i);
const startInput = inputs[0] as HTMLInputElement;
const endInput = inputs[1] as HTMLInputElement;
fireEvent.change(startInput.parentElement?.querySelector("input") ?? startInput, {
target: { value: "2026-02-10T10:00" },
});
fireEvent.change(endInput.parentElement?.querySelector("input") ?? endInput, {
target: { value: "2026-02-10T09:00" },
});
fireEvent.click(screen.getByRole("button", { name: /report\.generate/i }));
expect(authFetchMock).not.toHaveBeenCalled();
expect(onClose).not.toHaveBeenCalled();
expect(screen.getByText(/report\.invalidDateRange/i)).toBeInTheDocument();
});
});
@@ -10,13 +10,21 @@ describe("ShareDialog", () => {
onShareSelectedPersonChange: vi.fn(),
shareSelectedDays: 30,
onShareSelectedDaysChange: vi.fn(),
shareSelectedExpiryDays: null,
onShareSelectedExpiryDaysChange: vi.fn(),
shareAllowJournalNotes: false,
onShareAllowJournalNotesChange: vi.fn(),
shareGenerating: false,
shareLink: null,
onShareLinkChange: vi.fn(),
shareCopied: false,
onShareCopiedChange: vi.fn(),
activeShareLinks: [],
activeSharesLoading: false,
revokingShareToken: null,
onClose: vi.fn(),
onGenerateShareLink: vi.fn(),
onRevokeShareLink: vi.fn().mockResolvedValue(true),
onCopyShareLink: vi.fn(),
};
@@ -105,9 +113,13 @@ describe("ShareDialog", () => {
const selects = screen.getAllByRole("combobox");
fireEvent.change(selects[0], { target: { value: "Bob" } });
fireEvent.change(selects[1], { target: { value: "90" } });
fireEvent.change(selects[2], { target: { value: "30" } });
fireEvent.click(screen.getByLabelText(/share\.allowJournalNotes/i));
expect(defaultProps.onShareSelectedPersonChange).toHaveBeenCalledWith("Bob");
expect(defaultProps.onShareSelectedDaysChange).toHaveBeenCalledWith(90);
expect(defaultProps.onShareSelectedExpiryDaysChange).toHaveBeenCalledWith(30);
expect(defaultProps.onShareAllowJournalNotesChange).toHaveBeenCalledWith(true);
});
it("disables generate button when no person is selected", () => {
@@ -116,4 +128,58 @@ describe("ShareDialog", () => {
const generateButton = screen.getByRole("button", { name: /share\.generateLink/i });
expect(generateButton).toBeDisabled();
});
it("keeps active share management collapsed until opened", () => {
render(
<ShareDialog
{...defaultProps}
activeShareLinks={[
{
token: "abcdef0123456789",
takenBy: "Alice",
scheduleDays: 30,
createdAt: "2026-05-17T12:00:00.000Z",
expiresAt: null,
allowJournalNotes: true,
shareUrl: "/share/abcdef0123456789",
},
]}
/>
);
expect(screen.getByText(/share\.manageLinksSummary/i)).toBeInTheDocument();
expect(screen.queryByRole("button", { name: /share\.revoke/i })).not.toBeInTheDocument();
fireEvent.click(screen.getByText(/share\.manageLinksSummary/i));
expect(screen.getByRole("button", { name: /share\.revoke/i })).toBeInTheDocument();
});
it("uses an in-app confirm modal before revoking an active share link", async () => {
render(
<ShareDialog
{...defaultProps}
activeShareLinks={[
{
token: "abcdef0123456789",
takenBy: "Alice",
scheduleDays: 30,
createdAt: "2026-05-17T12:00:00.000Z",
expiresAt: null,
allowJournalNotes: true,
shareUrl: "/share/abcdef0123456789",
},
]}
/>
);
fireEvent.click(screen.getByText(/share\.manageLinksSummary/i));
fireEvent.click(screen.getByRole("button", { name: /share\.revoke/i }));
expect(screen.getByText(/share\.revokeConfirm/i)).toBeInTheDocument();
fireEvent.click(screen.getAllByRole("button", { name: /share\.revoke/i })[1]);
expect(defaultProps.onRevokeShareLink).toHaveBeenCalledWith("abcdef0123456789");
});
});
@@ -141,6 +141,7 @@ function createSharedDataWithTodayDose(referenceNow: Date) {
sharedBy: "Owner",
takenBy: "Max",
scheduleDays: 30,
allowJournalNotes: false,
automaticDoseId: `1-0-${dateOnlyMs}`,
medications: [
{
@@ -171,17 +172,24 @@ function createSharedDataWithTodayDose(referenceNow: Date) {
function createSharedDoseFetchMock(options: {
token?: string;
sharedData: ReturnType<typeof createSharedDataWithTodayDose>;
initialDoses?: Array<{ doseId: string; skipped?: boolean; dismissed?: boolean; takenSource?: string }>;
initialDoses?: Array<{
doseId: string;
skipped?: boolean;
dismissed?: boolean;
takenSource?: string;
hasJournalNote?: boolean;
}>;
}) {
const token = options.token ?? "token-123";
const doseState = new Map((options.initialDoses ?? []).map((dose) => [dose.doseId, { ...dose }]));
const journalState = new Map<string, { note: string | null; createdAt: string | null; updatedAt: string | null }>();
const requests: Array<{ url: string; method: string; body?: unknown }> = [];
const fetchMock = vi.fn(async (url: string, init?: RequestInit) => {
const method = init?.method ?? "GET";
const body =
typeof init?.body === "string" && init.body.length > 0
? (JSON.parse(init.body) as { doseId: string })
? (JSON.parse(init.body) as { doseId?: string; note?: string | null })
: undefined;
requests.push({ url, method, body });
@@ -190,7 +198,11 @@ function createSharedDoseFetchMock(options: {
}
if (url === `/api/share/${token}/doses` && method === "GET") {
return { ok: true, json: async () => ({ doses: Array.from(doseState.values()) }) };
const doses = Array.from(doseState.values()).map((dose) => ({
...dose,
hasJournalNote: dose.hasJournalNote === true || Boolean(journalState.get(dose.doseId)?.note?.trim()),
}));
return { ok: true, json: async () => ({ doses }) };
}
if (url === `/api/share/${token}/doses/skip` && method === "POST" && body?.doseId) {
@@ -203,6 +215,61 @@ function createSharedDoseFetchMock(options: {
return { ok: true, json: async () => ({}) };
}
if (url.startsWith(`/api/share/${token}/journal/event/`) && method === "GET") {
const doseId = decodeURIComponent(url.split("/").at(-1) ?? "");
const journal = journalState.get(doseId) ?? { note: null, createdAt: null, updatedAt: null };
return {
ok: true,
json: async () => ({
entry: {
doseTrackingId: 1,
doseId,
medicationId: 1,
medicationName: "Ibuprofen",
scheduledFor: new Date().toISOString(),
takenAt: new Date().toISOString(),
dismissed: false,
takenSource: "manual",
markedBy: "Max",
note: journal.note,
createdAt: journal.createdAt,
updatedAt: journal.updatedAt,
},
}),
};
}
if (url.startsWith(`/api/share/${token}/journal/event/`) && method === "PUT") {
const doseId = decodeURIComponent(url.split("/").at(-1) ?? "");
const timestamp = new Date().toISOString();
journalState.set(doseId, { note: body?.note ?? null, createdAt: timestamp, updatedAt: timestamp });
return {
ok: true,
json: async () => ({
entry: {
doseTrackingId: 1,
doseId,
medicationId: 1,
medicationName: "Ibuprofen",
scheduledFor: new Date().toISOString(),
takenAt: new Date().toISOString(),
dismissed: false,
takenSource: "manual",
markedBy: "Max",
note: body?.note ?? null,
createdAt: timestamp,
updatedAt: timestamp,
},
}),
};
}
if (url.startsWith(`/api/share/${token}/journal/event/`) && method === "DELETE") {
const doseId = decodeURIComponent(url.split("/").at(-1) ?? "");
journalState.delete(doseId);
return { ok: true, json: async () => ({ success: true }) };
}
if (url.startsWith(`/api/share/${token}/doses/skip/`) && method === "DELETE") {
const doseId = decodeURIComponent(url.split("/").at(-1) ?? "");
doseState.delete(doseId);
@@ -244,10 +311,109 @@ describe("SharedSchedule", () => {
await waitFor(() => {
expect(screen.getByText(/share\.scheduleFor/i)).toBeInTheDocument();
expect(screen.getByText("share.publicAccessHelp")).toBeInTheDocument();
expect(screen.getByText("share.noSchedule")).toBeInTheDocument();
});
});
it("opens and saves a shared journal note when the share link allows notes", async () => {
const referenceNow = new Date();
referenceNow.setHours(12, 0, 0, 0);
vi.spyOn(Date, "now").mockReturnValue(referenceNow.getTime());
const sharedData = {
...createSharedDataWithTodayDose(referenceNow),
allowJournalNotes: true,
};
const { fetchMock, requests } = createSharedDoseFetchMock({
sharedData,
});
globalThis.fetch = fetchMock as unknown as typeof fetch;
renderSharedSchedule("/share/token-123");
await waitFor(() => {
expect(document.querySelector(".dose-btn.take")).toBeInTheDocument();
});
const unavailableJournalButton = document.querySelector(".dose-btn.journal") as HTMLButtonElement;
expect(unavailableJournalButton).toBeDisabled();
expect(unavailableJournalButton).not.toHaveClass("has-note");
expect(unavailableJournalButton.closest("span")).toHaveAttribute("data-tooltip", "journal.actions.noteTakenOnly");
fireEvent.click(screen.getByText("dose.take"));
await waitFor(() => {
expect(requests).toContainEqual({
url: "/api/share/token-123/doses",
method: "POST",
body: { doseId: sharedData.automaticDoseId },
});
expect(document.querySelector(".day-block.today")).not.toHaveClass("collapsed");
});
await waitFor(() => {
const availableJournalButton = document.querySelector(".dose-btn.journal") as HTMLButtonElement;
expect(availableJournalButton).not.toBeDisabled();
expect(availableJournalButton).not.toHaveClass("has-note");
expect(availableJournalButton.closest("span")).not.toHaveAttribute("data-tooltip");
});
fireEvent.click(document.querySelector(".dose-btn.journal") as Element);
await waitFor(() => {
expect(requests).toContainEqual({
url: `/api/share/token-123/journal/event/${sharedData.automaticDoseId}`,
method: "GET",
body: undefined,
});
});
await waitFor(() => {
expect(screen.getByLabelText("journal.editor.noteLabel")).toHaveValue("");
});
expect(screen.queryByRole("button", { name: "common.delete" })).not.toBeInTheDocument();
fireEvent.change(screen.getByLabelText("journal.editor.noteLabel"), { target: { value: "Shared note" } });
fireEvent.click(screen.getByRole("button", { name: "common.save" }));
await waitFor(() => {
expect(requests).toContainEqual({
url: `/api/share/token-123/journal/event/${sharedData.automaticDoseId}`,
method: "PUT",
body: { note: "Shared note" },
});
});
await waitFor(() => {
expect(screen.queryByLabelText("journal.editor.noteLabel")).not.toBeInTheDocument();
const savedJournalButton = document.querySelector(".dose-btn.journal") as HTMLButtonElement;
expect(savedJournalButton).toHaveClass("has-note");
});
});
it("marks shared journal notes from the shared dose read state", async () => {
const referenceNow = new Date();
referenceNow.setHours(12, 0, 0, 0);
vi.spyOn(Date, "now").mockReturnValue(referenceNow.getTime());
const sharedData = {
...createSharedDataWithTodayDose(referenceNow),
allowJournalNotes: true,
};
const { fetchMock } = createSharedDoseFetchMock({
sharedData,
initialDoses: [{ doseId: sharedData.automaticDoseId, takenSource: "manual", hasJournalNote: true }],
});
globalThis.fetch = fetchMock as unknown as typeof fetch;
renderSharedSchedule("/share/token-123");
await waitFor(() => {
const journalButton = document.querySelector(".dose-btn.journal") as HTMLButtonElement;
expect(journalButton).not.toBeDisabled();
expect(journalButton).toHaveClass("has-note");
});
});
it("renders not found state for missing share link", async () => {
(globalThis.fetch as ReturnType<typeof vi.fn>).mockImplementation((url: string) => {
if (url === "/api/share/token-123/doses") {
@@ -275,7 +275,7 @@ describe("UserFilterModal", () => {
const meds: Medication[] = [
{ ...mockMedication, id: 1, name: "Med1", takenBy: ["John"] },
{ ...mockMedication, id: 2, name: "Med2", takenBy: ["Jane"] },
{ ...mockMedication, id: 3, name: "Med3", takenBy: ["John", "Jane"] },
{ ...mockMedication, id: 3, name: "Med3", takenBy: ["john", "Jane"] },
];
render(
+111 -22
View File
@@ -4,10 +4,13 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { AppProvider, useAppContext } from "../../context/AppContext";
import type { Medication } from "../../types";
const feedbackMock = vi.hoisted(() => ({ showFeedback: vi.fn() }));
const authFetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => fetch(input, init));
const mockUseAuth = vi.fn();
const mockUseMedications = vi.fn();
const mockUseSettings = vi.fn();
const mockUseDoses = vi.fn();
const mockUseIntakeJournal = vi.fn();
const mockUseCollapsedDays = vi.fn();
const mockUseShare = vi.fn();
const mockUseRefill = vi.fn();
@@ -26,10 +29,19 @@ vi.mock("../../components/Auth", () => ({
useAuth: () => mockUseAuth(),
}));
vi.mock("../../context/FeedbackContext", () => ({
useFeedback: () => ({
showFeedback: feedbackMock.showFeedback,
dismissFeedback: vi.fn(),
clearFeedback: vi.fn(),
}),
}));
vi.mock("../../hooks", () => ({
useMedications: () => mockUseMedications(),
useSettings: () => mockUseSettings(),
useDoses: () => mockUseDoses(),
useIntakeJournal: () => mockUseIntakeJournal(),
useCollapsedDays: () => mockUseCollapsedDays(),
useShare: () => mockUseShare(),
useRefill: () => mockUseRefill(),
@@ -55,7 +67,7 @@ const meds: Medication[] = [
{
id: 11,
name: "Aspirin",
takenBy: ["Max", "Anna"],
takenBy: ["Max", "Anna", "max"],
packageType: "blister",
packCount: 1,
blistersPerPack: 1,
@@ -80,7 +92,8 @@ describe("useAppContext", () => {
const loadSettings = vi.fn();
const loadTakenDoses = vi.fn();
mockUseAuth.mockReturnValue({ user: { id: 7, username: "owner" } });
authFetchMock.mockImplementation((input: RequestInfo | URL, init?: RequestInit) => fetch(input, init));
mockUseAuth.mockReturnValue({ user: { id: 7, username: "owner" }, authFetch: authFetchMock });
mockUseMedications.mockReturnValue({
meds,
@@ -206,6 +219,35 @@ describe("useAppContext", () => {
loadTakenDoses,
});
mockUseIntakeJournal.mockReturnValue({
journalEditorOpen: false,
journalHistoryOpen: false,
journalTargetDoseId: null,
journalEvent: null,
journalEventLoading: false,
journalEventSaving: false,
journalEventDeleting: false,
journalEventError: null,
journalHistoryEntries: [],
journalHistoryFilters: {
medicationId: null,
from: "",
until: "",
},
journalHistoryLoading: false,
journalHistoryError: null,
openJournalEditor: vi.fn(),
closeJournalEditor: vi.fn(),
saveJournalNote: vi.fn(async () => true),
deleteJournalNote: vi.fn(async () => true),
openJournalHistory: vi.fn(),
closeJournalHistory: vi.fn(),
setJournalHistoryFilters: vi.fn(),
reloadJournalHistory: vi.fn(async () => {}),
reopenJournalHistoryEntry: vi.fn(async () => {}),
resetJournalState: vi.fn(),
});
mockUseCollapsedDays.mockReturnValue({
manuallyCollapsedDays: new Set<string>(),
manuallyExpandedDays: new Set<string>(),
@@ -219,11 +261,19 @@ describe("useAppContext", () => {
setShareSelectedPerson: vi.fn(),
shareSelectedDays: 30,
setShareSelectedDays: vi.fn(),
shareSelectedExpiryDays: null,
setShareSelectedExpiryDays: vi.fn(),
shareAllowJournalNotes: false,
setShareAllowJournalNotes: vi.fn(),
shareGenerating: false,
shareLink: null,
setShareLink: vi.fn(),
shareCopied: false,
setShareCopied: vi.fn(),
activeShareLinks: [],
activeSharesLoading: false,
revokingShareToken: null,
revokeShareLink: vi.fn(),
openShareDialog: vi.fn(),
generateShareLink: vi.fn(),
copyShareLink: vi.fn(),
@@ -345,7 +395,7 @@ describe("useAppContext", () => {
const clearRefillStateBefore = mockUseRefill().clearRefillState.mock.calls.length;
const resetShareDialogStateBefore = mockUseShare().resetShareDialogState.mock.calls.length;
mockUseAuth.mockReturnValue({ user: { id: 8, username: "other-user" } });
mockUseAuth.mockReturnValue({ user: { id: 8, username: "other-user" }, authFetch: authFetchMock });
rerender();
await waitFor(() => {
@@ -407,11 +457,10 @@ describe("useAppContext", () => {
await result.current.handleImportConfirm();
});
expect(fetch).toHaveBeenCalledWith(
expect(authFetchMock).toHaveBeenCalledWith(
"/api/import",
expect.objectContaining({
method: "POST",
credentials: "include",
})
);
expect(mockUseMedications().loadMeds).toHaveBeenCalled();
@@ -447,9 +496,7 @@ describe("useAppContext", () => {
await result.current.handleExport(true);
});
expect(fetch).toHaveBeenCalledWith("/api/export?includeSensitive=true&includeImages=true", {
credentials: "include",
});
expect(authFetchMock).toHaveBeenCalledWith("/api/export?includeSensitive=true&includeImages=true");
expect(createObjectURL).toHaveBeenCalled();
expect(click).toHaveBeenCalled();
expect(appendChild).toHaveBeenCalled();
@@ -458,9 +505,6 @@ describe("useAppContext", () => {
});
it("handles invalid import JSON file", () => {
const mockAlert = vi.fn();
global.alert = mockAlert;
class MockFileReader {
onload: ((event: ProgressEvent<FileReader>) => void) | null = null;
readAsText = vi.fn(() => {
@@ -478,10 +522,46 @@ describe("useAppContext", () => {
} as unknown as React.ChangeEvent<HTMLInputElement>);
});
expect(mockAlert).toHaveBeenCalledWith("exportImport.invalidFile");
expect(feedbackMock.showFeedback).toHaveBeenCalledWith({ message: "exportImport.invalidFile", tone: "error" });
});
it("parses valid import file and opens confirm modal", () => {
it("parses valid import file and opens confirm modal", async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
text: () =>
Promise.resolve(
JSON.stringify({
preview: {
version: "1",
exportedAt: "2026-01-01T00:00:00.000Z",
includeSensitiveData: true,
incoming: {
medications: 0,
doseHistory: 0,
refillHistory: 0,
shareLinks: 0,
journalEntries: 0,
imageCount: 0,
hasSettings: false,
},
current: {
medications: 1,
doseHistory: 0,
refillHistory: 0,
shareLinks: 0,
hasSettings: true,
},
warnings: {
replacesExistingData: true,
regeneratesShareLinks: false,
containsImages: false,
containsSensitiveData: true,
},
},
})
),
});
class MockFileReader {
onload: ((event: ProgressEvent<FileReader>) => void) | null = null;
readAsText = vi.fn(() => {
@@ -503,11 +583,20 @@ describe("useAppContext", () => {
} as unknown as React.ChangeEvent<HTMLInputElement>);
});
expect(result.current.showImportConfirm).toBe(true);
expect(result.current.pendingImportData).toEqual({
version: "1",
exportedAt: "2026-01-01T00:00:00.000Z",
medications: [],
await waitFor(() => {
expect(authFetchMock).toHaveBeenCalledWith(
"/api/import/preview",
expect.objectContaining({
method: "POST",
body: JSON.stringify({ version: "1", exportedAt: "2026-01-01T00:00:00.000Z", medications: [] }),
})
);
expect(result.current.showImportConfirm).toBe(true);
expect(result.current.pendingImportData).toEqual({
version: "1",
exportedAt: "2026-01-01T00:00:00.000Z",
medications: [],
});
});
});
@@ -550,9 +639,6 @@ describe("useAppContext", () => {
});
it("shows import error alert when import API returns non-ok response", async () => {
const mockAlert = vi.fn();
global.alert = mockAlert;
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: false,
status: 500,
@@ -569,6 +655,9 @@ describe("useAppContext", () => {
await result.current.handleImportConfirm();
});
expect(mockAlert).toHaveBeenCalledWith("exportImport.importError: Import failed");
expect(feedbackMock.showFeedback).toHaveBeenCalledWith({
message: "exportImport.importError: Import failed",
tone: "error",
});
});
});
+35 -4
View File
@@ -2,9 +2,27 @@ import { act, renderHook, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useDoses } from "../../hooks/useDoses";
const feedbackMock = vi.hoisted(() => ({ showFeedback: vi.fn() }));
const authFetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => fetch(input, init));
vi.mock("../../components/Auth", () => ({
useAuth: () => ({
authFetch: authFetchMock,
}),
}));
vi.mock("../../context/FeedbackContext", () => ({
useFeedback: () => ({
showFeedback: feedbackMock.showFeedback,
dismissFeedback: vi.fn(),
clearFeedback: vi.fn(),
}),
}));
describe("useDoses", () => {
beforeEach(() => {
vi.clearAllMocks();
authFetchMock.mockImplementation((input: RequestInfo | URL, init?: RequestInit) => fetch(input, init));
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: true,
json: () => Promise.resolve({ doses: [] }),
@@ -15,6 +33,19 @@ describe("useDoses", () => {
vi.clearAllMocks();
});
it("loads taken doses through authFetch", async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ doses: [] }),
});
renderHook(() => useDoses());
await waitFor(() => {
expect(authFetchMock).toHaveBeenCalledWith("/api/doses/taken");
});
});
it("initializes with empty state", () => {
const { result } = renderHook(() => useDoses());
@@ -273,9 +304,6 @@ describe("useDoses", () => {
});
it("shows an out-of-stock alert and reverts the optimistic mark", async () => {
const alertMock = vi.fn();
global.alert = alertMock;
(global.fetch as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ doses: [] }) })
.mockResolvedValueOnce({
@@ -297,7 +325,10 @@ describe("useDoses", () => {
await waitFor(() => {
expect(result.current.takenDoses.has("blocked-dose")).toBe(false);
});
expect(alertMock).toHaveBeenCalledWith("common.outOfStockTakeBlocked");
expect(feedbackMock.showFeedback).toHaveBeenCalledWith({
message: "common.outOfStockTakeBlocked",
tone: "error",
});
});
it("undoDoseTaken encodes special characters in dose ID", async () => {
@@ -0,0 +1,183 @@
import { act, renderHook, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { type IntakeJournalEntry, useIntakeJournal } from "../../hooks/useIntakeJournal";
const authFetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => fetch(input, init));
vi.mock("../../components/Auth", () => ({
useAuth: () => ({
authFetch: authFetchMock,
}),
}));
function buildEntry(overrides: Partial<IntakeJournalEntry> = {}): IntakeJournalEntry {
return {
doseTrackingId: 1,
doseId: "11-0-1760000000000-Daniel",
medicationId: 11,
medicationName: "Journal Med",
scheduledFor: "2026-02-10T08:00:00.000Z",
takenAt: "2026-02-10T08:05:00.000Z",
dismissed: false,
takenSource: "manual",
markedBy: "Daniel",
note: null,
updatedAt: null,
createdAt: null,
...overrides,
};
}
describe("useIntakeJournal", () => {
beforeEach(() => {
vi.clearAllMocks();
authFetchMock.mockImplementation((input: RequestInfo | URL, init?: RequestInit) => fetch(input, init));
});
afterEach(() => {
vi.restoreAllMocks();
});
it("loads an event and updates local note state on save and delete", async () => {
const fetchMock = global.fetch as ReturnType<typeof vi.fn>;
const initialEntry = buildEntry();
const savedEntry = buildEntry({
note: "Took after breakfast",
createdAt: "2026-02-10T08:06:00.000Z",
updatedAt: "2026-02-10T08:07:00.000Z",
});
fetchMock
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ entry: initialEntry }),
})
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ entry: savedEntry }),
})
.mockResolvedValueOnce({ ok: true });
const { result } = renderHook(() => useIntakeJournal());
await act(async () => {
await result.current.openJournalEditor(initialEntry.doseId);
});
expect(authFetchMock).toHaveBeenNthCalledWith(
1,
`/api/intake-journal/event/${encodeURIComponent(initialEntry.doseId)}`
);
expect(result.current.journalEditorOpen).toBe(true);
expect(result.current.journalTargetDoseId).toBe(initialEntry.doseId);
expect(result.current.journalEvent).toEqual(initialEntry);
let saveResult = false;
await act(async () => {
saveResult = await result.current.saveJournalNote("Took after breakfast");
});
expect(saveResult).toBe(true);
expect(authFetchMock).toHaveBeenNthCalledWith(
2,
`/api/intake-journal/event/${encodeURIComponent(initialEntry.doseId)}`,
expect.objectContaining({
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ note: "Took after breakfast" }),
})
);
expect(result.current.journalEvent?.note).toBe("Took after breakfast");
let deleteResult = false;
await act(async () => {
deleteResult = await result.current.deleteJournalNote();
});
expect(deleteResult).toBe(true);
expect(authFetchMock).toHaveBeenNthCalledWith(
3,
`/api/intake-journal/event/${encodeURIComponent(initialEntry.doseId)}`,
expect.objectContaining({ method: "DELETE" })
);
expect(result.current.journalEvent).toEqual(
expect.objectContaining({
doseId: initialEntry.doseId,
note: null,
createdAt: null,
updatedAt: null,
})
);
});
it("loads filtered history and reopens an entry in the editor", async () => {
const fetchMock = global.fetch as ReturnType<typeof vi.fn>;
const historyEntry = buildEntry({
doseId: "11-0-1760086400000-Daniel",
note: "Evening note",
updatedAt: "2026-02-11T18:30:00.000Z",
createdAt: "2026-02-11T18:20:00.000Z",
});
fetchMock
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ entries: [historyEntry] }),
})
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ entry: historyEntry }),
});
const { result } = renderHook(() => useIntakeJournal());
act(() => {
result.current.setJournalHistoryFilters({
medicationId: 11,
from: "2026-02-11T00:00:00.000Z",
to: "2026-02-11T23:59:59.000Z",
limit: 25,
});
result.current.openJournalHistory();
});
await waitFor(() => {
expect(result.current.journalHistoryEntries).toEqual([historyEntry]);
});
expect(authFetchMock).toHaveBeenNthCalledWith(
1,
"/api/intake-journal?medicationId=11&from=2026-02-11T00%3A00%3A00.000Z&to=2026-02-11T23%3A59%3A59.000Z&limit=25"
);
await act(async () => {
await result.current.reopenJournalHistoryEntry(historyEntry.doseId);
});
expect(authFetchMock).toHaveBeenNthCalledWith(
2,
`/api/intake-journal/event/${encodeURIComponent(historyEntry.doseId)}`
);
expect(result.current.journalHistoryOpen).toBe(false);
expect(result.current.journalEditorOpen).toBe(true);
expect(result.current.journalTargetDoseId).toBe(historyEntry.doseId);
expect(result.current.journalEvent).toEqual(historyEntry);
});
it("surfaces owner access errors instead of swallowing them", async () => {
const fetchMock = global.fetch as ReturnType<typeof vi.fn>;
fetchMock.mockResolvedValueOnce({
ok: false,
json: () => Promise.resolve({ error: "Tracked dose event not found for the current owner" }),
});
const { result } = renderHook(() => useIntakeJournal());
await act(async () => {
await result.current.openJournalEditor("99-0-1760000000000-Daniel");
});
expect(result.current.journalEvent).toBeNull();
expect(result.current.journalEventError).toBe("Tracked dose event not found for the current owner");
});
});
@@ -332,6 +332,7 @@ describe("useMedicationForm", () => {
act(() => {
result.current.addTakenByPerson("Alice");
result.current.addTakenByPerson("alice");
result.current.addTakenByPerson("");
});
+31 -5
View File
@@ -3,9 +3,18 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useMedications } from "../../hooks/useMedications";
import type { Medication } from "../../types";
const authFetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => fetch(input, init));
vi.mock("../../components/Auth", () => ({
useAuth: () => ({
authFetch: authFetchMock,
}),
}));
describe("useMedications", () => {
beforeEach(() => {
vi.clearAllMocks();
authFetchMock.mockImplementation((input: RequestInfo | URL, init?: RequestInit) => fetch(input, init));
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: true,
json: () => Promise.resolve([]),
@@ -16,6 +25,23 @@ describe("useMedications", () => {
vi.clearAllMocks();
});
it("loads medications through authFetch", async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve([]),
});
const { result } = renderHook(() => useMedications());
act(() => {
result.current.loadMeds();
});
await waitFor(() => {
expect(authFetchMock).toHaveBeenCalledWith("/api/medications?includeObsolete=true");
});
});
it("initializes with empty state", () => {
const { result } = renderHook(() => useMedications());
@@ -45,7 +71,7 @@ describe("useMedications", () => {
expect(result.current.meds).toEqual(mockMeds);
});
expect(fetch).toHaveBeenCalledWith("/api/medications?includeObsolete=true", { credentials: "include" });
expect(authFetchMock).toHaveBeenCalledWith("/api/medications?includeObsolete=true");
});
it("handles API error gracefully", async () => {
@@ -107,7 +133,7 @@ describe("useMedications", () => {
await result.current.deleteMed(1, 1, mockResetForm);
});
expect(fetch).toHaveBeenCalledWith("/api/medications/1", { method: "DELETE", credentials: "include" });
expect(authFetchMock).toHaveBeenCalledWith("/api/medications/1", { method: "DELETE" });
expect(mockResetForm).toHaveBeenCalled();
});
@@ -123,8 +149,8 @@ describe("useMedications", () => {
await result.current.deleteMed(5, 5, mockResetForm);
});
expect(fetch).toHaveBeenCalledWith("/api/medications/5", { method: "DELETE", credentials: "include" });
expect(fetch).toHaveBeenCalledWith("/api/medications?includeObsolete=true", { credentials: "include" });
expect(authFetchMock).toHaveBeenCalledWith("/api/medications/5", { method: "DELETE" });
expect(authFetchMock).toHaveBeenCalledWith("/api/medications?includeObsolete=true");
expect(mockResetForm).toHaveBeenCalled();
});
@@ -190,7 +216,7 @@ describe("useMedications", () => {
await result.current.deleteMedImage(1);
});
expect(fetch).toHaveBeenCalledWith("/api/medications/1/image", { method: "DELETE", credentials: "include" });
expect(authFetchMock).toHaveBeenCalledWith("/api/medications/1/image", { method: "DELETE" });
});
it("allows setting meds directly", () => {
@@ -0,0 +1,47 @@
import { act, renderHook } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useModalHistory } from "../../hooks/useModalHistory";
describe("useModalHistory", () => {
beforeEach(() => {
vi.spyOn(window.history, "pushState").mockImplementation(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
});
it("pushes a modal history entry and closes the modal on browser back", () => {
const onClose = vi.fn();
const { rerender } = renderHook(({ isOpen }) => useModalHistory(isOpen, "journal-editor", onClose), {
initialProps: { isOpen: false },
});
rerender({ isOpen: true });
expect(window.history.pushState).toHaveBeenCalledWith({ modal: "journal-editor" }, "");
act(() => {
window.dispatchEvent(new PopStateEvent("popstate"));
});
expect(onClose).toHaveBeenCalledTimes(1);
});
it("stops parent popstate handlers when a nested modal consumes browser back", () => {
const onClose = vi.fn();
const parentClose = vi.fn();
window.addEventListener("popstate", parentClose);
renderHook(() => useModalHistory(true, "nested-confirm", onClose));
act(() => {
window.dispatchEvent(new PopStateEvent("popstate"));
});
expect(onClose).toHaveBeenCalledTimes(1);
expect(parentClose).not.toHaveBeenCalled();
window.removeEventListener("popstate", parentClose);
});
});
+51 -33
View File
@@ -3,9 +3,24 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useRefill } from "../../hooks/useRefill";
import type { Coverage, Medication } from "../../types";
const authFetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => fetch(input, init));
vi.mock("../../components/Auth", () => ({
useAuth: () => ({
authFetch: authFetchMock,
}),
}));
function parseRequestBody(requestInit: RequestInit | undefined) {
expect(requestInit).toBeDefined();
expect(typeof requestInit?.body).toBe("string");
return JSON.parse(requestInit?.body as string) as Record<string, unknown>;
}
describe("useRefill", () => {
beforeEach(() => {
vi.clearAllMocks();
authFetchMock.mockImplementation((input: RequestInfo | URL, init?: RequestInit) => fetch(input, init));
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: true,
json: () => Promise.resolve({}),
@@ -18,6 +33,21 @@ describe("useRefill", () => {
vi.restoreAllMocks();
});
it("loads refill history through authFetch", async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve([]),
});
const { result } = renderHook(() => useRefill());
await act(async () => {
await result.current.loadRefillHistory(1);
});
expect(authFetchMock).toHaveBeenCalledWith("/api/medications/1/refills");
});
it("initializes with default state", () => {
const { result } = renderHook(() => useRefill());
@@ -159,7 +189,7 @@ describe("useRefill", () => {
await result.current.submitRefill(1, 1, mockSetForm, mockLoadMeds);
});
expect(fetch).toHaveBeenNthCalledWith(
expect(authFetchMock).toHaveBeenNthCalledWith(
1,
"/api/medications/1/refill",
expect.objectContaining({
@@ -167,11 +197,7 @@ describe("useRefill", () => {
body: JSON.stringify({ packsAdded: 1, loosePillsAdded: 0, quantityAdded: 0, usePrescription: false }),
})
);
expect(fetch).toHaveBeenNthCalledWith(
2,
"/api/medications/1/refills",
expect.objectContaining({ credentials: "include" })
);
expect(authFetchMock).toHaveBeenNthCalledWith(2, "/api/medications/1/refills");
expect(mockSetForm).toHaveBeenCalled();
expect(mockLoadMeds).toHaveBeenCalled();
});
@@ -191,7 +217,7 @@ describe("useRefill", () => {
await result.current.submitRefill(1, 1, mockSetForm, mockLoadMeds);
});
expect(fetch).not.toHaveBeenCalled();
expect(authFetchMock).not.toHaveBeenCalled();
});
it("opens edit stock modal", () => {
@@ -306,7 +332,7 @@ describe("useRefill", () => {
await result.current.submitStockCorrection(1, mockMed, mockLoadMeds);
});
expect(fetch).toHaveBeenCalledWith(
expect(authFetchMock).toHaveBeenCalledWith(
"/api/medications/1/stock-adjustment",
expect.objectContaining({ method: "PATCH" })
);
@@ -342,7 +368,7 @@ describe("useRefill", () => {
await result.current.submitStockCorrection(1, mockMed, mockLoadMeds);
});
expect(fetch).toHaveBeenCalled();
expect(authFetchMock).toHaveBeenCalled();
expect(mockLoadMeds).toHaveBeenCalled();
});
@@ -379,8 +405,8 @@ describe("useRefill", () => {
await result.current.submitStockCorrection(8, blisterMed, mockLoadMeds);
});
const [, requestInit] = (global.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
const body = JSON.parse(requestInit.body as string);
const [, requestInit] = authFetchMock.mock.calls[0] ?? [];
const body = parseRequestBody(requestInit);
expect(body).toEqual({
stockAdjustment: 0,
packCount: 0,
@@ -431,8 +457,8 @@ describe("useRefill", () => {
await result.current.submitStockCorrection(id, med, mockLoadMeds);
});
const [, requestInit] = (global.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
const body = JSON.parse(requestInit.body as string);
const [, requestInit] = authFetchMock.mock.calls[0] ?? [];
const body = parseRequestBody(requestInit);
expect(body).toEqual({
stockAdjustment: 0,
packCount: 0,
@@ -506,8 +532,8 @@ describe("useRefill", () => {
await result.current.submitStockCorrection(id, med, mockLoadMeds);
});
const [, requestInit] = (global.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
const body = JSON.parse(requestInit.body as string);
const [, requestInit] = authFetchMock.mock.calls[0] ?? [];
const body = parseRequestBody(requestInit);
expect(body).toEqual({
stockAdjustment: 0,
packCount: 0,
@@ -554,8 +580,8 @@ describe("useRefill", () => {
await result.current.submitStockCorrection(12, liquidMed, mockLoadMeds);
});
const [, requestInit] = (global.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
const body = JSON.parse(requestInit.body as string);
const [, requestInit] = authFetchMock.mock.calls[0] ?? [];
const body = parseRequestBody(requestInit);
expect(body).toEqual({
stockAdjustment: -60,
packCount: 2,
@@ -604,11 +630,9 @@ describe("useRefill", () => {
// baseTotal (fixed) = getPackageSize(bottle) = looseTablets = 150
// newStockAdjustment = 149 - 150 = -1
// → getMedTotal = 150 + (-1) = 149 ✓
const fetchCall = (global.fetch as ReturnType<typeof vi.fn>).mock.calls.find(
(call) => call[0] === "/api/medications/4/stock-adjustment"
);
const fetchCall = authFetchMock.mock.calls.find((call) => call[0] === "/api/medications/4/stock-adjustment");
expect(fetchCall).toBeDefined();
const body = JSON.parse(fetchCall![1].body as string);
const body = parseRequestBody(fetchCall?.[1]);
expect(body.stockAdjustment).toBe(50);
expect(body.looseTablets).toBeUndefined();
});
@@ -657,11 +681,9 @@ describe("useRefill", () => {
// desiredTotal is capped to package max (25)
// baseTotal = getPackageSize(blister) = 25
// newStockAdjustment = 25 - 25 = 0
const fetchCall = (global.fetch as ReturnType<typeof vi.fn>).mock.calls.find(
(call) => call[0] === "/api/medications/2/stock-adjustment"
);
const fetchCall = authFetchMock.mock.calls.find((call) => call[0] === "/api/medications/2/stock-adjustment");
expect(fetchCall).toBeDefined();
const body = JSON.parse(fetchCall![1].body as string);
const body = parseRequestBody(fetchCall?.[1]);
expect(body.stockAdjustment).toBe(0);
});
@@ -699,11 +721,9 @@ describe("useRefill", () => {
await result.current.submitStockCorrection(5, blisterMed, mockLoadMeds);
});
const fetchCall = (global.fetch as ReturnType<typeof vi.fn>).mock.calls.find(
(call) => call[0] === "/api/medications/5/stock-adjustment"
);
const fetchCall = authFetchMock.mock.calls.find((call) => call[0] === "/api/medications/5/stock-adjustment");
expect(fetchCall).toBeDefined();
const body = JSON.parse(fetchCall![1].body as string);
const body = parseRequestBody(fetchCall?.[1]);
// NEW: baseTotal = structuralMax + finalLoosePills = 20 + 7 = 27; desiredTotal = 27 => stockAdjustment=0
// looseTablets is sent separately so DB reflects the actual loose count after correction
expect(body.stockAdjustment).toBe(0);
@@ -744,11 +764,9 @@ describe("useRefill", () => {
await result.current.submitStockCorrection(6, blisterMed, mockLoadMeds);
});
const fetchCall = (global.fetch as ReturnType<typeof vi.fn>).mock.calls.find(
(call) => call[0] === "/api/medications/6/stock-adjustment"
);
const fetchCall = authFetchMock.mock.calls.find((call) => call[0] === "/api/medications/6/stock-adjustment");
expect(fetchCall).toBeDefined();
const body = JSON.parse(fetchCall![1].body as string);
const body = parseRequestBody(fetchCall?.[1]);
// baseTotal = structuralMax + finalLoosePills = 275 + 2 = 277; desiredTotal = 57 => stockAdjustment = -220
expect(body.stockAdjustment).toBe(-220);
expect(body.looseTablets).toBe(2);
+224 -16
View File
@@ -3,16 +3,30 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useShare } from "../../hooks/useShare";
import type { Medication } from "../../types";
const feedbackMock = vi.hoisted(() => ({ showFeedback: vi.fn() }));
const authFetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => fetch(input, init));
vi.mock("../../components/Auth", () => ({
useAuth: () => ({
authFetch: authFetchMock,
}),
}));
vi.mock("../../context/FeedbackContext", () => ({
useFeedback: () => ({
showFeedback: feedbackMock.showFeedback,
dismissFeedback: vi.fn(),
clearFeedback: vi.fn(),
}),
}));
describe("useShare", () => {
let mockAlert: ReturnType<typeof vi.fn>;
let mockClipboard: { writeText: ReturnType<typeof vi.fn> };
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
mockAlert = vi.fn();
global.alert = mockAlert as unknown as typeof global.alert;
authFetchMock.mockImplementation((input: RequestInfo | URL, init?: RequestInit) => fetch(input, init));
mockClipboard = { writeText: vi.fn().mockResolvedValue(undefined) };
Object.defineProperty(navigator, "clipboard", {
@@ -48,10 +62,13 @@ describe("useShare", () => {
expect(result.current.sharePeople).toEqual([]);
expect(result.current.shareSelectedPerson).toBe("");
expect(result.current.shareSelectedDays).toBe(30);
expect(result.current.shareSelectedExpiryDays).toBeNull();
expect(result.current.shareAllowJournalNotes).toBe(false);
expect(result.current.shareLink).toBeNull();
expect(result.current.activeShareLinks).toEqual([]);
});
it("opens share dialog with people from medications", () => {
it("opens share dialog with people from medications", async () => {
const { result } = renderHook(() => useShare());
const meds: Medication[] = [
@@ -85,6 +102,10 @@ describe("useShare", () => {
result.current.openShareDialog(meds);
});
await act(async () => {
await Promise.resolve();
});
expect(result.current.showShareDialog).toBe(true);
expect(result.current.sharePeople).toEqual(["all", "Alice", "Bob", "Charlie"]);
expect(result.current.shareSelectedPerson).toBe("Alice");
@@ -146,24 +167,26 @@ describe("useShare", () => {
});
await act(async () => {
await Promise.resolve();
await result.current.generateShareLink();
});
expect(fetch).toHaveBeenCalledWith(
expect(authFetchMock).toHaveBeenNthCalledWith(
2,
"/api/share",
expect.objectContaining({
method: "POST",
body: JSON.stringify({ takenBy: "Alice", scheduleDays: 30 }),
body: JSON.stringify({ takenBy: "Alice", scheduleDays: 30, expiryDays: null, allowJournalNotes: false }),
})
);
expect(authFetchMock).toHaveBeenNthCalledWith(1, "/api/share");
expect(result.current.shareLink).toBe("http://localhost:5173/share/test-token");
});
it("handles share link generation error", async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: false,
json: () => Promise.resolve({ error: "Failed to generate" }),
});
authFetchMock
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ shareLinks: [] }) } as Response)
.mockResolvedValueOnce({ ok: false, json: () => Promise.resolve({ error: "Failed to generate" }) } as Response);
const { result } = renderHook(() => useShare());
@@ -187,15 +210,18 @@ describe("useShare", () => {
});
await act(async () => {
await Promise.resolve();
await result.current.generateShareLink();
});
expect(mockAlert).toHaveBeenCalled();
expect(feedbackMock.showFeedback).toHaveBeenCalledWith({ message: "Failed to generate", tone: "error" });
expect(result.current.shareLink).toBeNull();
});
it("handles network error on share link generation", async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error("Network error"));
authFetchMock
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ shareLinks: [] }) } as Response)
.mockRejectedValueOnce(new Error("Network error"));
const { result } = renderHook(() => useShare());
@@ -219,10 +245,11 @@ describe("useShare", () => {
});
await act(async () => {
await Promise.resolve();
await result.current.generateShareLink();
});
expect(mockAlert).toHaveBeenCalled();
expect(feedbackMock.showFeedback).toHaveBeenCalledWith({ message: "share.generateFailed", tone: "error" });
});
it("does nothing when generateShareLink called without selected person", async () => {
@@ -233,7 +260,7 @@ describe("useShare", () => {
await result.current.generateShareLink();
});
expect(fetch).not.toHaveBeenCalled();
expect(authFetchMock).not.toHaveBeenCalled();
});
it("copies share link to clipboard", async () => {
@@ -338,17 +365,198 @@ describe("useShare", () => {
expect(result.current.showShareDialog).toBe(false);
expect(result.current.shareLink).toBeNull();
expect(result.current.shareCopied).toBe(false);
expect(result.current.shareSelectedExpiryDays).toBeNull();
expect(result.current.shareAllowJournalNotes).toBe(false);
expect(result.current.activeShareLinks).toEqual([]);
});
it("allows changing selected person and days", () => {
it("includes selected expiry when generating a share link", async () => {
const { result } = renderHook(() => useShare());
const meds: Medication[] = [
{
id: 1,
name: "Med1",
takenBy: ["Alice"],
packageType: "blister",
packCount: 1,
blistersPerPack: 1,
pillsPerBlister: 10,
looseTablets: 0,
blisters: [],
updatedAt: null,
},
];
act(() => {
result.current.openShareDialog(meds);
result.current.setShareSelectedExpiryDays(7);
});
await act(async () => {
await Promise.resolve();
await result.current.generateShareLink();
});
expect(authFetchMock).toHaveBeenCalledWith(
"/api/share",
expect.objectContaining({
method: "POST",
body: JSON.stringify({ takenBy: "Alice", scheduleDays: 30, expiryDays: 7, allowJournalNotes: false }),
})
);
});
it("includes the shared journal-note permission when generating a share link", async () => {
const { result } = renderHook(() => useShare());
const meds: Medication[] = [
{
id: 1,
name: "Med1",
takenBy: ["Alice"],
packageType: "blister",
packCount: 1,
blistersPerPack: 1,
pillsPerBlister: 10,
looseTablets: 0,
blisters: [],
updatedAt: null,
},
];
act(() => {
result.current.openShareDialog(meds);
result.current.setShareAllowJournalNotes(true);
});
await act(async () => {
await Promise.resolve();
await result.current.generateShareLink();
});
expect(authFetchMock).toHaveBeenCalledWith(
"/api/share",
expect.objectContaining({
method: "POST",
body: JSON.stringify({ takenBy: "Alice", scheduleDays: 30, expiryDays: null, allowJournalNotes: true }),
})
);
});
it("loads active share links when opening the dialog", async () => {
(global.fetch as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
shareLinks: [
{
token: "abcdef0123456789",
takenBy: "Alice",
scheduleDays: 30,
createdAt: "2026-05-17T12:00:00.000Z",
expiresAt: null,
allowJournalNotes: true,
shareUrl: "/share/abcdef0123456789",
},
],
}),
})
.mockResolvedValue({ ok: true, json: () => Promise.resolve({ token: "test-token" }) });
const { result } = renderHook(() => useShare());
const meds: Medication[] = [
{
id: 1,
name: "Med1",
takenBy: ["Alice"],
packageType: "blister",
packCount: 1,
blistersPerPack: 1,
pillsPerBlister: 10,
looseTablets: 0,
blisters: [],
updatedAt: null,
},
];
act(() => {
result.current.openShareDialog(meds);
});
await act(async () => {
await Promise.resolve();
});
expect(result.current.activeShareLinks).toHaveLength(1);
expect(result.current.activeShareLinks[0].token).toBe("abcdef0123456789");
});
it("revokes an active share link", async () => {
(global.fetch as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
shareLinks: [
{
token: "abcdef0123456789",
takenBy: "Alice",
scheduleDays: 30,
createdAt: "2026-05-17T12:00:00.000Z",
expiresAt: null,
allowJournalNotes: false,
shareUrl: "/share/abcdef0123456789",
},
],
}),
})
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({}) });
const { result } = renderHook(() => useShare());
const meds: Medication[] = [
{
id: 1,
name: "Med1",
takenBy: ["Alice"],
packageType: "blister",
packCount: 1,
blistersPerPack: 1,
pillsPerBlister: 10,
looseTablets: 0,
blisters: [],
updatedAt: null,
},
];
act(() => {
result.current.openShareDialog(meds);
});
await act(async () => {
await Promise.resolve();
});
await act(async () => {
await result.current.revokeShareLink("abcdef0123456789");
});
expect(authFetchMock).toHaveBeenCalledWith("/api/share/abcdef0123456789", { method: "DELETE" });
expect(result.current.activeShareLinks).toEqual([]);
});
it("allows changing selected person, days, and expiry", () => {
const { result } = renderHook(() => useShare());
act(() => {
result.current.setShareSelectedPerson("Bob");
result.current.setShareSelectedDays(90);
result.current.setShareSelectedExpiryDays(30);
result.current.setShareAllowJournalNotes(true);
});
expect(result.current.shareSelectedPerson).toBe("Bob");
expect(result.current.shareSelectedDays).toBe(90);
expect(result.current.shareSelectedExpiryDays).toBe(30);
expect(result.current.shareAllowJournalNotes).toBe(true);
});
});
+150 -8
View File
@@ -11,6 +11,9 @@ import {
userStorageKey,
} from "../../pages/dashboard-helpers";
const feedbackMock = vi.hoisted(() => ({ showFeedback: vi.fn() }));
const authFetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => fetch(input, init));
// Mock data for tests with medications
const mockMeds = [
{
@@ -130,6 +133,21 @@ const mockTodayDay = {
],
};
const mockJournalEntry = {
doseTrackingId: 1,
doseId: "1-0-1760000000000",
medicationId: 1,
medicationName: "Aspirin",
scheduledFor: "2026-05-21T09:00:00.000Z",
takenAt: "2026-05-21T09:05:00.000Z",
dismissed: false,
takenSource: "manual" as const,
markedBy: null,
note: "",
updatedAt: null,
createdAt: null,
};
function getRouteDateKey(value: Date): string {
const year = value.getFullYear();
const month = String(value.getMonth() + 1).padStart(2, "0");
@@ -321,12 +339,22 @@ vi.mock("../../context", () => ({
vi.mock("../../components/Auth", () => ({
useAuth: () => ({
user: { id: 1, username: "testuser" },
authFetch: authFetchMock,
}),
}));
vi.mock("../../context/FeedbackContext", () => ({
useFeedback: () => ({
showFeedback: feedbackMock.showFeedback,
dismissFeedback: vi.fn(),
clearFeedback: vi.fn(),
}),
}));
describe("DashboardPage", () => {
beforeEach(() => {
vi.clearAllMocks();
authFetchMock.mockImplementation((input: RequestInfo | URL, init?: RequestInit) => fetch(input, init));
localStorage.clear();
mockContextValue = createMockAppContext();
HTMLElement.prototype.scrollIntoView = vi.fn();
@@ -421,6 +449,121 @@ describe("DashboardPage", () => {
expect(screen.getByText("09:00")).toBeInTheDocument();
});
it("disables the journal note action for untaken doses", () => {
const openJournalEditor = vi.fn();
mockContextValue = createMockAppContext({
openJournalEditor,
todayDay: {
dateStr: "Today",
date: new Date(),
isPast: false,
meds: [
{
medName: "Aspirin",
total: 1,
doses: [
{
id: "untaken-dose",
timeStr: "09:00",
when: Date.now() + 60_000,
usage: 1,
takenBy: ["John"],
},
],
lastWhen: Date.now() + 60_000,
},
],
},
});
render(
<MemoryRouter>
<DashboardPage />
</MemoryRouter>
);
const noteButton = screen.getByRole("button", { name: "journal.actions.note" });
expect(noteButton).toBeDisabled();
expect(noteButton.closest("span")).toHaveAttribute("data-tooltip", "journal.actions.noteTakenOnly");
fireEvent.click(noteButton);
expect(openJournalEditor).not.toHaveBeenCalled();
});
it("enables the journal note action for skipped doses", () => {
const openJournalEditor = vi.fn();
const skippedDoseId = "skipped-dose-John";
mockContextValue = createMockAppContext({
openJournalEditor,
skippedDoses: new Set([skippedDoseId]),
todayDay: {
dateStr: "Today",
date: new Date(),
isPast: false,
meds: [
{
medName: "Aspirin",
total: 1,
doses: [
{
id: "skipped-dose",
timeStr: "09:00",
when: Date.now() - 60_000,
usage: 1,
takenBy: ["John"],
},
],
lastWhen: Date.now() - 60_000,
},
],
},
});
render(
<MemoryRouter>
<DashboardPage />
</MemoryRouter>
);
const noteButton = screen.getByRole("button", { name: "journal.actions.note" });
expect(noteButton).not.toBeDisabled();
fireEvent.click(noteButton);
expect(openJournalEditor).toHaveBeenCalledWith(skippedDoseId);
});
it("closes the journal editor after saving a main app note", async () => {
const saveJournalNote = vi.fn(async () => true);
const closeJournalEditor = vi.fn();
mockContextValue = createMockAppContext({
journalEditorOpen: true,
journalEvent: mockJournalEntry,
journalEventLoading: false,
journalEventSaving: false,
journalEventDeleting: false,
journalEventError: null,
saveJournalNote,
closeJournalEditor,
deleteJournalNote: vi.fn(),
});
render(
<MemoryRouter>
<DashboardPage />
</MemoryRouter>
);
fireEvent.change(screen.getByLabelText("journal.editor.noteLabel"), {
target: { value: "Main app note" },
});
fireEvent.click(screen.getByRole("button", { name: "common.save" }));
await waitFor(() => {
expect(saveJournalNote).toHaveBeenCalledWith("Main app note");
expect(closeJournalEditor).toHaveBeenCalled();
});
});
it("renders schedule days selector", () => {
render(
<MemoryRouter>
@@ -1033,12 +1176,11 @@ describe("DashboardPage with shoutrrr notifications", () => {
fireEvent.click(sendButton);
await waitFor(() => {
expect(global.fetch).toHaveBeenCalledWith(
expect(authFetchMock).toHaveBeenCalledWith(
"/api/reminder/send-email",
expect.objectContaining({
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
})
);
});
@@ -1153,8 +1295,6 @@ describe("DashboardPage with past days", () => {
it("posts the computed dismiss-until payload when clearing missed doses", async () => {
const loadMeds = vi.fn();
const alertMock = vi.fn();
global.alert = alertMock;
global.fetch = vi.fn().mockResolvedValue({ ok: true });
mockContextValue = createMockAppContext({
@@ -1175,22 +1315,24 @@ describe("DashboardPage with past days", () => {
fireEvent.click(screen.getByRole("button", { name: /dashboard\.schedules\.clearMissedConfirm/i }));
await waitFor(() => {
expect(global.fetch).toHaveBeenCalledWith(
expect(authFetchMock).toHaveBeenCalledWith(
"/api/medications/dismiss-until",
expect.objectContaining({
method: "POST",
credentials: "include",
})
);
});
const body = JSON.parse(((global.fetch as ReturnType<typeof vi.fn>).mock.calls[0]?.[1]?.body as string) ?? "{}");
const body = JSON.parse(((authFetchMock as ReturnType<typeof vi.fn>).mock.calls[0]?.[1]?.body as string) ?? "{}");
expect(body).toEqual({
medicationIds: [1],
until: mockPastDays[0].date.toISOString().slice(0, 10),
});
expect(loadMeds).toHaveBeenCalled();
expect(alertMock).toHaveBeenCalledWith(expect.stringContaining("dashboard.schedules.clearMissedSuccess"));
expect(feedbackMock.showFeedback).toHaveBeenCalledWith({
message: expect.stringContaining("dashboard.schedules.clearMissedSuccess"),
tone: "success",
});
});
});
@@ -3,6 +3,8 @@ import { MemoryRouter } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { MedicationsPage } from "../../pages/MedicationsPage";
const authFetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => fetch(input, init));
const mockMeds = [
{
id: 1,
@@ -140,7 +142,7 @@ vi.mock("../../context", () => ({
}));
vi.mock("../../components/Auth", () => ({
useAuth: () => ({ user: { id: 1, username: "testuser" }, isAuthenticated: true }),
useAuth: () => ({ user: { id: 1, username: "testuser" }, isAuthenticated: true, authFetch: authFetchMock }),
}));
vi.mock("../../components", async () => {
@@ -286,10 +288,22 @@ function createGroupedOpenFdaMedicationEnrichmentResults(count: number, name: st
describe("MedicationsPage", () => {
beforeEach(() => {
vi.clearAllMocks();
authFetchMock.mockImplementation((input: RequestInfo | URL, init?: RequestInit) => fetch(input, init));
mockContextValue = createMockContext();
mockFormHookValue = createMockFormHook();
Object.defineProperty(window, "innerWidth", { value: 1200, writable: true });
Object.defineProperty(HTMLElement.prototype, "scrollIntoView", {
Object.defineProperty(Element.prototype, "scrollIntoView", {
configurable: true,
value: vi.fn(),
});
Object.defineProperty(window, "requestAnimationFrame", {
configurable: true,
value: (callback: FrameRequestCallback) => {
callback(0);
return 1;
},
});
Object.defineProperty(window, "cancelAnimationFrame", {
configurable: true,
value: vi.fn(),
});
@@ -538,9 +552,8 @@ describe("MedicationsPage with items", () => {
fireEvent.click(confirmButtons[confirmButtons.length - 1]);
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledWith("/api/medications/1/obsolete", {
expect(authFetchMock).toHaveBeenCalledWith("/api/medications/1/obsolete", {
method: "POST",
credentials: "include",
});
});
});
@@ -562,9 +575,8 @@ describe("MedicationsPage with items", () => {
fireEvent.click(screen.getByText("medications.list.reactivate"));
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledWith("/api/medications/2/reactivate", {
expect(authFetchMock).toHaveBeenCalledWith("/api/medications/2/reactivate", {
method: "POST",
credentials: "include",
});
});
});
@@ -750,18 +762,14 @@ describe("MedicationsPage form interactions", () => {
fireEvent.click(screen.getByRole("button", { name: "form.enrichment.searchAction" }));
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledWith("/api/medication-enrichment/search?q=Aspirin&limit=6", {
credentials: "include",
});
expect(authFetchMock).toHaveBeenCalledWith("/api/medication-enrichment/search?q=Aspirin&limit=6");
});
await screen.findByText("Aspirin 500 mg tablets");
fireEvent.click(screen.getByRole("button", { name: "form.enrichment.showMoreAction" }));
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledWith("/api/medication-enrichment/search?q=Aspirin&limit=12", {
credentials: "include",
});
expect(authFetchMock).toHaveBeenCalledWith("/api/medication-enrichment/search?q=Aspirin&limit=12");
});
await screen.findByText("Bayer Aspirin");
@@ -769,7 +777,7 @@ describe("MedicationsPage form interactions", () => {
fireEvent.click(screen.getAllByRole("button", { name: "form.enrichment.applyAction" })[0]);
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledWith("/api/medication-enrichment/enrich", {
expect(authFetchMock).toHaveBeenCalledWith("/api/medication-enrichment/enrich", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
@@ -779,7 +787,6 @@ describe("MedicationsPage form interactions", () => {
code: "RX-ASPIRIN",
source: "rxnorm",
}),
credentials: "include",
});
expect(setForm).toHaveBeenCalledWith(
expect.objectContaining({
@@ -825,9 +832,7 @@ describe("MedicationsPage form interactions", () => {
fireEvent.click(screen.getByRole("button", { name: "form.enrichment.searchAction" }));
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledWith("/api/medication-enrichment/search?q=Aspirin&limit=6", {
credentials: "include",
});
expect(authFetchMock).toHaveBeenCalledWith("/api/medication-enrichment/search?q=Aspirin&limit=6");
});
expect(await screen.findByText("form.enrichment.authRequired")).toBeInTheDocument();
@@ -867,9 +872,7 @@ describe("MedicationsPage form interactions", () => {
fireEvent.click(screen.getByRole("button", { name: "form.enrichment.showMoreAction" }));
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledWith("/api/medication-enrichment/search?q=Aspirin&limit=12", {
credentials: "include",
});
expect(authFetchMock).toHaveBeenCalledWith("/api/medication-enrichment/search?q=Aspirin&limit=12");
});
await waitFor(() => {
@@ -912,9 +915,9 @@ describe("MedicationsPage form interactions", () => {
fireEvent.click(screen.getByRole("button", { name: "form.enrichment.showMoreAction" }));
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledWith(`/api/medication-enrichment/search?q=Aspirin&limit=${expectedLimit}`, {
credentials: "include",
});
expect(authFetchMock).toHaveBeenCalledWith(
`/api/medication-enrichment/search?q=Aspirin&limit=${expectedLimit}`
);
});
}
@@ -976,9 +979,7 @@ describe("MedicationsPage form interactions", () => {
fireEvent.click(screen.getByRole("button", { name: "form.enrichment.showMoreAction" }));
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledWith("/api/medication-enrichment/search?q=Aspirin&limit=12", {
credentials: "include",
});
expect(authFetchMock).toHaveBeenCalledWith("/api/medication-enrichment/search?q=Aspirin&limit=12");
});
expect(screen.getByRole("button", { name: "form.enrichment.loadingMoreResults" })).toBeDisabled();
@@ -1057,12 +1058,8 @@ describe("MedicationsPage form interactions", () => {
fireEvent.click(screen.getByRole("button", { name: "form.enrichment.showMoreAction" }));
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledWith("/api/medication-enrichment/search?q=Tecfidera&limit=12", {
credentials: "include",
});
expect(fetchMock).toHaveBeenCalledWith("/api/medication-enrichment/search?q=Tecfidera&limit=18", {
credentials: "include",
});
expect(authFetchMock).toHaveBeenCalledWith("/api/medication-enrichment/search?q=Tecfidera&limit=12");
expect(authFetchMock).toHaveBeenCalledWith("/api/medication-enrichment/search?q=Tecfidera&limit=18");
});
await screen.findByText("Result 1");
@@ -1448,7 +1445,7 @@ describe("MedicationsPage form interactions", () => {
fireEvent.click(getEnrichmentPackageButtons()[1]);
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledWith("/api/medication-enrichment/enrich", {
expect(authFetchMock).toHaveBeenCalledWith("/api/medication-enrichment/enrich", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
@@ -1458,7 +1455,6 @@ describe("MedicationsPage form interactions", () => {
code: "NDC-IBU",
source: "openfda",
}),
credentials: "include",
});
expect(setForm).toHaveBeenCalledWith(
expect.objectContaining({
@@ -1610,7 +1606,7 @@ describe("MedicationsPage form interactions", () => {
fireEvent.click(initialPackageButtons[0]);
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledWith("/api/medication-enrichment/enrich", {
expect(authFetchMock).toHaveBeenCalledWith("/api/medication-enrichment/enrich", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
@@ -1620,7 +1616,6 @@ describe("MedicationsPage form interactions", () => {
code: "NDC-IBU-STRENGTH",
source: "openfda",
}),
credentials: "include",
});
});
@@ -1719,7 +1714,7 @@ describe("MedicationsPage form interactions", () => {
fireEvent.click(packageButtons[0]);
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledWith("/api/medication-enrichment/enrich", {
expect(authFetchMock).toHaveBeenCalledWith("/api/medication-enrichment/enrich", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
@@ -1729,7 +1724,6 @@ describe("MedicationsPage form interactions", () => {
code: "NDC-PENDING-PACKAGE",
source: "openfda",
}),
credentials: "include",
});
});
+7 -5
View File
@@ -3,6 +3,8 @@ import { MemoryRouter } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { PlannerPage } from "../../pages/PlannerPage";
const authFetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => fetch(input, init));
// Mock data
const mockMeds = [
{
@@ -48,12 +50,14 @@ vi.mock("../../context", () => ({
vi.mock("../../components/Auth", () => ({
useAuth: () => ({
user: { id: 1, username: "testuser" },
authFetch: authFetchMock,
}),
}));
describe("PlannerPage", () => {
beforeEach(() => {
vi.clearAllMocks();
authFetchMock.mockImplementation((input: RequestInfo | URL, init?: RequestInit) => fetch(input, init));
localStorage.clear();
mockContextValue = createMockContext();
});
@@ -440,12 +444,11 @@ describe("PlannerPage with email enabled", () => {
fireEvent.click(notifyBtn);
});
expect(global.fetch).toHaveBeenCalledWith(
expect(authFetchMock).toHaveBeenCalledWith(
"/api/planner/send-email",
expect.objectContaining({
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
})
);
@@ -580,16 +583,15 @@ describe("PlannerPage form interactions", () => {
fireEvent.submit(form);
});
expect(global.fetch).toHaveBeenCalledWith(
expect(authFetchMock).toHaveBeenCalledWith(
"/api/medications/usage",
expect.objectContaining({
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
})
);
const fetchCall = (global.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
const fetchCall = (authFetchMock as ReturnType<typeof vi.fn>).mock.calls[0];
const body = JSON.parse(fetchCall[1].body);
expect(body.includeUntilStart).toBe(true);
expect(typeof body.startDate).toBe("string");
+134 -1
View File
@@ -1,8 +1,11 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { SchedulePage } from "../../pages/SchedulePage";
const feedbackMock = vi.hoisted(() => ({ showFeedback: vi.fn() }));
const authFetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => fetch(input, init));
// Mock data
const mockMeds = [
{
@@ -85,6 +88,21 @@ const mockPastDays = [
},
];
const mockJournalEntry = {
doseTrackingId: 1,
doseId: `1-0-${FIXED_TIMESTAMP}-John`,
medicationId: 1,
medicationName: "Aspirin",
scheduledFor: "2026-05-21T09:00:00.000Z",
takenAt: "2026-05-21T09:05:00.000Z",
dismissed: false,
takenSource: "manual" as const,
markedBy: "John",
note: "",
updatedAt: null,
createdAt: null,
};
// Factory function for mock context
const createMockContext = (overrides = {}) => ({
meds: [],
@@ -116,6 +134,7 @@ const createMockContext = (overrides = {}) => ({
openUserFilter: vi.fn(),
isDoseTakenAutomatically: vi.fn(() => false),
missedPastDoseIds: [],
loadMeds: vi.fn(),
...overrides,
});
@@ -129,12 +148,22 @@ vi.mock("../../context", () => ({
vi.mock("../../components/Auth", () => ({
useAuth: () => ({
user: { id: 1, username: "testuser" },
authFetch: authFetchMock,
}),
}));
vi.mock("../../context/FeedbackContext", () => ({
useFeedback: () => ({
showFeedback: feedbackMock.showFeedback,
dismissFeedback: vi.fn(),
clearFeedback: vi.fn(),
}),
}));
describe("SchedulePage", () => {
beforeEach(() => {
vi.clearAllMocks();
authFetchMock.mockImplementation((input: RequestInfo | URL, init?: RequestInit) => fetch(input, init));
localStorage.clear();
mockContextValue = createMockContext();
});
@@ -185,6 +214,29 @@ describe("SchedulePage", () => {
expect(timeline).toBeInTheDocument();
});
it("disables the journal note action for untaken doses", () => {
const openJournalEditor = vi.fn();
mockContextValue = createMockContext({
meds: mockMeds,
coverageByMed: mockCoverageByMed,
futureDays: mockFutureDays,
openJournalEditor,
});
render(
<MemoryRouter>
<SchedulePage />
</MemoryRouter>
);
const noteButton = screen.getByRole("button", { name: "journal.actions.note" });
expect(noteButton).toBeDisabled();
expect(noteButton.closest("span")).toHaveAttribute("data-tooltip", "journal.actions.noteTakenOnly");
fireEvent.click(noteButton);
expect(openJournalEditor).not.toHaveBeenCalled();
});
it("shows empty state when no medications", () => {
render(
<MemoryRouter>
@@ -248,6 +300,48 @@ describe("SchedulePage", () => {
fireEvent.change(select, { target: { value: "90" } });
expect(setScheduleDays).toHaveBeenCalledWith(90);
});
it("posts the computed dismiss-until payload when clearing missed doses", async () => {
const loadMeds = vi.fn();
global.fetch = vi.fn().mockResolvedValue({ ok: true });
mockContextValue = createMockContext({
meds: mockMeds,
coverageByMed: mockCoverageByMed,
pastDays: mockPastDays,
missedPastDoseIds: [`${mockPastDays[0].meds[0].doses[0].id}-John`],
loadMeds,
});
render(
<MemoryRouter>
<SchedulePage />
</MemoryRouter>
);
fireEvent.click(screen.getByRole("button", { name: /dashboard\.schedules\.clearMissed/i }));
fireEvent.click(screen.getByRole("button", { name: /dashboard\.schedules\.clearMissedConfirm/i }));
await waitFor(() => {
expect(authFetchMock).toHaveBeenCalledWith(
"/api/medications/dismiss-until",
expect.objectContaining({
method: "POST",
})
);
});
const body = JSON.parse(((authFetchMock as ReturnType<typeof vi.fn>).mock.calls[0]?.[1]?.body as string) ?? "{}");
expect(body).toEqual({
medicationIds: [1],
until: mockPastDays[0].date.toISOString().slice(0, 10),
});
expect(loadMeds).toHaveBeenCalled();
expect(feedbackMock.showFeedback).toHaveBeenCalledWith({
message: expect.stringContaining("dashboard.schedules.clearMissedSuccess"),
tone: "success",
});
});
});
describe("SchedulePage structure", () => {
@@ -726,11 +820,13 @@ describe("SchedulePage skip behavior", () => {
it("renders undo skip state for skipped doses", () => {
const skippedDoseId = `1-0-${FIXED_TIMESTAMP}-John`;
const openJournalEditor = vi.fn();
mockContextValue = createMockContext({
meds: mockMeds,
futureDays: mockFutureDays,
coverageByMed: mockCoverageByMed,
skippedDoses: new Set([skippedDoseId]),
openJournalEditor,
});
render(
@@ -741,6 +837,43 @@ describe("SchedulePage skip behavior", () => {
expect(document.querySelector(".dose-btn.undo.skip")).toBeInTheDocument();
expect(screen.getByText("John").closest(".dose-person")).toHaveClass("skipped");
const noteButton = screen.getByRole("button", { name: "journal.actions.note" });
expect(noteButton).not.toBeDisabled();
fireEvent.click(noteButton);
expect(openJournalEditor).toHaveBeenCalledWith(skippedDoseId);
});
it("closes the journal editor after saving a main app note", async () => {
const saveJournalNote = vi.fn(async () => true);
const closeJournalEditor = vi.fn();
mockContextValue = createMockContext({
journalEditorOpen: true,
journalEvent: mockJournalEntry,
journalEventLoading: false,
journalEventSaving: false,
journalEventDeleting: false,
journalEventError: null,
saveJournalNote,
closeJournalEditor,
deleteJournalNote: vi.fn(),
});
render(
<MemoryRouter>
<SchedulePage />
</MemoryRouter>
);
fireEvent.change(screen.getByLabelText("journal.editor.noteLabel"), {
target: { value: "Main app note" },
});
fireEvent.click(screen.getByRole("button", { name: "common.save" }));
await waitFor(() => {
expect(saveJournalNote).toHaveBeenCalledWith("Main app note");
expect(closeJournalEditor).toHaveBeenCalled();
});
});
it("calls undoDoseSkipped when clicking undo skip", () => {
+115 -35
View File
@@ -1,9 +1,10 @@
import { fireEvent, render, screen } from "@testing-library/react";
import type { ReactNode } from "react";
import { MemoryRouter } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { SettingsPage } from "../../pages/SettingsPage";
const authFetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => fetch(input, init));
const changeLanguageMock = vi.fn();
vi.mock("react-i18next", async () => {
@@ -42,6 +43,9 @@ const createMockContext = (overrides = {}) => ({
reminderRepeatIntervalMinutes: 30,
maxNaggingReminders: 5,
language: "en",
timezone: "Europe/Berlin",
serverTimezone: "Europe/Berlin",
availableTimezones: ["Europe/Berlin", "UTC"],
stockCalculationMode: "automatic",
smtpHost: "",
smtpPort: 587,
@@ -84,6 +88,8 @@ const createMockContext = (overrides = {}) => ({
setShowImportConfirm: vi.fn(),
pendingImportData: null,
setPendingImportData: vi.fn(),
importPreview: null,
setImportPreview: vi.fn(),
handleImportConfirm: vi.fn(),
importResult: null,
setImportResult: vi.fn(),
@@ -98,14 +104,9 @@ vi.mock("../../context", () => ({
useAppContext: () => mockContextValue,
}));
interface MockConfirmModalProps {
title: string;
message: ReactNode;
confirmLabel: string;
cancelLabel: string;
onConfirm: () => void;
onCancel: () => void;
}
vi.mock("../../components/Auth", () => ({
useAuth: () => ({ authFetch: authFetchMock }),
}));
interface MockExportModalProps {
isOpen: boolean;
@@ -113,31 +114,53 @@ interface MockExportModalProps {
onExport: () => void;
}
vi.mock("../../components", () => ({
ConfirmModal: ({ title, message, confirmLabel, cancelLabel, onConfirm, onCancel }: MockConfirmModalProps) => (
<div>
<div>{title}</div>
<div>{message}</div>
<button type="button" onClick={onConfirm}>
{confirmLabel}
</button>
<button type="button" onClick={onCancel}>
{cancelLabel}
</button>
</div>
),
ExportModal: ({ isOpen, onClose, onExport }: MockExportModalProps) =>
isOpen ? (
<div>
<button type="button" onClick={onExport}>
export-modal-export
</button>
<button type="button" onClick={onClose}>
export-modal-close
</button>
</div>
) : null,
}));
const createImportPreview = (overrides = {}) => ({
version: "1.6",
exportedAt: "2026-05-17T10:00:00.000Z",
includeSensitiveData: false,
incoming: {
medications: 1,
doseHistory: 2,
refillHistory: 3,
shareLinks: 4,
journalEntries: 1,
imageCount: 0,
hasSettings: true,
},
current: {
medications: 1,
doseHistory: 0,
refillHistory: 0,
shareLinks: 0,
hasSettings: false,
},
warnings: {
replacesExistingData: true,
regeneratesShareLinks: true,
containsImages: false,
containsSensitiveData: false,
},
...overrides,
});
vi.mock("../../components", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../components")>();
return {
...actual,
ExportModal: ({ isOpen, onClose, onExport }: MockExportModalProps) =>
isOpen ? (
<div>
<button type="button" onClick={onExport}>
export-modal-export
</button>
<button type="button" onClick={onClose}>
export-modal-close
</button>
</div>
) : null,
};
});
function renderPage() {
render(
@@ -151,6 +174,7 @@ describe("SettingsPage", () => {
beforeEach(() => {
vi.clearAllMocks();
mockContextValue = createMockContext();
authFetchMock.mockImplementation((input: RequestInfo | URL, init?: RequestInit) => fetch(input, init));
fetchMock.mockResolvedValue({ ok: true, json: async () => ({}) });
vi.stubGlobal("fetch", fetchMock);
});
@@ -200,7 +224,24 @@ describe("SettingsPage", () => {
expect(select).toBeInTheDocument();
fireEvent.change(select as HTMLSelectElement, { target: { value: "de" } });
expect(changeLanguageMock).toHaveBeenCalledWith("de");
expect(fetchMock).toHaveBeenCalledWith("/api/settings/language", expect.objectContaining({ method: "PUT" }));
expect(authFetchMock).toHaveBeenCalledWith("/api/settings/language", expect.objectContaining({ method: "PUT" }));
});
it("generates an API key through authFetch and shows the returned token", async () => {
fetchMock.mockResolvedValueOnce({ ok: true, json: async () => ({ token: "new-token-123" }) });
renderPage();
fireEvent.click(screen.getByText("settings.apiKey.generateButton"));
expect(authFetchMock).toHaveBeenCalledWith(
"/api/auth/api-keys",
expect.objectContaining({
method: "POST",
body: JSON.stringify({ name: "Default API Key", scope: "write" }),
})
);
expect(await screen.findByDisplayValue("new-token-123")).toBeInTheDocument();
});
it("updates timeline toggles through setSettings", () => {
@@ -379,10 +420,13 @@ describe("SettingsPage", () => {
it("cancels import confirm and clears pending import", () => {
const setShowImportConfirm = vi.fn();
const setPendingImportData = vi.fn();
const setImportPreview = vi.fn();
mockContextValue = createMockContext({
setShowImportConfirm,
setPendingImportData,
setImportPreview,
showImportConfirm: true,
importPreview: createImportPreview(),
meds: [{ id: 1 }],
});
@@ -390,6 +434,7 @@ describe("SettingsPage", () => {
fireEvent.click(screen.getByText("exportImport.cancelButton"));
expect(setShowImportConfirm).toHaveBeenCalledWith(false);
expect(setPendingImportData).toHaveBeenCalledWith(null);
expect(setImportPreview).toHaveBeenCalledWith(null);
});
it("renders notification matrix with toggle switches", () => {
@@ -452,11 +497,13 @@ describe("SettingsPage", () => {
mockContextValue = createMockContext({
handleImportConfirm,
showImportConfirm: true,
importPreview: createImportPreview(),
meds: [{ id: 1 }],
});
renderPage();
expect(screen.getByText("exportImport.confirmImport")).toBeInTheDocument();
expect(screen.getByText("exportImport.reviewDescription")).toBeInTheDocument();
expect(screen.getByText(/exportImport\.confirmImportWarning/i)).toBeInTheDocument();
fireEvent.click(screen.getByText("exportImport.confirmButton"));
@@ -466,19 +513,52 @@ describe("SettingsPage", () => {
it("renders import confirm for empty state and handles cancel", () => {
const setShowImportConfirm = vi.fn();
const setPendingImportData = vi.fn();
const setImportPreview = vi.fn();
mockContextValue = createMockContext({
setShowImportConfirm,
setPendingImportData,
setImportPreview,
showImportConfirm: true,
importPreview: createImportPreview({
current: {
medications: 0,
doseHistory: 0,
refillHistory: 0,
shareLinks: 0,
hasSettings: false,
},
warnings: {
replacesExistingData: false,
regeneratesShareLinks: false,
containsImages: false,
containsSensitiveData: false,
},
}),
meds: [],
});
renderPage();
expect(screen.getByText("exportImport.confirmImportEmpty")).toBeInTheDocument();
expect(screen.getByText("exportImport.reviewDescriptionEmpty")).toBeInTheDocument();
expect(screen.getByText("exportImport.confirmImportEmptyMessage")).toBeInTheDocument();
fireEvent.click(screen.getByText("exportImport.cancelButton"));
expect(setShowImportConfirm).toHaveBeenCalledWith(false);
expect(setPendingImportData).toHaveBeenCalledWith(null);
expect(setImportPreview).toHaveBeenCalledWith(null);
});
it("offers backup-first from the import review modal", () => {
const handleExport = vi.fn();
mockContextValue = createMockContext({
handleExport,
showImportConfirm: true,
importPreview: createImportPreview(),
meds: [{ id: 1 }],
});
renderPage();
fireEvent.click(screen.getByText("exportImport.backupFirst"));
expect(handleExport).toHaveBeenCalledWith(true);
});
});
+1
View File
@@ -328,6 +328,7 @@ export type SharedScheduleData = {
takenBy: string;
sharedBy: string | null;
scheduleDays: number;
allowJournalNotes?: boolean;
medications: SharedMedication[];
stockThresholds?: {
lowStockDays: number;

Some files were not shown because too many files have changed in this diff Show More