Compare commits
6 Commits
482b7573c4
...
cedd62223c
| Author | SHA1 | Date | |
|---|---|---|---|
| cedd62223c | |||
| 47d230ace2 | |||
| 812b14df03 | |||
| c78fc43083 | |||
| e4a1b449c6 | |||
| 767ae23843 |
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
Generated
+5
-5
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "medassist-ng-backend",
|
||||
"version": "1.25.1",
|
||||
"version": "1.26.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "medassist-ng-backend",
|
||||
"version": "1.25.1",
|
||||
"version": "1.26.0",
|
||||
"dependencies": {
|
||||
"@fastify/cookie": "^11.0.2",
|
||||
"@fastify/cors": "^11.2.0",
|
||||
@@ -4473,9 +4473,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.14.2",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz",
|
||||
"integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==",
|
||||
"version": "6.15.2",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
|
||||
"integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "medassist-ng-backend",
|
||||
"version": "1.25.1",
|
||||
"version": "1.26.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -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) {
|
||||
@@ -96,6 +97,16 @@ export async function runAlterMigrations(client: Client): Promise<{ success: boo
|
||||
packs_added INTEGER NOT NULL DEFAULT 0,
|
||||
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,
|
||||
@@ -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)`,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
// =============================================================================
|
||||
|
||||
@@ -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
@@ -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}`
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+252
-56
@@ -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,32 +847,23 @@ 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)) {
|
||||
const oldImagePaths = existingMeds
|
||||
.map((med) => (med.imageUrl ? resolve(IMAGES_DIR, med.imageUrl) : null))
|
||||
.filter((path): path is string => path !== null);
|
||||
const newImagePaths: string[] = [];
|
||||
|
||||
try {
|
||||
unlinkSync(imagePath);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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));
|
||||
|
||||
// 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));
|
||||
|
||||
// 3. Import medications and build ID mapping
|
||||
const exportIdToNewId = new Map<string, number>();
|
||||
|
||||
for (const med of importData.medications) {
|
||||
@@ -711,14 +883,11 @@ export async function exportRoutes(app: FastifyInstance) {
|
||||
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);
|
||||
|
||||
// Check if any schedule has remind enabled
|
||||
const intakeRemindersEnabled =
|
||||
normalizedSchedules.some((schedule) => schedule.intakeRemindersEnabled) || med.intakeRemindersEnabled;
|
||||
|
||||
const [inserted] = await db
|
||||
const [inserted] = await tx
|
||||
.insert(medications)
|
||||
.values({
|
||||
userId,
|
||||
@@ -753,52 +922,64 @@ export async function exportRoutes(app: FastifyInstance) {
|
||||
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,
|
||||
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
|
||||
imageUrl: null,
|
||||
})
|
||||
.returning();
|
||||
|
||||
// Save mapping
|
||||
exportIdToNewId.set(med._exportId, inserted.id);
|
||||
|
||||
// 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));
|
||||
newImagePaths.push(resolve(IMAGES_DIR, imageUrl));
|
||||
await tx.update(medications).set({ imageUrl }).where(eq(medications.id, inserted.id));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
if (!newMedId) continue;
|
||||
|
||||
// Convert ISO timestamp back to milliseconds for dose ID
|
||||
const timestampMs = new Date(dose.scheduledTime).getTime();
|
||||
// Rebuild dose ID with optional person suffix
|
||||
const scheduledFor = new Date(dose.scheduledTime);
|
||||
const timestampMs = scheduledFor.getTime();
|
||||
const doseId = buildDoseId(newMedId, dose.scheduleIndex, timestampMs, dose.takenByPerson);
|
||||
|
||||
await db.insert(doseTracking).values({
|
||||
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 });
|
||||
|
||||
await restoreIntakeJournalForImportedDose({
|
||||
userId,
|
||||
doseTrackingId: insertedDose.id,
|
||||
medicationId: newMedId,
|
||||
scheduledFor,
|
||||
journalNote: dose.journalNote,
|
||||
journalCreatedAt: dose.journalCreatedAt,
|
||||
journalUpdatedAt: dose.journalUpdatedAt,
|
||||
database: tx,
|
||||
});
|
||||
}
|
||||
|
||||
// 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({
|
||||
await tx.insert(userSettings).values({
|
||||
userId,
|
||||
emailEnabled: importData.settings.emailEnabled ?? false,
|
||||
notificationEmail: importData.settings.notificationEmail || null,
|
||||
@@ -826,26 +1007,22 @@ export async function exportRoutes(app: FastifyInstance) {
|
||||
});
|
||||
}
|
||||
|
||||
// 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({
|
||||
await tx.insert(shareTokens).values({
|
||||
userId,
|
||||
token,
|
||||
token: randomBytes(8).toString("hex"),
|
||||
takenBy: share.takenBy,
|
||||
scheduleDays: share.scheduleDays,
|
||||
allowJournalNotes: share.allowJournalNotes ?? false,
|
||||
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
|
||||
if (!newMedId) continue;
|
||||
|
||||
await db.insert(refillHistory).values({
|
||||
await tx.insert(refillHistory).values({
|
||||
medicationId: newMedId,
|
||||
userId,
|
||||
packsAdded: refill.packsAdded ?? 0,
|
||||
@@ -854,6 +1031,25 @@ export async function exportRoutes(app: FastifyInstance) {
|
||||
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" });
|
||||
}
|
||||
|
||||
for (const imagePath of oldImagePaths) {
|
||||
const removalError = removeFileIfPresent(imagePath);
|
||||
if (removalError) {
|
||||
request.log.warn(`[Import] Failed to remove replaced image path=${imagePath}: ${removalError}`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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({
|
||||
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,9 +180,16 @@ 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
|
||||
@@ -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
@@ -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,
|
||||
}));
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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" })]);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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("");
|
||||
}
|
||||
@@ -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)}`;
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
+10
-1
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "medassist-ng-frontend",
|
||||
"private": true,
|
||||
"version": "1.25.1",
|
||||
"version": "1.26.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
+80
-64
@@ -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,12 +38,62 @@ 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>
|
||||
<FeedbackProvider>
|
||||
<Suspense fallback={<RouteLoadingFallback />}>
|
||||
<Routes>
|
||||
{/* Public share route - accessible without auth */}
|
||||
@@ -53,6 +103,7 @@ export default function App() {
|
||||
<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>
|
||||
<AuthStatusCard theme={authTheme}>
|
||||
<div className="auth-error" style={{ marginBottom: "1rem" }}>
|
||||
<strong>Connection Error</strong>
|
||||
<strong>{t("auth.connectionErrorTitle")}</strong>
|
||||
<br />
|
||||
{authError}
|
||||
</div>
|
||||
<p style={{ fontSize: "0.9rem", color: "var(--text-muted)" }}>
|
||||
Please check if the server is running and try again.
|
||||
</p>
|
||||
<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" }}>
|
||||
Retry
|
||||
{t("common.retry")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</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}
|
||||
/>
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
} else {
|
||||
const filterArr = takenByFilter.size > 0 ? Array.from(takenByFilter) : null;
|
||||
const content = generateTextReport(selectedMeds, reportData, format, t, filterArr);
|
||||
downloadFile(content, format);
|
||||
}
|
||||
const imageMap = await fetchMedImages(selectedMeds, authFetch);
|
||||
openPrintView(selectedMeds, reportData, t, imageMap, filterArr, resolvedDateRange);
|
||||
setPreview(null);
|
||||
setErrorMessage(null);
|
||||
onClose();
|
||||
} else {
|
||||
const content = generateTextReport(selectedMeds, reportData, format, t, filterArr, resolvedDateRange);
|
||||
setPreview({ format, content });
|
||||
}
|
||||
} 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>`;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}, [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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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("");
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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,9 +95,10 @@ export function useRefill(): UseRefillReturn {
|
||||
}, [resetRefillForm]);
|
||||
|
||||
// Load refill history for a medication
|
||||
const loadRefillHistory = useCallback(async (medId: number) => {
|
||||
const loadRefillHistory = useCallback(
|
||||
async (medId: number) => {
|
||||
try {
|
||||
const res = await fetch(`/api/medications/${medId}/refills`, { credentials: "include" });
|
||||
const res = await authFetch(`/api/medications/${medId}/refills`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setRefillHistory(Array.isArray(data) ? data : data.refills || []);
|
||||
@@ -105,7 +108,9 @@ export function useRefill(): UseRefillReturn {
|
||||
} 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,
|
||||
|
||||
@@ -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,34 +29,74 @@ 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[]) => {
|
||||
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;
|
||||
}
|
||||
|
||||
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 = [
|
||||
@@ -63,7 +116,9 @@ export function useShare(): UseShareReturn {
|
||||
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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,6 +313,7 @@ export function SchedulePage() {
|
||||
<article className="card schedule-full">
|
||||
<div className="card-head">
|
||||
<h2>{t("dashboard.schedules.title")}</h2>
|
||||
<div className="card-head-actions">
|
||||
<select
|
||||
className="select-field schedule-days-select"
|
||||
value={scheduleDays}
|
||||
@@ -246,6 +327,18 @@ export function SchedulePage() {
|
||||
<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>
|
||||
);
|
||||
|
||||
@@ -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")}
|
||||
<ImportReviewModal
|
||||
isOpen={showImportConfirm}
|
||||
importPreview={importPreview}
|
||||
formattedExportedAt={formattedImportPreviewDate}
|
||||
importing={importing}
|
||||
exporting={exporting}
|
||||
onClose={closeImportReview}
|
||||
onBackup={() => handleExport(true)}
|
||||
onConfirm={handleImportConfirm}
|
||||
onCancel={() => {
|
||||
setShowImportConfirm(false);
|
||||
setPendingImportData(null);
|
||||
}}
|
||||
confirmVariant={hasExistingData ? "danger" : "primary"}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Export Options Modal */}
|
||||
<ExportModal
|
||||
isOpen={showExportModal}
|
||||
onClose={() => setShowExportModal(false)}
|
||||
onExport={handleExport}
|
||||
exporting={exporting}
|
||||
/>
|
||||
<ExportModal isOpen={showExportModal} onClose={closeExportModal} onExport={handleExport} exporting={exporting} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Vendored
+1
-1
@@ -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;
|
||||
|
||||
@@ -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%;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,13 +58,15 @@ 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({
|
||||
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",
|
||||
@@ -50,20 +75,24 @@ describe("ReportModal", () => {
|
||||
}),
|
||||
});
|
||||
|
||||
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("radio", { name: new RegExp(`report\\.format${format === "txt" ? "Txt" : "Md"}`, "i") })
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: /report\.generate/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
"/api/medications/report-data",
|
||||
expect.objectContaining({ method: "POST" })
|
||||
);
|
||||
expectPreviewToBeVisible();
|
||||
});
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
expect(URL.createObjectURL).toHaveBeenCalled();
|
||||
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(
|
||||
|
||||
@@ -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", 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,
|
||||
},
|
||||
},
|
||||
})
|
||||
),
|
||||
});
|
||||
|
||||
it("parses valid import file and opens confirm modal", () => {
|
||||
class MockFileReader {
|
||||
onload: ((event: ProgressEvent<FileReader>) => void) | null = null;
|
||||
readAsText = vi.fn(() => {
|
||||
@@ -503,6 +583,14 @@ describe("useAppContext", () => {
|
||||
} as unknown as React.ChangeEvent<HTMLInputElement>);
|
||||
});
|
||||
|
||||
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",
|
||||
@@ -510,6 +598,7 @@ describe("useAppContext", () => {
|
||||
medications: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("computes day stock status as warning and danger for low/out stock", async () => {
|
||||
mockCalculateCoverage.mockReturnValue({
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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("");
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user