Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8e2fd0a761 | |||
| 0a4f8c5948 | |||
| fd055a3a2a | |||
| 8718311876 | |||
| 89edd74de3 | |||
| 30d72f625d | |||
| cea1a8b119 | |||
| 3aa2b608b0 | |||
| e24a540f17 | |||
| fae96c9fdd | |||
| 11b55fc638 |
@@ -4,6 +4,7 @@
|
||||
|
||||
- **English is the primary language**: All code, comments, documentation, commit messages, PR descriptions, and GitHub releases MUST be written in English. The user may communicate in German, but all project artifacts must be in English.
|
||||
- **NEVER release without explicit permission**: Do NOT create tags, releases, or version bumps unless the user explicitly asks for it. Always wait for explicit confirmation before any release action.
|
||||
- **NEVER create PRs without explicit permission**: Do NOT create Pull Requests, push branches, or merge code unless the user explicitly asks for it. Always present changes and wait for the user to confirm before any git operations that affect the remote repository.
|
||||
- **No temporary files**: Delete temporary scripts/files immediately after use. Do not commit temporary debug scripts, test files, or one-off utilities to the repository.
|
||||
- **Clean workspace**: Always clean up after yourself. If you create a file for a specific task, delete it once done.
|
||||
|
||||
@@ -190,11 +191,39 @@ gh pr merge --squash --delete-branch
|
||||
|
||||
> ⚠️ **IMPORTANT**: All GitHub Releases must be written in **English**!
|
||||
|
||||
### Release Workflow (MANDATORY for minor/major releases)
|
||||
|
||||
The `main` branch is protected - releases must go through the automated release script.
|
||||
|
||||
**Release Process:**
|
||||
```bash
|
||||
# 1. Run release script (creates PR, waits for CI, merges, creates tag)
|
||||
./scripts/release.sh [patch|minor|major]
|
||||
|
||||
# 2. GitHub Actions creates a DRAFT release automatically
|
||||
# 3. User asks AI to write release notes:
|
||||
# "Write the release notes for vX.Y.Z"
|
||||
# 4. AI writes descriptive release notes following the style guide below
|
||||
# 5. User publishes the draft release with the written notes
|
||||
```
|
||||
|
||||
> ⚠️ **MANDATORY for minor and major releases**: The AI assistant MUST write proper descriptive release notes!
|
||||
> Do NOT just publish the auto-generated commit list. Follow the process above.
|
||||
|
||||
**AI Assistant Release Notes Workflow:**
|
||||
1. When user asks to write release notes for a version:
|
||||
- Check commits since previous tag: `git log vPREV..vNEW --oneline`
|
||||
- Read through the changes to understand what was added/fixed
|
||||
- Write release notes following the style guide below
|
||||
- Present the notes to the user for copying to GitHub
|
||||
|
||||
### Creating Release Notes
|
||||
|
||||
> ⚠️ **MANDATORY**: GitHub Releases MUST contain a written message!
|
||||
> Not just auto-generated commit lists, but a brief descriptive text.
|
||||
|
||||
**Release title:** Use just `vX.Y.Z` (e.g., `v1.4.1`), NOT "Release vX.Y.Z".
|
||||
|
||||
**Keep it informative but concise.** Users want to know what changed and where to find it.
|
||||
|
||||
**Required structure of release notes:**
|
||||
@@ -216,6 +245,12 @@ gh pr merge --squash --delete-branch
|
||||
- ❌ Number of tests added
|
||||
- ❌ Internal API changes (unless breaking)
|
||||
- ❌ Excessive emoji on every bullet point
|
||||
- ❌ .gitignore changes or other developer-only file changes
|
||||
- ❌ AI/Copilot instruction updates
|
||||
- ❌ CI/CD workflow changes (unless affecting users)
|
||||
- ❌ Code refactoring without user-visible changes
|
||||
|
||||
**Only include user-relevant changes** - things that affect what users see or experience in the app.
|
||||
|
||||
**Example of good release notes:**
|
||||
|
||||
|
||||
@@ -16,41 +16,63 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Get previous tag
|
||||
id: prev_tag
|
||||
- name: Get version info
|
||||
id: version
|
||||
run: |
|
||||
# Get all tags sorted by version, find the one before current
|
||||
CURRENT_TAG=${GITHUB_REF#refs/tags/}
|
||||
PREV_TAG=$(git tag --sort=-v:refname | grep -A1 "^${CURRENT_TAG}$" | tail -1)
|
||||
VERSION=${CURRENT_TAG#v}
|
||||
echo "tag=$CURRENT_TAG" >> $GITHUB_OUTPUT
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
# If no previous tag found (first release), use empty
|
||||
# Get previous tag
|
||||
PREV_TAG=$(git tag --sort=-v:refname | grep -A1 "^${CURRENT_TAG}$" | tail -1)
|
||||
if [ "$PREV_TAG" = "$CURRENT_TAG" ]; then
|
||||
PREV_TAG=""
|
||||
fi
|
||||
|
||||
echo "previous_tag=$PREV_TAG" >> $GITHUB_OUTPUT
|
||||
echo "Current tag: $CURRENT_TAG, Previous tag: $PREV_TAG"
|
||||
|
||||
- name: Generate changelog
|
||||
id: changelog
|
||||
- name: Generate release template
|
||||
run: |
|
||||
PREV_TAG="${{ steps.prev_tag.outputs.previous_tag }}"
|
||||
cat > release_notes.md << 'EOF'
|
||||
## What's New
|
||||
|
||||
if [ -z "$PREV_TAG" ]; then
|
||||
# First release - get all commits
|
||||
CHANGES=$(git log --pretty=format:"- %s" HEAD)
|
||||
else
|
||||
# Get commits since last tag
|
||||
CHANGES=$(git log --pretty=format:"- %s" ${PREV_TAG}..HEAD)
|
||||
fi
|
||||
<!--
|
||||
Write 1-2 sentences describing the main changes in this release.
|
||||
Example: This release introduces a medication refill tracking feature and improves the mobile user experience.
|
||||
-->
|
||||
|
||||
# Write to file for multiline support
|
||||
echo "$CHANGES" > changelog.txt
|
||||
### New Features
|
||||
|
||||
<!-- List new features with **bold** names and descriptions -->
|
||||
- **Feature Name**: Description of the feature
|
||||
|
||||
### Improvements
|
||||
|
||||
<!-- List improvements and fixes -->
|
||||
- **Improvement**: Description
|
||||
|
||||
### Where to Find It
|
||||
|
||||
<!-- Tell users where they can access new features -->
|
||||
|
||||
---
|
||||
|
||||
## Docker Images
|
||||
|
||||
```bash
|
||||
docker pull ghcr.io/danielvolz/medassist-ng-backend:${{ steps.version.outputs.version }}
|
||||
docker pull ghcr.io/danielvolz/medassist-ng-frontend:${{ steps.version.outputs.version }}
|
||||
```
|
||||
|
||||
**Full Changelog**: https://github.com/DanielVolz/medassist-ng/compare/${{ steps.version.outputs.previous_tag }}...${{ steps.version.outputs.tag }}
|
||||
EOF
|
||||
|
||||
- name: Create Release
|
||||
- name: Create Draft Release
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
body_path: changelog.txt
|
||||
body_path: release_notes.md
|
||||
draft: true
|
||||
generate_release_notes: false
|
||||
name: "Release ${{ steps.version.outputs.tag }}"
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
+67
-26
@@ -1,33 +1,74 @@
|
||||
# Node
|
||||
# ===================
|
||||
# Dependencies
|
||||
# ===================
|
||||
node_modules/
|
||||
.pnpm-store/
|
||||
|
||||
# ===================
|
||||
# Build outputs
|
||||
# ===================
|
||||
dist/
|
||||
build/
|
||||
.tmp/
|
||||
*.tsbuildinfo
|
||||
|
||||
# ===================
|
||||
# Test & Coverage
|
||||
# ===================
|
||||
coverage/
|
||||
.nyc_output/
|
||||
|
||||
# ===================
|
||||
# Environment
|
||||
# ===================
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# ===================
|
||||
# Database & Data
|
||||
# ===================
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
*.db-journal
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
data/
|
||||
|
||||
# ===================
|
||||
# Logs
|
||||
# ===================
|
||||
logs/
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
# Build outputs
|
||||
dist/
|
||||
build/
|
||||
coverage/
|
||||
.tmp/
|
||||
|
||||
# Env
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# SQLite
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
*.db-journal
|
||||
backend/data/
|
||||
|
||||
# Logs
|
||||
logs/
|
||||
*.log
|
||||
|
||||
# Editor
|
||||
.vscode/
|
||||
.idea/
|
||||
# ===================
|
||||
# OS files
|
||||
# ===================
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# ===================
|
||||
# IDE / Editor
|
||||
# ===================
|
||||
.idea/
|
||||
*.sublime-project
|
||||
*.sublime-workspace
|
||||
|
||||
# Keep shared VS Code settings
|
||||
# .vscode/ is NOT ignored - settings.json is useful for the team
|
||||
|
||||
# ===================
|
||||
# Misc
|
||||
# ===================
|
||||
*.local
|
||||
.cache/
|
||||
.turbo/
|
||||
docs/TECH_STACK.md
|
||||
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"vitest.root": "backend",
|
||||
"vitest.enable": true,
|
||||
"vitest.commandLine": "npm test --"
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Build outputs
|
||||
dist/
|
||||
coverage/
|
||||
|
||||
# Development files
|
||||
*.log
|
||||
npm-debug.log*
|
||||
|
||||
# Test files
|
||||
src/test/
|
||||
*.test.ts
|
||||
vitest.config.ts
|
||||
|
||||
# Local data (mounted as volume in production)
|
||||
data/
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Git
|
||||
.git/
|
||||
.gitignore
|
||||
|
||||
# Docker
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
docker-compose*.yml
|
||||
@@ -46,6 +46,9 @@ COPY --from=builder /app/node_modules ./node_modules
|
||||
COPY --from=builder /app/dist ./dist
|
||||
COPY --from=builder /app/package.json ./
|
||||
|
||||
# Copy drizzle migrations folder (required for database setup)
|
||||
COPY drizzle ./drizzle
|
||||
|
||||
# Create data directory and set ownership to node user (UID 1000)
|
||||
RUN mkdir -p /app/data && chown -R node:node /app
|
||||
|
||||
|
||||
Generated
+2
-8
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "medassist-ng-backend",
|
||||
"version": "1.1.0",
|
||||
"version": "1.4.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "medassist-ng-backend",
|
||||
"version": "1.1.0",
|
||||
"version": "1.4.1",
|
||||
"dependencies": {
|
||||
"@fastify/cookie": "^10.0.1",
|
||||
"@fastify/cors": "^10.0.1",
|
||||
@@ -2079,7 +2079,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@libsql/client/-/client-0.10.0.tgz",
|
||||
"integrity": "sha512-2ERn08T4XOVx34yBtUPq0RDjAdd9TJ5qNH/izugr208ml2F94mk92qC64kXyDVQINodWJvp3kAdq6P4zTtCZ7g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@libsql/core": "^0.10.0",
|
||||
"@libsql/hrana-client": "^0.6.2",
|
||||
@@ -4579,7 +4578,6 @@
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
@@ -5776,7 +5774,6 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -6538,7 +6535,6 @@
|
||||
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "~0.27.0",
|
||||
"get-tsconfig": "^4.7.5"
|
||||
@@ -6602,7 +6598,6 @@
|
||||
"integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.27.0",
|
||||
"fdir": "^6.5.0",
|
||||
@@ -6678,7 +6673,6 @@
|
||||
"integrity": "sha512-E4t7DJ9pESL6E3I8nFjPa4xGUd3PmiWDLsDztS2qXSJWfHtbQnwAWylaBvSNY48I3vr8PTqIZlyK8TE3V3CA4Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vitest/expect": "4.0.16",
|
||||
"@vitest/mocker": "4.0.16",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "medassist-ng-backend",
|
||||
"version": "1.4.0",
|
||||
"version": "1.5.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
+43
-12
@@ -121,12 +121,28 @@ export async function doseRoutes(app: FastifyInstance) {
|
||||
|
||||
const { doseId } = request.params;
|
||||
|
||||
await db.delete(doseTracking).where(
|
||||
and(
|
||||
eq(doseTracking.userId, userId),
|
||||
eq(doseTracking.doseId, doseId)
|
||||
)
|
||||
);
|
||||
// Check if this dose was dismissed
|
||||
const [existing] = await db.select()
|
||||
.from(doseTracking)
|
||||
.where(
|
||||
and(
|
||||
eq(doseTracking.userId, userId),
|
||||
eq(doseTracking.doseId, doseId)
|
||||
)
|
||||
);
|
||||
|
||||
if (existing?.dismissed) {
|
||||
// Already dismissed - keep the record as-is
|
||||
// The dose stays dismissed, we just acknowledge the undo request
|
||||
} else {
|
||||
// Not dismissed - delete the record entirely
|
||||
await db.delete(doseTracking).where(
|
||||
and(
|
||||
eq(doseTracking.userId, userId),
|
||||
eq(doseTracking.doseId, doseId)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
@@ -321,12 +337,27 @@ export async function doseRoutes(app: FastifyInstance) {
|
||||
return reply.notFound("Share link not found");
|
||||
}
|
||||
|
||||
await db.delete(doseTracking).where(
|
||||
and(
|
||||
eq(doseTracking.userId, share.userId),
|
||||
eq(doseTracking.doseId, doseId)
|
||||
)
|
||||
);
|
||||
// Check if this dose was dismissed
|
||||
const [existing] = await db.select()
|
||||
.from(doseTracking)
|
||||
.where(
|
||||
and(
|
||||
eq(doseTracking.userId, share.userId),
|
||||
eq(doseTracking.doseId, doseId)
|
||||
)
|
||||
);
|
||||
|
||||
if (existing?.dismissed) {
|
||||
// Already dismissed - keep the record as-is
|
||||
} else {
|
||||
// Not dismissed - delete the record entirely
|
||||
await db.delete(doseTracking).where(
|
||||
and(
|
||||
eq(doseTracking.userId, share.userId),
|
||||
eq(doseTracking.doseId, doseId)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { resolve, extname } from "path";
|
||||
import { pipeline } from "stream/promises";
|
||||
import { requireAuth, getAnonymousUserId } from "../plugins/auth.js";
|
||||
import { env } from "../plugins/env.js";
|
||||
import { parseLocalDateTime } from "../utils/scheduler-utils.js";
|
||||
import type { AuthUser } from "../types/fastify.js";
|
||||
|
||||
const IMAGES_DIR = resolve(process.cwd(), "data/images");
|
||||
@@ -15,7 +16,7 @@ const IMAGES_DIR = resolve(process.cwd(), "data/images");
|
||||
const blisterSchema = z.object({
|
||||
usage: z.number().nonnegative(),
|
||||
every: z.number().int().min(1),
|
||||
start: z.string().datetime(),
|
||||
start: z.string().datetime({ local: true }),
|
||||
});
|
||||
|
||||
const medicationSchema = z.object({
|
||||
@@ -205,7 +206,7 @@ export async function medicationRoutes(app: FastifyInstance) {
|
||||
|
||||
// Clean up dose tracking entries that are before the earliest start date
|
||||
// This ensures consistency when the user changes the start date
|
||||
const earliestStart = Math.min(...blisters.map(b => new Date(b.start).getTime()));
|
||||
const earliestStart = Math.min(...blisters.map(b => parseLocalDateTime(b.start).getTime()));
|
||||
if (!Number.isNaN(earliestStart)) {
|
||||
// Get all dose tracking entries for this medication and filter out invalid ones
|
||||
const allDoses = await db.select().from(doseTracking)
|
||||
@@ -386,7 +387,7 @@ export async function medicationRoutes(app: FastifyInstance) {
|
||||
// Calculate consumption up to now (same logic as frontend)
|
||||
let consumedUntilNow = 0;
|
||||
blisters.forEach((blister) => {
|
||||
const blisterStart = new Date(blister.start);
|
||||
const blisterStart = parseLocalDateTime(blister.start);
|
||||
if (Number.isNaN(blisterStart.getTime()) || blisterStart > now) return;
|
||||
const msPerDay = 86400000;
|
||||
const period = Math.max(1, blister.every) * msPerDay;
|
||||
@@ -430,7 +431,7 @@ export async function medicationRoutes(app: FastifyInstance) {
|
||||
function calculateUsageInRange(blisters: Array<{ usage: number; every: number; start: string }>, start: Date, end: Date) {
|
||||
let total = 0;
|
||||
blisters.forEach((blister) => {
|
||||
const blisterStart = new Date(blister.start);
|
||||
const blisterStart = parseLocalDateTime(blister.start);
|
||||
if (Number.isNaN(blisterStart.getTime())) return;
|
||||
// iterate occurrences from blisterStart up to end
|
||||
for (let dt = new Date(blisterStart); dt < end; dt.setDate(dt.getDate() + blister.every)) {
|
||||
|
||||
@@ -73,11 +73,23 @@ async function registerDoseRoutes(ctx: TestContext) {
|
||||
const userId = 1;
|
||||
const { doseId } = request.params;
|
||||
|
||||
await client.execute({
|
||||
sql: `DELETE FROM dose_tracking WHERE user_id = ? AND dose_id = ?`,
|
||||
// Check if this dose was also dismissed
|
||||
const existing = await client.execute({
|
||||
sql: `SELECT id, dismissed FROM dose_tracking WHERE user_id = ? AND dose_id = ?`,
|
||||
args: [userId, doseId],
|
||||
});
|
||||
|
||||
if (existing.rows.length > 0 && existing.rows[0].dismissed) {
|
||||
// Already dismissed - keep the record as-is (don't delete)
|
||||
// The dose stays dismissed, we just ignore the undo request
|
||||
} else {
|
||||
// Not dismissed - delete the record entirely
|
||||
await client.execute({
|
||||
sql: `DELETE FROM dose_tracking WHERE user_id = ? AND dose_id = ?`,
|
||||
args: [userId, doseId],
|
||||
});
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
@@ -346,6 +358,43 @@ describe("Dose Tracking API", () => {
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual({ success: true });
|
||||
});
|
||||
|
||||
it("should preserve dismissed status when unmarking a dose", async () => {
|
||||
const doseId = "1-0-1735344000000";
|
||||
|
||||
// First dismiss the dose
|
||||
await ctx.app.inject({
|
||||
method: "POST",
|
||||
url: "/doses/dismiss",
|
||||
payload: { doseIds: [doseId] },
|
||||
});
|
||||
|
||||
// Verify it's dismissed
|
||||
let result = await ctx.client.execute({
|
||||
sql: `SELECT dismissed, taken_at FROM dose_tracking WHERE dose_id = ?`,
|
||||
args: [doseId],
|
||||
});
|
||||
expect(result.rows[0].dismissed).toBe(1);
|
||||
const originalTakenAt = result.rows[0].taken_at;
|
||||
|
||||
// Now try to unmark it (undo) - should keep the dismissed record
|
||||
const response = await ctx.app.inject({
|
||||
method: "DELETE",
|
||||
url: `/doses/taken/${encodeURIComponent(doseId)}`,
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual({ success: true });
|
||||
|
||||
// Verify the record still exists and is still dismissed
|
||||
result = await ctx.client.execute({
|
||||
sql: `SELECT dose_id, dismissed, taken_at FROM dose_tracking WHERE dose_id = ?`,
|
||||
args: [doseId],
|
||||
});
|
||||
expect(result.rows.length).toBe(1);
|
||||
expect(result.rows[0].dismissed).toBe(1);
|
||||
expect(result.rows[0].taken_at).toBe(originalTakenAt); // unchanged
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -338,18 +338,19 @@ describe("Scheduler Utils - Depletion Calculation", () => {
|
||||
describe("Scheduler Utils - Upcoming Intakes", () => {
|
||||
describe("getUpcomingIntakes", () => {
|
||||
it("should return empty array when no intakes in window", () => {
|
||||
const blisters: Blister[] = [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }];
|
||||
// Set "now" to a time far from any scheduled intake
|
||||
const now = new Date("2025-01-01T12:00:00.000Z").getTime();
|
||||
// With parseLocalDateTime, times are treated as local - use same format for consistency
|
||||
const blisters: Blister[] = [{ usage: 1, every: 1, start: "2025-01-01T08:00:00" }];
|
||||
// Set "now" to a time far from any scheduled intake (12:00 local)
|
||||
const now = new Date(2025, 0, 1, 12, 0, 0).getTime();
|
||||
|
||||
const result = getUpcomingIntakes("TestMed", blisters, 15, [], null, "en-US", "UTC", now);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("should find intake within reminder window", () => {
|
||||
// Schedule intake at 08:00, check at 07:45 (15 minutes before)
|
||||
const blisters: Blister[] = [{ usage: 2, every: 1, start: "2025-01-01T08:00:00.000Z" }];
|
||||
const now = new Date("2025-01-01T07:45:00.000Z").getTime();
|
||||
// Schedule intake at 08:00 local, check at 07:45 local (15 minutes before)
|
||||
const blisters: Blister[] = [{ usage: 2, every: 1, start: "2025-01-01T08:00:00" }];
|
||||
const now = new Date(2025, 0, 1, 7, 45, 0).getTime();
|
||||
|
||||
const result = getUpcomingIntakes("TestMed", blisters, 15, ["Alice"], 500, "en-US", "UTC", now);
|
||||
|
||||
@@ -361,20 +362,20 @@ describe("Scheduler Utils - Upcoming Intakes", () => {
|
||||
});
|
||||
|
||||
it("should skip blisters with zero interval", () => {
|
||||
const blisters: Blister[] = [{ usage: 1, every: 0, start: "2025-01-01T08:00:00.000Z" }];
|
||||
const now = new Date("2025-01-01T07:45:00.000Z").getTime();
|
||||
const blisters: Blister[] = [{ usage: 1, every: 0, start: "2025-01-01T08:00:00" }];
|
||||
const now = new Date(2025, 0, 1, 7, 45, 0).getTime();
|
||||
|
||||
const result = getUpcomingIntakes("TestMed", blisters, 15, [], null, "en-US", "UTC", now);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("should handle multiple blisters", () => {
|
||||
// Two intakes at 08:00 and 08:01
|
||||
// Two intakes at 08:00 and 08:01 local
|
||||
const blisters: Blister[] = [
|
||||
{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" },
|
||||
{ usage: 2, every: 1, start: "2025-01-01T08:01:00.000Z" },
|
||||
{ usage: 1, every: 1, start: "2025-01-01T08:00:00" },
|
||||
{ usage: 2, every: 1, start: "2025-01-01T08:01:00" },
|
||||
];
|
||||
const now = new Date("2025-01-01T07:45:00.000Z").getTime();
|
||||
const now = new Date(2025, 0, 1, 7, 45, 0).getTime();
|
||||
|
||||
const result = getUpcomingIntakes("TestMed", blisters, 15, [], null, "en-US", "UTC", now);
|
||||
|
||||
@@ -386,13 +387,14 @@ describe("Scheduler Utils - Upcoming Intakes", () => {
|
||||
describe("getTodaysIntakes", () => {
|
||||
it("should return all intakes for today", () => {
|
||||
// Daily medication at 08:00 starting yesterday
|
||||
// With parseLocalDateTime, "08:00:00.000Z" is treated as 08:00 local time
|
||||
const blisters: Blister[] = [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }];
|
||||
|
||||
// Get intakes for 2025-01-02 (today's intake should be at 08:00)
|
||||
// Get intakes for today (today's intake should be at 08:00 local)
|
||||
const result = getTodaysIntakes("TestMed", blisters, [], null, "en-US", "UTC");
|
||||
|
||||
expect(result.length).toBeGreaterThanOrEqual(1);
|
||||
const intake = result.find(i => i.intakeTime.getUTCHours() === 8);
|
||||
const intake = result.find(i => i.intakeTime.getHours() === 8);
|
||||
expect(intake).toBeDefined();
|
||||
expect(intake?.medName).toBe("TestMed");
|
||||
expect(intake?.usage).toBe(1);
|
||||
@@ -454,19 +456,23 @@ describe("Scheduler Utils - Upcoming Intakes", () => {
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
});
|
||||
|
||||
it("should handle timezone correctly", () => {
|
||||
// 23:00 in Europe/Berlin on a specific date
|
||||
it("should handle local time correctly (ignore Z suffix)", () => {
|
||||
// With parseLocalDateTime, the Z suffix is ignored and time is treated as local server time
|
||||
// The intakeTimeStr is then formatted for the target timezone (Europe/Berlin)
|
||||
// So if server is in UTC, 14:00 server time becomes 15:00 Europe/Berlin time
|
||||
const blisters: Blister[] = [{
|
||||
usage: 1,
|
||||
every: 1,
|
||||
start: "2025-01-01T22:00:00.000Z" // 23:00 Berlin time
|
||||
start: "2025-01-01T14:00:00.000Z" // Treated as 14:00 server local time
|
||||
}];
|
||||
|
||||
const result = getTodaysIntakes("TzMed", blisters, [], null, "de-DE", "Europe/Berlin");
|
||||
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
if (result.length > 0) {
|
||||
expect(result[0].intakeTimeStr).toContain("23:");
|
||||
// The intakeTimeStr should be a valid time format (HH:MM)
|
||||
// Exact value depends on server timezone vs target timezone offset
|
||||
expect(result[0].intakeTimeStr).toMatch(/^\d{2}:\d{2}$/);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -119,6 +119,34 @@ export function getMsUntilNextCheck(reminderHour: number, tz?: string): number {
|
||||
// Blister/medication parsing utilities
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Parse an ISO datetime string to local timestamp.
|
||||
* Extracts date/time components directly from the string to avoid
|
||||
* timezone conversion issues with Z suffix.
|
||||
*
|
||||
* "2026-01-23T20:55:00" → treated as local time 20:55
|
||||
* "2026-01-23T20:55:00.000Z" → also treated as local time 20:55 (Z ignored)
|
||||
*/
|
||||
export function parseLocalDateTime(isoString: string): Date {
|
||||
// Extract components: YYYY-MM-DDTHH:MM:SS (ignore Z and milliseconds)
|
||||
const match = isoString.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):?(\d{2})?/);
|
||||
if (!match) {
|
||||
// Fallback to Date parsing if format doesn't match
|
||||
return new Date(isoString);
|
||||
}
|
||||
|
||||
const [, year, month, day, hour, minute, second] = match;
|
||||
// Create date using local time interpretation (no UTC conversion)
|
||||
return new Date(
|
||||
parseInt(year, 10),
|
||||
parseInt(month, 10) - 1, // Month is 0-indexed
|
||||
parseInt(day, 10),
|
||||
parseInt(hour, 10),
|
||||
parseInt(minute, 10),
|
||||
parseInt(second ?? "0", 10)
|
||||
);
|
||||
}
|
||||
|
||||
/** Parse blister schedules from JSON columns */
|
||||
export function parseBlisters(row: { usageJson: string; everyJson: string; startJson: string }): Blister[] {
|
||||
try {
|
||||
@@ -213,7 +241,7 @@ export function getTodaysIntakes(
|
||||
const intakes: UpcomingIntake[] = [];
|
||||
|
||||
for (const blister of blisters) {
|
||||
const startTime = new Date(blister.start).getTime();
|
||||
const startTime = parseLocalDateTime(blister.start).getTime();
|
||||
const intervalMs = blister.every * 24 * 60 * 60 * 1000;
|
||||
|
||||
if (intervalMs <= 0) continue;
|
||||
@@ -277,7 +305,7 @@ export function getUpcomingIntakes(
|
||||
const upcoming: UpcomingIntake[] = [];
|
||||
|
||||
for (const blister of blisters) {
|
||||
const startTime = new Date(blister.start).getTime();
|
||||
const startTime = parseLocalDateTime(blister.start).getTime();
|
||||
const intervalMs = blister.every * 24 * 60 * 60 * 1000;
|
||||
|
||||
if (intervalMs <= 0) continue;
|
||||
|
||||
@@ -6,7 +6,7 @@ services:
|
||||
volumes:
|
||||
- ./backend:/app
|
||||
- backend_node_modules:/app/node_modules
|
||||
- ./backend/data:/app/data
|
||||
- ./data:/app/data
|
||||
env_file:
|
||||
- .env
|
||||
ports:
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Build outputs (rebuilt in Docker)
|
||||
dist/
|
||||
coverage/
|
||||
|
||||
# Development files
|
||||
*.log
|
||||
npm-debug.log*
|
||||
|
||||
# Test files
|
||||
src/test/
|
||||
*.test.ts
|
||||
*.test.tsx
|
||||
vitest.config.ts
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Git
|
||||
.git/
|
||||
.gitignore
|
||||
|
||||
# Docker
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
docker-compose*.yml
|
||||
Generated
+1379
-3
File diff suppressed because it is too large
Load Diff
+11
-3
@@ -1,13 +1,15 @@
|
||||
{
|
||||
"name": "medassist-ng-frontend",
|
||||
"private": true,
|
||||
"version": "1.4.0",
|
||||
"version": "1.5.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "echo 'add lint config'"
|
||||
"lint": "echo 'add lint config'",
|
||||
"test": "vitest",
|
||||
"test:coverage": "vitest run --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"i18next": "^24.2.2",
|
||||
@@ -19,11 +21,17 @@
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/react": "^18.3.4",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/react-router-dom": "^5.3.3",
|
||||
"@vitejs/plugin-react": "^4.3.2",
|
||||
"@vitest/coverage-v8": "^4.0.17",
|
||||
"jsdom": "^27.4.0",
|
||||
"typescript": "^5.5.4",
|
||||
"vite": "^7.3.0"
|
||||
"vite": "^7.3.0",
|
||||
"vitest": "^4.0.17"
|
||||
}
|
||||
}
|
||||
|
||||
+129
-5123
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,152 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { FRONTEND_VERSION, GITHUB_URL } from '../App';
|
||||
|
||||
interface UpdateCheckResult {
|
||||
status: 'checking' | 'up-to-date' | 'update-available' | 'error';
|
||||
latestVersion?: string;
|
||||
lastChecked?: string;
|
||||
}
|
||||
|
||||
interface AboutModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function AboutModal({ isOpen, onClose }: AboutModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const [backendVersion, setBackendVersion] = useState<string | null>(null);
|
||||
const [updateCheckResult, setUpdateCheckResult] = useState<UpdateCheckResult | null>(null);
|
||||
|
||||
// Fetch backend version and cached update result on mount
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
// Fetch backend version
|
||||
fetch('/api/health')
|
||||
.then(res => res.json())
|
||||
.then(data => setBackendVersion(data.version || 'unknown'))
|
||||
.catch(() => setBackendVersion('unknown'));
|
||||
|
||||
// Load cached update check result
|
||||
const cached = sessionStorage.getItem('updateCheckResult');
|
||||
if (cached) {
|
||||
try {
|
||||
const parsed = JSON.parse(cached);
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
setUpdateCheckResult(parsed);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
async function checkForUpdates() {
|
||||
setUpdateCheckResult({ status: 'checking' });
|
||||
try {
|
||||
const res = await fetch(`https://api.github.com/repos/DanielVolz/medassist-ng/releases/latest`);
|
||||
if (!res.ok) throw new Error('Failed to fetch');
|
||||
const data = await res.json();
|
||||
const latestVersion = (data.tag_name || '').replace(/^v/, '');
|
||||
const currentVersion = FRONTEND_VERSION.replace(/^v/, '');
|
||||
const isUpToDate = latestVersion === currentVersion;
|
||||
const result: UpdateCheckResult = {
|
||||
status: isUpToDate ? 'up-to-date' : 'update-available',
|
||||
latestVersion,
|
||||
lastChecked: new Date().toISOString()
|
||||
};
|
||||
setUpdateCheckResult(result);
|
||||
// Cache the result
|
||||
sessionStorage.setItem('updateCheckResult', JSON.stringify(result));
|
||||
} catch {
|
||||
setUpdateCheckResult({ status: 'error' });
|
||||
}
|
||||
}
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="modal-content about-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<button className="modal-close" onClick={onClose}>×</button>
|
||||
<div className="about-header">
|
||||
<div className="about-logo">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M19.5 12c0 4.14-3.36 7.5-7.5 7.5S4.5 16.14 4.5 12 7.86 4.5 12 4.5s7.5 3.36 7.5 7.5z"/>
|
||||
<path d="M12 8v4l2.5 2.5"/>
|
||||
<path d="M9 2h6M12 2v2"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h2>{t('about.appName', 'MedAssist')}</h2>
|
||||
<p className="about-tagline">{t('about.description', 'Personal medication tracking and reminder app')}</p>
|
||||
</div>
|
||||
<div className="about-versions">
|
||||
<div className="about-version-row">
|
||||
<span className="about-version-label">{t('about.frontendVersion', 'Frontend')}</span>
|
||||
<span className="about-version-value">{FRONTEND_VERSION}</span>
|
||||
</div>
|
||||
<div className="about-version-row">
|
||||
<span className="about-version-label">{t('about.backendVersion', 'Backend')}</span>
|
||||
<span className="about-version-value">{backendVersion || '...'}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="about-update-section">
|
||||
<button className="about-update-btn" onClick={checkForUpdates} disabled={updateCheckResult?.status === 'checking'}>
|
||||
{updateCheckResult?.status === 'checking' ? (
|
||||
<>
|
||||
<span className="spinner-small"></span>
|
||||
{t('about.checking', 'Checking...')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/>
|
||||
<path d="M3 3v5h5"/>
|
||||
<path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16"/>
|
||||
<path d="M16 16h5v5"/>
|
||||
</svg>
|
||||
{t('about.checkForUpdates', 'Check for Updates')}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
{updateCheckResult && updateCheckResult.status !== 'checking' && (
|
||||
<div className={`about-update-result ${updateCheckResult.status}`}>
|
||||
{updateCheckResult.status === 'up-to-date' && (
|
||||
<span className="update-status-text">✓ {t('about.upToDate', 'You are up to date!')}</span>
|
||||
)}
|
||||
{updateCheckResult.status === 'update-available' && (
|
||||
<span className="update-status-text">
|
||||
⬆ {t('about.updateAvailable', 'Update available')}: <strong>v{updateCheckResult.latestVersion}</strong>
|
||||
<a href={`${GITHUB_URL}/releases/latest`} target="_blank" rel="noopener noreferrer" className="update-download-link">
|
||||
{t('about.downloadUpdate', 'Download')}
|
||||
</a>
|
||||
</span>
|
||||
)}
|
||||
{updateCheckResult.status === 'error' && (
|
||||
<span className="update-status-text">⚠ {t('about.checkFailed', 'Could not check for updates')}</span>
|
||||
)}
|
||||
{updateCheckResult.lastChecked && (
|
||||
<span className="update-last-checked">
|
||||
{t('about.lastChecked', 'Last checked')}: {new Date(updateCheckResult.lastChecked).toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="about-links">
|
||||
<a href={GITHUB_URL} target="_blank" rel="noopener noreferrer" className="about-link">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
|
||||
</svg>
|
||||
{t('about.viewOnGitHub', 'View on GitHub')}
|
||||
</a>
|
||||
</div>
|
||||
<div className="about-footer">
|
||||
<p className="about-copyright">{t('about.copyright', '© {{year}} Daniel Volz', { year: new Date().getFullYear() })}</p>
|
||||
<p className="about-license">{t('about.license', 'GPL-3.0 License')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* AppHeader - Main application header with navigation and user menu
|
||||
*/
|
||||
import { useState, useEffect } from "react";
|
||||
import { useNavigate, useLocation } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAuth } from "./Auth";
|
||||
import { useTheme } from "../hooks";
|
||||
|
||||
interface AppHeaderProps {
|
||||
onOpenProfile: () => void;
|
||||
onOpenAbout: () => void;
|
||||
}
|
||||
|
||||
export function AppHeader({ onOpenProfile, onOpenAbout }: AppHeaderProps) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const currentPath = location.pathname;
|
||||
const { user, authState, logout } = useAuth();
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
|
||||
// User dropdown state (for mobile click-based behavior)
|
||||
const [userDropdownOpen, setUserDropdownOpen] = useState(false);
|
||||
|
||||
// Close user dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
if (!userDropdownOpen) return;
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (!target.closest('.user-menu')) {
|
||||
setUserDropdownOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("click", handleClickOutside);
|
||||
return () => document.removeEventListener("click", handleClickOutside);
|
||||
}, [userDropdownOpen]);
|
||||
|
||||
// Page titles based on current route
|
||||
const pageInfo = {
|
||||
"/dashboard": { eyebrow: t('header.eyebrow.overview'), title: t('nav.dashboard') },
|
||||
"/medications": { eyebrow: t('header.eyebrow.inventory'), title: t('nav.medications') },
|
||||
"/planner": { eyebrow: t('header.eyebrow.planner'), title: t('nav.planner') },
|
||||
"/settings": { eyebrow: t('header.eyebrow.settings'), title: t('nav.settings') },
|
||||
"/schedule": { eyebrow: t('header.eyebrow.schedule'), title: t('dashboard.schedules.title') },
|
||||
}[currentPath] || { eyebrow: t('header.eyebrow.overview'), title: t('nav.dashboard') };
|
||||
|
||||
return (
|
||||
<header className="hero">
|
||||
<div className="hero-title">
|
||||
<img src="/favicon.svg" alt="MedAssist-ng" className="hero-logo" />
|
||||
<div>
|
||||
<p className="eyebrow">{pageInfo.eyebrow}</p>
|
||||
<h1>{pageInfo.title}</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div className="header-actions">
|
||||
<div className="tabs">
|
||||
<button className={currentPath === "/dashboard" || currentPath === "/" ? "pill primary" : "pill"} onClick={() => navigate("/dashboard")}>{t('nav.dashboard')}</button>
|
||||
<button className={currentPath === "/medications" ? "pill primary" : "pill"} onClick={() => navigate("/medications")}>{t('nav.medications')}</button>
|
||||
<button className={currentPath === "/planner" ? "pill primary" : "pill"} onClick={() => navigate("/planner")}>{t('nav.planner')}</button>
|
||||
</div>
|
||||
{/* Settings button only shown when auth is disabled (no user dropdown available) */}
|
||||
{!authState?.authEnabled && (
|
||||
<button className={`icon-btn ${currentPath === "/settings" ? "active" : ""}`} onClick={() => navigate("/settings")} title={t('nav.settings')}>⚙️</button>
|
||||
)}
|
||||
<button className="icon-btn" onClick={toggleTheme} title={theme === "dark" ? t('tooltips.lightMode') : t('tooltips.darkMode')}>
|
||||
{theme === "dark" ? "☀️" : "🌙"}
|
||||
</button>
|
||||
{authState?.authEnabled && user && (
|
||||
<div className={`user-menu ${userDropdownOpen ? 'open' : ''}`}>
|
||||
<button className="user-menu-btn" onClick={() => setUserDropdownOpen(!userDropdownOpen)}>
|
||||
{user.avatarUrl ? (
|
||||
<img src={`/api/images/${user.avatarUrl}`} alt={user.username} className="user-avatar-img" />
|
||||
) : (
|
||||
<span className="user-avatar">{user.username.charAt(0).toUpperCase()}</span>
|
||||
)}
|
||||
</button>
|
||||
<div className="user-dropdown">
|
||||
<div className="dropdown-header">
|
||||
{user.avatarUrl ? (
|
||||
<img src={`/api/images/${user.avatarUrl}`} alt={user.username} className="dropdown-avatar-img" />
|
||||
) : (
|
||||
<div className="dropdown-avatar">{user.username.charAt(0).toUpperCase()}</div>
|
||||
)}
|
||||
<span className="dropdown-username">{user.username}</span>
|
||||
</div>
|
||||
<div className="dropdown-menu">
|
||||
<button className="dropdown-item" onClick={() => { onOpenProfile(); setUserDropdownOpen(false); }}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
|
||||
{t('auth.profile', 'Profile')}
|
||||
</button>
|
||||
<button className="dropdown-item" onClick={() => { navigate('/settings'); setUserDropdownOpen(false); }}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
|
||||
{t('nav.settings', 'Settings')}
|
||||
</button>
|
||||
<button className="dropdown-item" onClick={() => { onOpenAbout(); setUserDropdownOpen(false); }}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg>
|
||||
{t('about.title', 'About')}
|
||||
</button>
|
||||
<button className="dropdown-item danger" onClick={() => { logout(); setUserDropdownOpen(false); }}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
|
||||
{t('auth.signOut', 'Sign Out')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
// =============================================================================
|
||||
// ConfirmModal Component - Simple confirmation dialog
|
||||
// =============================================================================
|
||||
|
||||
import { ReactNode } from "react";
|
||||
|
||||
export interface ConfirmModalProps {
|
||||
title: string;
|
||||
message: string | ReactNode;
|
||||
confirmLabel: string;
|
||||
cancelLabel: string;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
isLoading?: boolean;
|
||||
confirmVariant?: "primary" | "danger" | "success";
|
||||
}
|
||||
|
||||
export function ConfirmModal({
|
||||
title,
|
||||
message,
|
||||
confirmLabel,
|
||||
cancelLabel,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
isLoading = false,
|
||||
confirmVariant = "primary"
|
||||
}: ConfirmModalProps) {
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onCancel}>
|
||||
<div className="modal-content" onClick={(e) => e.stopPropagation()} style={{ maxWidth: "450px" }}>
|
||||
<button className="modal-close" onClick={onCancel}>
|
||||
×
|
||||
</button>
|
||||
<h2 style={{ marginBottom: "16px", paddingRight: "2rem" }}>{title}</h2>
|
||||
<div style={{ marginBottom: "24px" }}>{typeof message === "string" ? <p>{message}</p> : message}</div>
|
||||
<div
|
||||
className="modal-footer"
|
||||
style={{ padding: "1rem 0 0 0", borderTop: "none", justifyContent: "flex-end" }}
|
||||
>
|
||||
<button type="button" className="ghost" onClick={onCancel} disabled={isLoading}>
|
||||
{cancelLabel}
|
||||
</button>
|
||||
<button type="button" className={confirmVariant} onClick={onConfirm} disabled={isLoading}>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface ExportModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onExport: (includeImages: boolean) => void;
|
||||
exporting: boolean;
|
||||
}
|
||||
|
||||
export default function ExportModal({ isOpen, onClose, onExport, exporting }: ExportModalProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="modal-content" onClick={(e) => e.stopPropagation()} style={{maxWidth: "450px"}}>
|
||||
<button className="modal-close" onClick={onClose}>×</button>
|
||||
<h2 style={{marginBottom: "16px", paddingRight: "2rem"}}>{t('exportImport.exportOptions')}</h2>
|
||||
<div style={{display: 'flex', flexDirection: 'column', gap: '12px'}}>
|
||||
<button
|
||||
type="button"
|
||||
className="action-card"
|
||||
onClick={() => {
|
||||
onClose();
|
||||
onExport(true);
|
||||
}}
|
||||
disabled={exporting}
|
||||
style={{textAlign: 'left', cursor: 'pointer', border: '1px solid var(--border)', borderRadius: '8px'}}
|
||||
>
|
||||
<div className="action-card-content" style={{flex: 1}}>
|
||||
<span className="action-card-title">{t('exportImport.exportWithImages')}</span>
|
||||
<span className="action-card-desc">{t('exportImport.exportWithImagesDesc')}</span>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="action-card"
|
||||
onClick={() => {
|
||||
onClose();
|
||||
onExport(false);
|
||||
}}
|
||||
disabled={exporting}
|
||||
style={{textAlign: 'left', cursor: 'pointer', border: '1px solid var(--border)', borderRadius: '8px'}}
|
||||
>
|
||||
<div className="action-card-content" style={{flex: 1}}>
|
||||
<span className="action-card-title">{t('exportImport.exportDataOnly')}</span>
|
||||
<span className="action-card-desc">{t('exportImport.exportDataOnlyDesc')}</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div className="modal-footer" style={{padding: "1rem 0 0 0", borderTop: "none", justifyContent: "flex-end"}}>
|
||||
<button
|
||||
type="button"
|
||||
className="ghost"
|
||||
onClick={onClose}
|
||||
>
|
||||
{t('exportImport.cancelButton')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
// =============================================================================
|
||||
// Lightbox Component - Full-screen image viewer
|
||||
// =============================================================================
|
||||
|
||||
import { MouseEvent } from "react";
|
||||
|
||||
export interface LightboxProps {
|
||||
src: string;
|
||||
alt: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function Lightbox({ src, alt, onClose }: LightboxProps) {
|
||||
function handleOverlayClick(e: MouseEvent) {
|
||||
if (e.target === e.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="lightbox-overlay" onClick={handleOverlayClick}>
|
||||
<button className="lightbox-close" onClick={onClose}>
|
||||
×
|
||||
</button>
|
||||
<img src={src} alt={alt} className="lightbox-image" onClick={(e) => e.stopPropagation()} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,498 @@
|
||||
/**
|
||||
* MedDetailModal - Medication detail view with nested modals
|
||||
* Displays medication information, stock, schedules, and provides refill/edit functionality
|
||||
*
|
||||
* Can work in two modes:
|
||||
* 1. Context mode: Uses useAppContext() for all state (when no props provided)
|
||||
* 2. Props mode: Accepts all required data as props (for gradual adoption)
|
||||
*/
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { Medication, Coverage, RefillEntry, StockThresholds } from "../types";
|
||||
import { MedicationAvatar, Lightbox } from "../components";
|
||||
import { getMedTotal, getPackageSize } from "../types";
|
||||
import { formatNumber, generateICS, getExpiryClass, getSystemLocale } from "../utils";
|
||||
import { getStockStatus } from "../utils/schedule";
|
||||
|
||||
// =============================================================================
|
||||
// Local Helper Functions
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Calculate blister stock - divides current pills into full blisters and partial
|
||||
*/
|
||||
function getBlisterStock(
|
||||
currentPills: number,
|
||||
pillsPerBlister: number,
|
||||
_originalLooseTablets: number,
|
||||
_originalTotalPills: number
|
||||
): { fullBlisters: number; openBlisterPills: number; loosePills: number } {
|
||||
if (pillsPerBlister <= 0 || pillsPerBlister === 1) {
|
||||
return { fullBlisters: 0, openBlisterPills: 0, loosePills: currentPills };
|
||||
}
|
||||
const fullBlisters = Math.floor(currentPills / pillsPerBlister);
|
||||
const openBlisterPills = currentPills % pillsPerBlister;
|
||||
return { fullBlisters, openBlisterPills, loosePills: 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Format full blisters column
|
||||
*/
|
||||
function formatFullBlisters(fullBlisters: number, t: (key: string) => string): string {
|
||||
if (fullBlisters === 0) return "—";
|
||||
return `${fullBlisters} ${fullBlisters === 1 ? t("common.blister") : t("common.blisters")}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format open blister column
|
||||
*/
|
||||
function formatOpenBlisterAndLoose(
|
||||
openBlisterPills: number,
|
||||
_loosePills: number,
|
||||
pillsPerBlister: number,
|
||||
t: (key: string) => string
|
||||
): string {
|
||||
if (openBlisterPills > 0) {
|
||||
return `${openBlisterPills} ${t("common.of")} ${pillsPerBlister} ${t("common.pills")}`;
|
||||
}
|
||||
return "—";
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Props Interface
|
||||
// =============================================================================
|
||||
|
||||
export interface MedDetailModalProps {
|
||||
// Required
|
||||
selectedMed: Medication | null;
|
||||
coverage: { all: Coverage[] };
|
||||
settings: StockThresholds;
|
||||
// Modal state
|
||||
showImageLightbox: boolean;
|
||||
showRefillModal: boolean;
|
||||
showEditStockModal: boolean;
|
||||
// Modal actions
|
||||
onClose: () => void;
|
||||
onOpenImageLightbox: () => void;
|
||||
onCloseImageLightbox: () => void;
|
||||
onOpenRefillModal: () => void;
|
||||
onCloseRefillModal: () => void;
|
||||
onOpenEditStockModal: () => void;
|
||||
onCloseEditStockModal: () => void;
|
||||
// Refill state
|
||||
refillPacks: number;
|
||||
onRefillPacksChange: (value: number) => void;
|
||||
refillLoose: number;
|
||||
onRefillLooseChange: (value: number) => void;
|
||||
refillSaving: boolean;
|
||||
refillHistory: RefillEntry[];
|
||||
refillHistoryExpanded: boolean;
|
||||
onRefillHistoryExpandedChange: (value: boolean) => void;
|
||||
onSubmitRefill: (medId: number) => Promise<void>;
|
||||
// Edit stock state
|
||||
editStockFullBlisters: number;
|
||||
onEditStockFullBlistersChange: (value: number) => void;
|
||||
editStockPartialBlisterPills: number;
|
||||
onEditStockPartialBlisterPillsChange: (value: number) => void;
|
||||
editStockSaving: boolean;
|
||||
onSubmitStockCorrection: (medId: number) => Promise<void>;
|
||||
}
|
||||
|
||||
export function MedDetailModal({
|
||||
selectedMed,
|
||||
coverage,
|
||||
settings,
|
||||
showImageLightbox,
|
||||
showRefillModal,
|
||||
showEditStockModal,
|
||||
onClose,
|
||||
onOpenImageLightbox,
|
||||
onCloseImageLightbox,
|
||||
onOpenRefillModal,
|
||||
onCloseRefillModal,
|
||||
onOpenEditStockModal,
|
||||
onCloseEditStockModal,
|
||||
refillPacks,
|
||||
onRefillPacksChange,
|
||||
refillLoose,
|
||||
onRefillLooseChange,
|
||||
refillSaving,
|
||||
refillHistory,
|
||||
refillHistoryExpanded,
|
||||
onRefillHistoryExpandedChange,
|
||||
onSubmitRefill,
|
||||
editStockFullBlisters,
|
||||
onEditStockFullBlistersChange,
|
||||
editStockPartialBlisterPills,
|
||||
onEditStockPartialBlisterPillsChange,
|
||||
editStockSaving,
|
||||
onSubmitStockCorrection,
|
||||
}: MedDetailModalProps) {
|
||||
const { t, i18n } = useTranslation();
|
||||
|
||||
if (!selectedMed) return null;
|
||||
|
||||
const medCoverage = coverage.all.find((c) => c.name === selectedMed.name);
|
||||
const packageSize = getPackageSize(selectedMed);
|
||||
const currentStock = medCoverage ? Math.round(medCoverage.medsLeft) : getMedTotal(selectedMed);
|
||||
const status = medCoverage ? getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, settings) : null;
|
||||
const textClass = status?.className === "danger" ? "danger-text" : status?.className === "warning" ? "warning-text" : "success-text";
|
||||
const stock = getBlisterStock(currentStock, selectedMed.pillsPerBlister, selectedMed.looseTablets, packageSize);
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="modal-content med-detail-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<button className="modal-close" onClick={onClose}>
|
||||
×
|
||||
</button>
|
||||
|
||||
<div className="med-detail-body">
|
||||
{/* Header */}
|
||||
<div className="med-detail-header">
|
||||
<div
|
||||
className={`med-detail-avatar-wrapper ${selectedMed.imageUrl ? "clickable" : ""}`}
|
||||
onClick={() => selectedMed.imageUrl && onOpenImageLightbox()}
|
||||
>
|
||||
<MedicationAvatar name={selectedMed.name} imageUrl={selectedMed.imageUrl} size="lg" />
|
||||
{selectedMed.imageUrl && <span className="expand-icon">🔍</span>}
|
||||
</div>
|
||||
<div className="med-detail-titles">
|
||||
<h2>{selectedMed.name}</h2>
|
||||
{selectedMed.genericName && <span className="med-generic-name">{selectedMed.genericName}</span>}
|
||||
{selectedMed.takenBy && (selectedMed.takenBy || []).length > 0 && (
|
||||
<span className="med-taken-by">
|
||||
{t("modal.for")} {selectedMed.takenBy.join(", ")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stock Info Section */}
|
||||
<div className="med-detail-section">
|
||||
<h3>{t("modal.stockInfo")}</h3>
|
||||
<div className="med-detail-grid">
|
||||
<div className="med-detail-item">
|
||||
<span className="med-detail-label">{t("table.fullBlisters")}</span>
|
||||
<span className={`med-detail-value ${textClass}`}>{formatFullBlisters(stock.fullBlisters, t)}</span>
|
||||
</div>
|
||||
<div className="med-detail-item">
|
||||
<span className="med-detail-label">{t("table.openBlister")}</span>
|
||||
<span className={`med-detail-value ${textClass}`}>
|
||||
{formatOpenBlisterAndLoose(stock.openBlisterPills, stock.loosePills, selectedMed.pillsPerBlister ?? 1, t)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="med-detail-item full-width">
|
||||
<span className="med-detail-label">{t("modal.currentStock")}</span>
|
||||
<span className={`med-detail-value ${textClass}`}>
|
||||
{currentStock} / {packageSize}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Package Details Section */}
|
||||
<div className="med-detail-section">
|
||||
<h3>{t("modal.packageDetails")}</h3>
|
||||
<div className="med-detail-grid">
|
||||
<div className="med-detail-item">
|
||||
<span className="med-detail-label">{t("modal.packs")}</span>
|
||||
<span className="med-detail-value">{selectedMed.packCount}</span>
|
||||
</div>
|
||||
<div className="med-detail-item">
|
||||
<span className="med-detail-label">{t("modal.blistersPerPack")}</span>
|
||||
<span className="med-detail-value">{selectedMed.blistersPerPack}</span>
|
||||
</div>
|
||||
<div className="med-detail-item">
|
||||
<span className="med-detail-label">{t("modal.pillsPerBlister")}</span>
|
||||
<span className="med-detail-value">{selectedMed.pillsPerBlister}</span>
|
||||
</div>
|
||||
{selectedMed.pillWeightMg && (
|
||||
<div className="med-detail-item">
|
||||
<span className="med-detail-label">{t("modal.pillWeight")}</span>
|
||||
<span className="med-detail-value">{selectedMed.pillWeightMg} mg</span>
|
||||
</div>
|
||||
)}
|
||||
{selectedMed.expiryDate && (
|
||||
<div className="med-detail-item">
|
||||
<span className="med-detail-label">{t("modal.expiryDate")}</span>
|
||||
<span className={`med-detail-value ${getExpiryClass(selectedMed.expiryDate, settings.expiryWarningDays)}`}>
|
||||
{new Date(selectedMed.expiryDate).toLocaleDateString(getSystemLocale(i18n.language), {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Intake Schedule Section */}
|
||||
{selectedMed.blisters.length > 0 && (
|
||||
<div className="med-detail-section">
|
||||
<h3>
|
||||
{t("modal.intakeSchedule")}{" "}
|
||||
{selectedMed.intakeRemindersEnabled && (
|
||||
<span className="reminder-icon info-tooltip" data-tooltip={t("tooltips.intakeReminders")}>
|
||||
🔔
|
||||
</span>
|
||||
)}
|
||||
</h3>
|
||||
<div className="med-detail-schedules">
|
||||
{selectedMed.blisters.map((blister, idx) => {
|
||||
const personCount = Math.max(1, selectedMed.takenBy?.length || 1);
|
||||
const totalUsage = blister.usage * personCount;
|
||||
return (
|
||||
<div key={idx} className="med-schedule-item">
|
||||
<span className="med-schedule-usage">
|
||||
{totalUsage} {totalUsage !== 1 ? t("common.pills") : t("common.pill")}
|
||||
{selectedMed.pillWeightMg && ` (${totalUsage * selectedMed.pillWeightMg} mg)`}
|
||||
</span>
|
||||
<span className="med-schedule-freq">
|
||||
{t("form.blisters.every")} {blister.every} {blister.every !== 1 ? t("common.days") : t("common.day")}
|
||||
</span>
|
||||
<span className="med-schedule-time">
|
||||
{t("modal.at")}{" "}
|
||||
{new Date(blister.start).toLocaleTimeString(getSystemLocale(i18n.language), {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Coverage Status Section */}
|
||||
{medCoverage && status && (
|
||||
<div className="med-detail-section">
|
||||
<h3 className="section-header-with-badge">
|
||||
{t("modal.coverageStatus")}
|
||||
<span className={`status-chip small ${status.className}`}>{t(status.label)}</span>
|
||||
</h3>
|
||||
<div className="med-detail-grid">
|
||||
<div className="med-detail-item">
|
||||
<span className="med-detail-label">{t("modal.daysLeft")}</span>
|
||||
<span className="med-detail-value">{medCoverage.daysLeft !== null ? formatNumber(medCoverage.daysLeft) : "—"}</span>
|
||||
</div>
|
||||
<div className="med-detail-item">
|
||||
<span className="med-detail-label">{t("modal.runsOut")}</span>
|
||||
<span className="med-detail-value">{medCoverage.depletionDate ?? "—"}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Notes Section */}
|
||||
{selectedMed.notes && (
|
||||
<div className="med-detail-section">
|
||||
<h3>📝 {t("modal.notes")}</h3>
|
||||
<div className="med-notes-content">{selectedMed.notes}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Refill History Section */}
|
||||
{refillHistory.length > 0 && (
|
||||
<div className="med-detail-section">
|
||||
<h3 className="section-header-clickable" onClick={() => onRefillHistoryExpandedChange(!refillHistoryExpanded)}>
|
||||
{t("refill.history")} ({refillHistory.length})
|
||||
<span className="expand-arrow">{refillHistoryExpanded ? "▼" : "▶"}</span>
|
||||
</h3>
|
||||
{refillHistoryExpanded && (
|
||||
<div className="refill-history-list">
|
||||
{refillHistory.map((entry) => (
|
||||
<div key={entry.id} className="refill-history-item">
|
||||
<span className="refill-date">
|
||||
{new Date(entry.refillDate).toLocaleDateString(getSystemLocale(i18n.language), {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
})}
|
||||
,{" "}
|
||||
{new Date(entry.refillDate).toLocaleTimeString(getSystemLocale(i18n.language), {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</span>
|
||||
<span className="refill-amount">
|
||||
+{entry.packsAdded * selectedMed.blistersPerPack * selectedMed.pillsPerBlister + entry.loosePillsAdded}{" "}
|
||||
{t("common.pills")}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="med-detail-footer">
|
||||
<button onClick={onClose}>{t("common.close")}</button>
|
||||
<div className="footer-actions">
|
||||
<button className="success" onClick={onOpenRefillModal}>
|
||||
{t("refill.button")}
|
||||
</button>
|
||||
<button className="info" onClick={onOpenEditStockModal}>
|
||||
{t("common.edit")}
|
||||
</button>
|
||||
{selectedMed.blisters.length > 0 && (
|
||||
<button className="secondary icon-only" onClick={() => generateICS(selectedMed)} title={t("modal.exportTooltip")}>
|
||||
📅
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Image Lightbox */}
|
||||
{showImageLightbox && selectedMed.imageUrl && (
|
||||
<Lightbox src={`/api/images/${selectedMed.imageUrl}`} alt={selectedMed.name} onClose={onCloseImageLightbox} />
|
||||
)}
|
||||
|
||||
{/* Refill Modal */}
|
||||
{showRefillModal && (
|
||||
<div
|
||||
className="modal-overlay"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onCloseRefillModal();
|
||||
}}
|
||||
>
|
||||
<div className="modal-content refill-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<button className="modal-close" onClick={onCloseRefillModal}>
|
||||
×
|
||||
</button>
|
||||
<h2>{t("refill.title")}</h2>
|
||||
<p className="refill-med-name">{selectedMed.name}</p>
|
||||
|
||||
<div className="refill-form">
|
||||
<label>
|
||||
{t("refill.packs")}
|
||||
<input type="number" min="0" value={refillPacks} onChange={(e) => onRefillPacksChange(parseInt(e.target.value) || 0)} />
|
||||
</label>
|
||||
<label>
|
||||
{t("refill.loosePills")}
|
||||
<input type="number" min="0" value={refillLoose} onChange={(e) => onRefillLooseChange(parseInt(e.target.value) || 0)} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="modal-footer">
|
||||
<button className="ghost" onClick={onCloseRefillModal}>
|
||||
{t("common.cancel")}
|
||||
</button>
|
||||
<div className="refill-footer-right">
|
||||
<button
|
||||
className="success"
|
||||
onClick={() => onSubmitRefill(selectedMed.id)}
|
||||
disabled={(refillPacks < 1 && refillLoose < 1) || refillSaving}
|
||||
>
|
||||
{refillSaving ? t("common.saving") : t("refill.button")}
|
||||
</button>
|
||||
{(refillPacks > 0 || refillLoose > 0) && (
|
||||
<span className="refill-preview">
|
||||
+{refillPacks * selectedMed.blistersPerPack * selectedMed.pillsPerBlister + refillLoose} {t("common.pills")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Edit Stock Modal */}
|
||||
{showEditStockModal && (
|
||||
<div
|
||||
className="modal-overlay"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onCloseEditStockModal();
|
||||
}}
|
||||
>
|
||||
<div className="modal-content edit-stock-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<button className="modal-close" onClick={onCloseEditStockModal}>
|
||||
×
|
||||
</button>
|
||||
<h2>{t("editStock.title")}</h2>
|
||||
<p className="edit-stock-med-name">{selectedMed.name}</p>
|
||||
<p className="edit-stock-hint">{t("editStock.hint")}</p>
|
||||
|
||||
{(() => {
|
||||
const dbTotal = getMedTotal(selectedMed);
|
||||
const currentTotal = medCoverage ? Math.round(medCoverage.medsLeft) : dbTotal;
|
||||
const newTotal = editStockFullBlisters * selectedMed.pillsPerBlister + editStockPartialBlisterPills;
|
||||
const difference = newTotal - currentTotal;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="edit-stock-form">
|
||||
<label>
|
||||
{t("editStock.fullBlisters")} {t("editStock.pillsPerBlister", { count: selectedMed.pillsPerBlister })}
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
value={editStockFullBlisters}
|
||||
onChange={(e) => onEditStockFullBlistersChange(parseInt(e.target.value) || 0)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
{t("editStock.partialBlisterPills")}
|
||||
<input
|
||||
type="number"
|
||||
min={editStockFullBlisters > 0 ? -(selectedMed.pillsPerBlister - 1) : 0}
|
||||
max={selectedMed.pillsPerBlister}
|
||||
value={editStockPartialBlisterPills}
|
||||
onChange={(e) => {
|
||||
const val = parseInt(e.target.value) || 0;
|
||||
const min = editStockFullBlisters > 0 ? -(selectedMed.pillsPerBlister - 1) : 0;
|
||||
const max = selectedMed.pillsPerBlister;
|
||||
onEditStockPartialBlisterPillsChange(Math.max(min, Math.min(val, max)));
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="edit-stock-summary">
|
||||
<div className="summary-row">
|
||||
<span>{t("editStock.currentTotal")}:</span>
|
||||
<span>
|
||||
{currentTotal} {t("common.pills")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="summary-row">
|
||||
<span>{t("editStock.newTotal")}:</span>
|
||||
<span>
|
||||
{newTotal} {t("common.pills")}
|
||||
</span>
|
||||
</div>
|
||||
<div className={`summary-row difference ${difference > 0 ? "positive" : difference < 0 ? "negative" : ""}`}>
|
||||
<span>{t("editStock.difference")}:</span>
|
||||
<span>
|
||||
{difference > 0 ? "+" : ""}
|
||||
{difference} {t("common.pills")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
|
||||
<div className="modal-footer">
|
||||
<button className="ghost" onClick={onCloseEditStockModal}>
|
||||
{t("common.cancel")}
|
||||
</button>
|
||||
<button
|
||||
className="info"
|
||||
onClick={() => onSubmitStockCorrection(selectedMed.id)}
|
||||
disabled={editStockSaving}
|
||||
>
|
||||
{editStockSaving ? t("editStock.saving") : t("editStock.save")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
// =============================================================================
|
||||
// MedicationAvatar Component
|
||||
// =============================================================================
|
||||
|
||||
export type MedicationAvatarProps = {
|
||||
name: string;
|
||||
imageUrl?: string | null;
|
||||
size?: "sm" | "md" | "lg";
|
||||
};
|
||||
|
||||
export function MedicationAvatar({ name, imageUrl, size = "sm" }: MedicationAvatarProps) {
|
||||
const initials = name.split(" ").map(w => w[0]).join("").toUpperCase().slice(0, 2) || "?";
|
||||
const sizeClass = `med-avatar med-avatar-${size}`;
|
||||
|
||||
if (imageUrl) {
|
||||
return <img src={`/api/images/${imageUrl}`} alt={name} className={sizeClass} />;
|
||||
}
|
||||
return <div className={`${sizeClass} med-avatar-initials`}>{initials}</div>;
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
/**
|
||||
* MobileEditModal - Full-screen edit form for medications (mobile-optimized)
|
||||
* Handles new medication creation and editing existing medications
|
||||
*/
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { Medication, FormState, FormBlister, FieldErrors } from "../types";
|
||||
|
||||
// Field limits for validation
|
||||
const FIELD_LIMITS = {
|
||||
name: { max: 100 },
|
||||
genericName: { max: 100 },
|
||||
takenBy: { max: 50 },
|
||||
notes: { max: 1000 },
|
||||
};
|
||||
|
||||
export interface MobileEditModalProps {
|
||||
show: boolean;
|
||||
editingId: number | null;
|
||||
form: FormState;
|
||||
onFormChange: (form: FormState) => void;
|
||||
fieldErrors: FieldErrors;
|
||||
saving: boolean;
|
||||
formSaved: boolean;
|
||||
formChanged: boolean;
|
||||
hasValidationErrors: boolean;
|
||||
// TakenBy tag input
|
||||
takenByInput: string;
|
||||
onTakenByInputChange: (value: string) => void;
|
||||
existingPeople: string[];
|
||||
onAddTakenByPerson: (person: string) => void;
|
||||
onRemoveTakenByPerson: (person: string) => void;
|
||||
onTakenByKeyDown: (e: React.KeyboardEvent<HTMLInputElement>) => void;
|
||||
// Blister helpers
|
||||
onSetBlisterValue: (idx: number, field: keyof FormBlister, value: string) => void;
|
||||
onAddBlister: () => void;
|
||||
onRemoveBlister: (idx: number) => void;
|
||||
// Value change handler for numeric fields
|
||||
onHandleValueChange: <K extends keyof FormState>(field: K, value: string) => void;
|
||||
// Refill state (for edit mode)
|
||||
refillPacks: number;
|
||||
onRefillPacksChange: (value: number) => void;
|
||||
refillLoose: number;
|
||||
onRefillLooseChange: (value: number) => void;
|
||||
refillSaving: boolean;
|
||||
onSubmitRefill: (medId: number) => Promise<void>;
|
||||
// Image handling
|
||||
meds: Medication[];
|
||||
onUploadMedImage: (medId: number, file: File) => Promise<void>;
|
||||
onDeleteMedImage: (medId: number) => Promise<void>;
|
||||
// Actions
|
||||
onClose: () => void;
|
||||
onResetForm: () => void;
|
||||
onSaveMedication: (e: React.FormEvent) => void;
|
||||
}
|
||||
|
||||
function deriveTotal(form: FormState) {
|
||||
const packCount = Number(form.packCount) || 0;
|
||||
const blistersPerPack = Number(form.blistersPerPack) || 0;
|
||||
const pillsPerBlister = Number(form.pillsPerBlister) || 1;
|
||||
const looseTablets = Number(form.looseTablets) || 0;
|
||||
return packCount * blistersPerPack * pillsPerBlister + looseTablets;
|
||||
}
|
||||
|
||||
export function MobileEditModal({
|
||||
show,
|
||||
editingId,
|
||||
form,
|
||||
onFormChange,
|
||||
fieldErrors,
|
||||
saving,
|
||||
formSaved,
|
||||
formChanged,
|
||||
hasValidationErrors,
|
||||
takenByInput,
|
||||
onTakenByInputChange,
|
||||
existingPeople,
|
||||
onAddTakenByPerson,
|
||||
onRemoveTakenByPerson,
|
||||
onTakenByKeyDown,
|
||||
onSetBlisterValue,
|
||||
onAddBlister,
|
||||
onRemoveBlister,
|
||||
onHandleValueChange,
|
||||
refillPacks,
|
||||
onRefillPacksChange,
|
||||
refillLoose,
|
||||
onRefillLooseChange,
|
||||
refillSaving,
|
||||
onSubmitRefill,
|
||||
meds,
|
||||
onUploadMedImage,
|
||||
onDeleteMedImage,
|
||||
onClose,
|
||||
onResetForm,
|
||||
onSaveMedication,
|
||||
}: MobileEditModalProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!show) return null;
|
||||
|
||||
const currentMed = editingId ? meds.find((m) => m.id === editingId) : null;
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="modal-content edit-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<button
|
||||
className="modal-close"
|
||||
onClick={() => {
|
||||
onClose();
|
||||
onResetForm();
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
<div className="edit-modal-header">
|
||||
<h2>{editingId ? t("form.editEntry") : t("form.newEntry")}</h2>
|
||||
</div>
|
||||
<form className="form-grid mobile-edit-form" onSubmit={onSaveMedication}>
|
||||
<label className={`full ${fieldErrors.name ? "has-error" : ""}`}>
|
||||
{t("form.commercialName")}
|
||||
<input
|
||||
value={form.name}
|
||||
onChange={(e) => onFormChange({ ...form, name: e.target.value })}
|
||||
placeholder={t("form.placeholders.commercial")}
|
||||
maxLength={FIELD_LIMITS.name.max}
|
||||
required
|
||||
/>
|
||||
{fieldErrors.name && <span className="field-error">{fieldErrors.name}</span>}
|
||||
</label>
|
||||
<label className={`full ${fieldErrors.genericName ? "has-error" : ""}`}>
|
||||
{t("form.genericName")}
|
||||
<input
|
||||
value={form.genericName}
|
||||
onChange={(e) => onFormChange({ ...form, genericName: e.target.value })}
|
||||
placeholder={t("form.placeholders.generic")}
|
||||
maxLength={FIELD_LIMITS.genericName.max}
|
||||
/>
|
||||
{fieldErrors.genericName && <span className="field-error">{fieldErrors.genericName}</span>}
|
||||
</label>
|
||||
<label className={`full ${fieldErrors.takenBy ? "has-error" : ""}`}>
|
||||
{t("form.takenBy")}
|
||||
<div className="tag-input-container">
|
||||
{form.takenBy.map((person) => (
|
||||
<span key={person} className="tag">
|
||||
{person}
|
||||
<button type="button" className="tag-remove" onClick={() => onRemoveTakenByPerson(person)}>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
<input
|
||||
value={takenByInput}
|
||||
onChange={(e) => onTakenByInputChange(e.target.value)}
|
||||
onKeyDown={onTakenByKeyDown}
|
||||
onBlur={() => {
|
||||
if (takenByInput.trim()) onAddTakenByPerson(takenByInput);
|
||||
}}
|
||||
placeholder={form.takenBy.length === 0 ? t("form.placeholders.takenBy") : t("form.placeholders.addPerson")}
|
||||
maxLength={FIELD_LIMITS.takenBy.max}
|
||||
list="takenby-suggestions-modal"
|
||||
/>
|
||||
<datalist id="takenby-suggestions-modal">
|
||||
{existingPeople
|
||||
.filter((p) => !form.takenBy.includes(p))
|
||||
.map((person) => (
|
||||
<option key={person} value={person} />
|
||||
))}
|
||||
</datalist>
|
||||
</div>
|
||||
{fieldErrors.takenBy && <span className="field-error">{fieldErrors.takenBy}</span>}
|
||||
</label>
|
||||
<label>
|
||||
{t("form.packs")}
|
||||
<input type="number" min="0" value={form.packCount} onChange={(e) => onHandleValueChange("packCount", e.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
{t("form.blistersPerPack")}
|
||||
<input type="number" min="0" value={form.blistersPerPack} onChange={(e) => onHandleValueChange("blistersPerPack", e.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
{t("form.pillsPerBlister")}
|
||||
<input type="number" min="1" value={form.pillsPerBlister} onChange={(e) => onHandleValueChange("pillsPerBlister", e.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
{t("form.loosePills")}
|
||||
<input type="number" min="0" value={form.looseTablets} onChange={(e) => onHandleValueChange("looseTablets", e.target.value)} />
|
||||
</label>
|
||||
<div className="full">
|
||||
<p className="sub">
|
||||
<strong>{t("form.total")}:</strong> {deriveTotal(form)} {t("common.pills")}
|
||||
</p>
|
||||
</div>
|
||||
<label className="full">
|
||||
{t("form.pillWeight")}
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.1"
|
||||
value={form.pillWeightMg}
|
||||
onChange={(e) => onFormChange({ ...form, pillWeightMg: e.target.value })}
|
||||
placeholder={t("form.placeholders.weight")}
|
||||
/>
|
||||
</label>
|
||||
<label className="full">
|
||||
{t("form.expiryDate")}
|
||||
<input type="date" value={form.expiryDate} onChange={(e) => onFormChange({ ...form, expiryDate: e.target.value })} />
|
||||
</label>
|
||||
|
||||
{/* Refill section - only shown when editing (mobile) */}
|
||||
{editingId && (
|
||||
<div className="full refill-section">
|
||||
<h4 className="refill-title">{t("refill.title")}</h4>
|
||||
<div className="refill-form-inline">
|
||||
<label>
|
||||
{t("refill.packs")}
|
||||
<input type="number" min="0" value={refillPacks} onChange={(e) => onRefillPacksChange(parseInt(e.target.value) || 0)} />
|
||||
</label>
|
||||
<label>
|
||||
{t("refill.loosePills")}
|
||||
<input type="number" min="0" value={refillLoose} onChange={(e) => onRefillLooseChange(parseInt(e.target.value) || 0)} />
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="success"
|
||||
onClick={() => onSubmitRefill(editingId)}
|
||||
disabled={(refillPacks < 1 && refillLoose < 1) || refillSaving}
|
||||
>
|
||||
{refillSaving ? t("common.saving") : t("refill.button")}
|
||||
</button>
|
||||
{(refillPacks > 0 || refillLoose > 0) && (
|
||||
<span className="refill-preview">
|
||||
+{refillPacks * Number(form.blistersPerPack || 0) * Number(form.pillsPerBlister || 1) + refillLoose} {t("common.pills")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<label className={`full ${fieldErrors.notes ? "has-error" : ""}`}>
|
||||
{t("form.notes")}
|
||||
<textarea
|
||||
value={form.notes}
|
||||
onChange={(e) => onFormChange({ ...form, notes: e.target.value })}
|
||||
placeholder={t("form.placeholders.notes")}
|
||||
rows={2}
|
||||
maxLength={FIELD_LIMITS.notes.max}
|
||||
className="auto-resize"
|
||||
onInput={(e) => {
|
||||
const target = e.target as HTMLTextAreaElement;
|
||||
target.style.height = "auto";
|
||||
target.style.height = target.scrollHeight + "px";
|
||||
}}
|
||||
/>
|
||||
{form.notes.length > 0 && (
|
||||
<span className={`char-count ${form.notes.length > FIELD_LIMITS.notes.max * 0.9 ? "warning" : ""}`}>
|
||||
{t("common.validation.tooLong", { current: form.notes.length, max: FIELD_LIMITS.notes.max })}
|
||||
</span>
|
||||
)}
|
||||
{fieldErrors.notes && <span className="field-error">{fieldErrors.notes}</span>}
|
||||
</label>
|
||||
|
||||
{editingId && currentMed?.imageUrl ? (
|
||||
<div className="full image-field">
|
||||
<span className="field-label">{t("form.medicationImage")}</span>
|
||||
<div className="image-preview">
|
||||
<img src={currentMed.imageUrl} alt={currentMed.name} />
|
||||
<button type="button" className="danger" onClick={() => onDeleteMedImage(editingId)}>
|
||||
{t("form.removeImage")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : editingId ? (
|
||||
<label className="full">
|
||||
{t("form.medicationImage")}
|
||||
<input type="file" accept="image/*" onChange={(e) => e.target.files?.[0] && onUploadMedImage(editingId, e.target.files[0])} />
|
||||
</label>
|
||||
) : null}
|
||||
|
||||
<fieldset className="full blister-section">
|
||||
<legend>
|
||||
{t("form.blisters.title")}
|
||||
<label className="toggle-switch small" title={t("form.blisters.remindTooltip")}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.intakeRemindersEnabled}
|
||||
onChange={(e) => onFormChange({ ...form, intakeRemindersEnabled: e.target.checked })}
|
||||
/>
|
||||
<span className="toggle-slider"></span>
|
||||
</label>
|
||||
<span className="legend-hint">{t("form.blisters.remind")}</span>
|
||||
</legend>
|
||||
{form.blisters.map((b, idx) => (
|
||||
<div key={idx} className="blister-row">
|
||||
<label className="compact">
|
||||
<span>{t("form.blisters.usage")}</span>
|
||||
<input type="number" min="0.5" step="0.5" value={b.usage} onChange={(e) => onSetBlisterValue(idx, "usage", e.target.value)} />
|
||||
</label>
|
||||
<label className="compact">
|
||||
<span>{t("form.blisters.everyDays")}</span>
|
||||
<input type="number" min="1" value={b.every} onChange={(e) => onSetBlisterValue(idx, "every", e.target.value)} />
|
||||
</label>
|
||||
<label className="compact full-row">
|
||||
<span>{t("form.blisters.startDate")}</span>
|
||||
<input type="date" value={b.startDate} onChange={(e) => onSetBlisterValue(idx, "startDate", e.target.value)} />
|
||||
</label>
|
||||
<label className="compact time-label">
|
||||
<span>{t("form.blisters.startTime")}</span>
|
||||
<input type="time" value={b.startTime} onChange={(e) => onSetBlisterValue(idx, "startTime", e.target.value)} />
|
||||
</label>
|
||||
{form.blisters.length > 1 && (
|
||||
<button type="button" className="danger remove-blister-btn" onClick={() => onRemoveBlister(idx)}>
|
||||
{t("common.remove")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<button type="button" className="ghost add-blister" onClick={onAddBlister}>
|
||||
+ {t("form.blisters.addIntake")}
|
||||
</button>
|
||||
</fieldset>
|
||||
|
||||
<div className="modal-footer">
|
||||
<button
|
||||
type="button"
|
||||
className="ghost"
|
||||
onClick={() => {
|
||||
onClose();
|
||||
onResetForm();
|
||||
}}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</button>
|
||||
<button type="submit" disabled={saving || hasValidationErrors || (!formChanged && (formSaved || !!editingId))}>
|
||||
{formSaved && !formChanged ? t("common.saved") : t("common.save")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { UserProfile } from './Auth';
|
||||
|
||||
interface ProfileModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function ProfileModal({ isOpen, onClose }: ProfileModalProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="modal-content profile-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<button className="modal-close" onClick={onClose}>×</button>
|
||||
<UserProfile onClose={onClose} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* ShareDialog - Modal for generating share links for medication schedules
|
||||
* Allows sharing schedule view for a specific person
|
||||
*/
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export interface ShareDialogProps {
|
||||
show: boolean;
|
||||
sharePeople: string[];
|
||||
shareSelectedPerson: string;
|
||||
onShareSelectedPersonChange: (person: string) => void;
|
||||
shareSelectedDays: number;
|
||||
onShareSelectedDaysChange: (days: number) => void;
|
||||
shareGenerating: boolean;
|
||||
shareLink: string | null;
|
||||
onShareLinkChange: (link: string | null) => void;
|
||||
shareCopied: boolean;
|
||||
onShareCopiedChange: (copied: boolean) => void;
|
||||
onClose: () => void;
|
||||
onGenerateShareLink: () => Promise<void>;
|
||||
onCopyShareLink: () => void;
|
||||
}
|
||||
|
||||
export function ShareDialog({
|
||||
show,
|
||||
sharePeople,
|
||||
shareSelectedPerson,
|
||||
onShareSelectedPersonChange,
|
||||
shareSelectedDays,
|
||||
onShareSelectedDaysChange,
|
||||
shareGenerating,
|
||||
shareLink,
|
||||
onShareLinkChange,
|
||||
shareCopied,
|
||||
onShareCopiedChange,
|
||||
onClose,
|
||||
onGenerateShareLink,
|
||||
onCopyShareLink,
|
||||
}: ShareDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!show) return null;
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="modal-content share-dialog-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<button className="modal-close" onClick={onClose}>
|
||||
×
|
||||
</button>
|
||||
|
||||
<div className="share-dialog-header">
|
||||
<h2>🔗 {t("share.title")}</h2>
|
||||
<p className="share-dialog-description">{t("share.description")}</p>
|
||||
</div>
|
||||
|
||||
{sharePeople.length === 0 ? (
|
||||
<div className="share-dialog-empty">
|
||||
<p>{t("share.noPeople")}</p>
|
||||
</div>
|
||||
) : shareLink ? (
|
||||
<div className="share-dialog-result">
|
||||
<p className="share-success">{t("share.linkGenerated")}</p>
|
||||
<div className="share-link-box">
|
||||
<input
|
||||
type="text"
|
||||
value={shareLink}
|
||||
readOnly
|
||||
className="share-link-input"
|
||||
onClick={(e) => (e.target as HTMLInputElement).select()}
|
||||
/>
|
||||
<button className="btn-copy" onClick={onCopyShareLink}>
|
||||
{shareCopied ? "✓" : "📋"}
|
||||
</button>
|
||||
</div>
|
||||
{shareCopied && <span className="share-copied-hint">{t("share.copied")}</span>}
|
||||
<div className="share-dialog-footer">
|
||||
<button
|
||||
className="ghost"
|
||||
onClick={() => {
|
||||
onShareLinkChange(null);
|
||||
onShareCopiedChange(false);
|
||||
}}
|
||||
>
|
||||
{t("share.generateAnother")}
|
||||
</button>
|
||||
<button onClick={onClose}>{t("common.close")}</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="share-dialog-form">
|
||||
<div className="form-group">
|
||||
<label>{t("share.selectPerson")}</label>
|
||||
<select value={shareSelectedPerson} onChange={(e) => onShareSelectedPersonChange(e.target.value)}>
|
||||
{sharePeople.map((person) => (
|
||||
<option key={person} value={person}>
|
||||
{person}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>{t("share.selectPeriod")}</label>
|
||||
<select value={shareSelectedDays} onChange={(e) => onShareSelectedDaysChange(Number(e.target.value))}>
|
||||
<option value={30}>{t("dashboard.schedules.1month")}</option>
|
||||
<option value={90}>{t("dashboard.schedules.3months")}</option>
|
||||
<option value={180}>{t("dashboard.schedules.6months")}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="share-dialog-footer">
|
||||
<button className="ghost" onClick={onClose}>
|
||||
{t("common.cancel")}
|
||||
</button>
|
||||
<button onClick={onGenerateShareLink} disabled={shareGenerating || !shareSelectedPerson}>
|
||||
{shareGenerating ? t("share.generating") : t("share.generateLink")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,824 @@
|
||||
// =============================================================================
|
||||
// SharedSchedule Component - Public view for shared schedules
|
||||
// =============================================================================
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { SharedScheduleData, ExpiredLinkData } from "../types";
|
||||
import { getMedTotal } from "../types";
|
||||
import { loadCollapsedDaysFromStorage } from "../utils/storage";
|
||||
import { getSystemLocale } from "../utils/formatters";
|
||||
import { MedicationAvatar } from "./MedicationAvatar";
|
||||
|
||||
export function SharedSchedule() {
|
||||
const { token } = useParams<{ token: string }>();
|
||||
const { t, i18n } = useTranslation();
|
||||
const [data, setData] = useState<SharedScheduleData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [expiredData, setExpiredData] = useState<ExpiredLinkData | null>(null);
|
||||
const [takenDoses, setTakenDoses] = useState<Set<string>>(new Set());
|
||||
const [lightboxImage, setLightboxImage] = useState<{ url: string; name: string } | null>(null);
|
||||
const [showPastDays, setShowPastDays] = useState(false);
|
||||
const [theme, setTheme] = useState<"light" | "dark">(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
return (localStorage.getItem("theme") as "light" | "dark") || "dark";
|
||||
}
|
||||
return "dark";
|
||||
});
|
||||
|
||||
// Apply theme to document
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute("data-theme", theme);
|
||||
localStorage.setItem("theme", theme);
|
||||
}, [theme]);
|
||||
|
||||
function toggleTheme() {
|
||||
setTheme((prev) => (prev === "dark" ? "light" : "dark"));
|
||||
}
|
||||
|
||||
// Collapsed days state for SharedSchedule (token-specific localStorage)
|
||||
const [manuallyCollapsedDays, setManuallyCollapsedDays] = useState<Set<string>>(new Set());
|
||||
const [manuallyExpandedDays, setManuallyExpandedDays] = useState<Set<string>>(new Set());
|
||||
|
||||
// Load collapsed/expanded state from localStorage
|
||||
useEffect(() => {
|
||||
if (token && typeof window !== "undefined") {
|
||||
const { collapsed, expanded } = loadCollapsedDaysFromStorage(
|
||||
`share_${token}_collapsedDays`,
|
||||
`share_${token}_expandedDays`
|
||||
);
|
||||
setManuallyCollapsedDays(collapsed);
|
||||
setManuallyExpandedDays(expanded);
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
// Toggle day collapse/expand for SharedSchedule
|
||||
function toggleDayCollapse(dateStr: string, isAutoCollapsed: boolean) {
|
||||
if (isAutoCollapsed) {
|
||||
setManuallyExpandedDays((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(dateStr)) {
|
||||
next.delete(dateStr);
|
||||
} else {
|
||||
next.add(dateStr);
|
||||
}
|
||||
if (token) localStorage.setItem(`share_${token}_expandedDays`, JSON.stringify([...next]));
|
||||
return next;
|
||||
});
|
||||
} else {
|
||||
setManuallyCollapsedDays((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(dateStr)) {
|
||||
next.delete(dateStr);
|
||||
} else {
|
||||
next.add(dateStr);
|
||||
}
|
||||
if (token) localStorage.setItem(`share_${token}_collapsedDays`, JSON.stringify([...next]));
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Helper functions for lightbox with history support (mobile back swipe)
|
||||
function openLightbox(url: string, name: string) {
|
||||
setLightboxImage({ url, name });
|
||||
window.history.pushState({ modal: "lightbox" }, "");
|
||||
}
|
||||
function closeLightbox() {
|
||||
if (lightboxImage) {
|
||||
window.history.back();
|
||||
}
|
||||
}
|
||||
|
||||
// Close lightbox on Escape key
|
||||
useEffect(() => {
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape" && lightboxImage) {
|
||||
closeLightbox();
|
||||
}
|
||||
}
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [lightboxImage]);
|
||||
|
||||
// Handle browser back button to close lightbox
|
||||
useEffect(() => {
|
||||
function handlePopState() {
|
||||
if (lightboxImage) {
|
||||
setLightboxImage(null);
|
||||
}
|
||||
}
|
||||
window.addEventListener("popstate", handlePopState);
|
||||
return () => window.removeEventListener("popstate", handlePopState);
|
||||
}, [lightboxImage]);
|
||||
|
||||
// Load taken doses from server with polling for real-time sync
|
||||
useEffect(() => {
|
||||
if (token) {
|
||||
async function loadTakenDoses() {
|
||||
try {
|
||||
const res = await fetch(`/api/share/${token}/doses`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setTakenDoses(new Set(data.doses.map((d: { doseId: string }) => d.doseId)));
|
||||
} else {
|
||||
setTakenDoses(new Set());
|
||||
}
|
||||
} catch {
|
||||
setTakenDoses(new Set());
|
||||
}
|
||||
}
|
||||
loadTakenDoses();
|
||||
|
||||
// Poll for updates every 5 seconds (real-time sync with dashboard)
|
||||
const interval = setInterval(loadTakenDoses, 5000);
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
// Get dose ID with optional person suffix
|
||||
function getDoseId(baseDoseId: string, person: string | null): string {
|
||||
return person ? `${baseDoseId}-${person}` : baseDoseId;
|
||||
}
|
||||
|
||||
// Count taken doses for a day/item
|
||||
function countTakenDoses(doses: Array<{ id: string; takenBy: string[] }>): { total: number; taken: number } {
|
||||
let total = 0;
|
||||
let taken = 0;
|
||||
for (const d of doses) {
|
||||
const people = (d.takenBy || []).length > 0 ? d.takenBy : [null];
|
||||
for (const person of people) {
|
||||
total++;
|
||||
if (takenDoses.has(getDoseId(d.id, person))) taken++;
|
||||
}
|
||||
}
|
||||
return { total, taken };
|
||||
}
|
||||
|
||||
async function markDoseTaken(doseId: string) {
|
||||
// Optimistic update
|
||||
setTakenDoses((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.add(doseId);
|
||||
return next;
|
||||
});
|
||||
|
||||
// Send to server
|
||||
try {
|
||||
await fetch(`/api/share/${token}/doses`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ doseId })
|
||||
});
|
||||
} catch {
|
||||
// Revert on error
|
||||
setTakenDoses((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(doseId);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function undoDoseTaken(doseId: string) {
|
||||
// Optimistic update
|
||||
setTakenDoses((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(doseId);
|
||||
return next;
|
||||
});
|
||||
|
||||
// Send to server
|
||||
try {
|
||||
await fetch(`/api/share/${token}/doses/${encodeURIComponent(doseId)}`, {
|
||||
method: "DELETE"
|
||||
});
|
||||
} catch {
|
||||
// Revert on error
|
||||
setTakenDoses((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.add(doseId);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchData() {
|
||||
if (!token) {
|
||||
setError("Invalid link");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/share/${token}`);
|
||||
if (res.ok) {
|
||||
const json = await res.json();
|
||||
setData(json);
|
||||
} else if (res.status === 410) {
|
||||
// Link expired - get owner info
|
||||
const json = await res.json();
|
||||
setExpiredData({
|
||||
ownerUsername: json.ownerUsername,
|
||||
takenBy: json.takenBy,
|
||||
expiredAt: json.expiredAt
|
||||
});
|
||||
} else if (res.status === 404) {
|
||||
setError(t("share.notFound"));
|
||||
} else {
|
||||
setError(t("share.error"));
|
||||
}
|
||||
} catch {
|
||||
setError(t("share.error"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
fetchData();
|
||||
}, [token, t]);
|
||||
|
||||
// Build schedule from medications - matches buildSchedulePreview logic exactly
|
||||
const schedule = useMemo(() => {
|
||||
if (!data) return [];
|
||||
|
||||
// Use same logic as buildSchedulePreview in main app
|
||||
const now = new Date();
|
||||
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()); // Midnight today
|
||||
|
||||
// Use 180 days horizon like main app (scheduleDays only limits futureDays display)
|
||||
const end = new Date();
|
||||
end.setDate(end.getDate() + 180);
|
||||
|
||||
const doses: {
|
||||
id: string;
|
||||
when: number;
|
||||
medName: string;
|
||||
usage: number;
|
||||
timeStr: string;
|
||||
isPast: boolean;
|
||||
takenBy: string[];
|
||||
dateStr: string;
|
||||
}[] = [];
|
||||
|
||||
for (const med of data.medications) {
|
||||
med.blisters.forEach((blister, blisterIdx) => {
|
||||
const startDate = new Date(blister.start);
|
||||
if (Number.isNaN(startDate.getTime())) return;
|
||||
|
||||
// Use the same iteration method as buildSchedulePreview (setDate instead of adding ms)
|
||||
// This ensures identical timestamps even across DST changes
|
||||
for (let d = new Date(startDate); d <= end; d.setDate(d.getDate() + blister.every)) {
|
||||
const t = d.getTime();
|
||||
const isPast = d < todayStart;
|
||||
// Generate dose ID matching Dashboard format: ${med.id}-${blisterIdx}-${whenMs}
|
||||
const doseId = `${med.id}-${blisterIdx}-${t}`;
|
||||
doses.push({
|
||||
id: doseId,
|
||||
when: t,
|
||||
medName: med.name,
|
||||
usage: blister.usage,
|
||||
isPast,
|
||||
takenBy: med.takenBy || [],
|
||||
timeStr: d.toLocaleTimeString(getSystemLocale(i18n.language), { hour: "2-digit", minute: "2-digit" }),
|
||||
dateStr: d.toLocaleDateString(getSystemLocale(i18n.language), { weekday: "short", day: "2-digit", month: "short" })
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
doses.sort((a, b) => a.when - b.when);
|
||||
|
||||
// Group by date - matches groupedSchedule logic in main app
|
||||
type DoseInfo = (typeof doses)[number];
|
||||
const days = new Map<
|
||||
string,
|
||||
{
|
||||
dateStr: string;
|
||||
date: Date;
|
||||
isPast: boolean;
|
||||
meds: Map<string, { medName: string; total: number; doses: DoseInfo[]; lastWhen: number }>;
|
||||
}
|
||||
>();
|
||||
|
||||
for (const dose of doses.slice(0, 2000)) {
|
||||
const day = days.get(dose.dateStr) ?? { dateStr: dose.dateStr, date: new Date(dose.when), isPast: dose.isPast, meds: new Map() };
|
||||
const medEntry = day.meds.get(dose.medName) ?? { medName: dose.medName, total: 0, doses: [], lastWhen: dose.when };
|
||||
medEntry.total += dose.usage;
|
||||
medEntry.doses.push(dose);
|
||||
medEntry.lastWhen = Math.max(medEntry.lastWhen, dose.when);
|
||||
day.meds.set(dose.medName, medEntry);
|
||||
days.set(dose.dateStr, day);
|
||||
}
|
||||
|
||||
return Array.from(days.values()).map((d) => ({
|
||||
dateStr: d.dateStr,
|
||||
date: d.date,
|
||||
isPast: d.isPast,
|
||||
meds: Array.from(d.meds.values())
|
||||
}));
|
||||
}, [data, i18n.language]);
|
||||
|
||||
// Split into past and future - matches main app logic
|
||||
const pastDays = useMemo(() => schedule.filter((d) => d.isPast), [schedule]);
|
||||
// Limit future days by scheduleDays setting (same as main app)
|
||||
const futureDays = useMemo(() => schedule.filter((d) => !d.isPast).slice(0, data?.scheduleDays ?? 30), [schedule, data?.scheduleDays]);
|
||||
|
||||
// Calculate coverage for stock status colors (matches main app logic)
|
||||
// This needs to account for taken doses and calculate depletion time
|
||||
const { coverageByMed, depletionByMed } = useMemo(() => {
|
||||
if (!data) return { coverageByMed: {}, depletionByMed: {} };
|
||||
const coverage: Record<string, { daysLeft: number | null; medsLeft: number; dailyUsage: number }> = {};
|
||||
const depletion: Record<string, number | null> = {};
|
||||
|
||||
// Calculate total pills taken per medication from takenDoses
|
||||
// Each person's taken dose counts separately toward pills consumed
|
||||
const takenByMed: Record<string, number> = {};
|
||||
for (const dose of schedule.flatMap((d) => d.meds.flatMap((m) => m.doses))) {
|
||||
// Check all person-specific dose IDs for this dose
|
||||
const people = (dose.takenBy || []).length > 0 ? dose.takenBy : [null];
|
||||
for (const person of people) {
|
||||
const doseId = person ? `${dose.id}-${person}` : dose.id;
|
||||
if (takenDoses.has(doseId)) {
|
||||
takenByMed[dose.medName] = (takenByMed[dose.medName] || 0) + dose.usage;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const med of data.medications) {
|
||||
const totalCount = getMedTotal(med);
|
||||
const taken = takenByMed[med.name] || 0;
|
||||
const currentCount = Math.max(0, totalCount - taken);
|
||||
// Calculate daily usage from blisters, multiplied by number of people
|
||||
const personCount = Math.max(1, med.takenBy?.length || 1);
|
||||
const dailyUsage = med.blisters.reduce((sum, b) => sum + b.usage / b.every, 0) * personCount;
|
||||
const daysLeft = dailyUsage > 0 ? currentCount / dailyUsage : null;
|
||||
coverage[med.name] = { daysLeft, medsLeft: currentCount, dailyUsage };
|
||||
|
||||
// Calculate depletion time (when medication will run out)
|
||||
if (dailyUsage > 0 && currentCount > 0) {
|
||||
const daysUntilEmpty = currentCount / dailyUsage;
|
||||
depletion[med.name] = Date.now() + daysUntilEmpty * 24 * 60 * 60 * 1000;
|
||||
} else if (currentCount <= 0) {
|
||||
depletion[med.name] = Date.now(); // Already empty
|
||||
} else {
|
||||
depletion[med.name] = null; // No usage schedule
|
||||
}
|
||||
}
|
||||
return { coverageByMed: coverage, depletionByMed: depletion };
|
||||
}, [data, schedule, takenDoses]);
|
||||
|
||||
// Stock thresholds from user settings (provided by API) or defaults
|
||||
const lowStockDays = data?.stockThresholds?.lowStockDays ?? 30;
|
||||
|
||||
// Get worst stock status for a day's medications (matches main app logic with depletion)
|
||||
const getDayStockStatus = (meds: { medName: string; lastWhen: number }[]) => {
|
||||
const statuses = meds.map((item) => {
|
||||
const coverage = coverageByMed[item.medName];
|
||||
const depletionTime = depletionByMed[item.medName];
|
||||
|
||||
// Will be out of stock by this day?
|
||||
if (typeof depletionTime === "number" && item.lastWhen > depletionTime) {
|
||||
return "danger";
|
||||
}
|
||||
|
||||
if (!coverage) return "success";
|
||||
const { daysLeft, medsLeft } = coverage;
|
||||
|
||||
// Currently out of stock
|
||||
if (medsLeft <= 0 || daysLeft === 0) return "danger";
|
||||
// No schedule (can't calculate)
|
||||
if (daysLeft === null) return "success";
|
||||
// Low stock: < lowStockDays (warning)
|
||||
if (daysLeft < lowStockDays) return "warning";
|
||||
// Normal/High stock
|
||||
return "success";
|
||||
});
|
||||
return statuses.includes("danger") ? "danger" : statuses.includes("warning") ? "warning" : "success";
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="shared-schedule-page">
|
||||
<div className="shared-schedule-loading">
|
||||
<h1>💊 MedAssist</h1>
|
||||
<p>{t("common.loading")}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (expiredData) {
|
||||
return (
|
||||
<div className="shared-schedule-page">
|
||||
<div className="shared-schedule-error expired">
|
||||
<h1>💊 MedAssist</h1>
|
||||
<div className="expired-icon">⏰</div>
|
||||
<h2>{t("share.expired.title")}</h2>
|
||||
<p className="expired-message">{t("share.expired.message", { takenBy: expiredData.takenBy })}</p>
|
||||
<p className="expired-contact">{t("share.expired.contact", { username: expiredData.ownerUsername })}</p>
|
||||
<p className="expired-date">{t("share.expired.expiredOn", { date: new Date(expiredData.expiredAt).toLocaleDateString(getSystemLocale(i18n.language)) })}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
return (
|
||||
<div className="shared-schedule-page">
|
||||
<div className="shared-schedule-error">
|
||||
<h1>💊 MedAssist</h1>
|
||||
<p className="error-message">{error || "Unknown error"}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="shared-schedule-page">
|
||||
<div className="shared-schedule-container">
|
||||
<header className="shared-schedule-header">
|
||||
<h1>
|
||||
💊 {t("share.scheduleFor")} {data.takenBy}
|
||||
</h1>
|
||||
<div className="shared-schedule-header-actions">
|
||||
<button className="icon-btn" onClick={toggleTheme} title={theme === "dark" ? t("tooltips.lightMode") : t("tooltips.darkMode")}>
|
||||
{theme === "dark" ? "☀️" : "🌙"}
|
||||
</button>
|
||||
</div>
|
||||
<p className="shared-schedule-period">
|
||||
{t("share.period")}:{" "}
|
||||
{data.scheduleDays === 30
|
||||
? t("dashboard.schedules.1month")
|
||||
: data.scheduleDays === 90
|
||||
? t("dashboard.schedules.3months")
|
||||
: t("dashboard.schedules.6months")}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="timeline">
|
||||
{schedule.length === 0 ? (
|
||||
<p className="shared-schedule-empty">{t("share.noSchedule")}</p>
|
||||
) : (
|
||||
<>
|
||||
{/* Past days toggle */}
|
||||
{pastDays.length > 0 &&
|
||||
(() => {
|
||||
const totalPastDoses = pastDays.flatMap((d) =>
|
||||
d.meds.flatMap((m) =>
|
||||
m.doses.flatMap((dose) =>
|
||||
(dose.takenBy || []).length > 0 ? dose.takenBy.map((p) => `${dose.id}-${p}`) : [dose.id]
|
||||
)
|
||||
)
|
||||
);
|
||||
const missedPastDoses = totalPastDoses.filter((id) => !takenDoses.has(id)).length;
|
||||
return (
|
||||
<div
|
||||
className={`past-days-toggle ${showPastDays ? "expanded" : ""} ${missedPastDoses > 0 ? "has-missed" : ""}`}
|
||||
onClick={() => setShowPastDays(!showPastDays)}
|
||||
>
|
||||
<span className="past-days-icon">{showPastDays ? "▼" : "▶"}</span>
|
||||
<span className="past-days-label">
|
||||
{showPastDays ? t("dashboard.schedules.hidePastDays") : t("dashboard.schedules.showPastDays")}
|
||||
</span>
|
||||
<span className="past-days-count">({t("dashboard.schedules.pastDaysCount", { count: pastDays.length })})</span>
|
||||
{missedPastDoses > 0 ? (
|
||||
<span className="past-days-warning" title={t("dashboard.schedules.missedDoses", { count: missedPastDoses })}>
|
||||
⚠️ {missedPastDoses}
|
||||
</span>
|
||||
) : totalPastDoses.length > 0 ? (
|
||||
<span className="past-days-complete" title={t("dashboard.schedules.allTaken")}>
|
||||
✓
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
{/* Past days (when expanded) */}
|
||||
{showPastDays &&
|
||||
pastDays.map((day) => {
|
||||
const allDoseIds = day.meds.flatMap((item) =>
|
||||
item.doses.flatMap((d) => ((d.takenBy || []).length > 0 ? d.takenBy.map((p) => `${d.id}-${p}`) : [d.id]))
|
||||
);
|
||||
const allDayTaken = allDoseIds.length > 0 && allDoseIds.every((id) => takenDoses.has(id));
|
||||
const takenCount = allDoseIds.filter((id) => takenDoses.has(id)).length;
|
||||
const isManuallyExpanded = manuallyExpandedDays.has(day.dateStr);
|
||||
const isCollapsed = !isManuallyExpanded;
|
||||
|
||||
// Calculate stock status for this day
|
||||
const worstStatus = getDayStockStatus(day.meds);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={day.dateStr}
|
||||
className={`day-block past ${isCollapsed ? "collapsed" : ""} ${allDayTaken ? "all-taken" : ""} stock-${worstStatus}`}
|
||||
>
|
||||
<div
|
||||
className="day-divider clickable"
|
||||
onClick={() => toggleDayCollapse(day.dateStr, true)}
|
||||
title={isCollapsed ? t("common.expand") : t("common.collapse")}
|
||||
>
|
||||
<span className="day-collapse-icon">{isCollapsed ? "▶" : "▼"}</span>
|
||||
<span className="day-date">{day.dateStr}</span>
|
||||
<span className="day-summary">
|
||||
{allDayTaken ? (
|
||||
<span className="day-complete">✓ {t("dashboard.schedules.allTaken")}</span>
|
||||
) : (
|
||||
<>
|
||||
<span className="day-warning" title={t("dashboard.schedules.missedDoses", { count: allDoseIds.length - takenCount })}>
|
||||
⚠️
|
||||
</span>
|
||||
<span className="day-progress">
|
||||
{takenCount}/{allDoseIds.length}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{!isCollapsed &&
|
||||
day.meds.map((item) => {
|
||||
const med = data.medications.find((m) => m.name === item.medName);
|
||||
const medCoverage = coverageByMed[item.medName];
|
||||
const isEmpty = medCoverage ? medCoverage.medsLeft <= 0 : false;
|
||||
const depletionTime = depletionByMed[item.medName];
|
||||
const willBeOutOfStock = typeof depletionTime === "number" && item.lastWhen > depletionTime;
|
||||
|
||||
// Calculate status for this medication on this day
|
||||
let status: { className: string; label: string } | null = null;
|
||||
if (willBeOutOfStock) {
|
||||
status = { className: "danger", label: "status.outOfStock" };
|
||||
} else if (medCoverage) {
|
||||
const { daysLeft, medsLeft } = medCoverage;
|
||||
if (medsLeft <= 0 || daysLeft === 0) {
|
||||
status = { className: "danger", label: "status.outOfStock" };
|
||||
} else if (daysLeft !== null && daysLeft < lowStockDays) {
|
||||
status = { className: "warning", label: "status.lowStock" };
|
||||
} else {
|
||||
status = { className: "success", label: "status.normal" };
|
||||
}
|
||||
}
|
||||
|
||||
const itemDoseIds = item.doses.flatMap((d) =>
|
||||
(d.takenBy || []).length > 0 ? d.takenBy.map((p) => `${d.id}-${p}`) : [d.id]
|
||||
);
|
||||
const allTaken = itemDoseIds.every((id) => takenDoses.has(id));
|
||||
return (
|
||||
<div key={`${day.dateStr}-${item.medName}`} className={`time-row ${allTaken ? "taken" : ""}`}>
|
||||
<div className="time-main">
|
||||
<div className="med-name">
|
||||
<span
|
||||
className={med?.imageUrl ? "clickable" : ""}
|
||||
onClick={() => med?.imageUrl && openLightbox(med.imageUrl, med.name)}
|
||||
>
|
||||
<MedicationAvatar name={item.medName} imageUrl={med?.imageUrl} size="sm" />
|
||||
</span>
|
||||
<span className="med-name-text">{item.medName}</span>
|
||||
{med?.genericName && <span className="med-generic-inline">({med.genericName})</span>}
|
||||
</div>
|
||||
<div className="tag-row">
|
||||
<span className="tag subtle">
|
||||
{item.total} {t("common.pills")} {t("common.total")}
|
||||
</span>
|
||||
{status && <span className={`tag ${status.className}`}>{t(status.label)}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="doses-col">
|
||||
{item.doses.map((dose) => {
|
||||
const people = (dose.takenBy || []).length > 0 ? dose.takenBy : [null];
|
||||
const allTaken = people.every((person) => takenDoses.has(getDoseId(dose.id, person)));
|
||||
return (
|
||||
<div key={dose.id} className={`dose-item past ${allTaken ? "all-taken" : ""}`}>
|
||||
<span className="dose-time">{dose.timeStr}</span>
|
||||
<span className="dose-usage">
|
||||
{dose.usage} {dose.usage !== 1 ? t("common.pills") : t("common.pill")}
|
||||
{med?.pillWeightMg && ` (${dose.usage * med.pillWeightMg} mg)`}
|
||||
</span>
|
||||
<div className="dose-checks">
|
||||
{people.map((person) => {
|
||||
const doseId = getDoseId(dose.id, person);
|
||||
const isTaken = takenDoses.has(doseId);
|
||||
return (
|
||||
<div key={doseId} className={`dose-person ${isTaken ? "taken" : ""}`}>
|
||||
{person && <span className="person-name">{person}</span>}
|
||||
{isTaken ? (
|
||||
<button
|
||||
className="dose-btn undo"
|
||||
onClick={() => undoDoseTaken(doseId)}
|
||||
title={t("common.undo")}
|
||||
>
|
||||
↩
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="dose-btn take"
|
||||
onClick={() => markDoseTaken(doseId)}
|
||||
disabled={isEmpty}
|
||||
title={t("dose.markAsTaken")}
|
||||
>
|
||||
✓
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{/* Current and future days */}
|
||||
{futureDays.map((day) => {
|
||||
// Check if all doses in this day are taken (auto-collapse)
|
||||
const allDoseIds = day.meds.flatMap((item) =>
|
||||
item.doses.flatMap((d) => ((d.takenBy || []).length > 0 ? d.takenBy.map((p) => `${d.id}-${p}`) : [d.id]))
|
||||
);
|
||||
const allDayTaken = allDoseIds.length > 0 && allDoseIds.every((id) => takenDoses.has(id));
|
||||
const takenCount = allDoseIds.filter((id) => takenDoses.has(id)).length;
|
||||
|
||||
// Calculate stock status for this day
|
||||
const worstStatus = getDayStockStatus(day.meds);
|
||||
|
||||
// Check if this is today
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const dayDate = new Date(day.date);
|
||||
dayDate.setHours(0, 0, 0, 0);
|
||||
const isToday = dayDate.getTime() === today.getTime();
|
||||
|
||||
// Determine if day should be collapsed: only today is expanded by default
|
||||
const isAutoCollapsed = allDayTaken || !isToday;
|
||||
const isManuallyExpanded = manuallyExpandedDays.has(day.dateStr);
|
||||
const isManuallyCollapsed = manuallyCollapsedDays.has(day.dateStr);
|
||||
const isCollapsed = isAutoCollapsed ? !isManuallyExpanded : isManuallyCollapsed;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={day.dateStr}
|
||||
className={`day-block ${isCollapsed ? "collapsed" : ""} ${allDayTaken ? "all-taken" : ""} ${isToday ? "today" : ""} stock-${worstStatus}`}
|
||||
>
|
||||
<div
|
||||
className="day-divider clickable"
|
||||
onClick={() => toggleDayCollapse(day.dateStr, isAutoCollapsed)}
|
||||
title={isCollapsed ? t("common.expand") : t("common.collapse")}
|
||||
>
|
||||
<span className="day-collapse-icon">{isCollapsed ? "▶" : "▼"}</span>
|
||||
<span className="day-date">{day.dateStr}</span>
|
||||
<span className="day-summary">
|
||||
{allDayTaken ? (
|
||||
<span className="day-complete">✓ {t("dashboard.schedules.allTaken")}</span>
|
||||
) : (
|
||||
<span className="day-progress">
|
||||
{takenCount}/{allDoseIds.length}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{!isCollapsed &&
|
||||
day.meds.map((item) => {
|
||||
const med = data.medications.find((m) => m.name === item.medName);
|
||||
const medCoverage = coverageByMed[item.medName];
|
||||
const isEmpty = medCoverage ? medCoverage.medsLeft <= 0 : false;
|
||||
const depletionTime = depletionByMed[item.medName];
|
||||
const willBeOutOfStock = typeof depletionTime === "number" && item.lastWhen > depletionTime;
|
||||
|
||||
// Calculate status for this medication on this day
|
||||
let status: { className: string; label: string } | null = null;
|
||||
if (willBeOutOfStock) {
|
||||
status = { className: "danger", label: "status.outOfStock" };
|
||||
} else if (medCoverage) {
|
||||
const { daysLeft, medsLeft } = medCoverage;
|
||||
if (medsLeft <= 0 || daysLeft === 0) {
|
||||
status = { className: "danger", label: "status.outOfStock" };
|
||||
} else if (daysLeft !== null && daysLeft < lowStockDays) {
|
||||
status = { className: "warning", label: "status.lowStock" };
|
||||
} else {
|
||||
status = { className: "success", label: "status.normal" };
|
||||
}
|
||||
}
|
||||
|
||||
const itemDoseIds = item.doses.flatMap((d) =>
|
||||
(d.takenBy || []).length > 0 ? d.takenBy.map((p) => `${d.id}-${p}`) : [d.id]
|
||||
);
|
||||
const allTaken = itemDoseIds.every((id) => takenDoses.has(id));
|
||||
return (
|
||||
<div key={`${day.dateStr}-${item.medName}`} className={`time-row ${allTaken ? "taken" : ""}`}>
|
||||
<div className="time-main">
|
||||
<div className="med-name">
|
||||
<span
|
||||
className={med?.imageUrl ? "clickable" : ""}
|
||||
onClick={() => med?.imageUrl && openLightbox(med.imageUrl, med.name)}
|
||||
>
|
||||
<MedicationAvatar name={item.medName} imageUrl={med?.imageUrl} size="sm" />
|
||||
</span>
|
||||
<span className="med-name-text">{item.medName}</span>
|
||||
{med?.genericName && <span className="med-generic-inline">({med.genericName})</span>}
|
||||
</div>
|
||||
<div className="tag-row">
|
||||
<span className="tag subtle">
|
||||
{item.total} {t("common.pills")} {t("common.total")}
|
||||
</span>
|
||||
{status && <span className={`tag ${status.className}`}>{t(status.label)}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="doses-col">
|
||||
{item.doses.map((dose) => {
|
||||
const people = (dose.takenBy || []).length > 0 ? dose.takenBy : [null];
|
||||
const allTaken = people.every((person) => takenDoses.has(getDoseId(dose.id, person)));
|
||||
// Only disable doses on future DAYS, not later today
|
||||
const doseDate = new Date(dose.when);
|
||||
doseDate.setHours(0, 0, 0, 0);
|
||||
const todayMidnight = new Date();
|
||||
todayMidnight.setHours(0, 0, 0, 0);
|
||||
const isFutureDose = doseDate.getTime() > todayMidnight.getTime();
|
||||
return (
|
||||
<div key={dose.id} className={`dose-item ${isFutureDose ? "future" : ""} ${allTaken ? "all-taken" : ""}`}>
|
||||
<span className="dose-time">{dose.timeStr}</span>
|
||||
<span className="dose-usage">
|
||||
{dose.usage} {dose.usage !== 1 ? t("common.pills") : t("common.pill")}
|
||||
{med?.pillWeightMg && ` (${dose.usage * med.pillWeightMg} mg)`}
|
||||
</span>
|
||||
<div className="dose-checks">
|
||||
{people.map((person) => {
|
||||
const doseId = getDoseId(dose.id, person);
|
||||
const isTaken = takenDoses.has(doseId);
|
||||
const isOverdue = dose.when < Date.now() && !isTaken && !isFutureDose;
|
||||
return (
|
||||
<div
|
||||
key={doseId}
|
||||
className={`dose-person ${isTaken ? "taken" : ""} ${isOverdue ? "overdue" : ""}`}
|
||||
>
|
||||
{person && <span className="person-name">{person}</span>}
|
||||
{isTaken ? (
|
||||
<button
|
||||
className="dose-btn undo"
|
||||
onClick={() => undoDoseTaken(doseId)}
|
||||
title={t("common.undo")}
|
||||
>
|
||||
↩
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="dose-btn take"
|
||||
onClick={() => markDoseTaken(doseId)}
|
||||
title={t("dose.markAsTaken")}
|
||||
disabled={isFutureDose || isEmpty}
|
||||
>
|
||||
✓
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<footer className="shared-schedule-footer">
|
||||
<p>
|
||||
{t("share.generatedBy")}{" "}
|
||||
{data?.sharedBy && (
|
||||
<>
|
||||
<strong>{data.sharedBy}</strong> ·{" "}
|
||||
</>
|
||||
)}
|
||||
<a href="/">MedAssist</a>
|
||||
</p>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
{/* Image Lightbox */}
|
||||
{lightboxImage && (
|
||||
<div className="lightbox-overlay" onClick={closeLightbox}>
|
||||
<button className="lightbox-close" onClick={closeLightbox}>
|
||||
×
|
||||
</button>
|
||||
<img
|
||||
src={`/api/images/${lightboxImage.url}`}
|
||||
alt={lightboxImage.name}
|
||||
className="lightbox-image"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
// =============================================================================
|
||||
// TagInput Component - Reusable tag input with suggestions
|
||||
// =============================================================================
|
||||
|
||||
import { KeyboardEvent } from "react";
|
||||
|
||||
export interface TagInputProps {
|
||||
tags: string[];
|
||||
inputValue: string;
|
||||
onInputChange: (value: string) => void;
|
||||
onAddTag: (tag: string) => void;
|
||||
onRemoveTag: (tag: string) => void;
|
||||
suggestions?: string[];
|
||||
placeholder?: string;
|
||||
addPlaceholder?: string;
|
||||
maxLength?: number;
|
||||
error?: string;
|
||||
datalistId?: string;
|
||||
}
|
||||
|
||||
export function TagInput({
|
||||
tags,
|
||||
inputValue,
|
||||
onInputChange,
|
||||
onAddTag,
|
||||
onRemoveTag,
|
||||
suggestions = [],
|
||||
placeholder = "",
|
||||
addPlaceholder = "",
|
||||
maxLength,
|
||||
error,
|
||||
datalistId = "tag-suggestions"
|
||||
}: TagInputProps) {
|
||||
function handleKeyDown(e: KeyboardEvent<HTMLInputElement>) {
|
||||
if ((e.key === "Enter" || e.key === ",") && inputValue.trim()) {
|
||||
e.preventDefault();
|
||||
onAddTag(inputValue);
|
||||
}
|
||||
if (e.key === "Backspace" && !inputValue && tags.length > 0) {
|
||||
onRemoveTag(tags[tags.length - 1]);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="tag-input-container">
|
||||
{tags.map((tag) => (
|
||||
<span key={tag} className="tag">
|
||||
{tag}
|
||||
<button type="button" className="tag-remove" onClick={() => onRemoveTag(tag)}>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
<input
|
||||
value={inputValue}
|
||||
onChange={(e) => onInputChange(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={() => {
|
||||
if (inputValue.trim()) onAddTag(inputValue);
|
||||
}}
|
||||
placeholder={tags.length === 0 ? placeholder : addPlaceholder}
|
||||
maxLength={maxLength}
|
||||
list={datalistId}
|
||||
/>
|
||||
{suggestions.length > 0 && (
|
||||
<datalist id={datalistId}>
|
||||
{suggestions
|
||||
.filter((s) => !tags.includes(s))
|
||||
.map((suggestion) => (
|
||||
<option key={suggestion} value={suggestion} />
|
||||
))}
|
||||
</datalist>
|
||||
)}
|
||||
</div>
|
||||
{error && <span className="field-error">{error}</span>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* UserFilterModal - Shows medications for a specific person (takenBy filter)
|
||||
* Allows clicking through to medication details
|
||||
*/
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { Medication, Coverage, StockThresholds } from "../types";
|
||||
import { MedicationAvatar } from "../components";
|
||||
import { getMedTotal, getPackageSize } from "../types";
|
||||
import { formatNumber } from "../utils";
|
||||
import { getStockStatus } from "../utils/schedule";
|
||||
|
||||
export interface UserFilterModalProps {
|
||||
selectedUser: string | null;
|
||||
meds: Medication[];
|
||||
coverage: { all: Coverage[] };
|
||||
settings: StockThresholds;
|
||||
onClose: () => void;
|
||||
onOpenMedDetail: (med: Medication) => void;
|
||||
}
|
||||
|
||||
export function UserFilterModal({
|
||||
selectedUser,
|
||||
meds,
|
||||
coverage,
|
||||
settings,
|
||||
onClose,
|
||||
onOpenMedDetail,
|
||||
}: UserFilterModalProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!selectedUser) return null;
|
||||
|
||||
const userMeds = meds.filter((m) => (m.takenBy || []).includes(selectedUser));
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="modal-content user-meds-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<button className="modal-close" onClick={onClose}>
|
||||
×
|
||||
</button>
|
||||
|
||||
<div className="user-meds-header">
|
||||
<div className="user-avatar">{selectedUser.charAt(0).toUpperCase()}</div>
|
||||
<h2>{t("modal.userMedications", { name: selectedUser })}</h2>
|
||||
</div>
|
||||
|
||||
<div className="user-meds-list">
|
||||
{userMeds.map((med) => {
|
||||
const medCoverage = coverage.all.find((c) => c.name === med.name);
|
||||
const status = medCoverage ? getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, settings) : null;
|
||||
const packageSize = getPackageSize(med);
|
||||
const currentStock = medCoverage ? formatNumber(medCoverage.medsLeft) : formatNumber(getMedTotal(med));
|
||||
return (
|
||||
<div
|
||||
key={med.id}
|
||||
className="user-med-item clickable"
|
||||
onClick={() => {
|
||||
onClose();
|
||||
onOpenMedDetail(med);
|
||||
}}
|
||||
>
|
||||
<MedicationAvatar name={med.name} imageUrl={med.imageUrl} size="sm" />
|
||||
<div className="user-med-info">
|
||||
<span className="user-med-name">{med.name}</span>
|
||||
{med.genericName && <span className="user-med-generic">{med.genericName}</span>}
|
||||
</div>
|
||||
<div className="user-med-stats">
|
||||
<span className="user-med-pills">
|
||||
{currentStock}/{formatNumber(packageSize)} {t("common.pills")}
|
||||
</span>
|
||||
{status && <span className={`status-chip ${status.className}`}>{t(status.label)}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{userMeds.length === 0 && (
|
||||
<div className="user-meds-empty">{t("modal.noMedsForUser", { name: selectedUser })}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="user-meds-footer">
|
||||
<button onClick={onClose}>{t("common.close")}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// Components barrel export
|
||||
export { MedicationAvatar } from "./MedicationAvatar";
|
||||
export type { MedicationAvatarProps } from "./MedicationAvatar";
|
||||
|
||||
export { SharedSchedule } from "./SharedSchedule";
|
||||
|
||||
export { TagInput } from "./TagInput";
|
||||
export type { TagInputProps } from "./TagInput";
|
||||
|
||||
export { Lightbox } from "./Lightbox";
|
||||
export type { LightboxProps } from "./Lightbox";
|
||||
|
||||
export { ConfirmModal } from "./ConfirmModal";
|
||||
export type { ConfirmModalProps } from "./ConfirmModal";
|
||||
|
||||
export { MedDetailModal } from "./MedDetailModal";
|
||||
export type { MedDetailModalProps } from "./MedDetailModal";
|
||||
|
||||
export { UserFilterModal } from "./UserFilterModal";
|
||||
export type { UserFilterModalProps } from "./UserFilterModal";
|
||||
|
||||
export { ShareDialog } from "./ShareDialog";
|
||||
export type { ShareDialogProps } from "./ShareDialog";
|
||||
|
||||
export { MobileEditModal } from "./MobileEditModal";
|
||||
export type { MobileEditModalProps } from "./MobileEditModal";
|
||||
|
||||
export { default as ProfileModal } from "./ProfileModal";
|
||||
|
||||
export { default as AboutModal } from "./AboutModal";
|
||||
|
||||
export { default as ExportModal } from "./ExportModal";
|
||||
@@ -0,0 +1,763 @@
|
||||
import React, { createContext, useContext, useMemo, useState, useEffect, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAuth } from "../components/Auth";
|
||||
import {
|
||||
useDoses,
|
||||
useCollapsedDays,
|
||||
useSettings,
|
||||
useShare,
|
||||
useMedications,
|
||||
useRefill,
|
||||
} from "../hooks";
|
||||
import type {
|
||||
Medication,
|
||||
Coverage,
|
||||
ScheduleEvent,
|
||||
} from "../types";
|
||||
import { buildSchedulePreview, calculateCoverage } from "../utils/schedule";
|
||||
import { getSystemLocale } from "../utils/formatters";
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
// =============================================================================
|
||||
|
||||
export type DoseInfo = {
|
||||
id: string;
|
||||
timeStr: string;
|
||||
when: number;
|
||||
usage: number;
|
||||
takenBy: string[];
|
||||
};
|
||||
|
||||
export type DayMedEntry = {
|
||||
medName: string;
|
||||
total: number;
|
||||
doses: DoseInfo[];
|
||||
lastWhen: number;
|
||||
};
|
||||
|
||||
export type GroupedDay = {
|
||||
dateStr: string;
|
||||
date: Date;
|
||||
isPast: boolean;
|
||||
meds: DayMedEntry[];
|
||||
};
|
||||
|
||||
export interface AppContextValue {
|
||||
// From useMedications
|
||||
meds: Medication[];
|
||||
setMeds: React.Dispatch<React.SetStateAction<Medication[]>>;
|
||||
loading: boolean;
|
||||
saving: boolean;
|
||||
setSaving: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
uploadingImage: boolean;
|
||||
loadMeds: () => void;
|
||||
deleteMed: (id: number, editingId: number | null, resetForm: () => void) => Promise<void>;
|
||||
uploadMedImage: (medId: number, file: File) => Promise<void>;
|
||||
deleteMedImage: (medId: number) => Promise<void>;
|
||||
|
||||
// From useSettings (selected fields)
|
||||
settings: ReturnType<typeof useSettings>["settings"];
|
||||
setSettings: ReturnType<typeof useSettings>["setSettings"];
|
||||
savedSettings: ReturnType<typeof useSettings>["savedSettings"];
|
||||
settingsLoading: boolean;
|
||||
settingsSaving: boolean;
|
||||
settingsSaved: boolean;
|
||||
testingEmail: boolean;
|
||||
testEmailResult: { success: boolean; message: string } | null;
|
||||
testingShoutrrr: boolean;
|
||||
testShoutrrrResult: { success: boolean; message: string } | null;
|
||||
loadSettings: () => void;
|
||||
saveSettings: (e: React.FormEvent) => Promise<void>;
|
||||
testEmail: () => Promise<void>;
|
||||
testShoutrrr: () => Promise<void>;
|
||||
|
||||
// From useDoses
|
||||
takenDoses: Set<string>;
|
||||
setTakenDoses: React.Dispatch<React.SetStateAction<Set<string>>>;
|
||||
dismissedDoses: Set<string>;
|
||||
clearingMissed: boolean;
|
||||
showClearMissedConfirm: boolean;
|
||||
setShowClearMissedConfirm: (show: boolean) => void;
|
||||
getDoseId: (baseDoseId: string, person: string | null) => string;
|
||||
countTakenDoses: (doses: Array<{ id: string; takenBy: string[] }>) => { total: number; taken: number };
|
||||
markDoseTaken: (doseId: string) => Promise<void>;
|
||||
undoDoseTaken: (doseId: string) => Promise<void>;
|
||||
dismissMissedDoses: (doseIds: string[]) => Promise<void>;
|
||||
|
||||
// From useCollapsedDays
|
||||
manuallyCollapsedDays: Set<string>;
|
||||
manuallyExpandedDays: Set<string>;
|
||||
toggleDayCollapse: (dateStr: string, isCurrentlyExpanded: boolean) => void;
|
||||
|
||||
// From useShare
|
||||
showShareDialog: boolean;
|
||||
sharePeople: string[];
|
||||
shareSelectedPerson: string;
|
||||
setShareSelectedPerson: React.Dispatch<React.SetStateAction<string>>;
|
||||
shareSelectedDays: number;
|
||||
setShareSelectedDays: React.Dispatch<React.SetStateAction<number>>;
|
||||
shareGenerating: boolean;
|
||||
shareLink: string | null;
|
||||
setShareLink: React.Dispatch<React.SetStateAction<string | null>>;
|
||||
shareCopied: boolean;
|
||||
setShareCopied: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
openShareDialog: () => void;
|
||||
generateShareLink: () => Promise<void>;
|
||||
copyShareLink: () => void;
|
||||
closeShareDialog: () => void;
|
||||
resetShareDialogState: () => void;
|
||||
|
||||
// From useRefill
|
||||
showRefillModal: boolean;
|
||||
setShowRefillModal: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
refillPacks: number;
|
||||
setRefillPacks: React.Dispatch<React.SetStateAction<number>>;
|
||||
refillLoose: number;
|
||||
setRefillLoose: React.Dispatch<React.SetStateAction<number>>;
|
||||
refillSaving: boolean;
|
||||
refillHistory: ReturnType<typeof useRefill>["refillHistory"];
|
||||
refillHistoryExpanded: boolean;
|
||||
setRefillHistoryExpanded: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
showEditStockModal: boolean;
|
||||
setShowEditStockModal: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
editStockFullBlisters: number;
|
||||
setEditStockFullBlisters: React.Dispatch<React.SetStateAction<number>>;
|
||||
editStockPartialBlisterPills: number;
|
||||
setEditStockPartialBlisterPills: React.Dispatch<React.SetStateAction<number>>;
|
||||
editStockSaving: boolean;
|
||||
loadRefillHistory: (medId: number) => Promise<void>;
|
||||
submitRefill: (medId: number, editingId: number | null, setForm: React.Dispatch<React.SetStateAction<any>>, loadMeds: () => void) => Promise<void>;
|
||||
submitStockCorrection: (medId: number, selectedMed: Medication, loadMeds: () => void) => Promise<void>;
|
||||
openRefillModal: () => void;
|
||||
closeRefillModal: () => void;
|
||||
openEditStockModal: (selectedMed: Medication, coverage: { all: Coverage[] }) => void;
|
||||
closeEditStockModal: () => void;
|
||||
|
||||
// Computed values
|
||||
schedule: { events: ScheduleEvent[] };
|
||||
coverage: { all: Coverage[]; low: Coverage[] };
|
||||
coverageByMed: Record<string, Coverage>;
|
||||
depletionByMed: Record<string, number | null>;
|
||||
existingPeople: string[];
|
||||
groupedSchedule: GroupedDay[];
|
||||
pastDays: GroupedDay[];
|
||||
futureDays: GroupedDay[];
|
||||
missedPastDoseIds: string[];
|
||||
getDayStockStatus: (dayMeds: { medName: string; lastWhen: number }[]) => "success" | "warning" | "danger";
|
||||
|
||||
// Schedule UI state
|
||||
scheduleDays: number;
|
||||
setScheduleDays: React.Dispatch<React.SetStateAction<number>>;
|
||||
showPastDays: boolean;
|
||||
setShowPastDays: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
|
||||
// Modal state
|
||||
selectedMed: Medication | null;
|
||||
setSelectedMed: React.Dispatch<React.SetStateAction<Medication | null>>;
|
||||
showImageLightbox: boolean;
|
||||
setShowImageLightbox: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
scheduleLightboxImage: string | null;
|
||||
setScheduleLightboxImage: React.Dispatch<React.SetStateAction<string | null>>;
|
||||
selectedUser: string | null;
|
||||
setSelectedUser: React.Dispatch<React.SetStateAction<string | null>>;
|
||||
|
||||
// Export/Import state
|
||||
exporting: boolean;
|
||||
importing: boolean;
|
||||
showExportModal: boolean;
|
||||
setShowExportModal: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
showImportConfirm: boolean;
|
||||
setShowImportConfirm: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
pendingImportData: unknown;
|
||||
setPendingImportData: React.Dispatch<React.SetStateAction<unknown>>;
|
||||
importResult: { medications: number; doses: number; shares: number } | null;
|
||||
setImportResult: React.Dispatch<React.SetStateAction<{ medications: number; doses: number; shares: number } | null>>;
|
||||
handleExport: (includeImages?: boolean) => Promise<void>;
|
||||
handleImportFileSelect: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
handleImportConfirm: () => Promise<void>;
|
||||
settingsChanged: boolean;
|
||||
|
||||
// Modal helpers
|
||||
openMedDetail: (med: Medication) => void;
|
||||
closeMedDetail: () => void;
|
||||
openImageLightbox: () => void;
|
||||
closeImageLightbox: () => void;
|
||||
openScheduleLightbox: (imageUrl: string) => void;
|
||||
closeScheduleLightbox: () => void;
|
||||
openUserFilter: (person: string) => void;
|
||||
closeUserFilter: () => void;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Context
|
||||
// =============================================================================
|
||||
|
||||
const AppContext = createContext<AppContextValue | null>(null);
|
||||
|
||||
// Helper for user-specific localStorage keys
|
||||
function userStorageKey(userId: number | undefined, key: string): string {
|
||||
return userId ? `user_${userId}_${key}` : key;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Provider
|
||||
// =============================================================================
|
||||
|
||||
export function AppProvider({ children }: { children: React.ReactNode }) {
|
||||
const { i18n } = useTranslation();
|
||||
const { user } = useAuth();
|
||||
|
||||
// Compose hooks
|
||||
const medications = useMedications();
|
||||
const settingsHook = useSettings();
|
||||
const doses = useDoses();
|
||||
const collapsed = useCollapsedDays(user?.id);
|
||||
const share = useShare();
|
||||
const refill = useRefill();
|
||||
|
||||
// Schedule UI state
|
||||
const [scheduleDays, setScheduleDays] = useState<number>(30);
|
||||
const [showPastDays, setShowPastDays] = useState(false);
|
||||
|
||||
// Modal state
|
||||
const [selectedMed, setSelectedMed] = useState<Medication | null>(null);
|
||||
const [showImageLightbox, setShowImageLightbox] = useState(false);
|
||||
const [scheduleLightboxImage, setScheduleLightboxImage] = useState<string | null>(null);
|
||||
const [selectedUser, setSelectedUser] = useState<string | null>(null);
|
||||
|
||||
// Export/Import state
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [showExportModal, setShowExportModal] = useState(false);
|
||||
const [showImportConfirm, setShowImportConfirm] = useState(false);
|
||||
const [pendingImportData, setPendingImportData] = useState<unknown>(null);
|
||||
const [importResult, setImportResult] = useState<{ medications: number; doses: number; shares: number } | null>(null);
|
||||
|
||||
// Load user-specific scheduleDays when user changes
|
||||
useEffect(() => {
|
||||
if (typeof window !== "undefined" && user?.id) {
|
||||
const storedDays = localStorage.getItem(userStorageKey(user.id, "scheduleDays"));
|
||||
setScheduleDays(storedDays ? Number(storedDays) : 30);
|
||||
}
|
||||
}, [user?.id]);
|
||||
|
||||
// Load medications and settings when user changes
|
||||
useEffect(() => {
|
||||
medications.loadMeds();
|
||||
settingsHook.loadSettings();
|
||||
}, [user?.id]);
|
||||
|
||||
// Update selectedMed when meds change (e.g., after refill)
|
||||
useEffect(() => {
|
||||
if (selectedMed) {
|
||||
const updated = medications.meds.find(m => m.id === selectedMed.id);
|
||||
if (updated && (
|
||||
updated.packCount !== selectedMed.packCount ||
|
||||
updated.looseTablets !== selectedMed.looseTablets ||
|
||||
updated.updatedAt !== selectedMed.updatedAt
|
||||
)) {
|
||||
setSelectedMed(updated);
|
||||
}
|
||||
}
|
||||
}, [medications.meds, selectedMed]);
|
||||
|
||||
// Computed values - combine app language with timezone region for locale
|
||||
const systemLocale = getSystemLocale(i18n.language);
|
||||
const schedule = useMemo(
|
||||
() => buildSchedulePreview(medications.meds, systemLocale, true),
|
||||
[medications.meds, systemLocale]
|
||||
);
|
||||
|
||||
const coverage = useMemo(
|
||||
() => calculateCoverage(
|
||||
medications.meds,
|
||||
schedule.events,
|
||||
systemLocale,
|
||||
settingsHook.settings.reminderDaysBefore,
|
||||
settingsHook.settings.stockCalculationMode,
|
||||
doses.takenDoses
|
||||
),
|
||||
[medications.meds, schedule.events, systemLocale, settingsHook.settings.reminderDaysBefore, settingsHook.settings.stockCalculationMode, doses.takenDoses]
|
||||
);
|
||||
|
||||
const depletionByMed = useMemo(
|
||||
() => Object.fromEntries(coverage.all.map((c) => [c.name, c.depletionTime])),
|
||||
[coverage.all]
|
||||
);
|
||||
|
||||
const coverageByMed = useMemo(
|
||||
() => Object.fromEntries(coverage.all.map((c) => [c.name, c])),
|
||||
[coverage.all]
|
||||
);
|
||||
|
||||
const existingPeople = useMemo(() => {
|
||||
const allPeople = medications.meds.flatMap(m => m.takenBy || []);
|
||||
return [...new Set(allPeople)].filter(Boolean).sort();
|
||||
}, [medications.meds]);
|
||||
|
||||
// Get worst stock status for a day's medications
|
||||
const getDayStockStatus = useCallback((dayMeds: { medName: string; lastWhen: number }[]): "success" | "warning" | "danger" => {
|
||||
const statuses = dayMeds.map((item) => {
|
||||
const cov = coverageByMed[item.medName];
|
||||
const depletionTime = depletionByMed[item.medName];
|
||||
|
||||
// Will be out of stock by this day?
|
||||
if (typeof depletionTime === "number" && item.lastWhen > depletionTime) {
|
||||
return "danger";
|
||||
}
|
||||
|
||||
if (!cov) return "success";
|
||||
const { daysLeft, medsLeft } = cov;
|
||||
|
||||
// Currently out of stock
|
||||
if (medsLeft <= 0 || daysLeft === 0) return "danger";
|
||||
// No schedule (can't calculate)
|
||||
if (daysLeft === null) return "success";
|
||||
// Low stock: < lowStockDays (warning)
|
||||
if (daysLeft < settingsHook.settings.lowStockDays) return "warning";
|
||||
// Normal/High stock
|
||||
return "success";
|
||||
});
|
||||
return statuses.includes("danger") ? "danger" : statuses.includes("warning") ? "warning" : "success";
|
||||
}, [coverageByMed, depletionByMed, settingsHook.settings.lowStockDays]);
|
||||
|
||||
const groupedSchedule = useMemo(() => {
|
||||
const days = new Map<string, { dateStr: string; date: Date; isPast: boolean; meds: Map<string, DayMedEntry> }>();
|
||||
schedule.events.slice(0, 2000).forEach((event) => {
|
||||
const day = days.get(event.dateStr) ?? { dateStr: event.dateStr, date: new Date(event.when), isPast: event.isPast, meds: new Map() };
|
||||
const medEntry = day.meds.get(event.medName) ?? { medName: event.medName, total: 0, doses: [], lastWhen: event.when };
|
||||
medEntry.total += event.usage;
|
||||
medEntry.doses.push({ id: event.id, timeStr: event.timeStr, when: event.when, usage: event.usage, takenBy: event.takenBy || [] });
|
||||
medEntry.lastWhen = Math.max(medEntry.lastWhen, event.when);
|
||||
day.meds.set(event.medName, medEntry);
|
||||
days.set(event.dateStr, day);
|
||||
});
|
||||
return Array.from(days.values()).map((d) => ({ dateStr: d.dateStr, date: d.date, isPast: d.isPast, meds: Array.from(d.meds.values()) }));
|
||||
}, [schedule.events]);
|
||||
|
||||
const pastDays = useMemo(() => groupedSchedule.filter(d => d.isPast), [groupedSchedule]);
|
||||
const futureDays = useMemo(() => groupedSchedule.filter(d => !d.isPast).slice(0, scheduleDays), [groupedSchedule, scheduleDays]);
|
||||
|
||||
// Build a map of medId -> end-of-day timestamp of last dismissed dose
|
||||
// When user dismisses doses and then changes the schedule, old dismissed IDs no longer match
|
||||
// Compare by DAY (end of day) so time changes within a day don't cause doses to reappear
|
||||
const dismissedUntilByMed = useMemo(() => {
|
||||
const map = new Map<string, number>();
|
||||
for (const doseId of doses.dismissedDoses) {
|
||||
// Format: medId-blisterIdx-timestamp or medId-blisterIdx-timestamp-person
|
||||
const parts = doseId.split("-");
|
||||
if (parts.length >= 3) {
|
||||
const medId = parts[0];
|
||||
const timestamp = parseInt(parts[2], 10);
|
||||
if (!isNaN(timestamp)) {
|
||||
// Convert to end of that day (23:59:59.999) for day-level comparison
|
||||
const date = new Date(timestamp);
|
||||
date.setHours(23, 59, 59, 999);
|
||||
const endOfDay = date.getTime();
|
||||
const current = map.get(medId) ?? 0;
|
||||
if (endOfDay > current) map.set(medId, endOfDay);
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}, [doses.dismissedDoses]);
|
||||
|
||||
const missedPastDoseIds = useMemo(() => {
|
||||
const totalPastDoses = pastDays.flatMap(d =>
|
||||
d.meds.flatMap(m =>
|
||||
m.doses.flatMap(dose => {
|
||||
// Check if this dose is before the dismissed threshold for this medication
|
||||
const parts = dose.id.split("-");
|
||||
const medId = parts[0];
|
||||
const timestamp = parts.length >= 3 ? parseInt(parts[2], 10) : 0;
|
||||
const dismissedUntil = dismissedUntilByMed.get(medId) ?? 0;
|
||||
|
||||
// If this dose's day is at or before the dismissed day, treat as dismissed
|
||||
if (timestamp > 0 && timestamp <= dismissedUntil) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return (dose.takenBy || []).length > 0
|
||||
? dose.takenBy.map((p: string) => `${dose.id}-${p}`)
|
||||
: [dose.id];
|
||||
})
|
||||
)
|
||||
);
|
||||
return totalPastDoses.filter(id => !doses.takenDoses.has(id) && !doses.dismissedDoses.has(id));
|
||||
}, [pastDays, doses.takenDoses, doses.dismissedDoses, dismissedUntilByMed]);
|
||||
|
||||
// Modal helpers with browser history support
|
||||
const openMedDetail = useCallback((med: Medication) => {
|
||||
setSelectedMed(med);
|
||||
refill.setRefillHistoryExpanded(false);
|
||||
refill.loadRefillHistory(med.id);
|
||||
window.history.pushState({ modal: 'medDetail', medId: med.id }, '');
|
||||
}, [refill]);
|
||||
|
||||
const closeMedDetail = useCallback(() => {
|
||||
if (selectedMed) {
|
||||
window.history.back();
|
||||
}
|
||||
}, [selectedMed]);
|
||||
|
||||
const openImageLightbox = useCallback(() => {
|
||||
setShowImageLightbox(true);
|
||||
window.history.pushState({ modal: 'imageLightbox' }, '');
|
||||
}, []);
|
||||
|
||||
const closeImageLightbox = useCallback(() => {
|
||||
if (showImageLightbox) {
|
||||
window.history.back();
|
||||
}
|
||||
}, [showImageLightbox]);
|
||||
|
||||
const openScheduleLightbox = useCallback((imageUrl: string) => {
|
||||
setScheduleLightboxImage(imageUrl);
|
||||
window.history.pushState({ modal: 'scheduleLightbox' }, '');
|
||||
}, []);
|
||||
|
||||
const closeScheduleLightbox = useCallback(() => {
|
||||
if (scheduleLightboxImage) {
|
||||
window.history.back();
|
||||
}
|
||||
}, [scheduleLightboxImage]);
|
||||
|
||||
const openUserFilter = useCallback((person: string) => {
|
||||
setSelectedUser(person);
|
||||
window.history.pushState({ modal: 'userFilter', person }, '');
|
||||
}, []);
|
||||
|
||||
const closeUserFilter = useCallback(() => {
|
||||
if (selectedUser) {
|
||||
window.history.back();
|
||||
}
|
||||
}, [selectedUser]);
|
||||
|
||||
// Wrapper to pass meds to openShareDialog
|
||||
const openShareDialog = useCallback(() => {
|
||||
share.openShareDialog(medications.meds);
|
||||
}, [share, medications.meds]);
|
||||
|
||||
// Get t function for translations
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Export data to JSON file
|
||||
const handleExport = useCallback(async (includeImages: boolean = true) => {
|
||||
setExporting(true);
|
||||
try {
|
||||
const res = await fetch(`/api/export?includeSensitive=true&includeImages=${includeImages}`, {
|
||||
credentials: "include",
|
||||
});
|
||||
if (!res.ok) throw new Error("Export failed");
|
||||
const data = await res.json();
|
||||
|
||||
// Create download
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
const dateStr = new Date().toISOString().split("T")[0];
|
||||
a.href = url;
|
||||
a.download = `${t('exportImport.downloadFilename')}-${dateStr}.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (err) {
|
||||
console.error("Export error:", err);
|
||||
}
|
||||
setExporting(false);
|
||||
}, [t]);
|
||||
|
||||
// Handle file selection for import
|
||||
const handleImportFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.target?.result as string);
|
||||
if (!data.version || !data.exportedAt) {
|
||||
alert(t('exportImport.invalidFile'));
|
||||
return;
|
||||
}
|
||||
setPendingImportData(data);
|
||||
setShowImportConfirm(true);
|
||||
} catch {
|
||||
alert(t('exportImport.invalidFile'));
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
// Reset file input
|
||||
e.target.value = "";
|
||||
}, [t]);
|
||||
|
||||
// Confirm and execute import
|
||||
const handleImportConfirm = useCallback(async () => {
|
||||
if (!pendingImportData) return;
|
||||
setImporting(true);
|
||||
setShowImportConfirm(false);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/import", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify(pendingImportData),
|
||||
});
|
||||
|
||||
// Get the response text first to handle non-JSON responses
|
||||
const text = await res.text();
|
||||
let data;
|
||||
try {
|
||||
data = text ? JSON.parse(text) : {};
|
||||
} catch {
|
||||
console.error("Import response parse error:", text);
|
||||
alert(t('exportImport.importError') + ": Server returned invalid response");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
alert(t('exportImport.importError') + ": " + (data.error || `HTTP ${res.status}`));
|
||||
return;
|
||||
}
|
||||
|
||||
// Show success message in UI instead of browser alert
|
||||
setImportResult({
|
||||
medications: data.imported?.medications || 0,
|
||||
doses: data.imported?.doseHistory || 0,
|
||||
shares: data.imported?.shareLinks || 0,
|
||||
});
|
||||
|
||||
// Reload all data
|
||||
medications.loadMeds();
|
||||
settingsHook.loadSettings();
|
||||
doses.loadTakenDoses();
|
||||
} catch (err) {
|
||||
console.error("Import error:", err);
|
||||
alert(t('exportImport.importError'));
|
||||
}
|
||||
|
||||
setPendingImportData(null);
|
||||
setImporting(false);
|
||||
}, [pendingImportData, t, medications, settingsHook, doses]);
|
||||
|
||||
// Compute settingsChanged
|
||||
const settingsChanged = useMemo(() => {
|
||||
const settings = settingsHook.settings;
|
||||
const savedSettings = settingsHook.savedSettings;
|
||||
return settings.emailEnabled !== savedSettings.emailEnabled ||
|
||||
settings.notificationEmail !== savedSettings.notificationEmail ||
|
||||
settings.emailStockReminders !== savedSettings.emailStockReminders ||
|
||||
settings.emailIntakeReminders !== savedSettings.emailIntakeReminders ||
|
||||
settings.reminderDaysBefore !== savedSettings.reminderDaysBefore ||
|
||||
settings.repeatDailyReminders !== savedSettings.repeatDailyReminders ||
|
||||
settings.lowStockDays !== savedSettings.lowStockDays ||
|
||||
settings.normalStockDays !== savedSettings.normalStockDays ||
|
||||
settings.highStockDays !== savedSettings.highStockDays ||
|
||||
settings.shoutrrrEnabled !== savedSettings.shoutrrrEnabled ||
|
||||
settings.shoutrrrUrl !== savedSettings.shoutrrrUrl ||
|
||||
settings.shoutrrrStockReminders !== savedSettings.shoutrrrStockReminders ||
|
||||
settings.shoutrrrIntakeReminders !== savedSettings.shoutrrrIntakeReminders ||
|
||||
settings.skipRemindersForTakenDoses !== savedSettings.skipRemindersForTakenDoses ||
|
||||
settings.repeatRemindersEnabled !== savedSettings.repeatRemindersEnabled ||
|
||||
settings.reminderRepeatIntervalMinutes !== savedSettings.reminderRepeatIntervalMinutes ||
|
||||
settings.maxNaggingReminders !== savedSettings.maxNaggingReminders ||
|
||||
settings.stockCalculationMode !== savedSettings.stockCalculationMode;
|
||||
}, [settingsHook.settings, settingsHook.savedSettings]);
|
||||
|
||||
// Build context value
|
||||
const value: AppContextValue = useMemo(() => ({
|
||||
// From useMedications
|
||||
...medications,
|
||||
|
||||
// From useSettings
|
||||
settings: settingsHook.settings,
|
||||
setSettings: settingsHook.setSettings,
|
||||
savedSettings: settingsHook.savedSettings,
|
||||
settingsLoading: settingsHook.settingsLoading,
|
||||
settingsSaving: settingsHook.settingsSaving,
|
||||
settingsSaved: settingsHook.settingsSaved,
|
||||
testingEmail: settingsHook.testingEmail,
|
||||
testEmailResult: settingsHook.testEmailResult,
|
||||
testingShoutrrr: settingsHook.testingShoutrrr,
|
||||
testShoutrrrResult: settingsHook.testShoutrrrResult,
|
||||
loadSettings: settingsHook.loadSettings,
|
||||
saveSettings: settingsHook.saveSettings,
|
||||
testEmail: settingsHook.testEmail,
|
||||
testShoutrrr: settingsHook.testShoutrrr,
|
||||
|
||||
// From useDoses
|
||||
takenDoses: doses.takenDoses,
|
||||
setTakenDoses: doses.setTakenDoses,
|
||||
dismissedDoses: doses.dismissedDoses,
|
||||
clearingMissed: doses.clearingMissed,
|
||||
showClearMissedConfirm: doses.showClearMissedConfirm,
|
||||
setShowClearMissedConfirm: doses.setShowClearMissedConfirm,
|
||||
getDoseId: doses.getDoseId,
|
||||
countTakenDoses: doses.countTakenDoses,
|
||||
markDoseTaken: doses.markDoseTaken,
|
||||
undoDoseTaken: doses.undoDoseTaken,
|
||||
dismissMissedDoses: doses.dismissMissedDoses,
|
||||
|
||||
// From useCollapsedDays
|
||||
manuallyCollapsedDays: collapsed.manuallyCollapsedDays,
|
||||
manuallyExpandedDays: collapsed.manuallyExpandedDays,
|
||||
toggleDayCollapse: collapsed.toggleDayCollapse,
|
||||
|
||||
// From useShare
|
||||
showShareDialog: share.showShareDialog,
|
||||
sharePeople: share.sharePeople,
|
||||
shareSelectedPerson: share.shareSelectedPerson,
|
||||
setShareSelectedPerson: share.setShareSelectedPerson,
|
||||
shareSelectedDays: share.shareSelectedDays,
|
||||
setShareSelectedDays: share.setShareSelectedDays,
|
||||
shareGenerating: share.shareGenerating,
|
||||
shareLink: share.shareLink,
|
||||
setShareLink: share.setShareLink,
|
||||
shareCopied: share.shareCopied,
|
||||
setShareCopied: share.setShareCopied,
|
||||
openShareDialog,
|
||||
generateShareLink: share.generateShareLink,
|
||||
copyShareLink: share.copyShareLink,
|
||||
closeShareDialog: share.closeShareDialog,
|
||||
resetShareDialogState: share.resetShareDialogState,
|
||||
|
||||
// From useRefill
|
||||
showRefillModal: refill.showRefillModal,
|
||||
setShowRefillModal: refill.setShowRefillModal,
|
||||
refillPacks: refill.refillPacks,
|
||||
setRefillPacks: refill.setRefillPacks,
|
||||
refillLoose: refill.refillLoose,
|
||||
setRefillLoose: refill.setRefillLoose,
|
||||
refillSaving: refill.refillSaving,
|
||||
refillHistory: refill.refillHistory,
|
||||
refillHistoryExpanded: refill.refillHistoryExpanded,
|
||||
setRefillHistoryExpanded: refill.setRefillHistoryExpanded,
|
||||
showEditStockModal: refill.showEditStockModal,
|
||||
setShowEditStockModal: refill.setShowEditStockModal,
|
||||
editStockFullBlisters: refill.editStockFullBlisters,
|
||||
setEditStockFullBlisters: refill.setEditStockFullBlisters,
|
||||
editStockPartialBlisterPills: refill.editStockPartialBlisterPills,
|
||||
setEditStockPartialBlisterPills: refill.setEditStockPartialBlisterPills,
|
||||
editStockSaving: refill.editStockSaving,
|
||||
loadRefillHistory: refill.loadRefillHistory,
|
||||
submitRefill: refill.submitRefill,
|
||||
submitStockCorrection: refill.submitStockCorrection,
|
||||
openRefillModal: refill.openRefillModal,
|
||||
closeRefillModal: refill.closeRefillModal,
|
||||
openEditStockModal: refill.openEditStockModal,
|
||||
closeEditStockModal: refill.closeEditStockModal,
|
||||
|
||||
// Computed values
|
||||
schedule,
|
||||
coverage,
|
||||
coverageByMed,
|
||||
depletionByMed,
|
||||
existingPeople,
|
||||
groupedSchedule,
|
||||
pastDays,
|
||||
futureDays,
|
||||
missedPastDoseIds,
|
||||
getDayStockStatus,
|
||||
|
||||
// Schedule UI state
|
||||
scheduleDays,
|
||||
setScheduleDays,
|
||||
showPastDays,
|
||||
setShowPastDays,
|
||||
|
||||
// Modal state
|
||||
selectedMed,
|
||||
setSelectedMed,
|
||||
showImageLightbox,
|
||||
setShowImageLightbox,
|
||||
scheduleLightboxImage,
|
||||
setScheduleLightboxImage,
|
||||
selectedUser,
|
||||
setSelectedUser,
|
||||
|
||||
// Modal helpers
|
||||
openMedDetail,
|
||||
closeMedDetail,
|
||||
openImageLightbox,
|
||||
closeImageLightbox,
|
||||
openScheduleLightbox,
|
||||
closeScheduleLightbox,
|
||||
openUserFilter,
|
||||
closeUserFilter,
|
||||
|
||||
// Export/Import
|
||||
exporting,
|
||||
importing,
|
||||
showExportModal,
|
||||
setShowExportModal,
|
||||
showImportConfirm,
|
||||
setShowImportConfirm,
|
||||
pendingImportData,
|
||||
setPendingImportData,
|
||||
importResult,
|
||||
setImportResult,
|
||||
handleExport,
|
||||
handleImportFileSelect,
|
||||
handleImportConfirm,
|
||||
settingsChanged,
|
||||
}), [
|
||||
medications,
|
||||
settingsHook,
|
||||
doses,
|
||||
collapsed,
|
||||
share,
|
||||
refill,
|
||||
schedule,
|
||||
coverage,
|
||||
coverageByMed,
|
||||
depletionByMed,
|
||||
existingPeople,
|
||||
groupedSchedule,
|
||||
pastDays,
|
||||
futureDays,
|
||||
missedPastDoseIds,
|
||||
getDayStockStatus,
|
||||
scheduleDays,
|
||||
showPastDays,
|
||||
selectedMed,
|
||||
showImageLightbox,
|
||||
scheduleLightboxImage,
|
||||
selectedUser,
|
||||
openMedDetail,
|
||||
closeMedDetail,
|
||||
openImageLightbox,
|
||||
closeImageLightbox,
|
||||
openScheduleLightbox,
|
||||
closeScheduleLightbox,
|
||||
openUserFilter,
|
||||
closeUserFilter,
|
||||
openShareDialog,
|
||||
exporting,
|
||||
importing,
|
||||
showExportModal,
|
||||
showImportConfirm,
|
||||
pendingImportData,
|
||||
importResult,
|
||||
handleExport,
|
||||
handleImportFileSelect,
|
||||
handleImportConfirm,
|
||||
settingsChanged,
|
||||
]);
|
||||
|
||||
return <AppContext.Provider value={value}>{children}</AppContext.Provider>;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Hook
|
||||
// =============================================================================
|
||||
|
||||
export function useAppContext(): AppContextValue {
|
||||
const context = useContext(AppContext);
|
||||
if (!context) {
|
||||
throw new Error("useAppContext must be used within an AppProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
// Context barrel export
|
||||
export { AppProvider, useAppContext } from "./AppContext";
|
||||
export type { AppContextValue, DoseInfo, DayMedEntry, GroupedDay } from "./AppContext";
|
||||
@@ -0,0 +1,17 @@
|
||||
// Hooks barrel export
|
||||
export { useDoses } from "./useDoses";
|
||||
export type { UseDosesReturn } from "./useDoses";
|
||||
export { useCollapsedDays } from "./useCollapsedDays";
|
||||
export type { UseCollapsedDaysReturn } from "./useCollapsedDays";
|
||||
export { useTheme } from "./useTheme";
|
||||
export type { Theme, UseThemeReturn } from "./useTheme";
|
||||
export { useSettings } from "./useSettings";
|
||||
export type { Settings, UseSettingsReturn } from "./useSettings";
|
||||
export { useShare } from "./useShare";
|
||||
export type { UseShareReturn } from "./useShare";
|
||||
export { useMedications } from "./useMedications";
|
||||
export type { UseMedicationsReturn } from "./useMedications";
|
||||
export { useMedicationForm, defaultBlister, defaultForm } from "./useMedicationForm";
|
||||
export type { UseMedicationFormReturn } from "./useMedicationForm";
|
||||
export { useRefill } from "./useRefill";
|
||||
export type { UseRefillReturn } from "./useRefill";
|
||||
@@ -0,0 +1,67 @@
|
||||
// =============================================================================
|
||||
// useCollapsedDays Hook - Day collapse/expand state management
|
||||
// =============================================================================
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { loadCollapsedDaysFromStorage, userStorageKey } from "../utils/storage";
|
||||
|
||||
export interface UseCollapsedDaysReturn {
|
||||
manuallyCollapsedDays: Set<string>;
|
||||
manuallyExpandedDays: Set<string>;
|
||||
toggleDayCollapse: (dateStr: string, isAutoCollapsed: boolean) => void;
|
||||
}
|
||||
|
||||
export function useCollapsedDays(userId: number | undefined): UseCollapsedDaysReturn {
|
||||
const [manuallyCollapsedDays, setManuallyCollapsedDays] = useState<Set<string>>(new Set());
|
||||
const [manuallyExpandedDays, setManuallyExpandedDays] = useState<Set<string>>(new Set());
|
||||
|
||||
// Load collapsed/expanded state from localStorage when user changes
|
||||
useEffect(() => {
|
||||
if (typeof window !== "undefined" && userId) {
|
||||
const { collapsed, expanded } = loadCollapsedDaysFromStorage(
|
||||
userStorageKey(userId, "collapsedDays"),
|
||||
userStorageKey(userId, "expandedDays")
|
||||
);
|
||||
setManuallyCollapsedDays(collapsed);
|
||||
setManuallyExpandedDays(expanded);
|
||||
}
|
||||
}, [userId]);
|
||||
|
||||
// Toggle day collapse/expand
|
||||
const toggleDayCollapse = useCallback(
|
||||
(dateStr: string, isAutoCollapsed: boolean) => {
|
||||
if (isAutoCollapsed) {
|
||||
// Day is auto-collapsed (all taken) - toggle the expanded override
|
||||
setManuallyExpandedDays((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(dateStr)) {
|
||||
next.delete(dateStr);
|
||||
} else {
|
||||
next.add(dateStr);
|
||||
}
|
||||
if (userId) localStorage.setItem(userStorageKey(userId, "expandedDays"), JSON.stringify([...next]));
|
||||
return next;
|
||||
});
|
||||
} else {
|
||||
// Day is not auto-collapsed - toggle manual collapse
|
||||
setManuallyCollapsedDays((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(dateStr)) {
|
||||
next.delete(dateStr);
|
||||
} else {
|
||||
next.add(dateStr);
|
||||
}
|
||||
if (userId) localStorage.setItem(userStorageKey(userId, "collapsedDays"), JSON.stringify([...next]));
|
||||
return next;
|
||||
});
|
||||
}
|
||||
},
|
||||
[userId]
|
||||
);
|
||||
|
||||
return {
|
||||
manuallyCollapsedDays,
|
||||
manuallyExpandedDays,
|
||||
toggleDayCollapse
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
// =============================================================================
|
||||
// useDoses Hook - Dose tracking state and operations
|
||||
// =============================================================================
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
export interface UseDosesReturn {
|
||||
takenDoses: Set<string>;
|
||||
setTakenDoses: React.Dispatch<React.SetStateAction<Set<string>>>;
|
||||
dismissedDoses: Set<string>;
|
||||
clearingMissed: boolean;
|
||||
showClearMissedConfirm: boolean;
|
||||
setShowClearMissedConfirm: (show: boolean) => void;
|
||||
getDoseId: (baseDoseId: string, person: string | null) => string;
|
||||
countTakenDoses: (doses: Array<{ id: string; takenBy: string[] }>) => { total: number; taken: number };
|
||||
markDoseTaken: (doseId: string) => Promise<void>;
|
||||
undoDoseTaken: (doseId: string) => Promise<void>;
|
||||
dismissMissedDoses: (doseIds: string[]) => Promise<void>;
|
||||
loadTakenDoses: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function useDoses(): UseDosesReturn {
|
||||
const [takenDoses, setTakenDoses] = useState<Set<string>>(new Set());
|
||||
const [dismissedDoses, setDismissedDoses] = useState<Set<string>>(new Set());
|
||||
const [showClearMissedConfirm, setShowClearMissedConfirm] = useState(false);
|
||||
const [clearingMissed, setClearingMissed] = useState(false);
|
||||
|
||||
// Load taken doses from server
|
||||
const loadTakenDoses = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/doses/taken", { credentials: "include" });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
const taken = new Set<string>();
|
||||
const dismissed = new Set<string>();
|
||||
for (const d of data.doses) {
|
||||
if (d.dismissed) {
|
||||
dismissed.add(d.doseId);
|
||||
} else {
|
||||
taken.add(d.doseId);
|
||||
}
|
||||
}
|
||||
setTakenDoses(taken);
|
||||
setDismissedDoses(dismissed);
|
||||
}
|
||||
// Don't reset on error - keep current state
|
||||
} catch {
|
||||
// Don't reset on error - keep current state
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Poll for taken doses from server (works with or without auth)
|
||||
useEffect(() => {
|
||||
loadTakenDoses();
|
||||
|
||||
// Poll for updates every 5 seconds (real-time sync with share links)
|
||||
const interval = setInterval(loadTakenDoses, 5000);
|
||||
return () => clearInterval(interval);
|
||||
}, [loadTakenDoses]);
|
||||
|
||||
// Get dose ID with optional person suffix
|
||||
const getDoseId = useCallback((baseDoseId: string, person: string | null): string => {
|
||||
return person ? `${baseDoseId}-${person}` : baseDoseId;
|
||||
}, []);
|
||||
|
||||
// Count taken doses for a day/item
|
||||
const countTakenDoses = useCallback(
|
||||
(doses: Array<{ id: string; takenBy: string[] }>): { total: number; taken: number } => {
|
||||
let total = 0;
|
||||
let taken = 0;
|
||||
for (const d of doses) {
|
||||
const people = (d.takenBy || []).length > 0 ? d.takenBy : [null];
|
||||
for (const person of people) {
|
||||
total++;
|
||||
if (takenDoses.has(getDoseId(d.id, person))) taken++;
|
||||
}
|
||||
}
|
||||
return { total, taken };
|
||||
},
|
||||
[takenDoses, getDoseId]
|
||||
);
|
||||
|
||||
const markDoseTaken = useCallback(async (doseId: string) => {
|
||||
// Optimistic update
|
||||
setTakenDoses((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.add(doseId);
|
||||
return next;
|
||||
});
|
||||
|
||||
// Send to server
|
||||
try {
|
||||
await fetch("/api/doses/taken", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ doseId })
|
||||
});
|
||||
} catch {
|
||||
// Revert on error
|
||||
setTakenDoses((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(doseId);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
const undoDoseTaken = useCallback(async (doseId: string) => {
|
||||
// Optimistic update
|
||||
setTakenDoses((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(doseId);
|
||||
return next;
|
||||
});
|
||||
|
||||
// Send to server
|
||||
try {
|
||||
await fetch(`/api/doses/taken/${encodeURIComponent(doseId)}`, {
|
||||
method: "DELETE",
|
||||
credentials: "include"
|
||||
});
|
||||
} catch {
|
||||
// Revert on error
|
||||
setTakenDoses((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.add(doseId);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Dismiss missed doses without deducting from stock
|
||||
const dismissMissedDoses = useCallback(async (doseIds: string[]) => {
|
||||
if (doseIds.length === 0) return;
|
||||
|
||||
setClearingMissed(true);
|
||||
try {
|
||||
const res = await fetch("/api/doses/dismiss", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ doseIds })
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
// Update local state - move these from neither set to dismissed set
|
||||
setDismissedDoses((prev) => {
|
||||
const next = new Set(prev);
|
||||
for (const id of doseIds) next.add(id);
|
||||
return next;
|
||||
});
|
||||
setShowClearMissedConfirm(false);
|
||||
}
|
||||
} catch {
|
||||
// Error - dialog stays open
|
||||
} finally {
|
||||
setClearingMissed(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
takenDoses,
|
||||
setTakenDoses,
|
||||
dismissedDoses,
|
||||
clearingMissed,
|
||||
showClearMissedConfirm,
|
||||
setShowClearMissedConfirm,
|
||||
getDoseId,
|
||||
countTakenDoses,
|
||||
markDoseTaken,
|
||||
undoDoseTaken,
|
||||
dismissMissedDoses,
|
||||
loadTakenDoses
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
import { useState, useMemo, useEffect, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { Medication, FormState, FormBlister, FieldErrors } from "../types";
|
||||
import { FIELD_LIMITS } from "../types";
|
||||
import { toDateValue, toTimeValue } from "../utils/formatters";
|
||||
|
||||
export const defaultBlister = (): FormBlister => {
|
||||
const now = new Date();
|
||||
return {
|
||||
usage: "1",
|
||||
every: "1",
|
||||
startDate: toDateValue(now),
|
||||
startTime: toTimeValue(now)
|
||||
};
|
||||
};
|
||||
|
||||
export const defaultForm = (): FormState => ({
|
||||
name: "",
|
||||
genericName: "",
|
||||
takenBy: [],
|
||||
packCount: "1",
|
||||
blistersPerPack: "1",
|
||||
pillsPerBlister: "1",
|
||||
looseTablets: "0",
|
||||
pillWeightMg: "",
|
||||
expiryDate: "",
|
||||
notes: "",
|
||||
intakeRemindersEnabled: false,
|
||||
blisters: [defaultBlister()]
|
||||
});
|
||||
|
||||
export interface UseMedicationFormReturn {
|
||||
form: FormState;
|
||||
setForm: React.Dispatch<React.SetStateAction<FormState>>;
|
||||
originalForm: FormState;
|
||||
setOriginalForm: React.Dispatch<React.SetStateAction<FormState>>;
|
||||
editingId: number | null;
|
||||
setEditingId: React.Dispatch<React.SetStateAction<number | null>>;
|
||||
showEditModal: boolean;
|
||||
setShowEditModal: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
fieldErrors: FieldErrors;
|
||||
formSaved: boolean;
|
||||
setFormSaved: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
hasValidationErrors: boolean;
|
||||
formChanged: boolean;
|
||||
pendingImage: File | null;
|
||||
setPendingImage: React.Dispatch<React.SetStateAction<File | null>>;
|
||||
pendingImagePreview: string | null;
|
||||
setPendingImagePreview: React.Dispatch<React.SetStateAction<string | null>>;
|
||||
takenByInput: string;
|
||||
setTakenByInput: React.Dispatch<React.SetStateAction<string>>;
|
||||
validateField: (field: keyof FieldErrors, value: string | string[]) => string | undefined;
|
||||
setBlisterValue: (idx: number, field: keyof FormBlister, value: string) => void;
|
||||
addBlister: () => void;
|
||||
removeBlister: (idx: number) => void;
|
||||
startEdit: (med: Medication, openEditModal: () => void) => void;
|
||||
resetForm: () => void;
|
||||
handleValueChange: <K extends keyof FormState>(key: K, value: string) => void;
|
||||
addTakenByPerson: (name: string) => void;
|
||||
removeTakenByPerson: (name: string) => void;
|
||||
handleTakenByKeyDown: (e: React.KeyboardEvent<HTMLInputElement>) => void;
|
||||
}
|
||||
|
||||
export function useMedicationForm(): UseMedicationFormReturn {
|
||||
const { t } = useTranslation();
|
||||
const [form, setForm] = useState<FormState>(defaultForm());
|
||||
const [originalForm, setOriginalForm] = useState<FormState>(defaultForm());
|
||||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({});
|
||||
const [formSaved, setFormSaved] = useState(false);
|
||||
const [pendingImage, setPendingImage] = useState<File | null>(null);
|
||||
const [pendingImagePreview, setPendingImagePreview] = useState<string | null>(null);
|
||||
const [takenByInput, setTakenByInput] = useState("");
|
||||
|
||||
// Validate form fields
|
||||
const validateField = useCallback((field: keyof FieldErrors, value: string | string[]): string | undefined => {
|
||||
const limits = FIELD_LIMITS[field];
|
||||
// Skip validation for takenBy array (individual items validated on add)
|
||||
if (field === 'takenBy') return undefined;
|
||||
const strValue = typeof value === 'string' ? value : '';
|
||||
if (field === 'name' && (!strValue || strValue.trim().length === 0)) {
|
||||
return t('common.validation.required');
|
||||
}
|
||||
if ('max' in limits && strValue.length > limits.max) {
|
||||
return t('common.validation.maxLength', { max: limits.max, current: strValue.length });
|
||||
}
|
||||
return undefined;
|
||||
}, [t]);
|
||||
|
||||
// Check if form has any errors
|
||||
const hasValidationErrors = useMemo(() => {
|
||||
return Object.values(fieldErrors).some(error => error !== undefined);
|
||||
}, [fieldErrors]);
|
||||
|
||||
// Check if form has been modified from original state
|
||||
const formChanged = useMemo(() => {
|
||||
return JSON.stringify(form) !== JSON.stringify(originalForm);
|
||||
}, [form, originalForm]);
|
||||
|
||||
// Reset formSaved when form changes
|
||||
useEffect(() => {
|
||||
if (formChanged) {
|
||||
setFormSaved(false);
|
||||
}
|
||||
}, [formChanged]);
|
||||
|
||||
// Validate all fields when form changes
|
||||
useEffect(() => {
|
||||
const errors: FieldErrors = {};
|
||||
(['name', 'genericName', 'notes'] as const).forEach(field => {
|
||||
const error = validateField(field, form[field]);
|
||||
if (error) errors[field] = error;
|
||||
});
|
||||
setFieldErrors(errors);
|
||||
}, [form.name, form.genericName, form.notes, validateField]);
|
||||
|
||||
const setBlisterValue = useCallback((idx: number, field: keyof FormBlister, value: string) => {
|
||||
setForm((prev) => {
|
||||
const next = [...prev.blisters];
|
||||
next[idx] = { ...next[idx], [field]: value };
|
||||
return { ...prev, blisters: next };
|
||||
});
|
||||
}, []);
|
||||
|
||||
const addBlister = useCallback(() => {
|
||||
setForm((prev) => ({ ...prev, blisters: [...prev.blisters, defaultBlister()] }));
|
||||
}, []);
|
||||
|
||||
const removeBlister = useCallback((idx: number) => {
|
||||
setForm((prev) => ({ ...prev, blisters: prev.blisters.filter((_, i) => i !== idx) }));
|
||||
}, []);
|
||||
|
||||
const startEdit = useCallback((med: Medication, openEditModal: () => void) => {
|
||||
setEditingId(med.id);
|
||||
setTakenByInput(""); // Clear tag input when starting edit
|
||||
setFormSaved(true); // Existing medication is already saved
|
||||
const editForm: FormState = {
|
||||
name: med.name,
|
||||
genericName: med.genericName ?? "",
|
||||
takenBy: med.takenBy || [], // Already an array from API
|
||||
packCount: String(med.packCount),
|
||||
blistersPerPack: String(med.blistersPerPack),
|
||||
pillsPerBlister: String(med.pillsPerBlister),
|
||||
looseTablets: String(med.looseTablets),
|
||||
pillWeightMg: med.pillWeightMg ? String(med.pillWeightMg) : "",
|
||||
expiryDate: med.expiryDate ? med.expiryDate.slice(0, 10) : "",
|
||||
notes: med.notes ?? "",
|
||||
intakeRemindersEnabled: med.intakeRemindersEnabled ?? false,
|
||||
blisters: med.blisters.map((s) => ({
|
||||
usage: String(s.usage),
|
||||
every: String(s.every),
|
||||
startDate: toDateValue(s.start),
|
||||
startTime: toTimeValue(s.start)
|
||||
})),
|
||||
};
|
||||
setForm(editForm);
|
||||
setOriginalForm(editForm);
|
||||
// Show modal on mobile
|
||||
if (window.innerWidth <= 768) {
|
||||
openEditModal();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const resetForm = useCallback(() => {
|
||||
setEditingId(null);
|
||||
setShowEditModal(false);
|
||||
setPendingImage(null);
|
||||
setPendingImagePreview(null);
|
||||
setTakenByInput("");
|
||||
setFormSaved(false);
|
||||
const newForm = defaultForm();
|
||||
setForm(newForm);
|
||||
setOriginalForm(newForm);
|
||||
}, []);
|
||||
|
||||
const handleValueChange = useCallback(<K extends keyof FormState>(key: K, value: string) => {
|
||||
setForm((prev) => ({ ...prev, [key]: value }));
|
||||
}, []);
|
||||
|
||||
// Tag input helpers for "Taken By" field
|
||||
const addTakenByPerson = useCallback((name: string) => {
|
||||
const trimmed = name.trim();
|
||||
if (trimmed && trimmed.length <= FIELD_LIMITS.takenBy.max && !form.takenBy.includes(trimmed)) {
|
||||
setForm(prev => ({ ...prev, takenBy: [...prev.takenBy, trimmed] }));
|
||||
}
|
||||
setTakenByInput("");
|
||||
}, [form.takenBy]);
|
||||
|
||||
const removeTakenByPerson = useCallback((name: string) => {
|
||||
setForm(prev => ({ ...prev, takenBy: prev.takenBy.filter(p => p !== name) }));
|
||||
}, []);
|
||||
|
||||
const handleTakenByKeyDown = useCallback((e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter' || e.key === ',') {
|
||||
e.preventDefault();
|
||||
addTakenByPerson(takenByInput);
|
||||
} else if (e.key === 'Backspace' && !takenByInput && form.takenBy.length > 0) {
|
||||
// Remove last tag on backspace when input is empty
|
||||
removeTakenByPerson(form.takenBy[form.takenBy.length - 1]);
|
||||
}
|
||||
}, [takenByInput, form.takenBy, addTakenByPerson, removeTakenByPerson]);
|
||||
|
||||
return {
|
||||
form,
|
||||
setForm,
|
||||
originalForm,
|
||||
setOriginalForm,
|
||||
editingId,
|
||||
setEditingId,
|
||||
showEditModal,
|
||||
setShowEditModal,
|
||||
fieldErrors,
|
||||
formSaved,
|
||||
setFormSaved,
|
||||
hasValidationErrors,
|
||||
formChanged,
|
||||
pendingImage,
|
||||
setPendingImage,
|
||||
pendingImagePreview,
|
||||
setPendingImagePreview,
|
||||
takenByInput,
|
||||
setTakenByInput,
|
||||
validateField,
|
||||
setBlisterValue,
|
||||
addBlister,
|
||||
removeBlister,
|
||||
startEdit,
|
||||
resetForm,
|
||||
handleValueChange,
|
||||
addTakenByPerson,
|
||||
removeTakenByPerson,
|
||||
handleTakenByKeyDown,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import type { Medication } from "../types";
|
||||
|
||||
export interface UseMedicationsReturn {
|
||||
meds: Medication[];
|
||||
setMeds: React.Dispatch<React.SetStateAction<Medication[]>>;
|
||||
loading: boolean;
|
||||
saving: boolean;
|
||||
setSaving: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
uploadingImage: boolean;
|
||||
loadMeds: () => void;
|
||||
deleteMed: (id: number, editingId: number | null, resetForm: () => void) => Promise<void>;
|
||||
uploadMedImage: (medId: number, file: File) => Promise<void>;
|
||||
deleteMedImage: (medId: number) => Promise<void>;
|
||||
}
|
||||
|
||||
export function useMedications(): UseMedicationsReturn {
|
||||
const [meds, setMeds] = useState<Medication[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [uploadingImage, setUploadingImage] = useState(false);
|
||||
|
||||
const loadMeds = useCallback(() => {
|
||||
setLoading(true);
|
||||
fetch("/api/medications")
|
||||
.then((res) => res.json())
|
||||
.then((data) => setMeds(Array.isArray(data) ? data : []))
|
||||
.catch(() => setMeds([]))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const deleteMed = useCallback(async (id: number, editingId: number | null, resetForm: () => void) => {
|
||||
await fetch(`/api/medications/${id}`, { method: "DELETE" }).catch(() => null);
|
||||
if (editingId === id) resetForm();
|
||||
loadMeds();
|
||||
}, [loadMeds]);
|
||||
|
||||
const uploadMedImage = useCallback(async (medId: number, file: File) => {
|
||||
setUploadingImage(true);
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/medications/${medId}/image`, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
if (res.ok) {
|
||||
loadMeds();
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
setUploadingImage(false);
|
||||
}, [loadMeds]);
|
||||
|
||||
const deleteMedImage = useCallback(async (medId: number) => {
|
||||
await fetch(`/api/medications/${medId}/image`, { method: "DELETE" }).catch(() => null);
|
||||
loadMeds();
|
||||
}, [loadMeds]);
|
||||
|
||||
return {
|
||||
meds,
|
||||
setMeds,
|
||||
loading,
|
||||
saving,
|
||||
setSaving,
|
||||
uploadingImage,
|
||||
loadMeds,
|
||||
deleteMed,
|
||||
uploadMedImage,
|
||||
deleteMedImage,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import type { Medication, RefillEntry, Coverage, FormState } from "../types";
|
||||
import { getMedTotal } from "../types";
|
||||
|
||||
export interface UseRefillReturn {
|
||||
// Refill state
|
||||
showRefillModal: boolean;
|
||||
setShowRefillModal: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
refillPacks: number;
|
||||
setRefillPacks: React.Dispatch<React.SetStateAction<number>>;
|
||||
refillLoose: number;
|
||||
setRefillLoose: React.Dispatch<React.SetStateAction<number>>;
|
||||
refillSaving: boolean;
|
||||
refillHistory: RefillEntry[];
|
||||
refillHistoryExpanded: boolean;
|
||||
setRefillHistoryExpanded: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
|
||||
// Edit stock (correction) state
|
||||
showEditStockModal: boolean;
|
||||
setShowEditStockModal: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
editStockFullBlisters: number;
|
||||
setEditStockFullBlisters: React.Dispatch<React.SetStateAction<number>>;
|
||||
editStockPartialBlisterPills: number;
|
||||
setEditStockPartialBlisterPills: React.Dispatch<React.SetStateAction<number>>;
|
||||
editStockSaving: boolean;
|
||||
|
||||
// Actions
|
||||
loadRefillHistory: (medId: number) => Promise<void>;
|
||||
submitRefill: (
|
||||
medId: number,
|
||||
editingId: number | null,
|
||||
setForm: React.Dispatch<React.SetStateAction<FormState>>,
|
||||
loadMeds: () => void
|
||||
) => Promise<void>;
|
||||
submitStockCorrection: (
|
||||
medId: number,
|
||||
selectedMed: Medication,
|
||||
loadMeds: () => void
|
||||
) => Promise<void>;
|
||||
openRefillModal: () => void;
|
||||
closeRefillModal: () => void;
|
||||
openEditStockModal: (selectedMed: Medication, coverage: { all: Coverage[] }) => void;
|
||||
closeEditStockModal: () => void;
|
||||
}
|
||||
|
||||
export function useRefill(): UseRefillReturn {
|
||||
// Refill state
|
||||
const [showRefillModal, setShowRefillModal] = useState(false);
|
||||
const [refillPacks, setRefillPacks] = useState(1);
|
||||
const [refillLoose, setRefillLoose] = useState(0);
|
||||
const [refillSaving, setRefillSaving] = useState(false);
|
||||
const [refillHistory, setRefillHistory] = useState<RefillEntry[]>([]);
|
||||
const [refillHistoryExpanded, setRefillHistoryExpanded] = useState(false);
|
||||
|
||||
// Edit stock (correction) state
|
||||
const [showEditStockModal, setShowEditStockModal] = useState(false);
|
||||
const [editStockFullBlisters, setEditStockFullBlisters] = useState(0);
|
||||
const [editStockPartialBlisterPills, setEditStockPartialBlisterPills] = useState(0);
|
||||
const [editStockSaving, setEditStockSaving] = useState(false);
|
||||
|
||||
// Load refill history for a medication
|
||||
const loadRefillHistory = useCallback(async (medId: number) => {
|
||||
try {
|
||||
const res = await fetch(`/api/medications/${medId}/refills`, { credentials: "include" });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setRefillHistory(Array.isArray(data) ? data : (data.refills || []));
|
||||
} else {
|
||||
setRefillHistory([]);
|
||||
}
|
||||
} catch {
|
||||
setRefillHistory([]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Submit a refill
|
||||
const submitRefill = useCallback(async (
|
||||
medId: number,
|
||||
editingId: number | null,
|
||||
setForm: React.Dispatch<React.SetStateAction<FormState>>,
|
||||
loadMeds: () => void
|
||||
) => {
|
||||
if (refillPacks < 1 && refillLoose < 1) return;
|
||||
setRefillSaving(true);
|
||||
try {
|
||||
const res = await fetch(`/api/medications/${medId}/refill`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ packsAdded: refillPacks, loosePillsAdded: refillLoose }),
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
// Update form values if we're in edit mode
|
||||
if (editingId === medId && data.newStock) {
|
||||
setForm(f => ({
|
||||
...f,
|
||||
packCount: String(data.newStock.packCount),
|
||||
looseTablets: String(data.newStock.looseTablets),
|
||||
}));
|
||||
}
|
||||
// Reset refill form
|
||||
setRefillPacks(1);
|
||||
setRefillLoose(0);
|
||||
// Close refill modal via history back for proper back-button support
|
||||
if (showRefillModal) {
|
||||
window.history.back();
|
||||
}
|
||||
// Reload medications to get updated stock
|
||||
loadMeds();
|
||||
// Reload refill history
|
||||
await loadRefillHistory(medId);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
setRefillSaving(false);
|
||||
}, [refillPacks, refillLoose, showRefillModal, loadRefillHistory]);
|
||||
|
||||
// Submit a stock correction - user says how many pills they have RIGHT NOW
|
||||
const submitStockCorrection = useCallback(async (
|
||||
medId: number,
|
||||
selectedMed: Medication,
|
||||
loadMeds: () => void
|
||||
) => {
|
||||
if (!selectedMed) return;
|
||||
setEditStockSaving(true);
|
||||
try {
|
||||
// Auto-convert: handle full blister and negative partial blister
|
||||
let finalFullBlisters = editStockFullBlisters;
|
||||
let finalPartialPills = editStockPartialBlisterPills;
|
||||
|
||||
// Handle full blister: e.g. 9 pills in a 9-pill blister = +1 full blister, 0 partial
|
||||
if (finalPartialPills >= selectedMed.pillsPerBlister) {
|
||||
finalFullBlisters += 1;
|
||||
finalPartialPills = 0;
|
||||
}
|
||||
|
||||
// Handle negative partial: e.g. -3 with 136 full = 135 full, 6 partial (for 9-pill blister)
|
||||
if (finalPartialPills < 0 && finalFullBlisters > 0) {
|
||||
finalFullBlisters -= 1;
|
||||
finalPartialPills = selectedMed.pillsPerBlister + finalPartialPills;
|
||||
}
|
||||
|
||||
// Ensure we don't go negative
|
||||
if (finalPartialPills < 0) finalPartialPills = 0;
|
||||
if (finalFullBlisters < 0) finalFullBlisters = 0;
|
||||
|
||||
// What the user says they have RIGHT NOW = the new DB total
|
||||
const desiredTotal = finalFullBlisters * selectedMed.pillsPerBlister + finalPartialPills;
|
||||
|
||||
// The "base" from DB structure (without any stockAdjustment)
|
||||
const baseTotal = selectedMed.packCount * selectedMed.blistersPerPack * selectedMed.pillsPerBlister + selectedMed.looseTablets;
|
||||
|
||||
// stockAdjustment = what we need to make getMedTotal() return desiredTotal
|
||||
const newStockAdjustment = desiredTotal - baseTotal;
|
||||
|
||||
// Use the PATCH endpoint - it sets stockAdjustment AND lastStockCorrectionAt
|
||||
const res = await fetch(`/api/medications/${medId}/stock-adjustment`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ stockAdjustment: newStockAdjustment }),
|
||||
});
|
||||
if (res.ok) {
|
||||
// Close edit stock modal via history back
|
||||
if (showEditStockModal) {
|
||||
window.history.back();
|
||||
}
|
||||
// Reload medications to get updated stock
|
||||
loadMeds();
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
setEditStockSaving(false);
|
||||
}, [editStockFullBlisters, editStockPartialBlisterPills, showEditStockModal]);
|
||||
|
||||
const openRefillModal = useCallback(() => {
|
||||
setShowRefillModal(true);
|
||||
window.history.pushState({ modal: 'refill' }, '');
|
||||
}, []);
|
||||
|
||||
const closeRefillModal = useCallback(() => {
|
||||
if (showRefillModal) {
|
||||
window.history.back();
|
||||
}
|
||||
}, [showRefillModal]);
|
||||
|
||||
const openEditStockModal = useCallback((selectedMed: Medication, coverage: { all: Coverage[] }) => {
|
||||
if (!selectedMed) return;
|
||||
// Get current stock from coverage (after consumption)
|
||||
const medCoverage = coverage.all.find(c => c.name === selectedMed.name);
|
||||
const dbTotal = getMedTotal(selectedMed);
|
||||
const currentStock = medCoverage ? Math.round(medCoverage.medsLeft) : dbTotal;
|
||||
|
||||
// Simply divide into full blisters and partial
|
||||
const fullBlisters = Math.floor(currentStock / selectedMed.pillsPerBlister);
|
||||
const partialPills = currentStock % selectedMed.pillsPerBlister;
|
||||
|
||||
// Pre-fill with current values
|
||||
setEditStockFullBlisters(fullBlisters);
|
||||
setEditStockPartialBlisterPills(partialPills);
|
||||
setShowEditStockModal(true);
|
||||
window.history.pushState({ modal: 'editStock' }, '');
|
||||
}, []);
|
||||
|
||||
const closeEditStockModal = useCallback(() => {
|
||||
if (showEditStockModal) {
|
||||
window.history.back();
|
||||
}
|
||||
}, [showEditStockModal]);
|
||||
|
||||
return {
|
||||
showRefillModal,
|
||||
setShowRefillModal,
|
||||
refillPacks,
|
||||
setRefillPacks,
|
||||
refillLoose,
|
||||
setRefillLoose,
|
||||
refillSaving,
|
||||
refillHistory,
|
||||
refillHistoryExpanded,
|
||||
setRefillHistoryExpanded,
|
||||
showEditStockModal,
|
||||
setShowEditStockModal,
|
||||
editStockFullBlisters,
|
||||
setEditStockFullBlisters,
|
||||
editStockPartialBlisterPills,
|
||||
setEditStockPartialBlisterPills,
|
||||
editStockSaving,
|
||||
loadRefillHistory,
|
||||
submitRefill,
|
||||
submitStockCorrection,
|
||||
openRefillModal,
|
||||
closeRefillModal,
|
||||
openEditStockModal,
|
||||
closeEditStockModal,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
// =============================================================================
|
||||
// useSettings Hook - Settings state and operations
|
||||
// =============================================================================
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export interface Settings {
|
||||
emailEnabled: boolean;
|
||||
notificationEmail: string;
|
||||
reminderDaysBefore: number;
|
||||
repeatDailyReminders: boolean;
|
||||
skipRemindersForTakenDoses: boolean;
|
||||
repeatRemindersEnabled: boolean;
|
||||
reminderRepeatIntervalMinutes: number;
|
||||
maxNaggingReminders: number;
|
||||
lowStockDays: number;
|
||||
normalStockDays: number;
|
||||
highStockDays: number;
|
||||
smtpHost: string;
|
||||
smtpPort: number;
|
||||
smtpUser: string;
|
||||
smtpPass: string;
|
||||
smtpFrom: string;
|
||||
smtpSecure: boolean;
|
||||
hasSmtpPassword: boolean;
|
||||
lastAutoEmailSent: string | null;
|
||||
nextScheduledCheck: string | null;
|
||||
lastNotificationType: "stock" | "intake" | null;
|
||||
lastNotificationChannel: "email" | "push" | "both" | null;
|
||||
shoutrrrEnabled: boolean;
|
||||
shoutrrrUrl: string;
|
||||
emailStockReminders: boolean;
|
||||
emailIntakeReminders: boolean;
|
||||
shoutrrrStockReminders: boolean;
|
||||
shoutrrrIntakeReminders: boolean;
|
||||
stockCalculationMode: "automatic" | "manual";
|
||||
expiryWarningDays: number;
|
||||
}
|
||||
|
||||
const defaultSettings: Settings = {
|
||||
emailEnabled: false,
|
||||
notificationEmail: "",
|
||||
reminderDaysBefore: 7,
|
||||
repeatDailyReminders: false,
|
||||
skipRemindersForTakenDoses: false,
|
||||
repeatRemindersEnabled: false,
|
||||
reminderRepeatIntervalMinutes: 30,
|
||||
maxNaggingReminders: 5,
|
||||
lowStockDays: 30,
|
||||
normalStockDays: 90,
|
||||
highStockDays: 180,
|
||||
smtpHost: "",
|
||||
smtpPort: 587,
|
||||
smtpUser: "",
|
||||
smtpPass: "",
|
||||
smtpFrom: "",
|
||||
smtpSecure: false,
|
||||
hasSmtpPassword: false,
|
||||
lastAutoEmailSent: null,
|
||||
nextScheduledCheck: null,
|
||||
lastNotificationType: null,
|
||||
lastNotificationChannel: null,
|
||||
shoutrrrEnabled: false,
|
||||
shoutrrrUrl: "",
|
||||
emailStockReminders: true,
|
||||
emailIntakeReminders: true,
|
||||
shoutrrrStockReminders: true,
|
||||
shoutrrrIntakeReminders: true,
|
||||
stockCalculationMode: "automatic",
|
||||
expiryWarningDays: 30
|
||||
};
|
||||
|
||||
export interface UseSettingsReturn {
|
||||
settings: Settings;
|
||||
setSettings: React.Dispatch<React.SetStateAction<Settings>>;
|
||||
savedSettings: Settings;
|
||||
settingsLoading: boolean;
|
||||
settingsSaving: boolean;
|
||||
settingsSaved: boolean;
|
||||
testingEmail: boolean;
|
||||
testEmailResult: { success: boolean; message: string } | null;
|
||||
setTestEmailResult: React.Dispatch<React.SetStateAction<{ success: boolean; message: string } | null>>;
|
||||
testingShoutrrr: boolean;
|
||||
testShoutrrrResult: { success: boolean; message: string } | null;
|
||||
setTestShoutrrrResult: React.Dispatch<React.SetStateAction<{ success: boolean; message: string } | null>>;
|
||||
loadSettings: () => void;
|
||||
saveSettings: (e: React.FormEvent) => Promise<void>;
|
||||
testEmail: () => Promise<void>;
|
||||
testShoutrrr: () => Promise<void>;
|
||||
hasUnsavedChanges: boolean;
|
||||
}
|
||||
|
||||
export function useSettings(): UseSettingsReturn {
|
||||
const { i18n } = useTranslation();
|
||||
const [settings, setSettings] = useState<Settings>(defaultSettings);
|
||||
const [savedSettings, setSavedSettings] = useState<Settings>(defaultSettings);
|
||||
const [settingsLoading, setSettingsLoading] = useState(false);
|
||||
const [settingsSaving, setSettingsSaving] = useState(false);
|
||||
const [settingsSaved, setSettingsSaved] = useState(false);
|
||||
const [testingEmail, setTestingEmail] = useState(false);
|
||||
const [testEmailResult, setTestEmailResult] = useState<{ success: boolean; message: string } | null>(null);
|
||||
const [testingShoutrrr, setTestingShoutrrr] = useState(false);
|
||||
const [testShoutrrrResult, setTestShoutrrrResult] = useState<{ success: boolean; message: string } | null>(null);
|
||||
|
||||
// Load settings function - exposed for manual refresh (e.g., after auth)
|
||||
const loadSettings = useCallback(() => {
|
||||
setSettingsLoading(true);
|
||||
fetch("/api/settings", { credentials: "include" })
|
||||
.then((res) => (res.ok ? res.json() : Promise.reject()))
|
||||
.then((data) => {
|
||||
const newSettings = { ...defaultSettings, ...data, smtpPass: "" };
|
||||
setSettings(newSettings);
|
||||
setSavedSettings(newSettings);
|
||||
setSettingsSaved(false);
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setSettingsLoading(false));
|
||||
}, []);
|
||||
|
||||
// Load settings on mount
|
||||
useEffect(() => {
|
||||
loadSettings();
|
||||
}, [loadSettings]);
|
||||
|
||||
const saveSettings = useCallback(
|
||||
async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Auto-disable email if no recipient is set
|
||||
const effectiveEmailEnabled = settings.emailEnabled && !!settings.notificationEmail?.trim();
|
||||
// Auto-disable push if no URL is set
|
||||
const effectiveShoutrrrEnabled = settings.shoutrrrEnabled && !!settings.shoutrrrUrl?.trim();
|
||||
|
||||
// Validate email if email notifications are enabled
|
||||
if (effectiveEmailEnabled && settings.notificationEmail) {
|
||||
const emailRegex = /^[a-z0-9._%+\-]+@[a-z0-9.\-]+\.[a-z]{2,}$/i;
|
||||
if (!emailRegex.test(settings.notificationEmail)) {
|
||||
setTestEmailResult({ success: false, message: "Invalid email address" });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setSettingsSaving(true);
|
||||
setTestEmailResult(null);
|
||||
|
||||
const payload = {
|
||||
emailEnabled: effectiveEmailEnabled,
|
||||
notificationEmail: settings.notificationEmail,
|
||||
reminderDaysBefore: settings.reminderDaysBefore,
|
||||
repeatDailyReminders: settings.repeatDailyReminders,
|
||||
skipRemindersForTakenDoses: settings.skipRemindersForTakenDoses,
|
||||
repeatRemindersEnabled: settings.repeatRemindersEnabled,
|
||||
reminderRepeatIntervalMinutes: settings.reminderRepeatIntervalMinutes,
|
||||
maxNaggingReminders: settings.maxNaggingReminders ?? 5,
|
||||
lowStockDays: settings.lowStockDays,
|
||||
normalStockDays: settings.normalStockDays,
|
||||
highStockDays: settings.highStockDays,
|
||||
shoutrrrEnabled: effectiveShoutrrrEnabled,
|
||||
shoutrrrUrl: settings.shoutrrrUrl,
|
||||
emailStockReminders: settings.emailStockReminders,
|
||||
emailIntakeReminders: settings.emailIntakeReminders,
|
||||
shoutrrrStockReminders: settings.shoutrrrStockReminders,
|
||||
shoutrrrIntakeReminders: settings.shoutrrrIntakeReminders,
|
||||
stockCalculationMode: settings.stockCalculationMode,
|
||||
language: i18n.language,
|
||||
smtpHost: settings.smtpHost,
|
||||
smtpPort: settings.smtpPort,
|
||||
smtpUser: settings.smtpUser,
|
||||
smtpPass: settings.smtpPass || undefined,
|
||||
smtpFrom: settings.smtpFrom,
|
||||
smtpSecure: settings.smtpSecure
|
||||
};
|
||||
|
||||
await fetch("/api/settings", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload)
|
||||
}).catch(() => null);
|
||||
|
||||
const updatedSettings = {
|
||||
...settings,
|
||||
emailEnabled: effectiveEmailEnabled,
|
||||
shoutrrrEnabled: effectiveShoutrrrEnabled
|
||||
};
|
||||
setSettings(updatedSettings);
|
||||
setSettingsSaving(false);
|
||||
setSavedSettings(updatedSettings);
|
||||
setSettingsSaved(true);
|
||||
},
|
||||
[settings, i18n.language]
|
||||
);
|
||||
|
||||
const testEmail = useCallback(async () => {
|
||||
setTestingEmail(true);
|
||||
setTestEmailResult(null);
|
||||
try {
|
||||
const res = await fetch("/api/settings/test-email", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email: settings.notificationEmail })
|
||||
});
|
||||
const data = await res.json();
|
||||
setTestEmailResult({ success: res.ok, message: data.message || (res.ok ? "Email sent!" : "Failed to send email") });
|
||||
} catch {
|
||||
setTestEmailResult({ success: false, message: "Failed to send test email" });
|
||||
} finally {
|
||||
setTestingEmail(false);
|
||||
}
|
||||
}, [settings.notificationEmail]);
|
||||
|
||||
const testShoutrrr = useCallback(async () => {
|
||||
setTestingShoutrrr(true);
|
||||
setTestShoutrrrResult(null);
|
||||
try {
|
||||
const res = await fetch("/api/settings/test-shoutrrr", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ url: settings.shoutrrrUrl })
|
||||
});
|
||||
const data = await res.json();
|
||||
setTestShoutrrrResult({
|
||||
success: res.ok,
|
||||
message: data.message || (res.ok ? "Notification sent!" : "Failed to send notification")
|
||||
});
|
||||
} catch {
|
||||
setTestShoutrrrResult({ success: false, message: "Failed to send test notification" });
|
||||
} finally {
|
||||
setTestingShoutrrr(false);
|
||||
}
|
||||
}, [settings.shoutrrrUrl]);
|
||||
|
||||
// Check for unsaved changes
|
||||
const hasUnsavedChanges = JSON.stringify(settings) !== JSON.stringify(savedSettings);
|
||||
|
||||
return {
|
||||
settings,
|
||||
setSettings,
|
||||
savedSettings,
|
||||
settingsLoading,
|
||||
settingsSaving,
|
||||
settingsSaved,
|
||||
testingEmail,
|
||||
testEmailResult,
|
||||
setTestEmailResult,
|
||||
testingShoutrrr,
|
||||
testShoutrrrResult,
|
||||
setTestShoutrrrResult,
|
||||
loadSettings,
|
||||
saveSettings,
|
||||
testEmail,
|
||||
testShoutrrr,
|
||||
hasUnsavedChanges
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
// =============================================================================
|
||||
// useShare Hook - Share dialog state and operations
|
||||
// =============================================================================
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import type { Medication } from "../types";
|
||||
|
||||
export interface UseShareReturn {
|
||||
showShareDialog: boolean;
|
||||
sharePeople: string[];
|
||||
shareSelectedPerson: string;
|
||||
setShareSelectedPerson: React.Dispatch<React.SetStateAction<string>>;
|
||||
shareSelectedDays: number;
|
||||
setShareSelectedDays: React.Dispatch<React.SetStateAction<number>>;
|
||||
shareGenerating: boolean;
|
||||
shareLink: string | null;
|
||||
setShareLink: React.Dispatch<React.SetStateAction<string | null>>;
|
||||
shareCopied: boolean;
|
||||
setShareCopied: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
openShareDialog: (meds: Medication[]) => void;
|
||||
generateShareLink: () => Promise<void>;
|
||||
copyShareLink: () => void;
|
||||
closeShareDialog: () => void;
|
||||
resetShareDialogState: () => void;
|
||||
}
|
||||
|
||||
export function useShare(): UseShareReturn {
|
||||
const [showShareDialog, setShowShareDialog] = useState(false);
|
||||
const [sharePeople, setSharePeople] = useState<string[]>([]);
|
||||
const [shareSelectedPerson, setShareSelectedPerson] = useState<string>("");
|
||||
const [shareSelectedDays, setShareSelectedDays] = useState<number>(30);
|
||||
const [shareGenerating, setShareGenerating] = useState(false);
|
||||
const [shareLink, setShareLink] = useState<string | null>(null);
|
||||
const [shareCopied, setShareCopied] = useState(false);
|
||||
|
||||
const openShareDialog = useCallback((meds: Medication[]) => {
|
||||
setShowShareDialog(true);
|
||||
window.history.pushState({ modal: "share" }, "");
|
||||
setShareLink(null);
|
||||
setShareCopied(false);
|
||||
setShareSelectedPerson("");
|
||||
setShareSelectedDays(30);
|
||||
|
||||
// Get unique takenBy people from all medications (flatten arrays)
|
||||
const allPeople = meds.flatMap((m) => m.takenBy || []);
|
||||
const uniquePeople = [...new Set(allPeople)].filter(Boolean).sort();
|
||||
setSharePeople(uniquePeople);
|
||||
if (uniquePeople.length > 0) {
|
||||
setShareSelectedPerson(uniquePeople[0]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const generateShareLink = useCallback(async () => {
|
||||
if (!shareSelectedPerson) return;
|
||||
setShareGenerating(true);
|
||||
setShareCopied(false);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/share", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
takenBy: shareSelectedPerson,
|
||||
scheduleDays: shareSelectedDays
|
||||
})
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
const fullUrl = `${window.location.origin}/share/${data.token}`;
|
||||
setShareLink(fullUrl);
|
||||
} else {
|
||||
const err = await res.json();
|
||||
alert(err.error || "Failed to generate share link");
|
||||
}
|
||||
} catch {
|
||||
alert("Failed to generate share link");
|
||||
} finally {
|
||||
setShareGenerating(false);
|
||||
}
|
||||
}, [shareSelectedPerson, shareSelectedDays]);
|
||||
|
||||
const copyShareLink = useCallback(() => {
|
||||
if (shareLink) {
|
||||
navigator.clipboard.writeText(shareLink);
|
||||
setShareCopied(true);
|
||||
setTimeout(() => setShareCopied(false), 2000);
|
||||
}
|
||||
}, [shareLink]);
|
||||
|
||||
const closeShareDialog = useCallback(() => {
|
||||
if (showShareDialog) {
|
||||
window.history.back();
|
||||
}
|
||||
}, [showShareDialog]);
|
||||
|
||||
// Internal function to reset share dialog state (called by popstate handler)
|
||||
const resetShareDialogState = useCallback(() => {
|
||||
setShowShareDialog(false);
|
||||
setShareLink(null);
|
||||
setShareCopied(false);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
showShareDialog,
|
||||
sharePeople,
|
||||
shareSelectedPerson,
|
||||
setShareSelectedPerson,
|
||||
shareSelectedDays,
|
||||
setShareSelectedDays,
|
||||
shareGenerating,
|
||||
shareLink,
|
||||
setShareLink,
|
||||
shareCopied,
|
||||
setShareCopied,
|
||||
openShareDialog,
|
||||
generateShareLink,
|
||||
copyShareLink,
|
||||
closeShareDialog,
|
||||
resetShareDialogState
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// =============================================================================
|
||||
// useTheme Hook - Theme (dark/light mode) state management
|
||||
// =============================================================================
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
export type Theme = "light" | "dark";
|
||||
|
||||
export interface UseThemeReturn {
|
||||
theme: Theme;
|
||||
toggleTheme: () => void;
|
||||
}
|
||||
|
||||
export function useTheme(): UseThemeReturn {
|
||||
const [theme, setTheme] = useState<Theme>(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
return (localStorage.getItem("theme") as Theme) || "dark";
|
||||
}
|
||||
return "dark";
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute("data-theme", theme);
|
||||
localStorage.setItem("theme", theme);
|
||||
}, [theme]);
|
||||
|
||||
const toggleTheme = useCallback(() => {
|
||||
setTheme((prev) => (prev === "dark" ? "light" : "dark"));
|
||||
}, []);
|
||||
|
||||
return { theme, toggleTheme };
|
||||
}
|
||||
@@ -50,8 +50,10 @@
|
||||
"reminders": {
|
||||
"active": "Automatische Erinnerungen aktiv",
|
||||
"allStockOk": "Bestand OK",
|
||||
"allOk": "Alles OK",
|
||||
"allOk": "✓ Alles OK",
|
||||
"lastReminder": "Letzte Erinnerung",
|
||||
"lastSent": "Zuletzt gesendet",
|
||||
"next": "Nächste",
|
||||
"nextIn": "Nächste",
|
||||
"inDays": "in {{days}} Tagen",
|
||||
"noRemindersNeeded": "keine Erinnerungen nötig",
|
||||
@@ -243,8 +245,8 @@
|
||||
"highStock": "Hoch",
|
||||
"noSchedule": "Kein Zeitplan",
|
||||
"enough": "Ausreichend",
|
||||
"noPillsLeft": "⚠ Keine Tabletten mehr",
|
||||
"stockOk": "✓ Bestand OK"
|
||||
"noPillsLeft": "Keine Tabletten mehr",
|
||||
"stockOk": "Bestand OK"
|
||||
},
|
||||
"tooltips": {
|
||||
"intakeReminders": "Einnahme-Erinnerungen aktiviert",
|
||||
|
||||
@@ -52,8 +52,10 @@
|
||||
"reminders": {
|
||||
"active": "Automatic reminders active",
|
||||
"allStockOk": "All stock OK",
|
||||
"allOk": "All OK",
|
||||
"allOk": "✓ All OK",
|
||||
"lastReminder": "Last reminder",
|
||||
"lastSent": "Last sent",
|
||||
"next": "Next",
|
||||
"nextIn": "Next",
|
||||
"inDays": "in {{days}} days",
|
||||
"noRemindersNeeded": "no reminders needed",
|
||||
@@ -245,8 +247,8 @@
|
||||
"highStock": "High",
|
||||
"noSchedule": "No Schedule",
|
||||
"enough": "Enough",
|
||||
"noPillsLeft": "⚠ No pills left",
|
||||
"stockOk": "✓ Stock OK"
|
||||
"noPillsLeft": "No pills left",
|
||||
"stockOk": "Stock OK"
|
||||
},
|
||||
"tooltips": {
|
||||
"intakeReminders": "Intake reminders enabled",
|
||||
|
||||
@@ -0,0 +1,627 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAuth } from "../components/Auth";
|
||||
import { useAppContext } from "../context";
|
||||
import { MedicationAvatar, ConfirmModal } from "../components";
|
||||
import { formatNumber, getExpiryClass, getSystemLocale } from "../utils/formatters";
|
||||
import type { Coverage } from "../types";
|
||||
|
||||
// Helper for user-specific localStorage keys
|
||||
function userStorageKey(userId: number | undefined, key: string): string {
|
||||
return userId ? `user_${userId}_${key}` : key;
|
||||
}
|
||||
|
||||
// Helper function to get stock status
|
||||
function getStockStatus(daysLeft: number | null, medsLeft: number, settings: { lowStockDays: number; normalStockDays: number; highStockDays: number }) {
|
||||
if (medsLeft <= 0 || daysLeft === null || daysLeft <= 0) return { className: "danger", label: "status.outOfStock" };
|
||||
if (daysLeft <= settings.lowStockDays) return { className: "danger", label: "status.lowStock" };
|
||||
if (daysLeft >= settings.highStockDays) return { className: "success", label: "status.highStock" };
|
||||
return { className: "success", label: "status.normal" };
|
||||
}
|
||||
|
||||
// Helper function to calculate blister stock
|
||||
function getBlisterStock(totalPills: number, pillsPerBlister: number, _looseTablets: number, _originalTotal: number) {
|
||||
const fullBlisters = Math.floor(totalPills / pillsPerBlister);
|
||||
const openBlisterPills = totalPills % pillsPerBlister;
|
||||
return { fullBlisters, openBlisterPills, loosePills: openBlisterPills };
|
||||
}
|
||||
|
||||
// Helper to format full blisters
|
||||
function formatFullBlisters(count: number, t: (key: string) => string): string {
|
||||
return `${count} ${t('common.blisters')}`;
|
||||
}
|
||||
|
||||
// Helper to format open blister and loose pills
|
||||
function formatOpenBlisterAndLoose(openBlisterPills: number, loosePills: number, pillsPerBlister: number, t: (key: string) => string): string {
|
||||
if (openBlisterPills === 0 && loosePills === 0) return "-";
|
||||
return `${openBlisterPills} ${t('common.of')} ${pillsPerBlister} ${t('common.pills')}`;
|
||||
}
|
||||
|
||||
// Get total pills for a medication
|
||||
function getMedTotal(med: { packCount: number; blistersPerPack: number; pillsPerBlister: number; looseTablets: number; stockAdjustment?: number | null }): number {
|
||||
return med.packCount * med.blistersPerPack * med.pillsPerBlister + med.looseTablets + (med.stockAdjustment ?? 0);
|
||||
}
|
||||
|
||||
// Get next reminder date for a medication
|
||||
function getNextReminderForMed(row: Coverage, reminderDaysBefore: number, locale: string): string {
|
||||
if (!row.depletionDate) return "-";
|
||||
const depletionDate = new Date(row.depletionDate);
|
||||
const reminderDate = new Date(depletionDate);
|
||||
reminderDate.setDate(reminderDate.getDate() - reminderDaysBefore);
|
||||
|
||||
const now = new Date();
|
||||
if (reminderDate <= now) return "-";
|
||||
|
||||
return reminderDate.toLocaleDateString(locale, { day: "2-digit", month: "short" });
|
||||
}
|
||||
|
||||
// Get reminder status as JSX with proper styling
|
||||
function getReminderStatusContent(
|
||||
reminderDaysBefore: number,
|
||||
lowStockDays: number,
|
||||
lowCoverage: Coverage[],
|
||||
allCoverage: Coverage[],
|
||||
lastAutoEmailSent: string | null,
|
||||
lastNotificationType: string | null,
|
||||
lastNotificationChannel: string | null,
|
||||
t: (key: string, options?: Record<string, unknown>) => string,
|
||||
locale: string
|
||||
): React.ReactNode {
|
||||
const criticalCount = lowCoverage.length;
|
||||
const lowCount = allCoverage.filter(c => {
|
||||
if (c.medsLeft <= 0) return false;
|
||||
if (c.daysLeft === null) return false;
|
||||
return c.daysLeft < lowStockDays && c.daysLeft > 3;
|
||||
}).length;
|
||||
|
||||
let statusElement: React.ReactNode;
|
||||
if (criticalCount > 0) {
|
||||
statusElement = <span className="danger-text">{t('dashboard.reminders.criticalMeds', { count: criticalCount })}</span>;
|
||||
} else if (lowCount > 0) {
|
||||
statusElement = <span className="warning-text">{t('dashboard.reminders.lowMeds', { count: lowCount })}</span>;
|
||||
} else {
|
||||
statusElement = <span className="success-text">{t('dashboard.reminders.allOk')}</span>;
|
||||
}
|
||||
|
||||
// Find next medication to hit reminder threshold (lowest daysLeft > reminderDaysBefore)
|
||||
const nextToRunOut = allCoverage
|
||||
.filter(c => c.daysLeft !== null && c.daysLeft > reminderDaysBefore)
|
||||
.sort((a, b) => (a.daysLeft ?? Infinity) - (b.daysLeft ?? Infinity))[0];
|
||||
|
||||
let nextText = "";
|
||||
if (nextToRunOut && nextToRunOut.daysLeft !== null) {
|
||||
// Show days until it hits the reminder threshold, not until empty
|
||||
const daysUntilReminder = Math.round(nextToRunOut.daysLeft - reminderDaysBefore);
|
||||
nextText = `${t('dashboard.reminders.next')}: ${nextToRunOut.name} ${t('dashboard.reminders.inDays', { days: daysUntilReminder })}`;
|
||||
}
|
||||
|
||||
let lastSentText = "";
|
||||
if (lastAutoEmailSent) {
|
||||
const lastSent = new Date(lastAutoEmailSent);
|
||||
const formattedDate = lastSent.toLocaleDateString(locale, { day: "2-digit", month: "short", hour: "2-digit", minute: "2-digit" });
|
||||
const channelIcon = lastNotificationChannel === "shoutrrr" ? "🔔" : "📧";
|
||||
lastSentText = `${t('dashboard.reminders.lastSent')}: ${channelIcon} ${formattedDate}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{statusElement}
|
||||
{nextText && <span className="next-reminder"> {nextText}</span>}
|
||||
{lastSentText && <span className="last-sent"> · {lastSentText}</span>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function DashboardPage() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { user } = useAuth();
|
||||
const {
|
||||
meds,
|
||||
settings,
|
||||
coverage,
|
||||
coverageByMed,
|
||||
depletionByMed,
|
||||
scheduleDays,
|
||||
setScheduleDays,
|
||||
showPastDays,
|
||||
setShowPastDays,
|
||||
pastDays,
|
||||
futureDays,
|
||||
takenDoses,
|
||||
dismissedDoses,
|
||||
markDoseTaken,
|
||||
undoDoseTaken,
|
||||
manuallyCollapsedDays,
|
||||
manuallyExpandedDays,
|
||||
toggleDayCollapse,
|
||||
missedPastDoseIds,
|
||||
getDayStockStatus,
|
||||
getDoseId,
|
||||
showClearMissedConfirm,
|
||||
setShowClearMissedConfirm,
|
||||
clearingMissed,
|
||||
dismissMissedDoses,
|
||||
openMedDetail,
|
||||
openUserFilter,
|
||||
openShareDialog,
|
||||
openScheduleLightbox,
|
||||
} = useAppContext();
|
||||
|
||||
// Local state for reminder email
|
||||
const [sendingReminderEmail, setSendingReminderEmail] = useState(false);
|
||||
const [reminderEmailResult, setReminderEmailResult] = useState<{ success: boolean; message: string } | null>(null);
|
||||
|
||||
async function sendReminderEmail() {
|
||||
if (!settings.notificationEmail || coverage.low.length === 0) return;
|
||||
setSendingReminderEmail(true);
|
||||
setReminderEmailResult(null);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/reminder/send-email", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
email: settings.notificationEmail,
|
||||
lowStock: coverage.low,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
setReminderEmailResult({ success: true, message: data.message || "Email sent!" });
|
||||
} else {
|
||||
setReminderEmailResult({ success: false, message: data.error || "Failed to send" });
|
||||
}
|
||||
} catch {
|
||||
setReminderEmailResult({ success: false, message: "Network error" });
|
||||
}
|
||||
setSendingReminderEmail(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{(settings.emailEnabled || settings.shoutrrrEnabled) && (
|
||||
<section className="email-status-bar">
|
||||
<span className="email-status-icon">{settings.emailEnabled && settings.shoutrrrEnabled ? "🔔" : settings.emailEnabled ? "📧" : "🔔"}</span>
|
||||
<span className="email-status-text">
|
||||
<span className="email-status-line">{t('dashboard.reminders.active')}</span>
|
||||
{getReminderStatusContent(settings.reminderDaysBefore, settings.lowStockDays, coverage.low, coverage.all, settings.lastAutoEmailSent, settings.lastNotificationType, settings.lastNotificationChannel, t, getSystemLocale(i18n.language))}
|
||||
</span>
|
||||
{settings.emailEnabled && settings.notificationEmail && <span className="email-status-recipient">→ {settings.notificationEmail}</span>}
|
||||
</section>
|
||||
)}
|
||||
<section className="grid">
|
||||
<article className="card">
|
||||
<div className="card-head">
|
||||
<h2>{t('dashboard.reorder.title')}</h2>
|
||||
</div>
|
||||
{(() => {
|
||||
if (meds.length === 0) {
|
||||
return <p className="muted">{t('dashboard.reorder.noMeds')}</p>;
|
||||
}
|
||||
|
||||
// Count medications with "Low" stock status (based on lowStockDays setting)
|
||||
const lowStockCount = coverage.all.filter(c => {
|
||||
if (c.medsLeft <= 0) return true; // out of stock
|
||||
if (c.daysLeft === null) return false; // no schedule
|
||||
return c.daysLeft < settings.lowStockDays;
|
||||
}).length;
|
||||
|
||||
if (coverage.low.length === 0) {
|
||||
// No critical meds (≤3 days)
|
||||
if (lowStockCount === 0) {
|
||||
// All good - everything is Normal or High
|
||||
return <p className="success-text">{t('dashboard.reorder.allGood')}</p>;
|
||||
} else {
|
||||
// Some meds are Low but not critical
|
||||
return <p className="warning-text">{t('dashboard.reorder.lowWarning', { count: lowStockCount })}</p>;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="table table-7">
|
||||
<div className="table-head">
|
||||
<span>{t('table.name')}</span>
|
||||
<span>{t('table.fullBlisters')}</span>
|
||||
<span>{t('table.openBlister')}</span>
|
||||
<span>{t('table.daysLeft')}</span>
|
||||
<span>{t('table.status')}</span>
|
||||
<span>{t('table.runsOut')}</span>
|
||||
<span>{t('table.autoRemind')}</span>
|
||||
</div>
|
||||
{coverage.low.map((row) => {
|
||||
const status = getStockStatus(row.daysLeft, row.medsLeft, settings);
|
||||
const med = meds.find(m => m.name === row.name);
|
||||
const textClass = status.className === "danger" ? "danger-text" : status.className === "warning" ? "warning-text" : "success-text";
|
||||
const stock = getBlisterStock(
|
||||
Math.round(row.medsLeft),
|
||||
med?.pillsPerBlister ?? 1,
|
||||
med?.looseTablets ?? 0,
|
||||
med ? getMedTotal(med) : Math.round(row.medsLeft)
|
||||
);
|
||||
return (
|
||||
<div key={row.name} className="table-row clickable" onClick={() => med && openMedDetail(med)}>
|
||||
<span data-label={t('table.name')} className="cell-with-avatar">
|
||||
<MedicationAvatar name={row.name} imageUrl={med?.imageUrl} />
|
||||
<span className="med-name-text">{row.name}</span>
|
||||
{med?.takenBy && med.takenBy.length > 0 && med.takenBy.map((person) => (
|
||||
<span key={person} className="taken-by-badge clickable" onClick={(e) => { e.stopPropagation(); openUserFilter(person); }}>{person}</span>
|
||||
))}
|
||||
{(med?.intakeRemindersEnabled || med?.notes) && (
|
||||
<span className="med-icons">
|
||||
{med?.intakeRemindersEnabled && <span className="reminder-icon info-tooltip" data-tooltip={t('tooltips.intakeReminders')}>🔔</span>}
|
||||
{med?.notes && <span className="notes-icon info-tooltip" data-tooltip={t('tooltips.hasNotes')}>📝</span>}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span data-label={t('table.fullBlisters')} className={textClass}>{formatFullBlisters(stock.fullBlisters, t)}</span>
|
||||
<span data-label={t('table.openBlister')} className={textClass}>{formatOpenBlisterAndLoose(stock.openBlisterPills, stock.loosePills, med?.pillsPerBlister ?? 1, t)}</span>
|
||||
<span data-label={t('table.days')} className={textClass}>{formatNumber(row.daysLeft)}</span>
|
||||
<span data-label={t('table.status')} className={`status-chip ${status.className}`}>{t(status.label)}</span>
|
||||
<span data-label={t('table.runsOut')}>{row.depletionDate ?? "-"}</span>
|
||||
<span data-label={t('table.autoRemind')} className="next-reminder-date">{getNextReminderForMed(row, settings.reminderDaysBefore, getSystemLocale(i18n.language))}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{(settings.emailEnabled || settings.shoutrrrEnabled) && (
|
||||
<div className="email-send-action">
|
||||
<button type="button" className="ghost" onClick={sendReminderEmail} disabled={sendingReminderEmail}>
|
||||
{sendingReminderEmail ? t('common.sending') : t('dashboard.reorder.sendReminder')}
|
||||
</button>
|
||||
{reminderEmailResult && (
|
||||
<span className={reminderEmailResult.success ? "success-text" : "danger-text"}>
|
||||
{reminderEmailResult.message}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section className="grid">
|
||||
<article className="card">
|
||||
<div className="card-head">
|
||||
<h2>{t('dashboard.overview.title')}</h2>
|
||||
</div>
|
||||
<div className="table table-7">
|
||||
<div className="table-head">
|
||||
<span>{t('table.name')}</span>
|
||||
<span>{t('table.fullBlisters')}</span>
|
||||
<span>{t('table.openBlister')}</span>
|
||||
<span>{t('table.daysLeft')}</span>
|
||||
<span>{t('table.runsOut')}</span>
|
||||
<span>{t('table.expiry')}</span>
|
||||
<span>{t('table.status')}</span>
|
||||
</div>
|
||||
{coverage.all.map((row) => {
|
||||
const status = getStockStatus(row.daysLeft, row.medsLeft, settings);
|
||||
const med = meds.find(m => m.name === row.name);
|
||||
const expiryClass = getExpiryClass(med?.expiryDate, settings.expiryWarningDays);
|
||||
const textClass = status.className === "danger" ? "danger-text" : status.className === "warning" ? "warning-text" : "success-text";
|
||||
const stock = getBlisterStock(
|
||||
Math.round(row.medsLeft),
|
||||
med?.pillsPerBlister ?? 1,
|
||||
med?.looseTablets ?? 0,
|
||||
med ? getMedTotal(med) : Math.round(row.medsLeft)
|
||||
);
|
||||
return (
|
||||
<div key={row.name} className="table-row clickable" onClick={() => med && openMedDetail(med)}>
|
||||
<span data-label={t('table.name')} className="cell-with-avatar">
|
||||
<span className="med-name-line">
|
||||
<MedicationAvatar name={row.name} imageUrl={med?.imageUrl} />
|
||||
<span className="med-name-text">{row.name}</span>
|
||||
{med?.takenBy && med.takenBy.length > 0 && med.takenBy.map((person) => (
|
||||
<span key={person} className="taken-by-badge clickable" onClick={(e) => { e.stopPropagation(); openUserFilter(person); }}>{person}</span>
|
||||
))}
|
||||
</span>
|
||||
{(med?.intakeRemindersEnabled || med?.notes) && (
|
||||
<span className="med-icons">
|
||||
{med?.intakeRemindersEnabled && <span className="reminder-icon info-tooltip" data-tooltip={t('tooltips.intakeReminders')}>🔔</span>}
|
||||
{med?.notes && <span className="notes-icon info-tooltip" data-tooltip={t('tooltips.hasNotes')}>📝</span>}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span data-label={t('table.fullBlisters')} className={textClass}>{formatFullBlisters(stock.fullBlisters, t)}</span>
|
||||
<span data-label={t('table.openBlister')} className={textClass}>{formatOpenBlisterAndLoose(stock.openBlisterPills, stock.loosePills, med?.pillsPerBlister ?? 1, t)}</span>
|
||||
<span data-label={t('table.daysLeft')} className={textClass}>{formatNumber(row.daysLeft)}</span>
|
||||
<span data-label={t('table.runsOut')}>{row.depletionDate ?? "-"}</span>
|
||||
<span data-label={t('table.expiry')} className={expiryClass}>{med?.expiryDate ? new Date(med.expiryDate).toLocaleDateString(getSystemLocale(i18n.language), { day: "2-digit", month: "short", year: "2-digit" }) : "-"}</span>
|
||||
<span data-label={t('table.status')} className={`status-chip ${status.className}`}>{t(status.label)}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section className="grid">
|
||||
<article className="card">
|
||||
<div className="card-head">
|
||||
<h2>{t('dashboard.schedules.title')}</h2>
|
||||
<div className="card-head-actions">
|
||||
{meds.some(m => m.takenBy && m.takenBy.length > 0) && (
|
||||
<button className="ghost share-btn" onClick={openShareDialog} title={t('share.button')}>
|
||||
🔗 {t('share.button')}
|
||||
</button>
|
||||
)}
|
||||
<select
|
||||
className="schedule-days-select"
|
||||
value={scheduleDays}
|
||||
onChange={(e) => {
|
||||
const val = Number(e.target.value);
|
||||
setScheduleDays(val);
|
||||
if (user?.id) localStorage.setItem(userStorageKey(user.id, "scheduleDays"), String(val));
|
||||
}}
|
||||
>
|
||||
<option value={30}>{t('dashboard.schedules.1month')}</option>
|
||||
<option value={90}>{t('dashboard.schedules.3months')}</option>
|
||||
<option value={180}>{t('dashboard.schedules.6months')}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="timeline">
|
||||
{/* Past days toggle */}
|
||||
{pastDays.length > 0 && (() => {
|
||||
const missedCount = missedPastDoseIds.length;
|
||||
const totalPastDoses = pastDays.flatMap(d => d.meds.flatMap(m => m.doses.flatMap(dose => (dose.takenBy || []).length > 0 ? dose.takenBy.map(p => `${dose.id}-${p}`) : [dose.id])));
|
||||
return (
|
||||
<div className="past-days-header">
|
||||
<div
|
||||
className={`past-days-toggle ${showPastDays ? 'expanded' : ''} ${missedCount > 0 ? 'has-missed' : ''}`}
|
||||
onClick={() => setShowPastDays(!showPastDays)}
|
||||
>
|
||||
<span className="past-days-icon">{showPastDays ? '▼' : '▶'}</span>
|
||||
<span className="past-days-label">
|
||||
{showPastDays ? t('dashboard.schedules.hidePastDays') : t('dashboard.schedules.showPastDays')}
|
||||
</span>
|
||||
<span className="past-days-count">({t('dashboard.schedules.pastDaysCount', { count: pastDays.length })})</span>
|
||||
{missedCount > 0 ? (
|
||||
<span className="past-days-warning" title={t('dashboard.schedules.missedDoses', { count: missedCount })}>⚠️ {missedCount}</span>
|
||||
) : totalPastDoses.length > 0 ? (
|
||||
<span className="past-days-complete" title={t('dashboard.schedules.allTaken')}>✓</span>
|
||||
) : null}
|
||||
</div>
|
||||
{missedCount > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
className="clear-missed-btn"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowClearMissedConfirm(true);
|
||||
}}
|
||||
title={t('dashboard.schedules.clearMissed')}
|
||||
>
|
||||
{t('dashboard.schedules.clearMissed')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
{/* Past days (when expanded) */}
|
||||
{showPastDays && pastDays.map((day) => {
|
||||
const allDoseIds = day.meds.flatMap((item) => item.doses.flatMap((d) => (d.takenBy || []).length > 0 ? d.takenBy.map((p) => `${d.id}-${p}`) : [d.id]));
|
||||
const allDayTaken = allDoseIds.length > 0 && allDoseIds.every((id) => takenDoses.has(id) || dismissedDoses.has(id));
|
||||
const takenCount = allDoseIds.filter((id) => takenDoses.has(id) || dismissedDoses.has(id)).length;
|
||||
const isAutoCollapsed = true; // Past days are always auto-collapsed
|
||||
const isManuallyExpanded = manuallyExpandedDays.has(day.dateStr);
|
||||
const isCollapsed = !isManuallyExpanded;
|
||||
const worstStatus = getDayStockStatus(day.meds);
|
||||
|
||||
return (
|
||||
<div key={day.dateStr} className={`day-block past ${isCollapsed ? "collapsed" : ""} ${allDayTaken ? "all-taken" : ""} stock-${worstStatus}`}>
|
||||
<div
|
||||
className="day-divider clickable"
|
||||
onClick={() => toggleDayCollapse(day.dateStr, isAutoCollapsed)}
|
||||
title={isCollapsed ? t('common.expand') : t('common.collapse')}
|
||||
>
|
||||
<span className="day-collapse-icon">{isCollapsed ? "▶" : "▼"}</span>
|
||||
<span className="day-date">{day.dateStr}</span>
|
||||
<span className="day-summary">
|
||||
{allDayTaken ? (
|
||||
<span className="day-complete">✓ {t('dashboard.schedules.allTaken')}</span>
|
||||
) : (
|
||||
<><span className="day-warning" title={t('dashboard.schedules.missedDoses', { count: allDoseIds.length - takenCount })}>⚠️</span><span className="day-progress">{takenCount}/{allDoseIds.length}</span></>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{!isCollapsed && day.meds.map((item) => {
|
||||
const med = meds.find(m => m.name === item.medName);
|
||||
const medCov = coverageByMed[item.medName];
|
||||
const isEmpty = medCov ? medCov.medsLeft <= 0 : false;
|
||||
const itemDoseIds = item.doses.flatMap((d) => (d.takenBy || []).length > 0 ? d.takenBy.map((p) => `${d.id}-${p}`) : [d.id]);
|
||||
const allTaken = itemDoseIds.every((id) => takenDoses.has(id));
|
||||
return (
|
||||
<div key={`${day.dateStr}-${item.medName}`} className={`time-row ${allTaken ? "taken" : ""}`}>
|
||||
<div className="time-main">
|
||||
<div className="med-name">
|
||||
<div
|
||||
className={med?.imageUrl ? "med-avatar clickable" : ""}
|
||||
onClick={() => med?.imageUrl && openScheduleLightbox(`/api/images/${med.imageUrl}`)}
|
||||
>
|
||||
<MedicationAvatar name={item.medName} imageUrl={med?.imageUrl} size="sm" />
|
||||
</div>
|
||||
<span className="med-name-text">{item.medName}</span>{med?.intakeRemindersEnabled && <span className="reminder-icon info-tooltip" data-tooltip={t('tooltips.intakeReminders')}>🔔</span>}
|
||||
</div>
|
||||
<div className="tag-row">
|
||||
<span className="tag subtle">{item.total} {t('common.pills')} {t('common.total')}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="doses-col">
|
||||
{item.doses.map((dose) => {
|
||||
// If no takenBy, show single checkbox; otherwise show one per person
|
||||
const people = (dose.takenBy || []).length > 0 ? dose.takenBy : [null];
|
||||
return (
|
||||
<div key={dose.id} className="dose-item past">
|
||||
<span className="dose-time">{dose.timeStr}</span>
|
||||
<span className="dose-usage">{dose.usage} {dose.usage !== 1 ? t('common.pills') : t('common.pill')}{med?.pillWeightMg && ` (${dose.usage * med.pillWeightMg} mg)`}</span>
|
||||
<div className="dose-checks">
|
||||
{people.map((person) => {
|
||||
const doseId = getDoseId(dose.id, person);
|
||||
const isTaken = takenDoses.has(doseId);
|
||||
return (
|
||||
<div key={doseId} className={`dose-person ${isTaken ? "taken" : ""}`}>
|
||||
{person && <span className="person-name clickable" onClick={() => openUserFilter(person)}>{person}</span>}
|
||||
{isTaken ? (
|
||||
<button className="dose-btn undo" onClick={() => undoDoseTaken(doseId)} title={t('common.undo')}>↩</button>
|
||||
) : (
|
||||
<button className="dose-btn take" onClick={() => markDoseTaken(doseId)} title={t('dose.markAsTaken')} disabled={isEmpty}>✓</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{/* Current and future days */}
|
||||
{futureDays.map((day) => {
|
||||
// Check if all doses in this day are taken (auto-collapse)
|
||||
const allDoseIds = day.meds.flatMap((item) => item.doses.flatMap((d) => (d.takenBy || []).length > 0 ? d.takenBy.map((p) => `${d.id}-${p}`) : [d.id]));
|
||||
const allDayTaken = allDoseIds.length > 0 && allDoseIds.every((id) => takenDoses.has(id));
|
||||
const takenCount = allDoseIds.filter((id) => takenDoses.has(id)).length;
|
||||
|
||||
// Calculate worst stock status for this day
|
||||
const dayStockStatuses = day.meds.map((item) => {
|
||||
const medCoverage = coverageByMed[item.medName];
|
||||
const depletionTime = depletionByMed[item.medName];
|
||||
const willBeOutOfStock = typeof depletionTime === "number" && item.lastWhen > depletionTime;
|
||||
if (willBeOutOfStock) return "danger";
|
||||
if (!medCoverage) return "success";
|
||||
const status = getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, settings);
|
||||
return status.className;
|
||||
});
|
||||
const worstStatus = dayStockStatuses.includes("danger") ? "danger" : dayStockStatuses.includes("warning") ? "warning" : "success";
|
||||
|
||||
// Check if this is today, past, or future
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const dayDate = new Date(day.date);
|
||||
dayDate.setHours(0, 0, 0, 0);
|
||||
const isToday = dayDate.getTime() === today.getTime();
|
||||
|
||||
// Determine if day should be collapsed: only today is expanded by default
|
||||
const isAutoCollapsed = allDayTaken || !isToday;
|
||||
const isManuallyExpanded = manuallyExpandedDays.has(day.dateStr);
|
||||
const isManuallyCollapsed = manuallyCollapsedDays.has(day.dateStr);
|
||||
const isCollapsed = isAutoCollapsed ? !isManuallyExpanded : isManuallyCollapsed;
|
||||
|
||||
return (
|
||||
<div key={day.dateStr} className={`day-block ${isCollapsed ? "collapsed" : ""} ${allDayTaken ? "all-taken" : ""} ${isToday ? "today" : ""} ${worstStatus ? `stock-${worstStatus}` : ""}`}>
|
||||
<div
|
||||
className="day-divider clickable"
|
||||
onClick={() => toggleDayCollapse(day.dateStr, isAutoCollapsed)}
|
||||
title={isCollapsed ? t('common.expand') : t('common.collapse')}
|
||||
>
|
||||
<span className="day-collapse-icon">{isCollapsed ? "▶" : "▼"}</span>
|
||||
<span className="day-date">{day.dateStr}</span>
|
||||
<span className="day-summary">
|
||||
{allDayTaken ? (
|
||||
<span className="day-complete">✓ {t('dashboard.schedules.allTaken')}</span>
|
||||
) : (
|
||||
<span className="day-progress">{takenCount}/{allDoseIds.length}</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{!isCollapsed && day.meds.map((item) => {
|
||||
const medCoverage = coverageByMed[item.medName];
|
||||
const med = meds.find(m => m.name === item.medName);
|
||||
const depletionTime = depletionByMed[item.medName];
|
||||
const isEmpty = medCoverage ? medCoverage.medsLeft <= 0 : false;
|
||||
// Check if this dose is scheduled after medication runs out
|
||||
const willBeOutOfStock = typeof depletionTime === "number" && item.lastWhen > depletionTime;
|
||||
const status = willBeOutOfStock
|
||||
? { className: "danger", label: "status.outOfStock" }
|
||||
: medCoverage ? getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, settings) : null;
|
||||
const itemDoseIds = item.doses.flatMap((d) => (d.takenBy || []).length > 0 ? d.takenBy.map((p) => `${d.id}-${p}`) : [d.id]);
|
||||
const allTaken = itemDoseIds.every((id) => takenDoses.has(id));
|
||||
return (
|
||||
<div key={`${day.dateStr}-${item.medName}`} className={`time-row ${allTaken ? "taken" : ""}`}>
|
||||
<div className="time-main">
|
||||
<div className="med-name">
|
||||
<div
|
||||
className={med?.imageUrl ? "med-avatar clickable" : ""}
|
||||
onClick={() => med?.imageUrl && openScheduleLightbox(`/api/images/${med.imageUrl}`)}
|
||||
>
|
||||
<MedicationAvatar name={item.medName} imageUrl={med?.imageUrl} size="sm" />
|
||||
</div>
|
||||
<span className="med-name-text">{item.medName}</span>{med?.intakeRemindersEnabled && <span className="reminder-icon info-tooltip" data-tooltip={t('tooltips.intakeReminders')}>🔔</span>}
|
||||
</div>
|
||||
<div className="tag-row">
|
||||
<span className="tag subtle">{item.total} {t('common.pills')} {t('common.total')}</span>
|
||||
{status && <span className={`tag ${status.className}`}>
|
||||
{t(status.label)}
|
||||
</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="doses-col">
|
||||
{item.doses.map((dose) => {
|
||||
const isOverdue = dose.when < Date.now();
|
||||
// Only disable doses on future DAYS, not later today
|
||||
const doseDate = new Date(dose.when);
|
||||
doseDate.setHours(0, 0, 0, 0);
|
||||
const todayMidnight = new Date();
|
||||
todayMidnight.setHours(0, 0, 0, 0);
|
||||
const isFutureDose = doseDate.getTime() > todayMidnight.getTime();
|
||||
// If no takenBy, show single checkbox; otherwise show one per person
|
||||
const people = (dose.takenBy || []).length > 0 ? dose.takenBy : [null];
|
||||
const allTaken = people.every((person) => takenDoses.has(getDoseId(dose.id, person)));
|
||||
return (
|
||||
<div key={dose.id} className={`dose-item ${isOverdue ? "overdue" : ""} ${isFutureDose ? "future" : ""} ${allTaken ? "all-taken" : ""}`}>
|
||||
<span className="dose-time">{dose.timeStr}</span>
|
||||
<span className="dose-usage">{dose.usage} {dose.usage !== 1 ? t('common.pills') : t('common.pill')}{med?.pillWeightMg && ` (${dose.usage * med.pillWeightMg} mg)`}</span>
|
||||
<div className="dose-checks">
|
||||
{people.map((person) => {
|
||||
const doseId = getDoseId(dose.id, person);
|
||||
const isTaken = takenDoses.has(doseId);
|
||||
return (
|
||||
<div key={doseId} className={`dose-person ${isTaken ? "taken" : ""}`}>
|
||||
{person && <span className="person-name clickable" onClick={() => openUserFilter(person)}>{person}</span>}
|
||||
{isTaken ? (
|
||||
<button className="dose-btn undo" onClick={() => undoDoseTaken(doseId)} title={t('common.undo')}>↩</button>
|
||||
) : (
|
||||
<button className="dose-btn take" onClick={() => markDoseTaken(doseId)} title={t('dose.markAsTaken')} disabled={isFutureDose || isEmpty}>✓</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
{/* Clear Missed Doses Confirmation Modal */}
|
||||
{showClearMissedConfirm && (
|
||||
<ConfirmModal
|
||||
title={t('dashboard.schedules.clearMissedConfirmTitle')}
|
||||
message={t('dashboard.schedules.clearMissedConfirmMessage', { count: missedPastDoseIds.length })}
|
||||
confirmLabel={clearingMissed ? t('common.loading') : t('dashboard.schedules.clearMissedConfirm')}
|
||||
cancelLabel={t('dashboard.schedules.clearMissedCancel')}
|
||||
onConfirm={() => dismissMissedDoses(missedPastDoseIds)}
|
||||
onCancel={() => setShowClearMissedConfirm(false)}
|
||||
isLoading={clearingMissed}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,531 @@
|
||||
import { useState, useMemo, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAppContext } from "../context";
|
||||
import { MedicationAvatar, MobileEditModal } from "../components";
|
||||
import { useMedicationForm } from "../hooks";
|
||||
import { formatNumber, formatDateTime, combineDateAndTime } from "../utils/formatters";
|
||||
import { getPackageSize, FIELD_LIMITS } from "../types";
|
||||
import type { Medication } from "../types";
|
||||
|
||||
export function MedicationsPage() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const {
|
||||
meds,
|
||||
loading,
|
||||
saving,
|
||||
setSaving,
|
||||
loadMeds,
|
||||
deleteMed,
|
||||
uploadMedImage,
|
||||
deleteMedImage,
|
||||
uploadingImage,
|
||||
existingPeople,
|
||||
refillPacks,
|
||||
setRefillPacks,
|
||||
refillLoose,
|
||||
setRefillLoose,
|
||||
refillSaving,
|
||||
submitRefill,
|
||||
} = useAppContext();
|
||||
|
||||
// Use the medication form hook
|
||||
const {
|
||||
form,
|
||||
setForm,
|
||||
setOriginalForm,
|
||||
editingId,
|
||||
setEditingId,
|
||||
formSaved,
|
||||
setFormSaved,
|
||||
formChanged,
|
||||
fieldErrors,
|
||||
hasValidationErrors,
|
||||
takenByInput,
|
||||
setTakenByInput,
|
||||
addTakenByPerson,
|
||||
removeTakenByPerson,
|
||||
handleTakenByKeyDown,
|
||||
handleValueChange,
|
||||
addBlister,
|
||||
removeBlister,
|
||||
setBlisterValue,
|
||||
resetForm,
|
||||
startEdit,
|
||||
} = useMedicationForm();
|
||||
|
||||
// Image state for new medications
|
||||
const [pendingImage, setPendingImage] = useState<File | null>(null);
|
||||
const [pendingImagePreview, setPendingImagePreview] = useState<string | null>(null);
|
||||
|
||||
// Mobile modal state
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
|
||||
// Calculate total tablets
|
||||
const totalTablets = useMemo(() => {
|
||||
const packCount = Number(form.packCount) || 0;
|
||||
const blistersPerPack = Number(form.blistersPerPack) || 0;
|
||||
const pillsPerBlister = Number(form.pillsPerBlister) || 1;
|
||||
const looseTablets = Number(form.looseTablets) || 0;
|
||||
return packCount * blistersPerPack * pillsPerBlister + looseTablets;
|
||||
}, [form.packCount, form.blistersPerPack, form.pillsPerBlister, form.looseTablets]);
|
||||
|
||||
// Open mobile edit modal
|
||||
function openEditModal() {
|
||||
setShowEditModal(true);
|
||||
window.history.pushState({ modal: 'edit' }, '');
|
||||
}
|
||||
|
||||
// Close mobile edit modal
|
||||
function closeEditModal() {
|
||||
if (showEditModal) {
|
||||
window.history.back();
|
||||
}
|
||||
}
|
||||
|
||||
// Handle delete medication
|
||||
async function handleDeleteMed(id: number) {
|
||||
if (!confirm(t('medications.deleteConfirm'))) return;
|
||||
await deleteMed(id, editingId, resetForm);
|
||||
}
|
||||
|
||||
// Handle submit refill
|
||||
async function handleSubmitRefill(medId: number) {
|
||||
await submitRefill(medId, editingId, setForm, loadMeds);
|
||||
}
|
||||
|
||||
// Save medication
|
||||
async function saveMedication(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (saving) return;
|
||||
setSaving(true);
|
||||
|
||||
// Prepare medication data
|
||||
const blisters = form.blisters.map(b => ({
|
||||
usage: Number(b.usage) || 1,
|
||||
every: Number(b.every) || 1,
|
||||
start: combineDateAndTime(b.startDate, b.startTime),
|
||||
}));
|
||||
|
||||
const body = {
|
||||
name: form.name.trim(),
|
||||
genericName: form.genericName.trim() || null,
|
||||
takenBy: form.takenBy.length > 0 ? form.takenBy : [],
|
||||
packCount: Number(form.packCount) || 0,
|
||||
blistersPerPack: Number(form.blistersPerPack) || 1,
|
||||
pillsPerBlister: Number(form.pillsPerBlister) || 1,
|
||||
looseTablets: Number(form.looseTablets) || 0,
|
||||
pillWeightMg: Number(form.pillWeightMg) || null,
|
||||
expiryDate: form.expiryDate || null,
|
||||
notes: form.notes.trim() || null,
|
||||
intakeRemindersEnabled: form.intakeRemindersEnabled,
|
||||
blisters,
|
||||
};
|
||||
|
||||
try {
|
||||
let url = "/api/medications";
|
||||
let method = "POST";
|
||||
if (editingId) {
|
||||
url = `/api/medications/${editingId}`;
|
||||
method = "PUT";
|
||||
}
|
||||
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error("Failed to save");
|
||||
}
|
||||
|
||||
const saved = await res.json();
|
||||
|
||||
// Upload image if pending (for new medications)
|
||||
if (!editingId && pendingImage && saved.id) {
|
||||
await uploadMedImage(saved.id, pendingImage);
|
||||
setPendingImage(null);
|
||||
setPendingImagePreview(null);
|
||||
}
|
||||
|
||||
setFormSaved(true);
|
||||
loadMeds();
|
||||
|
||||
// Reset form after successful save
|
||||
if (!editingId) {
|
||||
resetForm();
|
||||
} else {
|
||||
// Update originalForm so formChanged becomes false
|
||||
setOriginalForm(form);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Save error:", err);
|
||||
alert(t('common.saveFailed'));
|
||||
}
|
||||
|
||||
setSaving(false);
|
||||
}
|
||||
|
||||
// Handle browser back button for modals
|
||||
useEffect(() => {
|
||||
const handlePopState = () => {
|
||||
if (showEditModal) {
|
||||
setShowEditModal(false);
|
||||
}
|
||||
};
|
||||
window.addEventListener('popstate', handlePopState);
|
||||
return () => window.removeEventListener('popstate', handlePopState);
|
||||
}, [showEditModal]);
|
||||
|
||||
// Close modal on Escape key
|
||||
useEffect(() => {
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape" && showEditModal) {
|
||||
closeEditModal();
|
||||
resetForm();
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", handleEscape);
|
||||
return () => document.removeEventListener("keydown", handleEscape);
|
||||
}, [showEditModal]);
|
||||
|
||||
// Handle edit button click - open modal on mobile
|
||||
function handleEditClick(med: Medication) {
|
||||
startEdit(med, openEditModal);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="grid">
|
||||
<article className="card meds">
|
||||
<div className="card-head">
|
||||
<h2>{t('medications.list.title')}</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="btn primary small"
|
||||
onClick={() => {
|
||||
resetForm();
|
||||
// On mobile, open the edit modal
|
||||
if (window.innerWidth <= 768) {
|
||||
openEditModal();
|
||||
}
|
||||
}}
|
||||
>
|
||||
+ {t('form.newEntry')}
|
||||
</button>
|
||||
</div>
|
||||
<div className="med-list">
|
||||
{meds.map((med) => (
|
||||
<div key={med.id} className="med-row">
|
||||
<div className="med-header">
|
||||
<div className="med-info">
|
||||
<div className="med-name-row">
|
||||
<MedicationAvatar name={med.name} imageUrl={med.imageUrl} size="lg" />
|
||||
<div className="med-name">{med.name}</div>
|
||||
</div>
|
||||
<div className="med-details">
|
||||
<span>{t('medications.details.packs')}: <strong>{med.packCount}</strong></span>
|
||||
<span>{t('medications.details.blisters')}: <strong>{med.blistersPerPack}</strong></span>
|
||||
<span>{t('medications.details.pillsPerBlister')}: <strong>{med.pillsPerBlister}</strong></span>
|
||||
<span>{t('medications.details.loose')}: <strong>{med.looseTablets}</strong></span>
|
||||
</div>
|
||||
<div className="med-total">{t('medications.details.total')}: {getPackageSize(med)} {t('common.pills')}</div>
|
||||
</div>
|
||||
<div className="med-actions">
|
||||
<button className="info" onClick={() => handleEditClick(med)}>{t('common.edit')}</button>
|
||||
<button className="danger" onClick={() => handleDeleteMed(med.id)}>{t('common.delete')}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="blister-list">
|
||||
{med.blisters.map((s, idx) => (
|
||||
<div key={`${med.id}-${idx}`} className="blister-row-simple">
|
||||
{s.usage} {s.usage === 1 ? t('common.pill') : t('common.pills')} · {t('form.blisters.every')} {s.every} {s.every === 1 ? t('common.day') : t('common.days')} · {t('form.blisters.from')} {formatDateTime(s.start)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article className="card form desktop-only">
|
||||
<div className="card-head">
|
||||
<h2>{editingId ? t('form.editEntry') : t('form.newEntry')}</h2>
|
||||
</div>
|
||||
<form className="form-grid" onSubmit={saveMedication}>
|
||||
<label className={fieldErrors.name ? 'has-error' : ''}>
|
||||
{t('form.commercialName')}
|
||||
<input
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
placeholder={t('form.placeholders.commercial')}
|
||||
maxLength={FIELD_LIMITS.name.max}
|
||||
required
|
||||
/>
|
||||
{fieldErrors.name && <span className="field-error">{fieldErrors.name}</span>}
|
||||
</label>
|
||||
<label className={fieldErrors.genericName ? 'has-error' : ''}>
|
||||
{t('form.genericName')}
|
||||
<input
|
||||
value={form.genericName}
|
||||
onChange={(e) => setForm({ ...form, genericName: e.target.value })}
|
||||
placeholder={t('form.placeholders.generic')}
|
||||
maxLength={FIELD_LIMITS.genericName.max}
|
||||
/>
|
||||
{fieldErrors.genericName && <span className="field-error">{fieldErrors.genericName}</span>}
|
||||
</label>
|
||||
<label className={fieldErrors.takenBy ? 'has-error' : ''}>
|
||||
{t('form.takenBy')}
|
||||
<div className="tag-input-container">
|
||||
{form.takenBy.map((person) => (
|
||||
<span key={person} className="tag">
|
||||
{person}
|
||||
<button type="button" className="tag-remove" onClick={() => removeTakenByPerson(person)}>×</button>
|
||||
</span>
|
||||
))}
|
||||
<input
|
||||
value={takenByInput}
|
||||
onChange={(e) => setTakenByInput(e.target.value)}
|
||||
onKeyDown={handleTakenByKeyDown}
|
||||
onBlur={() => { if (takenByInput.trim()) addTakenByPerson(takenByInput); }}
|
||||
placeholder={form.takenBy.length === 0 ? t('form.placeholders.takenBy') : t('form.placeholders.addPerson')}
|
||||
maxLength={FIELD_LIMITS.takenBy.max}
|
||||
list="takenby-suggestions"
|
||||
/>
|
||||
<datalist id="takenby-suggestions">
|
||||
{existingPeople.filter(p => !form.takenBy.includes(p)).map(person => (
|
||||
<option key={person} value={person} />
|
||||
))}
|
||||
</datalist>
|
||||
</div>
|
||||
{fieldErrors.takenBy && <span className="field-error">{fieldErrors.takenBy}</span>}
|
||||
</label>
|
||||
<label>
|
||||
{t('form.packs')}
|
||||
<input type="number" min="0" value={form.packCount} onChange={(e) => handleValueChange("packCount", e.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
{t('form.blistersPerPack')}
|
||||
<input type="number" min="1" value={form.blistersPerPack} onChange={(e) => handleValueChange("blistersPerPack", e.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
{t('form.pillsPerBlister')}
|
||||
<input type="number" min="1" value={form.pillsPerBlister} onChange={(e) => handleValueChange("pillsPerBlister", e.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
{t('form.loosePills')}
|
||||
<input type="number" min="0" value={form.looseTablets} onChange={(e) => handleValueChange("looseTablets", e.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
{t('form.pillWeight')}
|
||||
<input type="number" min="1" value={form.pillWeightMg} onChange={(e) => handleValueChange("pillWeightMg", e.target.value)} placeholder={t('form.placeholders.weight')} />
|
||||
</label>
|
||||
<label>
|
||||
{t('form.total')}
|
||||
<div className="static-value">{formatNumber(totalTablets)}</div>
|
||||
</label>
|
||||
<label>
|
||||
{t('form.expiryDate')}
|
||||
<input type="date" value={form.expiryDate} onChange={(e) => handleValueChange("expiryDate", e.target.value)} placeholder={t('common.optional')} />
|
||||
</label>
|
||||
|
||||
{/* Refill section - only shown when editing */}
|
||||
{editingId && (
|
||||
<div className="full refill-section">
|
||||
<h4 className="refill-title">{t('refill.title')}</h4>
|
||||
<div className="refill-form-inline">
|
||||
<label>
|
||||
{t('refill.packs')}
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
value={refillPacks}
|
||||
onChange={(e) => setRefillPacks(parseInt(e.target.value) || 0)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
{t('refill.loosePills')}
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
value={refillLoose}
|
||||
onChange={(e) => setRefillLoose(parseInt(e.target.value) || 0)}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="success"
|
||||
onClick={() => handleSubmitRefill(editingId!)}
|
||||
disabled={(refillPacks < 1 && refillLoose < 1) || refillSaving}
|
||||
>
|
||||
{refillSaving ? t('refill.adding') : t('refill.button')}
|
||||
</button>
|
||||
{(refillPacks > 0 || refillLoose > 0) && (
|
||||
<span className="refill-preview">+{refillPacks * Number(form.blistersPerPack || 0) * Number(form.pillsPerBlister || 1) + refillLoose} {t('common.pills')}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<label className={`full ${fieldErrors.notes ? 'has-error' : ''}`}>
|
||||
{t('form.notes')}
|
||||
<textarea
|
||||
value={form.notes}
|
||||
onChange={(e) => handleValueChange("notes", e.target.value)}
|
||||
placeholder={t('form.placeholders.notes')}
|
||||
rows={2}
|
||||
maxLength={FIELD_LIMITS.notes.max}
|
||||
className="auto-resize"
|
||||
onInput={(e) => { const t = e.target as HTMLTextAreaElement; t.style.height = 'auto'; t.style.height = t.scrollHeight + 'px'; }}
|
||||
/>
|
||||
{form.notes.length > 0 && (
|
||||
<span className={`char-count ${form.notes.length > FIELD_LIMITS.notes.max * 0.9 ? 'warning' : ''}`}>
|
||||
{t('common.validation.tooLong', { current: form.notes.length, max: FIELD_LIMITS.notes.max })}
|
||||
</span>
|
||||
)}
|
||||
{fieldErrors.notes && <span className="field-error">{fieldErrors.notes}</span>}
|
||||
</label>
|
||||
|
||||
<div className="full blisters">
|
||||
<div className="card-head">
|
||||
<h3>{t('form.blisters.title')}</h3>
|
||||
<div className="blisters-actions">
|
||||
<label className="inline-checkbox" title={t('form.blisters.remindTooltip')}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.intakeRemindersEnabled}
|
||||
onChange={(e) => setForm(prev => ({ ...prev, intakeRemindersEnabled: e.target.checked }))}
|
||||
/>
|
||||
<span>🔔 {t('form.blisters.remind')}</span>
|
||||
</label>
|
||||
<button type="button" className="primary" onClick={addBlister}>+ {t('form.blisters.addIntake')}</button>
|
||||
</div>
|
||||
</div>
|
||||
{form.blisters.map((s, idx) => (
|
||||
<div key={idx} className="blister-row">
|
||||
<div className="blister-inputs">
|
||||
<label>
|
||||
{t('form.blisters.usage')}
|
||||
<input type="number" min="0" step="0.1" value={s.usage} onChange={(e) => setBlisterValue(idx, "usage", e.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
{t('form.blisters.everyDays')}
|
||||
<input type="number" min="1" value={s.every} onChange={(e) => setBlisterValue(idx, "every", e.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
{t('form.blisters.startDate')}
|
||||
<input type="date" value={s.startDate} onChange={(e) => setBlisterValue(idx, "startDate", e.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
{t('form.blisters.startTime')}
|
||||
<input type="time" value={s.startTime} onChange={(e) => setBlisterValue(idx, "startTime", e.target.value)} />
|
||||
</label>
|
||||
</div>
|
||||
{form.blisters.length > 1 && (
|
||||
<button type="button" className="danger" onClick={() => removeBlister(idx)}>{t('common.remove')}</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="full image-upload-section">
|
||||
<label className="setting-label">{t('form.medicationImage')}</label>
|
||||
{(() => {
|
||||
// When editing an existing medication
|
||||
if (editingId) {
|
||||
const currentMed = meds.find(m => m.id === editingId);
|
||||
if (currentMed?.imageUrl) {
|
||||
return (
|
||||
<div className="image-preview">
|
||||
<img src={`/api/images/${currentMed.imageUrl}`} alt={currentMed.name} />
|
||||
<button type="button" className="danger" onClick={() => deleteMedImage(editingId)}>{t('form.removeImage')}</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<input
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp,image/gif"
|
||||
onChange={(e) => e.target.files?.[0] && uploadMedImage(editingId, e.target.files[0])}
|
||||
disabled={uploadingImage}
|
||||
/>
|
||||
);
|
||||
}
|
||||
// When creating a new medication
|
||||
if (pendingImagePreview) {
|
||||
return (
|
||||
<div className="image-preview">
|
||||
<img src={pendingImagePreview} alt="Preview" />
|
||||
<button type="button" className="danger" onClick={() => { setPendingImage(null); setPendingImagePreview(null); }}>{t('form.removeImage')}</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<input
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp,image/gif"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
setPendingImage(file);
|
||||
const reader = new FileReader();
|
||||
reader.onload = (ev) => setPendingImagePreview(ev.target?.result as string);
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
|
||||
<div className="full align-end gap">
|
||||
{editingId && (
|
||||
<button type="button" className="ghost" onClick={resetForm}>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
)}
|
||||
<button type="submit" disabled={saving || hasValidationErrors || (!formChanged && (formSaved || !!editingId))}>
|
||||
{formSaved && !formChanged ? t('common.saved') : t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</article>
|
||||
|
||||
{/* Mobile Edit Modal */}
|
||||
<MobileEditModal
|
||||
show={showEditModal}
|
||||
editingId={editingId}
|
||||
form={form}
|
||||
onFormChange={setForm}
|
||||
fieldErrors={fieldErrors}
|
||||
saving={saving}
|
||||
formSaved={formSaved}
|
||||
formChanged={formChanged}
|
||||
hasValidationErrors={hasValidationErrors}
|
||||
takenByInput={takenByInput}
|
||||
onTakenByInputChange={setTakenByInput}
|
||||
existingPeople={existingPeople}
|
||||
onAddTakenByPerson={addTakenByPerson}
|
||||
onRemoveTakenByPerson={removeTakenByPerson}
|
||||
onTakenByKeyDown={handleTakenByKeyDown}
|
||||
onSetBlisterValue={setBlisterValue}
|
||||
onAddBlister={addBlister}
|
||||
onRemoveBlister={removeBlister}
|
||||
onHandleValueChange={handleValueChange}
|
||||
refillPacks={refillPacks}
|
||||
onRefillPacksChange={setRefillPacks}
|
||||
refillLoose={refillLoose}
|
||||
onRefillLooseChange={setRefillLoose}
|
||||
refillSaving={refillSaving}
|
||||
onSubmitRefill={handleSubmitRefill}
|
||||
meds={meds}
|
||||
onUploadMedImage={uploadMedImage}
|
||||
onDeleteMedImage={deleteMedImage}
|
||||
onClose={() => { closeEditModal(); }}
|
||||
onResetForm={resetForm}
|
||||
onSaveMedication={saveMedication}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAuth } from "../components/Auth";
|
||||
import { useAppContext } from "../context";
|
||||
import { MedicationAvatar } from "../components";
|
||||
import type { PlannerRow } from "../types";
|
||||
import { toInputValue } from "../utils/formatters";
|
||||
|
||||
// Date helpers
|
||||
function todayIso(): string {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function plusDaysIso(days: number): string {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() + days);
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
// Convert datetime-local value to ISO string
|
||||
function toIsoString(value: string): string {
|
||||
if (!value) return new Date().toISOString();
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? new Date().toISOString() : date.toISOString();
|
||||
}
|
||||
|
||||
// Helper for user-specific localStorage keys
|
||||
function userStorageKey(userId: number | undefined, key: string): string {
|
||||
return userId ? `user_${userId}_${key}` : key;
|
||||
}
|
||||
|
||||
export function PlannerPage() {
|
||||
const { t } = useTranslation();
|
||||
const { user } = useAuth();
|
||||
const { meds, settings, openMedDetail } = useAppContext();
|
||||
|
||||
// Local state for planner
|
||||
const [plannerRows, setPlannerRows] = useState<PlannerRow[]>([]);
|
||||
const [plannerLoading, setPlannerLoading] = useState(false);
|
||||
const [range, setRange] = useState<{ start: string; end: string }>({
|
||||
start: toInputValue(todayIso()),
|
||||
end: toInputValue(plusDaysIso(3))
|
||||
});
|
||||
const [sendingPlannerEmail, setSendingPlannerEmail] = useState(false);
|
||||
const [plannerEmailResult, setPlannerEmailResult] = useState<{ success: boolean; message: string } | null>(null);
|
||||
|
||||
// Load user-specific planner data when user changes
|
||||
useEffect(() => {
|
||||
if (typeof window !== "undefined" && user?.id) {
|
||||
const savedRows = localStorage.getItem(userStorageKey(user.id, "plannerRows"));
|
||||
const savedRange = localStorage.getItem(userStorageKey(user.id, "plannerRange"));
|
||||
|
||||
if (savedRows) {
|
||||
try { setPlannerRows(JSON.parse(savedRows)); } catch { setPlannerRows([]); }
|
||||
} else {
|
||||
setPlannerRows([]);
|
||||
}
|
||||
|
||||
if (savedRange) {
|
||||
try { setRange(JSON.parse(savedRange)); } catch { /* keep default */ }
|
||||
} else {
|
||||
setRange({ start: toInputValue(todayIso()), end: toInputValue(plusDaysIso(3)) });
|
||||
}
|
||||
} else {
|
||||
setPlannerRows([]);
|
||||
setRange({ start: toInputValue(todayIso()), end: toInputValue(plusDaysIso(3)) });
|
||||
}
|
||||
}, [user?.id]);
|
||||
|
||||
async function runPlanner(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setPlannerLoading(true);
|
||||
const body = { startDate: toIsoString(range.start), endDate: toIsoString(range.end) };
|
||||
const rows = await fetch("/api/medications/usage", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) })
|
||||
.then((res) => res.json())
|
||||
.catch(() => []) as PlannerRow[];
|
||||
setPlannerRows(rows);
|
||||
setPlannerLoading(false);
|
||||
// Save to user-specific localStorage
|
||||
if (user?.id) {
|
||||
localStorage.setItem(userStorageKey(user.id, "plannerRange"), JSON.stringify(range));
|
||||
localStorage.setItem(userStorageKey(user.id, "plannerRows"), JSON.stringify(rows));
|
||||
}
|
||||
}
|
||||
|
||||
function resetRange() {
|
||||
setRange({ start: toInputValue(todayIso()), end: toInputValue(plusDaysIso(3)) });
|
||||
setPlannerRows([]);
|
||||
if (user?.id) {
|
||||
localStorage.removeItem(userStorageKey(user.id, "plannerRange"));
|
||||
localStorage.removeItem(userStorageKey(user.id, "plannerRows"));
|
||||
}
|
||||
}
|
||||
|
||||
async function sendPlannerEmail() {
|
||||
if (!settings.notificationEmail || plannerRows.length === 0) return;
|
||||
setSendingPlannerEmail(true);
|
||||
setPlannerEmailResult(null);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/planner/send-email", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
email: settings.notificationEmail,
|
||||
from: range.start,
|
||||
until: range.end,
|
||||
rows: plannerRows,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
setPlannerEmailResult({ success: true, message: data.message || "Email sent!" });
|
||||
} else {
|
||||
setPlannerEmailResult({ success: false, message: data.error || "Failed to send" });
|
||||
}
|
||||
} catch {
|
||||
setPlannerEmailResult({ success: false, message: "Network error" });
|
||||
}
|
||||
setSendingPlannerEmail(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="grid">
|
||||
<article className="card">
|
||||
<div className="card-head">
|
||||
<h2>{t('planner.title')}</h2>
|
||||
</div>
|
||||
<form className="planner" onSubmit={runPlanner}>
|
||||
<label>
|
||||
{t('planner.from')}
|
||||
<input type="datetime-local" step="60" value={range.start} onChange={(e) => setRange({ ...range, start: e.target.value })} />
|
||||
</label>
|
||||
<label>
|
||||
{t('planner.until')}
|
||||
<input type="datetime-local" step="60" value={range.end} onChange={(e) => setRange({ ...range, end: e.target.value })} />
|
||||
</label>
|
||||
<div className="planner-actions">
|
||||
<button type="button" className="ghost" onClick={resetRange}>{t('common.reset')}</button>
|
||||
<button type="submit" disabled={plannerLoading}>{plannerLoading ? t('planner.calculating') : t('planner.calculate')}</button>
|
||||
</div>
|
||||
</form>
|
||||
{plannerRows.length > 0 && (
|
||||
<>
|
||||
<div className="table">
|
||||
<div className="table-head">
|
||||
<span>{t('planner.table.medication')}</span>
|
||||
<span>{t('planner.table.usage')}</span>
|
||||
<span>{t('planner.table.blistersNeeded')}</span>
|
||||
<span>{t('planner.table.available')}</span>
|
||||
<span>{t('table.status')}</span>
|
||||
</div>
|
||||
{plannerRows.map((row) => {
|
||||
const med = meds.find(m => m.name === row.medicationName);
|
||||
return (
|
||||
<div key={row.medicationId} className="table-row clickable" onClick={() => med && openMedDetail(med)}>
|
||||
<span data-label={t('planner.table.medication')} className="cell-with-avatar"><MedicationAvatar name={row.medicationName} imageUrl={med?.imageUrl} />{row.medicationName}</span>
|
||||
<span data-label={t('planner.table.usage')}><strong>{row.plannerUsage}</strong> {t('common.pills')}</span>
|
||||
<span data-label={t('planner.table.blisters')}>{row.blistersNeeded} × {row.blisterSize}</span>
|
||||
<span data-label={t('planner.table.available')}>
|
||||
{row.fullBlisters} {t('common.blisters')}{row.loosePills > 0 && ` + ${row.loosePills} ${t('common.pills')}`}
|
||||
</span>
|
||||
<span data-label={t('table.status')} className={row.enough ? "status-chip success" : "status-chip danger"}>{row.enough ? t('status.enough') : t('status.outOfStock')}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{settings.emailEnabled && settings.notificationEmail && (
|
||||
<div className="planner-email-action">
|
||||
<button type="button" className="ghost" onClick={sendPlannerEmail} disabled={sendingPlannerEmail}>
|
||||
{sendingPlannerEmail ? t('common.sending') : t('planner.sendEmail')}
|
||||
</button>
|
||||
{plannerEmailResult && (
|
||||
<span className={plannerEmailResult.success ? "success-text" : "danger-text"}>
|
||||
{plannerEmailResult.message}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</article>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAuth } from "../components/Auth";
|
||||
import { useAppContext } from "../context";
|
||||
import { MedicationAvatar } from "../components";
|
||||
import type { Coverage } from "../types";
|
||||
|
||||
// Helper for user-specific localStorage keys
|
||||
function userStorageKey(userId: number | undefined, key: string): string {
|
||||
return userId ? `user_${userId}_${key}` : key;
|
||||
}
|
||||
|
||||
// Helper function to get stock status
|
||||
function getStockStatus(daysLeft: number | null, medsLeft: number, settings: { lowStockDays: number; normalStockDays: number; highStockDays: number }) {
|
||||
if (medsLeft <= 0 || daysLeft === null || daysLeft <= 0) return { className: "danger", label: "status.outOfStock" };
|
||||
if (daysLeft <= settings.lowStockDays) return { className: "danger", label: "status.lowStock" };
|
||||
if (daysLeft >= settings.highStockDays) return { className: "success", label: "status.highStock" };
|
||||
return { className: "success", label: "status.normal" };
|
||||
}
|
||||
|
||||
// Helper function to get worst stock status for a day
|
||||
function getDayStockStatus(dayMeds: Array<{ medName: string }>, coverageByMed: Record<string, Coverage>, settings: { lowStockDays: number; normalStockDays: number; highStockDays: number }): string {
|
||||
let worstLevel = 3; // 3=success, 2=warning, 1=danger
|
||||
for (const item of dayMeds) {
|
||||
const cov = coverageByMed[item.medName];
|
||||
if (!cov) continue;
|
||||
const status = getStockStatus(cov.daysLeft, cov.medsLeft, settings);
|
||||
if (status.className === "danger") worstLevel = Math.min(worstLevel, 1);
|
||||
else if (status.className === "warning") worstLevel = Math.min(worstLevel, 2);
|
||||
}
|
||||
return worstLevel === 1 ? "danger" : worstLevel === 2 ? "warning" : "success";
|
||||
}
|
||||
|
||||
// Helper to get dose ID (with or without person)
|
||||
function getDoseId(baseId: string, person: string | null): string {
|
||||
return person ? `${baseId}-${person}` : baseId;
|
||||
}
|
||||
|
||||
export function SchedulePage() {
|
||||
const { t } = useTranslation();
|
||||
const { user } = useAuth();
|
||||
const {
|
||||
meds,
|
||||
settings,
|
||||
scheduleDays,
|
||||
setScheduleDays,
|
||||
showPastDays,
|
||||
setShowPastDays,
|
||||
pastDays,
|
||||
futureDays,
|
||||
takenDoses,
|
||||
markDoseTaken,
|
||||
undoDoseTaken,
|
||||
coverageByMed,
|
||||
depletionByMed,
|
||||
manuallyExpandedDays,
|
||||
toggleDayCollapse,
|
||||
openUserFilter,
|
||||
} = useAppContext();
|
||||
|
||||
return (
|
||||
<section className="grid">
|
||||
<article className="card schedule-full">
|
||||
<div className="card-head">
|
||||
<h2>{t('dashboard.schedules.title')}</h2>
|
||||
<select
|
||||
className="schedule-days-select"
|
||||
value={scheduleDays}
|
||||
onChange={(e) => {
|
||||
const val = Number(e.target.value);
|
||||
setScheduleDays(val);
|
||||
if (user?.id) localStorage.setItem(userStorageKey(user.id, "scheduleDays"), String(val));
|
||||
}}
|
||||
>
|
||||
<option value={30}>{t('dashboard.schedules.1month')}</option>
|
||||
<option value={90}>{t('dashboard.schedules.3months')}</option>
|
||||
<option value={180}>{t('dashboard.schedules.6months')}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="timeline">
|
||||
{/* Past days toggle */}
|
||||
{pastDays.length > 0 && (() => {
|
||||
const totalPastDoses = pastDays.flatMap(d => d.meds.flatMap(m => m.doses.flatMap(dose => (dose.takenBy || []).length > 0 ? dose.takenBy.map(p => `${dose.id}-${p}`) : [dose.id])));
|
||||
const missedPastDoses = totalPastDoses.filter(id => !takenDoses.has(id)).length;
|
||||
return (
|
||||
<div
|
||||
className={`past-days-toggle ${showPastDays ? 'expanded' : ''} ${missedPastDoses > 0 ? 'has-missed' : ''}`}
|
||||
onClick={() => setShowPastDays(!showPastDays)}
|
||||
>
|
||||
<span className="past-days-icon">{showPastDays ? '▼' : '▶'}</span>
|
||||
<span className="past-days-label">
|
||||
{showPastDays ? t('dashboard.schedules.hidePastDays') : t('dashboard.schedules.showPastDays')}
|
||||
</span>
|
||||
<span className="past-days-count">({t('dashboard.schedules.pastDaysCount', { count: pastDays.length })})</span>
|
||||
{missedPastDoses > 0 && <span className="past-days-warning" title={t('dashboard.schedules.missedDoses', { count: missedPastDoses })}>⚠️ {missedPastDoses}</span>}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
{/* Past days (when expanded) */}
|
||||
{showPastDays && pastDays.map((day) => {
|
||||
const allDoseIds = day.meds.flatMap((item) => item.doses.flatMap((d) => (d.takenBy || []).length > 0 ? d.takenBy.map((p) => `${d.id}-${p}`) : [d.id]));
|
||||
const allDayTaken = allDoseIds.length > 0 && allDoseIds.every((id) => takenDoses.has(id));
|
||||
const takenCount = allDoseIds.filter((id) => takenDoses.has(id)).length;
|
||||
const isManuallyExpanded = manuallyExpandedDays.has(day.dateStr);
|
||||
const isCollapsed = !isManuallyExpanded;
|
||||
const worstStatus = getDayStockStatus(day.meds, coverageByMed, settings);
|
||||
|
||||
return (
|
||||
<div key={day.dateStr} className={`day-block past ${isCollapsed ? "collapsed" : ""} ${allDayTaken ? "all-taken" : ""} stock-${worstStatus}`}>
|
||||
<div
|
||||
className="day-divider clickable"
|
||||
onClick={() => toggleDayCollapse(day.dateStr, true)}
|
||||
title={isCollapsed ? t('common.expand') : t('common.collapse')}
|
||||
>
|
||||
<span className="day-collapse-icon">{isCollapsed ? "▶" : "▼"}</span>
|
||||
<span className="day-date">{day.dateStr}</span>
|
||||
<span className="day-summary">
|
||||
{allDayTaken ? (
|
||||
<span className="day-complete">✓ {t('dashboard.schedules.allTaken')}</span>
|
||||
) : (
|
||||
<><span className="day-warning" title={t('dashboard.schedules.missedDoses', { count: allDoseIds.length - takenCount })}>⚠️</span><span className="day-progress">{takenCount}/{allDoseIds.length}</span></>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{!isCollapsed && day.meds.map((item) => {
|
||||
const med = meds.find(m => m.name === item.medName);
|
||||
const medCov = coverageByMed[item.medName];
|
||||
const isEmpty = medCov ? medCov.medsLeft <= 0 : false;
|
||||
const itemDoseIds = item.doses.flatMap((d) => (d.takenBy || []).length > 0 ? d.takenBy.map((p) => `${d.id}-${p}`) : [d.id]);
|
||||
const allTaken = itemDoseIds.every((id) => takenDoses.has(id));
|
||||
return (
|
||||
<div key={`${day.dateStr}-${item.medName}`} className={`time-row ${allTaken ? "taken" : ""}`}>
|
||||
<div className="time-main">
|
||||
<div className="med-name"><MedicationAvatar name={item.medName} imageUrl={med?.imageUrl} size="sm" /><span className="med-name-text">{item.medName}</span>{med?.intakeRemindersEnabled && <span className="reminder-icon info-tooltip" data-tooltip={t('tooltips.intakeReminders')}>🔔</span>}</div>
|
||||
<div className="tag-row">
|
||||
<span className="tag subtle">{item.total} {t('common.pills')} {t('common.total')}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="doses-col">
|
||||
{item.doses.map((dose) => {
|
||||
// If no takenBy, show single checkbox; otherwise show one per person
|
||||
const people = (dose.takenBy || []).length > 0 ? dose.takenBy : [null];
|
||||
return (
|
||||
<div key={dose.id} className="dose-item past">
|
||||
<span className="dose-time">{dose.timeStr}</span>
|
||||
<span className="dose-usage">{dose.usage} {dose.usage !== 1 ? t('common.pills') : t('common.pill')}{med?.pillWeightMg && ` (${dose.usage * med.pillWeightMg} mg)`}</span>
|
||||
<div className="dose-checks">
|
||||
{people.map((person) => {
|
||||
const doseId = getDoseId(dose.id, person);
|
||||
const isTaken = takenDoses.has(doseId);
|
||||
return (
|
||||
<div key={doseId} className={`dose-person ${isTaken ? "taken" : ""}`}>
|
||||
{person && <span className="person-name clickable" onClick={() => openUserFilter(person)}>{person}</span>}
|
||||
{isTaken ? (
|
||||
<button className="dose-btn undo" onClick={() => undoDoseTaken(doseId)} title={t('common.undo')}>↩</button>
|
||||
) : (
|
||||
<button className="dose-btn take" onClick={() => markDoseTaken(doseId)} disabled={isEmpty} title={t('dose.markAsTaken')}>✓</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{/* Current and future days */}
|
||||
{futureDays.map((day) => {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const dayDate = new Date(day.date);
|
||||
dayDate.setHours(0, 0, 0, 0);
|
||||
const isToday = dayDate.getTime() === today.getTime();
|
||||
return (
|
||||
<div key={day.dateStr} className={`day-block ${isToday ? "today" : ""}`}>
|
||||
<div className="day-divider">{day.dateStr}</div>
|
||||
{day.meds.map((item) => {
|
||||
const medCoverage = coverageByMed[item.medName];
|
||||
const isEmpty = medCoverage ? medCoverage.medsLeft <= 0 : false;
|
||||
const med = meds.find(m => m.name === item.medName);
|
||||
const depletionTime = depletionByMed[item.medName];
|
||||
// Check if this dose is scheduled after medication runs out
|
||||
const willBeOutOfStock = typeof depletionTime === "number" && item.lastWhen > depletionTime;
|
||||
const status = willBeOutOfStock
|
||||
? { className: "danger", label: "status.outOfStock" }
|
||||
: medCoverage ? getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, settings) : null;
|
||||
const itemDoseIds = item.doses.flatMap((d) => (d.takenBy || []).length > 0 ? d.takenBy.map((p) => `${d.id}-${p}`) : [d.id]);
|
||||
const allTaken = itemDoseIds.every((id) => takenDoses.has(id));
|
||||
return (
|
||||
<div key={`${day.dateStr}-${item.medName}`} className={`time-row ${allTaken ? "taken" : ""}`}>
|
||||
<div className="time-main">
|
||||
<div className="med-name"><MedicationAvatar name={item.medName} imageUrl={med?.imageUrl} size="sm" /><span className="med-name-text">{item.medName}</span>{med?.intakeRemindersEnabled && <span className="reminder-icon info-tooltip" data-tooltip={t('tooltips.intakeReminders')}>🔔</span>}</div>
|
||||
<div className="tag-row">
|
||||
<span className="tag subtle">{item.total} {t('common.pills')} {t('common.total')}</span>
|
||||
{status && <span className={`tag ${status.className}`}>
|
||||
{t(status.label)}
|
||||
</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="doses-col">
|
||||
{item.doses.map((dose) => {
|
||||
const people = (dose.takenBy || []).length > 0 ? dose.takenBy : [null];
|
||||
const now = Date.now();
|
||||
const dayStart = new Date(day.date).setHours(0, 0, 0, 0);
|
||||
const isPastDay = dayStart < new Date().setHours(0, 0, 0, 0);
|
||||
return (
|
||||
<div key={dose.id} className="dose-item">
|
||||
<span className="dose-time">{dose.timeStr}</span>
|
||||
<span className="dose-usage">{dose.usage} {dose.usage !== 1 ? t('common.pills') : t('common.pill')}{med?.pillWeightMg && ` (${dose.usage * med.pillWeightMg} mg)`}</span>
|
||||
<div className="dose-checks">
|
||||
{people.map((person) => {
|
||||
const doseId = getDoseId(dose.id, person);
|
||||
const isTaken = takenDoses.has(doseId);
|
||||
const isOverdue = !isTaken && dose.when < now && !isPastDay;
|
||||
return (
|
||||
<div key={doseId} className={`dose-person ${isTaken ? "taken" : ""} ${isOverdue ? "overdue" : ""}`}>
|
||||
{person && <span className="person-name clickable" onClick={() => openUserFilter(person)}>{person}</span>}
|
||||
{isTaken ? (
|
||||
<button className="dose-btn undo" onClick={() => undoDoseTaken(doseId)} title={t('common.undo')}>↩</button>
|
||||
) : (
|
||||
<button className="dose-btn take" onClick={() => markDoseTaken(doseId)} disabled={isEmpty} title={t('dose.markAsTaken')}>✓</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);})}
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,564 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAppContext } from "../context";
|
||||
import { ConfirmModal, ExportModal } from "../components";
|
||||
import { getSystemLocale } from "../utils/formatters";
|
||||
|
||||
export function SettingsPage() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const {
|
||||
settings,
|
||||
setSettings,
|
||||
settingsLoading,
|
||||
settingsSaving,
|
||||
settingsSaved,
|
||||
saveSettings,
|
||||
settingsChanged,
|
||||
// Email testing
|
||||
testEmail,
|
||||
testingEmail,
|
||||
testEmailResult,
|
||||
// Shoutrrr testing
|
||||
testShoutrrr,
|
||||
testingShoutrrr,
|
||||
testShoutrrrResult,
|
||||
// Export/Import
|
||||
exporting,
|
||||
importing,
|
||||
showExportModal,
|
||||
setShowExportModal,
|
||||
handleExport,
|
||||
handleImportFileSelect,
|
||||
showImportConfirm,
|
||||
setShowImportConfirm,
|
||||
pendingImportData,
|
||||
setPendingImportData,
|
||||
handleImportConfirm,
|
||||
importResult,
|
||||
setImportResult,
|
||||
} = useAppContext();
|
||||
|
||||
return (
|
||||
<section className="grid">
|
||||
{settingsLoading ? (
|
||||
<p>{t('settings.loading')}</p>
|
||||
) : (
|
||||
<form className="settings-form" onSubmit={saveSettings}>
|
||||
{/* Language */}
|
||||
<article className="card">
|
||||
<div className="card-head">
|
||||
<h2>{t('settings.language.title')}</h2>
|
||||
</div>
|
||||
<div className="setting-section">
|
||||
<label className="setting-row language-row">
|
||||
<span className="setting-label">{t('settings.language.select')}</span>
|
||||
<select
|
||||
value={i18n.language}
|
||||
onChange={(e) => i18n.changeLanguage(e.target.value)}
|
||||
className="language-select"
|
||||
>
|
||||
<option value="en">🇬🇧 English</option>
|
||||
<option value="de">🇩🇪 Deutsch</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
{/* Notifications */}
|
||||
<article className="card">
|
||||
<div className="card-head">
|
||||
<h2>{t('settings.notifications.title')}</h2>
|
||||
</div>
|
||||
|
||||
<div className="setting-section">
|
||||
<div className="section-header">
|
||||
<h3>{t('settings.notifications.channels')}</h3>
|
||||
</div>
|
||||
<div className="notification-matrix">
|
||||
<div className="matrix-header">
|
||||
<div className="matrix-label"></div>
|
||||
<div className="matrix-channel">{t('settings.notifications.email')}</div>
|
||||
<div className="matrix-channel">{t('settings.notifications.push')}</div>
|
||||
</div>
|
||||
<div className="matrix-row">
|
||||
<div className="matrix-label">{t('settings.notifications.stockReminders')}</div>
|
||||
<div className="matrix-cell">
|
||||
<label className={`toggle-switch small${!settings.emailEnabled ? ' disabled' : ''}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.smtpHost && settings.emailEnabled ? settings.emailStockReminders : false}
|
||||
onChange={(e) => setSettings({ ...settings, emailStockReminders: e.target.checked })}
|
||||
disabled={!settings.emailEnabled}
|
||||
/>
|
||||
<span className="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="matrix-cell">
|
||||
<label className={`toggle-switch small${!settings.shoutrrrEnabled ? ' disabled' : ''}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.shoutrrrUrl && settings.shoutrrrEnabled ? settings.shoutrrrStockReminders : false}
|
||||
onChange={(e) => setSettings({ ...settings, shoutrrrStockReminders: e.target.checked })}
|
||||
disabled={!settings.shoutrrrEnabled}
|
||||
/>
|
||||
<span className="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="matrix-row">
|
||||
<div className="matrix-label">{t('settings.notifications.intakeReminders')}</div>
|
||||
<div className="matrix-cell">
|
||||
<label className={`toggle-switch small${!settings.emailEnabled ? ' disabled' : ''}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.smtpHost && settings.emailEnabled ? settings.emailIntakeReminders : false}
|
||||
onChange={(e) => setSettings({ ...settings, emailIntakeReminders: e.target.checked })}
|
||||
disabled={!settings.emailEnabled}
|
||||
/>
|
||||
<span className="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="matrix-cell">
|
||||
<label className={`toggle-switch small${!settings.shoutrrrEnabled ? ' disabled' : ''}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.shoutrrrUrl && settings.shoutrrrEnabled ? settings.shoutrrrIntakeReminders : false}
|
||||
onChange={(e) => setSettings({ ...settings, shoutrrrIntakeReminders: e.target.checked })}
|
||||
disabled={!settings.shoutrrrEnabled}
|
||||
/>
|
||||
<span className="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{!settings.emailEnabled && !settings.shoutrrrEnabled && (
|
||||
<p className="hint-text">{t('settings.notifications.enableHint')}</p>
|
||||
)}
|
||||
|
||||
{/* Skip reminders for taken doses */}
|
||||
<div className="setting-row compact" style={{marginTop: "16px"}}>
|
||||
<label className="setting-label">
|
||||
{t('settings.notifications.skipTakenDoses')}
|
||||
<span className="info-tooltip small" data-tooltip={t('settings.notifications.skipTakenDosesTooltip')}>ⓘ</span>
|
||||
</label>
|
||||
<label className={`toggle-switch small${!settings.emailEnabled && !settings.shoutrrrEnabled ? ' disabled' : ''}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.skipRemindersForTakenDoses}
|
||||
onChange={(e) => setSettings({ ...settings, skipRemindersForTakenDoses: e.target.checked })}
|
||||
disabled={!settings.emailEnabled && !settings.shoutrrrEnabled}
|
||||
/>
|
||||
<span className="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Repeat reminders for missed doses */}
|
||||
<div className="setting-row compact" style={{marginTop: "12px"}}>
|
||||
<label className="setting-label">
|
||||
{t('settings.notifications.repeatReminders')}
|
||||
<span className="info-tooltip small" data-tooltip={t('settings.notifications.repeatRemindersTooltip')}>ⓘ</span>
|
||||
</label>
|
||||
<label className={`toggle-switch small${!settings.emailEnabled && !settings.shoutrrrEnabled ? ' disabled' : ''}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.repeatRemindersEnabled}
|
||||
onChange={(e) => setSettings({ ...settings, repeatRemindersEnabled: e.target.checked })}
|
||||
disabled={!settings.emailEnabled && !settings.shoutrrrEnabled}
|
||||
/>
|
||||
<span className="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Reminder interval (only shown when repeat is enabled) */}
|
||||
{settings.repeatRemindersEnabled && (
|
||||
<>
|
||||
<div className="setting-row compact" style={{marginTop: "12px", marginLeft: "24px"}}>
|
||||
<label className="setting-label">
|
||||
{t('settings.notifications.reminderInterval')}
|
||||
<span className="info-tooltip small" data-tooltip={t('settings.notifications.reminderIntervalTooltip')}>ⓘ</span>
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="5"
|
||||
max="480"
|
||||
step="5"
|
||||
value={settings.reminderRepeatIntervalMinutes}
|
||||
onChange={(e) => setSettings({ ...settings, reminderRepeatIntervalMinutes: parseInt(e.target.value) || 30 })}
|
||||
style={{width: "80px", textAlign: "center"}}
|
||||
/>
|
||||
</div>
|
||||
<div className="setting-row compact" style={{marginTop: "8px", marginLeft: "24px"}}>
|
||||
<label className="setting-label">
|
||||
{t('settings.notifications.maxNaggingReminders')}
|
||||
<span className="info-tooltip small" data-tooltip={t('settings.notifications.maxNaggingRemindersTooltip')}>ⓘ</span>
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="20"
|
||||
step="1"
|
||||
value={settings.maxNaggingReminders ?? 5}
|
||||
onChange={(e) => setSettings({ ...settings, maxNaggingReminders: parseInt(e.target.value) || 5 })}
|
||||
style={{width: "80px", textAlign: "center"}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="setting-section">
|
||||
<div className="section-header">
|
||||
<h3>{t('settings.notifications.email')}</h3>
|
||||
<label className={`toggle-switch small${!settings.smtpHost ? ' disabled' : ''}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.smtpHost ? settings.emailEnabled : false}
|
||||
onChange={(e) => {
|
||||
const newVal = e.target.checked;
|
||||
if (!newVal && !settings.shoutrrrEnabled) {
|
||||
setSettings({ ...settings, emailEnabled: false, emailStockReminders: false, emailIntakeReminders: false, skipRemindersForTakenDoses: false, repeatRemindersEnabled: false });
|
||||
} else {
|
||||
setSettings({ ...settings, emailEnabled: newVal });
|
||||
}
|
||||
}}
|
||||
disabled={!settings.smtpHost}
|
||||
/>
|
||||
<span className="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
{settings.emailEnabled && (
|
||||
<>
|
||||
<div className="setting-group">
|
||||
<label className="full">
|
||||
<span className="field-label">{t('settings.email.recipient')}</span>
|
||||
<div className="input-with-tooltip">
|
||||
<input
|
||||
type="email"
|
||||
value={settings.notificationEmail}
|
||||
onChange={(e) => setSettings({ ...settings, notificationEmail: e.target.value })}
|
||||
placeholder="your@email.com"
|
||||
pattern="[a-z0-9._%+\-]+@[a-z0-9.\-]+\.[a-z]{2,}$"
|
||||
autoComplete="email"
|
||||
/>
|
||||
<span className="info-tooltip" data-tooltip={`SMTP: ${settings.smtpHost || t('settings.email.notConfigured')}:${settings.smtpPort}${settings.hasSmtpPassword ? '\nPassword: ✓' : ''}`}>ⓘ</span>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<div className="setting-actions">
|
||||
<button type="button" className="ghost" onClick={testEmail} disabled={testingEmail || !settings.notificationEmail}>
|
||||
{testingEmail ? t('common.sending') : t('common.test')}
|
||||
</button>
|
||||
{testEmailResult && (
|
||||
<span className={testEmailResult.success ? "success-text" : "danger-text"}>
|
||||
{testEmailResult.message}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="setting-section">
|
||||
<div className="section-header">
|
||||
<h3>{t('settings.notifications.push')}</h3>
|
||||
<label className="toggle-switch small">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.shoutrrrEnabled}
|
||||
onChange={(e) => {
|
||||
const newVal = e.target.checked;
|
||||
if (!newVal && !settings.emailEnabled) {
|
||||
setSettings({ ...settings, shoutrrrEnabled: false, shoutrrrStockReminders: false, shoutrrrIntakeReminders: false, skipRemindersForTakenDoses: false, repeatRemindersEnabled: false });
|
||||
} else {
|
||||
setSettings({ ...settings, shoutrrrEnabled: newVal });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span className="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
{settings.shoutrrrEnabled && (
|
||||
<>
|
||||
<div className="setting-group">
|
||||
<label className="full">
|
||||
<span className="field-label">{t('settings.push.url')}</span>
|
||||
<div className="input-with-tooltip">
|
||||
<input
|
||||
type="text"
|
||||
value={settings.shoutrrrUrl}
|
||||
onChange={(e) => setSettings({ ...settings, shoutrrrUrl: e.target.value })}
|
||||
placeholder={t('settings.push.urlPlaceholder')}
|
||||
/>
|
||||
<span className="info-tooltip" data-tooltip={`${t('settings.push.supports')}\n\n${t('settings.push.docsLink')}`}>ⓘ</span>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<div className="setting-actions">
|
||||
<button type="button" className="ghost" onClick={testShoutrrr} disabled={testingShoutrrr || !settings.shoutrrrUrl}>
|
||||
{testingShoutrrr ? t('common.sending') : t('common.test')}
|
||||
</button>
|
||||
{testShoutrrrResult && (
|
||||
<span className={testShoutrrrResult.success ? "success-text" : "danger-text"}>
|
||||
{testShoutrrrResult.message}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="schedule-overview">
|
||||
<div className="schedule-header">
|
||||
<span className="schedule-title">{t('settings.schedule.title')}</span>
|
||||
<span className="info-tooltip" data-tooltip={t('settings.schedule.envHint')}>ⓘ</span>
|
||||
</div>
|
||||
<div className="schedule-row">
|
||||
<span className="schedule-label">{t('settings.schedule.stockCheck')}</span>
|
||||
<span className="schedule-value">{t('settings.schedule.dailyAt6')}</span>
|
||||
</div>
|
||||
<div className="schedule-row">
|
||||
<span className="schedule-label">{t('settings.schedule.intakeCheck')}</span>
|
||||
<span className="schedule-value">{t('settings.schedule.15minBefore')}</span>
|
||||
</div>
|
||||
{settings.nextScheduledCheck && (
|
||||
<div className="schedule-row">
|
||||
<span className="schedule-label">{t('settings.schedule.nextCheck')}</span>
|
||||
<span className="schedule-value">{new Date(settings.nextScheduledCheck).toLocaleString(getSystemLocale(i18n.language), { day: "2-digit", month: "2-digit", year: "numeric", hour: "2-digit", minute: "2-digit" })}</span>
|
||||
</div>
|
||||
)}
|
||||
{settings.lastAutoEmailSent && (
|
||||
<div className="schedule-row">
|
||||
<span className="schedule-label">{t('settings.schedule.lastSent')}</span>
|
||||
<span className="schedule-value">{new Date(settings.lastAutoEmailSent).toLocaleString(getSystemLocale(i18n.language), { day: "2-digit", month: "2-digit", year: "numeric", hour: "2-digit", minute: "2-digit" })}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
{/* Stock Settings */}
|
||||
<article className="card">
|
||||
<div className="card-head">
|
||||
<h2>{t('settings.stock.title')}</h2>
|
||||
</div>
|
||||
|
||||
<div className="setting-section">
|
||||
<div className="section-header">
|
||||
<h3>{t('settings.stock.threshold')}</h3>
|
||||
</div>
|
||||
<div className="threshold-input">
|
||||
<label>
|
||||
<span className="threshold-label">{t('settings.stock.remindWhen')}</span>
|
||||
<div className="threshold-field">
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="90"
|
||||
value={settings.reminderDaysBefore}
|
||||
onChange={(e) => setSettings({ ...settings, reminderDaysBefore: Number(e.target.value) || 7 })}
|
||||
/>
|
||||
<span className="threshold-unit">{t('common.days')}</span>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<div className="setting-row compact">
|
||||
<label className="setting-label">
|
||||
{t('settings.stock.repeatDaily')}
|
||||
<span className="info-tooltip small" data-tooltip={t('settings.stock.repeatTooltip')}>ⓘ</span>
|
||||
</label>
|
||||
<label className={`toggle-switch small${!((settings.emailEnabled && settings.emailStockReminders && settings.notificationEmail) || (settings.shoutrrrEnabled && settings.shoutrrrStockReminders && settings.shoutrrrUrl)) ? ' disabled' : ''}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.repeatDailyReminders}
|
||||
onChange={(e) => setSettings({ ...settings, repeatDailyReminders: e.target.checked })}
|
||||
disabled={!((settings.emailEnabled && settings.emailStockReminders && settings.notificationEmail) || (settings.shoutrrrEnabled && settings.shoutrrrStockReminders && settings.shoutrrrUrl))}
|
||||
/>
|
||||
<span className="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="setting-section">
|
||||
<div className="section-header">
|
||||
<h3>{t('settings.stock.calculationMode')}</h3>
|
||||
</div>
|
||||
<div className="setting-group calculation-mode-group">
|
||||
<label className={`radio-card ${settings.stockCalculationMode === 'automatic' ? 'selected' : ''}`}>
|
||||
<input
|
||||
type="radio"
|
||||
name="stockCalculationMode"
|
||||
value="automatic"
|
||||
checked={settings.stockCalculationMode === 'automatic'}
|
||||
onChange={(e) => setSettings({ ...settings, stockCalculationMode: e.target.value as 'automatic' | 'manual' })}
|
||||
/>
|
||||
<div className="radio-card-content">
|
||||
<div className="radio-card-text">
|
||||
<span className="radio-card-title">{t('settings.stock.automatic')}</span>
|
||||
<span className="radio-card-desc">{t('settings.stock.automaticDesc')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
<label className={`radio-card ${settings.stockCalculationMode === 'manual' ? 'selected' : ''}`}>
|
||||
<input
|
||||
type="radio"
|
||||
name="stockCalculationMode"
|
||||
value="manual"
|
||||
checked={settings.stockCalculationMode === 'manual'}
|
||||
onChange={(e) => setSettings({ ...settings, stockCalculationMode: e.target.value as 'automatic' | 'manual' })}
|
||||
/>
|
||||
<div className="radio-card-content">
|
||||
<div className="radio-card-text">
|
||||
<span className="radio-card-title">{t('settings.stock.manual')}</span>
|
||||
<span className="radio-card-desc">{t('settings.stock.manualDesc')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="setting-section">
|
||||
<div className="section-header">
|
||||
<h3>{t('settings.stock.display')}</h3>
|
||||
</div>
|
||||
<div className="setting-group">
|
||||
<label>
|
||||
<span className="field-label">{t('settings.stock.lowStockDays')}</span>
|
||||
<div className="input-with-tooltip">
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="365"
|
||||
value={settings.lowStockDays}
|
||||
onChange={(e) => setSettings({ ...settings, lowStockDays: Number(e.target.value) || 30 })}
|
||||
/>
|
||||
<span className="info-tooltip" data-tooltip={t('settings.stock.lowStockTooltip')}>ⓘ</span>
|
||||
</div>
|
||||
</label>
|
||||
<label>
|
||||
<span className="field-label">{t('settings.stock.highStockDays')}</span>
|
||||
<div className="input-with-tooltip">
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="730"
|
||||
value={settings.highStockDays}
|
||||
onChange={(e) => setSettings({ ...settings, highStockDays: Number(e.target.value) || 180 })}
|
||||
/>
|
||||
<span className="info-tooltip" data-tooltip={t('settings.stock.highStockTooltip')}>ⓘ</span>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
{/* Export/Import Section */}
|
||||
<article className="card">
|
||||
<div className="card-head">
|
||||
<h2>
|
||||
{t('exportImport.title')}
|
||||
<span className="info-tooltip" data-tooltip={t('exportImport.description')}>ⓘ</span>
|
||||
</h2>
|
||||
</div>
|
||||
<div className="setting-section">
|
||||
<div className="setting-group">
|
||||
{/* Import Success Message */}
|
||||
{importResult && (
|
||||
<div className="success-banner" style={{marginBottom: '16px', padding: '12px 16px', borderRadius: '8px', backgroundColor: 'var(--success-bg)', border: '1px solid var(--success)', color: 'var(--text-primary)'}}>
|
||||
<div style={{display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start'}}>
|
||||
<div>
|
||||
<strong style={{display: 'block', marginBottom: '4px', color: 'var(--success)'}}>✓ {t('exportImport.importSuccess')}</strong>
|
||||
<span style={{fontSize: '0.9em'}}>{t('exportImport.importSuccessDetails', {
|
||||
medications: importResult.medications,
|
||||
doses: importResult.doses,
|
||||
shares: importResult.shares
|
||||
})}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setImportResult(null)}
|
||||
style={{background: 'none', border: 'none', cursor: 'pointer', fontSize: '1.2em', padding: '0', lineHeight: '1', color: 'inherit', opacity: 0.7}}
|
||||
aria-label="Close"
|
||||
>×</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Export */}
|
||||
<div className="action-card">
|
||||
<div className="action-card-content">
|
||||
<span className="action-card-title">{t('exportImport.exportTitle')}</span>
|
||||
<span className="action-card-desc">{t('exportImport.exportDesc')}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary"
|
||||
onClick={() => setShowExportModal(true)}
|
||||
disabled={exporting}
|
||||
>
|
||||
{exporting ? t('exportImport.exporting') : t('exportImport.export')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Import */}
|
||||
<div className="action-card">
|
||||
<div className="action-card-content">
|
||||
<span className="action-card-title">{t('exportImport.importTitle')}</span>
|
||||
<span className="action-card-desc">{t('exportImport.importDesc')}</span>
|
||||
</div>
|
||||
<input
|
||||
type="file"
|
||||
id="import-file-input"
|
||||
accept=".json,application/json"
|
||||
onChange={handleImportFileSelect}
|
||||
disabled={importing}
|
||||
style={{display: 'none'}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary"
|
||||
onClick={() => document.getElementById('import-file-input')?.click()}
|
||||
disabled={importing}
|
||||
>
|
||||
{importing ? t('exportImport.importing') : t('exportImport.import')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<div className="form-footer">
|
||||
<button type="submit" disabled={settingsSaving || (!settingsChanged && settingsSaved)}>
|
||||
{settingsSaving ? t('common.saving') : settingsSaved && !settingsChanged ? t('common.saved') : t('settings.saveSettings')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* Import Confirmation Modal */}
|
||||
{showImportConfirm && (
|
||||
<ConfirmModal
|
||||
title={t('exportImport.confirmImport')}
|
||||
message={
|
||||
<>
|
||||
<p style={{ marginBottom: "12px" }}>{t('exportImport.confirmImportMessage')}</p>
|
||||
<p className="warning-text">⚠️ {t('exportImport.confirmImportWarning')}</p>
|
||||
</>
|
||||
}
|
||||
confirmLabel={t('exportImport.confirmButton')}
|
||||
cancelLabel={t('exportImport.cancelButton')}
|
||||
onConfirm={handleImportConfirm}
|
||||
onCancel={() => {
|
||||
setShowImportConfirm(false);
|
||||
setPendingImportData(null);
|
||||
}}
|
||||
confirmVariant="danger"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Export Options Modal */}
|
||||
<ExportModal
|
||||
isOpen={showExportModal}
|
||||
onClose={() => setShowExportModal(false)}
|
||||
onExport={handleExport}
|
||||
exporting={exporting}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
// Pages barrel export
|
||||
export { DashboardPage } from "./DashboardPage";
|
||||
export { MedicationsPage } from "./MedicationsPage";
|
||||
export { PlannerPage } from "./PlannerPage";
|
||||
export { SchedulePage } from "./SchedulePage";
|
||||
export { SettingsPage } from "./SettingsPage";
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import AboutModal from '../../components/AboutModal';
|
||||
|
||||
// Mock App module for constants
|
||||
vi.mock('../../App', () => ({
|
||||
FRONTEND_VERSION: '1.0.0',
|
||||
GITHUB_URL: 'https://github.com/test/repo'
|
||||
}));
|
||||
|
||||
describe('AboutModal', () => {
|
||||
const defaultProps = {
|
||||
isOpen: true,
|
||||
onClose: vi.fn()
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ version: '1.0.0' })
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null when not open', () => {
|
||||
const { container } = render(<AboutModal {...defaultProps} isOpen={false} />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it('renders when open', () => {
|
||||
render(<AboutModal {...defaultProps} />);
|
||||
expect(screen.getByText(/about\.appName/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays version number', () => {
|
||||
render(<AboutModal {...defaultProps} />);
|
||||
expect(screen.getByText(/1\.0\.0/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onClose when close button is clicked', () => {
|
||||
render(<AboutModal {...defaultProps} />);
|
||||
fireEvent.click(screen.getByText('×'));
|
||||
expect(defaultProps.onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls onClose when overlay is clicked', () => {
|
||||
const { container } = render(<AboutModal {...defaultProps} />);
|
||||
const overlay = container.querySelector('.modal-overlay');
|
||||
fireEvent.click(overlay!);
|
||||
expect(defaultProps.onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not call onClose when modal content is clicked', () => {
|
||||
const { container } = render(<AboutModal {...defaultProps} />);
|
||||
const content = container.querySelector('.about-modal');
|
||||
if (content) {
|
||||
fireEvent.click(content);
|
||||
expect(defaultProps.onClose).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it('renders GitHub link', () => {
|
||||
render(<AboutModal {...defaultProps} />);
|
||||
const links = screen.getAllByRole('link');
|
||||
expect(links.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('fetches backend version on open', async () => {
|
||||
render(<AboutModal {...defaultProps} />);
|
||||
expect(fetch).toHaveBeenCalledWith('/api/health');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,250 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { AppHeader } from '../../components/AppHeader';
|
||||
import { AuthProvider } from '../../components/Auth';
|
||||
|
||||
// Mock useNavigate
|
||||
const mockNavigate = vi.fn();
|
||||
vi.mock('react-router-dom', async () => {
|
||||
const actual = await vi.importActual('react-router-dom');
|
||||
return {
|
||||
...actual,
|
||||
useNavigate: () => mockNavigate,
|
||||
};
|
||||
});
|
||||
|
||||
describe('AppHeader', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockNavigate.mockClear();
|
||||
// Set up default auth mock - auth disabled
|
||||
(global.fetch as ReturnType<typeof vi.fn>)
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({
|
||||
authEnabled: false,
|
||||
localAuthEnabled: true,
|
||||
hasUsers: false,
|
||||
needsSetup: false
|
||||
})
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
status: 401,
|
||||
ok: false
|
||||
});
|
||||
});
|
||||
|
||||
it('renders header with logo', async () => {
|
||||
const mockOnOpenProfile = vi.fn();
|
||||
const mockOnOpenAbout = vi.fn();
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/dashboard']}>
|
||||
<AuthProvider>
|
||||
<AppHeader onOpenProfile={mockOnOpenProfile} onOpenAbout={mockOnOpenAbout} />
|
||||
</AuthProvider>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const logo = screen.getByAltText('MedAssist-ng');
|
||||
expect(logo).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders navigation tabs', async () => {
|
||||
const mockOnOpenProfile = vi.fn();
|
||||
const mockOnOpenAbout = vi.fn();
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/dashboard']}>
|
||||
<AuthProvider>
|
||||
<AppHeader onOpenProfile={mockOnOpenProfile} onOpenAbout={mockOnOpenAbout} />
|
||||
</AuthProvider>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
// Use getAllBy since there are multiple elements with same text
|
||||
const dashboardElements = screen.getAllByText(/nav\.dashboard/i);
|
||||
expect(dashboardElements.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('renders theme toggle button', async () => {
|
||||
const mockOnOpenProfile = vi.fn();
|
||||
const mockOnOpenAbout = vi.fn();
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/dashboard']}>
|
||||
<AuthProvider>
|
||||
<AppHeader onOpenProfile={mockOnOpenProfile} onOpenAbout={mockOnOpenAbout} />
|
||||
</AuthProvider>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const buttons = screen.getAllByRole('button');
|
||||
const themeBtn = buttons.find(btn => btn.textContent?.includes('🌙') || btn.textContent?.includes('☀️'));
|
||||
expect(themeBtn).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders settings button when auth is disabled', async () => {
|
||||
const mockOnOpenProfile = vi.fn();
|
||||
const mockOnOpenAbout = vi.fn();
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/dashboard']}>
|
||||
<AuthProvider>
|
||||
<AppHeader onOpenProfile={mockOnOpenProfile} onOpenAbout={mockOnOpenAbout} />
|
||||
</AuthProvider>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const settingsBtn = screen.queryByTitle(/nav\.settings/i);
|
||||
expect(settingsBtn).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows page eyebrow and title', async () => {
|
||||
const mockOnOpenProfile = vi.fn();
|
||||
const mockOnOpenAbout = vi.fn();
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/dashboard']}>
|
||||
<AuthProvider>
|
||||
<AppHeader onOpenProfile={mockOnOpenProfile} onOpenAbout={mockOnOpenAbout} />
|
||||
</AuthProvider>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/header\.eyebrow\.overview/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows medications page title on medications route', async () => {
|
||||
const mockOnOpenProfile = vi.fn();
|
||||
const mockOnOpenAbout = vi.fn();
|
||||
|
||||
// Reset mock for this test
|
||||
vi.clearAllMocks();
|
||||
(global.fetch as ReturnType<typeof vi.fn>)
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({
|
||||
authEnabled: false,
|
||||
localAuthEnabled: true,
|
||||
hasUsers: false,
|
||||
needsSetup: false
|
||||
})
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
status: 401,
|
||||
ok: false
|
||||
});
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/medications']}>
|
||||
<AuthProvider>
|
||||
<AppHeader onOpenProfile={mockOnOpenProfile} onOpenAbout={mockOnOpenAbout} />
|
||||
</AuthProvider>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/header\.eyebrow\.inventory/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows planner page title on planner route', async () => {
|
||||
const mockOnOpenProfile = vi.fn();
|
||||
const mockOnOpenAbout = vi.fn();
|
||||
|
||||
vi.clearAllMocks();
|
||||
(global.fetch as ReturnType<typeof vi.fn>)
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({
|
||||
authEnabled: false,
|
||||
localAuthEnabled: true,
|
||||
hasUsers: false,
|
||||
needsSetup: false
|
||||
})
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
status: 401,
|
||||
ok: false
|
||||
});
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/planner']}>
|
||||
<AuthProvider>
|
||||
<AppHeader onOpenProfile={mockOnOpenProfile} onOpenAbout={mockOnOpenAbout} />
|
||||
</AuthProvider>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/header\.eyebrow\.planner/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows settings page title on settings route', async () => {
|
||||
const mockOnOpenProfile = vi.fn();
|
||||
const mockOnOpenAbout = vi.fn();
|
||||
|
||||
vi.clearAllMocks();
|
||||
(global.fetch as ReturnType<typeof vi.fn>)
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({
|
||||
authEnabled: false,
|
||||
localAuthEnabled: true,
|
||||
hasUsers: false,
|
||||
needsSetup: false
|
||||
})
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
status: 401,
|
||||
ok: false
|
||||
});
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/settings']}>
|
||||
<AuthProvider>
|
||||
<AppHeader onOpenProfile={mockOnOpenProfile} onOpenAbout={mockOnOpenAbout} />
|
||||
</AuthProvider>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/header\.eyebrow\.settings/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('navigates when tab clicked', async () => {
|
||||
const mockOnOpenProfile = vi.fn();
|
||||
const mockOnOpenAbout = vi.fn();
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/dashboard']}>
|
||||
<AuthProvider>
|
||||
<AppHeader onOpenProfile={mockOnOpenProfile} onOpenAbout={mockOnOpenAbout} />
|
||||
</AuthProvider>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const buttons = screen.getAllByRole('button');
|
||||
const medsBtn = buttons.find(btn => btn.textContent?.includes('nav.medications'));
|
||||
if (medsBtn) {
|
||||
fireEvent.click(medsBtn);
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/medications');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,359 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { AuthProvider, useAuth, LoginForm, RegisterForm, UserProfile, AuthPage } from '../../components/Auth';
|
||||
import React from 'react';
|
||||
|
||||
// Wrapper component for testing hooks that require AuthProvider
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<AuthProvider>{children}</AuthProvider>
|
||||
);
|
||||
|
||||
describe('AuthProvider', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ authEnabled: true, localAuthEnabled: true })
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('provides auth context to children', () => {
|
||||
render(
|
||||
<AuthProvider>
|
||||
<div data-testid="child">Child content</div>
|
||||
</AuthProvider>
|
||||
);
|
||||
expect(screen.getByTestId('child')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('initializes with loading state', () => {
|
||||
const { result } = renderHook(() => useAuth(), { wrapper });
|
||||
// Initially loading
|
||||
expect(result.current.loading).toBe(true);
|
||||
});
|
||||
|
||||
it('fetches auth state on mount', async () => {
|
||||
renderHook(() => useAuth(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetch).toHaveBeenCalledWith('/api/auth/state');
|
||||
});
|
||||
});
|
||||
|
||||
it('throws error when useAuth is used outside AuthProvider', () => {
|
||||
expect(() => {
|
||||
renderHook(() => useAuth());
|
||||
}).toThrow('useAuth must be used within AuthProvider');
|
||||
});
|
||||
});
|
||||
|
||||
describe('LoginForm', () => {
|
||||
const mockAuthState = {
|
||||
authEnabled: true,
|
||||
localAuthEnabled: true,
|
||||
oidcEnabled: false,
|
||||
registrationEnabled: true,
|
||||
hasUsers: true,
|
||||
needsSetup: false,
|
||||
oidcProviderName: ''
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
(global.fetch as ReturnType<typeof vi.fn>)
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockAuthState)
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
status: 401,
|
||||
ok: false
|
||||
});
|
||||
});
|
||||
|
||||
it('renders login form', async () => {
|
||||
render(
|
||||
<AuthProvider>
|
||||
<LoginForm />
|
||||
</AuthProvider>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/MedAssist/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders username and password fields', async () => {
|
||||
render(
|
||||
<AuthProvider>
|
||||
<LoginForm />
|
||||
</AuthProvider>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/auth\.username/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/auth\.password/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders remember me checkbox', async () => {
|
||||
render(
|
||||
<AuthProvider>
|
||||
<LoginForm />
|
||||
</AuthProvider>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/auth\.rememberMe/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders create account link when registration enabled', async () => {
|
||||
const onSwitchToRegister = vi.fn();
|
||||
|
||||
render(
|
||||
<AuthProvider>
|
||||
<LoginForm onSwitchToRegister={onSwitchToRegister} />
|
||||
</AuthProvider>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const createAccountBtn = screen.getByText(/auth\.createAccount/i);
|
||||
expect(createAccountBtn).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('handles form input changes', async () => {
|
||||
render(
|
||||
<AuthProvider>
|
||||
<LoginForm />
|
||||
</AuthProvider>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/auth\.username/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/auth\.username/i), { target: { value: 'testuser' } });
|
||||
fireEvent.change(screen.getByLabelText(/auth\.password/i), { target: { value: 'password123' } });
|
||||
|
||||
expect(screen.getByLabelText(/auth\.username/i)).toHaveValue('testuser');
|
||||
expect(screen.getByLabelText(/auth\.password/i)).toHaveValue('password123');
|
||||
});
|
||||
|
||||
it('renders submit button', async () => {
|
||||
render(
|
||||
<AuthProvider>
|
||||
<LoginForm />
|
||||
</AuthProvider>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const buttons = screen.getAllByRole('button');
|
||||
const submitBtn = buttons.find(btn => btn.getAttribute('type') === 'submit');
|
||||
expect(submitBtn).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('RegisterForm', () => {
|
||||
const mockAuthState = {
|
||||
authEnabled: true,
|
||||
localAuthEnabled: true,
|
||||
oidcEnabled: false,
|
||||
registrationEnabled: true,
|
||||
hasUsers: false,
|
||||
needsSetup: true,
|
||||
oidcProviderName: ''
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
(global.fetch as ReturnType<typeof vi.fn>)
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockAuthState)
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
status: 401,
|
||||
ok: false
|
||||
});
|
||||
});
|
||||
|
||||
it('renders registration form', async () => {
|
||||
render(
|
||||
<AuthProvider>
|
||||
<RegisterForm />
|
||||
</AuthProvider>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/MedAssist/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders all required fields', async () => {
|
||||
render(
|
||||
<AuthProvider>
|
||||
<RegisterForm />
|
||||
</AuthProvider>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
// Check for username field
|
||||
expect(screen.getByLabelText(/auth\.username/i)).toBeInTheDocument();
|
||||
// Check for password field
|
||||
expect(screen.getByLabelText(/auth\.password/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders switch to login link', async () => {
|
||||
const onSwitchToLogin = vi.fn();
|
||||
|
||||
render(
|
||||
<AuthProvider>
|
||||
<RegisterForm onSwitchToLogin={onSwitchToLogin} />
|
||||
</AuthProvider>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const loginLink = screen.getByText(/auth\.alreadyHaveAccount/i);
|
||||
expect(loginLink).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('calls onSwitchToLogin when clicked', async () => {
|
||||
const onSwitchToLogin = vi.fn();
|
||||
|
||||
render(
|
||||
<AuthProvider>
|
||||
<RegisterForm onSwitchToLogin={onSwitchToLogin} />
|
||||
</AuthProvider>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const loginLink = screen.getByText(/auth\.alreadyHaveAccount/i);
|
||||
fireEvent.click(loginLink);
|
||||
});
|
||||
|
||||
expect(onSwitchToLogin).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('AuthPage', () => {
|
||||
const mockAuthState = {
|
||||
authEnabled: true,
|
||||
localAuthEnabled: true,
|
||||
oidcEnabled: false,
|
||||
registrationEnabled: true,
|
||||
hasUsers: true,
|
||||
needsSetup: false,
|
||||
oidcProviderName: ''
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
(global.fetch as ReturnType<typeof vi.fn>)
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockAuthState)
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
status: 401,
|
||||
ok: false
|
||||
});
|
||||
});
|
||||
|
||||
it('renders login form by default', async () => {
|
||||
render(
|
||||
<AuthProvider>
|
||||
<AuthPage />
|
||||
</AuthProvider>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
// Should show login form with username field
|
||||
expect(screen.getByLabelText(/auth\.username/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('UserProfile', () => {
|
||||
const mockUser = {
|
||||
id: 1,
|
||||
username: 'testuser',
|
||||
avatarUrl: null
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
(global.fetch as ReturnType<typeof vi.fn>)
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ authEnabled: true, localAuthEnabled: true })
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockUser)
|
||||
});
|
||||
});
|
||||
|
||||
it('renders user profile when user is logged in', async () => {
|
||||
render(
|
||||
<AuthProvider>
|
||||
<UserProfile />
|
||||
</AuthProvider>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('testuser')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('displays user avatar initial when no avatar', async () => {
|
||||
render(
|
||||
<AuthProvider>
|
||||
<UserProfile />
|
||||
</AuthProvider>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
// The avatar shows first letter of username
|
||||
expect(screen.getByText('T')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders change password section', async () => {
|
||||
render(
|
||||
<AuthProvider>
|
||||
<UserProfile />
|
||||
</AuthProvider>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/auth\.changePassword/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders cancel button that calls onClose', async () => {
|
||||
const onClose = vi.fn();
|
||||
|
||||
render(
|
||||
<AuthProvider>
|
||||
<UserProfile onClose={onClose} />
|
||||
</AuthProvider>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const cancelBtn = screen.getByText(/common\.cancel/i);
|
||||
fireEvent.click(cancelBtn);
|
||||
});
|
||||
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { ConfirmModal } from '../../components/ConfirmModal';
|
||||
|
||||
describe('ConfirmModal', () => {
|
||||
const defaultProps = {
|
||||
title: 'Confirm Action',
|
||||
message: 'Are you sure you want to proceed?',
|
||||
confirmLabel: 'Yes',
|
||||
cancelLabel: 'No',
|
||||
onConfirm: vi.fn(),
|
||||
onCancel: vi.fn()
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders title', () => {
|
||||
render(<ConfirmModal {...defaultProps} />);
|
||||
expect(screen.getByText('Confirm Action')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders message as string', () => {
|
||||
render(<ConfirmModal {...defaultProps} />);
|
||||
expect(screen.getByText('Are you sure you want to proceed?')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders message as ReactNode', () => {
|
||||
render(
|
||||
<ConfirmModal
|
||||
{...defaultProps}
|
||||
message={<span data-testid="custom-message">Custom message</span>}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId('custom-message')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders confirm and cancel buttons', () => {
|
||||
render(<ConfirmModal {...defaultProps} />);
|
||||
expect(screen.getByText('Yes')).toBeInTheDocument();
|
||||
expect(screen.getByText('No')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onConfirm when confirm button is clicked', () => {
|
||||
render(<ConfirmModal {...defaultProps} />);
|
||||
fireEvent.click(screen.getByText('Yes'));
|
||||
expect(defaultProps.onConfirm).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls onCancel when cancel button is clicked', () => {
|
||||
render(<ConfirmModal {...defaultProps} />);
|
||||
fireEvent.click(screen.getByText('No'));
|
||||
expect(defaultProps.onCancel).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls onCancel when close button is clicked', () => {
|
||||
render(<ConfirmModal {...defaultProps} />);
|
||||
fireEvent.click(screen.getByText('×'));
|
||||
expect(defaultProps.onCancel).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls onCancel when overlay is clicked', () => {
|
||||
const { container } = render(<ConfirmModal {...defaultProps} />);
|
||||
const overlay = container.querySelector('.modal-overlay');
|
||||
fireEvent.click(overlay!);
|
||||
expect(defaultProps.onCancel).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not call onCancel when modal content is clicked', () => {
|
||||
const { container } = render(<ConfirmModal {...defaultProps} />);
|
||||
const content = container.querySelector('.modal-content');
|
||||
fireEvent.click(content!);
|
||||
expect(defaultProps.onCancel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('disables buttons when loading', () => {
|
||||
render(<ConfirmModal {...defaultProps} isLoading={true} />);
|
||||
expect(screen.getByText('Yes')).toBeDisabled();
|
||||
expect(screen.getByText('No')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('applies primary variant by default', () => {
|
||||
render(<ConfirmModal {...defaultProps} />);
|
||||
const confirmBtn = screen.getByText('Yes');
|
||||
expect(confirmBtn.className).toContain('primary');
|
||||
});
|
||||
|
||||
it('applies danger variant when specified', () => {
|
||||
render(<ConfirmModal {...defaultProps} confirmVariant="danger" />);
|
||||
const confirmBtn = screen.getByText('Yes');
|
||||
expect(confirmBtn.className).toContain('danger');
|
||||
});
|
||||
|
||||
it('applies success variant when specified', () => {
|
||||
render(<ConfirmModal {...defaultProps} confirmVariant="success" />);
|
||||
const confirmBtn = screen.getByText('Yes');
|
||||
expect(confirmBtn.className).toContain('success');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import ExportModal from '../../components/ExportModal';
|
||||
|
||||
describe('ExportModal', () => {
|
||||
const defaultProps = {
|
||||
isOpen: true,
|
||||
onClose: vi.fn(),
|
||||
onExport: vi.fn(),
|
||||
exporting: false
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('returns null when not open', () => {
|
||||
const { container } = render(<ExportModal {...defaultProps} isOpen={false} />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it('renders when open', () => {
|
||||
render(<ExportModal {...defaultProps} />);
|
||||
expect(screen.getByText(/exportImport\.exportOptions/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onClose when close button is clicked', () => {
|
||||
render(<ExportModal {...defaultProps} />);
|
||||
fireEvent.click(screen.getByText('×'));
|
||||
expect(defaultProps.onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls onClose when overlay is clicked', () => {
|
||||
const { container } = render(<ExportModal {...defaultProps} />);
|
||||
const overlay = container.querySelector('.modal-overlay');
|
||||
fireEvent.click(overlay!);
|
||||
expect(defaultProps.onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('renders export options', () => {
|
||||
const { container } = render(<ExportModal {...defaultProps} />);
|
||||
// Should have action card buttons
|
||||
const actionCards = container.querySelectorAll('.action-card');
|
||||
expect(actionCards.length).toBe(2);
|
||||
});
|
||||
|
||||
it('calls onExport with true when export with images button clicked', () => {
|
||||
const { container } = render(<ExportModal {...defaultProps} />);
|
||||
const actionCards = container.querySelectorAll('.action-card');
|
||||
fireEvent.click(actionCards[0]);
|
||||
expect(defaultProps.onClose).toHaveBeenCalled();
|
||||
expect(defaultProps.onExport).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('calls onExport with false when export data only button clicked', () => {
|
||||
const { container } = render(<ExportModal {...defaultProps} />);
|
||||
const actionCards = container.querySelectorAll('.action-card');
|
||||
fireEvent.click(actionCards[1]);
|
||||
expect(defaultProps.onClose).toHaveBeenCalled();
|
||||
expect(defaultProps.onExport).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it('disables buttons when exporting', () => {
|
||||
const { container } = render(<ExportModal {...defaultProps} exporting={true} />);
|
||||
const actionCards = container.querySelectorAll('.action-card');
|
||||
actionCards.forEach(card => {
|
||||
expect(card).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders cancel button', () => {
|
||||
render(<ExportModal {...defaultProps} />);
|
||||
expect(screen.getByText(/exportImport\.cancelButton/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onClose when cancel button is clicked', () => {
|
||||
render(<ExportModal {...defaultProps} />);
|
||||
fireEvent.click(screen.getByText(/exportImport\.cancelButton/i));
|
||||
expect(defaultProps.onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { Lightbox } from '../../components/Lightbox';
|
||||
|
||||
describe('Lightbox', () => {
|
||||
const defaultProps = {
|
||||
src: '/test-image.jpg',
|
||||
alt: 'Test Image',
|
||||
onClose: vi.fn()
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders image with correct src and alt', () => {
|
||||
render(<Lightbox {...defaultProps} />);
|
||||
|
||||
const img = screen.getByAltText('Test Image');
|
||||
expect(img).toBeInTheDocument();
|
||||
expect(img).toHaveAttribute('src', '/test-image.jpg');
|
||||
});
|
||||
|
||||
it('renders close button', () => {
|
||||
render(<Lightbox {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText('×')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onClose when close button is clicked', () => {
|
||||
const onClose = vi.fn();
|
||||
render(<Lightbox {...defaultProps} onClose={onClose} />);
|
||||
|
||||
fireEvent.click(screen.getByText('×'));
|
||||
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls onClose when overlay is clicked', () => {
|
||||
const onClose = vi.fn();
|
||||
const { container } = render(<Lightbox {...defaultProps} onClose={onClose} />);
|
||||
|
||||
const overlay = container.querySelector('.lightbox-overlay');
|
||||
fireEvent.click(overlay!);
|
||||
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not call onClose when image is clicked', () => {
|
||||
const onClose = vi.fn();
|
||||
render(<Lightbox {...defaultProps} onClose={onClose} />);
|
||||
|
||||
fireEvent.click(screen.getByAltText('Test Image'));
|
||||
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('applies correct CSS classes', () => {
|
||||
const { container } = render(<Lightbox {...defaultProps} />);
|
||||
|
||||
expect(container.querySelector('.lightbox-overlay')).toBeInTheDocument();
|
||||
expect(container.querySelector('.lightbox-close')).toBeInTheDocument();
|
||||
expect(container.querySelector('.lightbox-image')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,377 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { MedDetailModal } from '../../components/MedDetailModal';
|
||||
import type { Medication, Coverage, StockThresholds, RefillEntry } from '../../types';
|
||||
|
||||
const defaultSettings: StockThresholds = {
|
||||
lowStockDays: 7,
|
||||
normalStockDays: 30,
|
||||
highStockDays: 90
|
||||
};
|
||||
|
||||
const mockMedication: Medication = {
|
||||
id: 1,
|
||||
name: 'Test Med',
|
||||
genericName: 'Generic Name',
|
||||
packCount: 1,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 30,
|
||||
looseTablets: 0,
|
||||
takenBy: ['John'],
|
||||
blisters: [{ usage: 1, every: 1, start: '2024-01-01T09:00:00' }],
|
||||
updatedAt: null,
|
||||
expiryDate: '2025-12-31',
|
||||
notes: 'Test notes'
|
||||
};
|
||||
|
||||
const mockCoverage: Coverage = {
|
||||
name: 'Test Med',
|
||||
medsLeft: 25,
|
||||
daysLeft: 25,
|
||||
depletionDate: '2024-04-01',
|
||||
depletionTime: Date.now() + 25 * 86400000,
|
||||
nextDose: null
|
||||
};
|
||||
|
||||
const defaultProps = {
|
||||
selectedMed: mockMedication,
|
||||
coverage: { all: [mockCoverage] },
|
||||
settings: defaultSettings,
|
||||
showImageLightbox: false,
|
||||
showRefillModal: false,
|
||||
showEditStockModal: false,
|
||||
onClose: vi.fn(),
|
||||
onOpenImageLightbox: vi.fn(),
|
||||
onCloseImageLightbox: vi.fn(),
|
||||
onOpenRefillModal: vi.fn(),
|
||||
onCloseRefillModal: vi.fn(),
|
||||
onOpenEditStockModal: vi.fn(),
|
||||
onCloseEditStockModal: vi.fn(),
|
||||
refillPacks: 0,
|
||||
onRefillPacksChange: vi.fn(),
|
||||
refillLoose: 0,
|
||||
onRefillLooseChange: vi.fn(),
|
||||
refillSaving: false,
|
||||
refillHistory: [] as RefillEntry[],
|
||||
refillHistoryExpanded: false,
|
||||
onRefillHistoryExpandedChange: vi.fn(),
|
||||
onSubmitRefill: vi.fn(),
|
||||
editStockFullBlisters: 0,
|
||||
onEditStockFullBlistersChange: vi.fn(),
|
||||
editStockPartialBlisterPills: 0,
|
||||
onEditStockPartialBlisterPillsChange: vi.fn(),
|
||||
editStockSaving: false,
|
||||
onSubmitStockCorrection: vi.fn()
|
||||
};
|
||||
|
||||
describe('MedDetailModal', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders nothing when selectedMed is null', () => {
|
||||
render(<MedDetailModal {...defaultProps} selectedMed={null} />);
|
||||
|
||||
expect(screen.queryByText('Test Med')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders modal when medication is selected', () => {
|
||||
render(<MedDetailModal {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText('Test Med')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays medication name', () => {
|
||||
render(<MedDetailModal {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText('Test Med')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays generic name', () => {
|
||||
render(<MedDetailModal {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText('Generic Name')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders close button', () => {
|
||||
render(<MedDetailModal {...defaultProps} />);
|
||||
|
||||
const closeBtn = screen.getByText('×');
|
||||
expect(closeBtn).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onClose when close button clicked', () => {
|
||||
const onClose = vi.fn();
|
||||
render(<MedDetailModal {...defaultProps} onClose={onClose} />);
|
||||
|
||||
const closeBtn = screen.getByText('×');
|
||||
fireEvent.click(closeBtn);
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('calls onClose when overlay clicked', () => {
|
||||
const onClose = vi.fn();
|
||||
render(<MedDetailModal {...defaultProps} onClose={onClose} />);
|
||||
|
||||
const overlay = document.querySelector('.modal-overlay');
|
||||
if (overlay) {
|
||||
fireEvent.click(overlay);
|
||||
}
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not call onClose when modal content clicked', () => {
|
||||
const onClose = vi.fn();
|
||||
render(<MedDetailModal {...defaultProps} onClose={onClose} />);
|
||||
|
||||
const content = document.querySelector('.modal-content');
|
||||
if (content) {
|
||||
fireEvent.click(content);
|
||||
}
|
||||
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('displays notes when available', () => {
|
||||
render(<MedDetailModal {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText('Test notes')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays schedule information', () => {
|
||||
render(<MedDetailModal {...defaultProps} />);
|
||||
|
||||
// Should have schedule section
|
||||
const scheduleSection = document.querySelector('.med-detail-schedules');
|
||||
expect(scheduleSection).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders med detail header', () => {
|
||||
render(<MedDetailModal {...defaultProps} />);
|
||||
|
||||
const header = document.querySelector('.med-detail-header');
|
||||
expect(header).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders med detail body', () => {
|
||||
render(<MedDetailModal {...defaultProps} />);
|
||||
|
||||
const body = document.querySelector('.med-detail-body');
|
||||
expect(body).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('MedDetailModal without coverage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('works without coverage data', () => {
|
||||
render(<MedDetailModal {...defaultProps} coverage={{ all: [] }} />);
|
||||
|
||||
// Should still render the medication name
|
||||
expect(screen.getByText('Test Med')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('MedDetailModal without optional fields', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('works without generic name', () => {
|
||||
const med = { ...mockMedication, genericName: null };
|
||||
render(<MedDetailModal {...defaultProps} selectedMed={med} />);
|
||||
|
||||
expect(screen.getByText('Test Med')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('works without notes', () => {
|
||||
const med = { ...mockMedication, notes: null };
|
||||
render(<MedDetailModal {...defaultProps} selectedMed={med} />);
|
||||
|
||||
expect(screen.getByText('Test Med')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('works without takenBy', () => {
|
||||
const med = { ...mockMedication, takenBy: [] };
|
||||
render(<MedDetailModal {...defaultProps} selectedMed={med} />);
|
||||
|
||||
expect(screen.getByText('Test Med')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('works without expiryDate', () => {
|
||||
const med = { ...mockMedication, expiryDate: null };
|
||||
render(<MedDetailModal {...defaultProps} selectedMed={med} />);
|
||||
|
||||
expect(screen.getByText('Test Med')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('MedDetailModal with refill modal', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('shows refill modal when open', () => {
|
||||
render(<MedDetailModal {...defaultProps} showRefillModal={true} />);
|
||||
|
||||
// Modal should show refill section
|
||||
const modal = document.querySelector('.modal-overlay');
|
||||
expect(modal).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onCloseRefillModal when refill modal closed', () => {
|
||||
const onCloseRefillModal = vi.fn();
|
||||
render(<MedDetailModal {...defaultProps} showRefillModal={true} onCloseRefillModal={onCloseRefillModal} />);
|
||||
|
||||
// Modal close button
|
||||
const closeButtons = document.querySelectorAll('button');
|
||||
const cancelBtn = Array.from(closeButtons).find(btn => btn.textContent?.includes('cancel') || btn.textContent?.includes('Cancel'));
|
||||
if (cancelBtn) {
|
||||
fireEvent.click(cancelBtn);
|
||||
}
|
||||
});
|
||||
|
||||
it('calls onSubmitRefill when refill submitted', () => {
|
||||
const onSubmitRefill = vi.fn();
|
||||
render(<MedDetailModal {...defaultProps} showRefillModal={true} onSubmitRefill={onSubmitRefill} />);
|
||||
|
||||
const submitBtns = document.querySelectorAll('button');
|
||||
const submitBtn = Array.from(submitBtns).find(btn => btn.textContent?.includes('refill') || btn.textContent?.includes('submit'));
|
||||
if (submitBtn) {
|
||||
fireEvent.click(submitBtn);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('MedDetailModal actions', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders action buttons', () => {
|
||||
render(<MedDetailModal {...defaultProps} />);
|
||||
|
||||
const buttons = document.querySelectorAll('button');
|
||||
expect(buttons.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('calls onOpenRefillModal when refill clicked', () => {
|
||||
const onOpenRefillModal = vi.fn();
|
||||
render(<MedDetailModal {...defaultProps} onOpenRefillModal={onOpenRefillModal} />);
|
||||
|
||||
const buttons = document.querySelectorAll('button');
|
||||
const refillBtn = Array.from(buttons).find(btn => btn.textContent?.includes('refill') || btn.textContent?.includes('Refill'));
|
||||
if (refillBtn) {
|
||||
fireEvent.click(refillBtn);
|
||||
expect(onOpenRefillModal).toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('MedDetailModal with multiple blisters', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders multiple schedule entries', () => {
|
||||
const med = {
|
||||
...mockMedication,
|
||||
blisters: [
|
||||
{ usage: 1, every: 1, start: '2024-01-01T09:00:00' },
|
||||
{ usage: 2, every: 7, start: '2024-01-01T20:00:00' }
|
||||
]
|
||||
};
|
||||
render(<MedDetailModal {...defaultProps} selectedMed={med} />);
|
||||
|
||||
const scheduleEntries = document.querySelectorAll('.schedule-entry');
|
||||
// Should have multiple schedule entries
|
||||
});
|
||||
});
|
||||
|
||||
describe('MedDetailModal with image', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders medication avatar', () => {
|
||||
render(<MedDetailModal {...defaultProps} />);
|
||||
|
||||
const avatar = document.querySelector('.med-avatar');
|
||||
expect(avatar).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows lightbox when image clicked', () => {
|
||||
const onOpenImageLightbox = vi.fn();
|
||||
const med = { ...mockMedication, imageUrl: 'test-image.jpg' };
|
||||
render(<MedDetailModal {...defaultProps} selectedMed={med} onOpenImageLightbox={onOpenImageLightbox} />);
|
||||
|
||||
const avatar = document.querySelector('.med-avatar');
|
||||
if (avatar) {
|
||||
fireEvent.click(avatar);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('MedDetailModal with low stock', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('shows stock status for low stock', () => {
|
||||
const lowCoverage: Coverage = {
|
||||
name: 'Test Med',
|
||||
medsLeft: 3,
|
||||
daysLeft: 3,
|
||||
depletionDate: '2024-01-05',
|
||||
depletionTime: Date.now() + 3 * 86400000,
|
||||
nextDose: null
|
||||
};
|
||||
|
||||
render(<MedDetailModal {...defaultProps} coverage={{ all: [lowCoverage] }} />);
|
||||
|
||||
// Should render status indicator
|
||||
const statusElements = document.querySelectorAll('.danger, .warning, .success');
|
||||
// Status should be visible
|
||||
});
|
||||
});
|
||||
|
||||
describe('MedDetailModal with refill history', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('shows refill history when expanded', () => {
|
||||
const refillHistory: RefillEntry[] = [
|
||||
{ id: 1, medicationId: 1, timestamp: new Date().toISOString(), packsAdded: 1, looseAdded: 0 }
|
||||
];
|
||||
|
||||
render(<MedDetailModal {...defaultProps} refillHistory={refillHistory} refillHistoryExpanded={true} />);
|
||||
|
||||
// Refill history should be visible
|
||||
const modal = document.querySelector('.modal-overlay');
|
||||
expect(modal).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onRefillHistoryExpandedChange when toggle clicked', () => {
|
||||
const onRefillHistoryExpandedChange = vi.fn();
|
||||
const refillHistory: RefillEntry[] = [
|
||||
{ id: 1, medicationId: 1, timestamp: new Date().toISOString(), packsAdded: 1, looseAdded: 0 }
|
||||
];
|
||||
|
||||
render(<MedDetailModal
|
||||
{...defaultProps}
|
||||
refillHistory={refillHistory}
|
||||
onRefillHistoryExpandedChange={onRefillHistoryExpandedChange}
|
||||
/>);
|
||||
|
||||
// Click expand toggle if exists
|
||||
const expandButton = document.querySelector('[class*="expand"], [class*="toggle"]');
|
||||
if (expandButton) {
|
||||
fireEvent.click(expandButton);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { MedicationAvatar } from '../../components/MedicationAvatar';
|
||||
|
||||
describe('MedicationAvatar', () => {
|
||||
it('renders initials when no image provided', () => {
|
||||
render(<MedicationAvatar name="Test Medication" />);
|
||||
|
||||
expect(screen.getByText('TM')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('uses first two initials from medication name', () => {
|
||||
render(<MedicationAvatar name="Very Long Medication Name" />);
|
||||
|
||||
expect(screen.getByText('VL')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles single word names', () => {
|
||||
render(<MedicationAvatar name="Aspirin" />);
|
||||
|
||||
expect(screen.getByText('A')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders image when imageUrl provided', () => {
|
||||
render(<MedicationAvatar name="Test Med" imageUrl="test-image.jpg" />);
|
||||
|
||||
const img = screen.getByAltText('Test Med');
|
||||
expect(img).toBeInTheDocument();
|
||||
expect(img).toHaveAttribute('src', '/api/images/test-image.jpg');
|
||||
});
|
||||
|
||||
it('applies small size class by default', () => {
|
||||
const { container } = render(<MedicationAvatar name="Test" />);
|
||||
|
||||
expect(container.querySelector('.med-avatar-sm')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies medium size class', () => {
|
||||
const { container } = render(<MedicationAvatar name="Test" size="md" />);
|
||||
|
||||
expect(container.querySelector('.med-avatar-md')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies large size class', () => {
|
||||
const { container } = render(<MedicationAvatar name="Test" size="lg" />);
|
||||
|
||||
expect(container.querySelector('.med-avatar-lg')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles empty name with fallback', () => {
|
||||
render(<MedicationAvatar name="" />);
|
||||
|
||||
expect(screen.getByText('?')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('converts initials to uppercase', () => {
|
||||
render(<MedicationAvatar name="lower case" />);
|
||||
|
||||
expect(screen.getByText('LC')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('adds initials class when no image', () => {
|
||||
const { container } = render(<MedicationAvatar name="Test" />);
|
||||
|
||||
expect(container.querySelector('.med-avatar-initials')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,487 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { MobileEditModal } from '../../components/MobileEditModal';
|
||||
import type { FormState, FormBlister } from '../../types';
|
||||
|
||||
const defaultForm: FormState = {
|
||||
name: '',
|
||||
genericName: '',
|
||||
takenBy: [],
|
||||
packCount: '1',
|
||||
blistersPerPack: '1',
|
||||
pillsPerBlister: '1',
|
||||
looseTablets: '0',
|
||||
pillWeightMg: '',
|
||||
expiryDate: '',
|
||||
notes: '',
|
||||
intakeRemindersEnabled: false,
|
||||
blisters: [{
|
||||
usage: '1',
|
||||
every: '1',
|
||||
startDate: '2024-01-01',
|
||||
startTime: '09:00'
|
||||
}]
|
||||
};
|
||||
|
||||
const defaultProps = {
|
||||
show: true,
|
||||
editingId: null,
|
||||
form: defaultForm,
|
||||
onFormChange: vi.fn(),
|
||||
fieldErrors: {},
|
||||
saving: false,
|
||||
formSaved: false,
|
||||
formChanged: false,
|
||||
hasValidationErrors: false,
|
||||
takenByInput: '',
|
||||
onTakenByInputChange: vi.fn(),
|
||||
existingPeople: [],
|
||||
onAddTakenByPerson: vi.fn(),
|
||||
onRemoveTakenByPerson: vi.fn(),
|
||||
onTakenByKeyDown: vi.fn(),
|
||||
onSetBlisterValue: vi.fn(),
|
||||
onAddBlister: vi.fn(),
|
||||
onRemoveBlister: vi.fn(),
|
||||
onHandleValueChange: vi.fn(),
|
||||
refillPacks: 0,
|
||||
onRefillPacksChange: vi.fn(),
|
||||
refillLoose: 0,
|
||||
onRefillLooseChange: vi.fn(),
|
||||
refillSaving: false,
|
||||
onSubmitRefill: vi.fn(),
|
||||
meds: [],
|
||||
onUploadMedImage: vi.fn(),
|
||||
onDeleteMedImage: vi.fn(),
|
||||
onClose: vi.fn(),
|
||||
onResetForm: vi.fn(),
|
||||
onSaveMedication: vi.fn()
|
||||
};
|
||||
|
||||
describe('MobileEditModal', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders nothing when show is false', () => {
|
||||
render(<MobileEditModal {...defaultProps} show={false} />);
|
||||
|
||||
expect(screen.queryByText(/form\.newEntry/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders modal when show is true', () => {
|
||||
render(<MobileEditModal {...defaultProps} />);
|
||||
|
||||
// Should render the modal overlay
|
||||
const modal = document.querySelector('.modal-overlay');
|
||||
expect(modal).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows new entry title when not editing', () => {
|
||||
render(<MobileEditModal {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText(/form\.newEntry/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows edit entry title when editing', () => {
|
||||
render(<MobileEditModal {...defaultProps} editingId={1} />);
|
||||
|
||||
expect(screen.getByText(/form\.editEntry/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders close button', () => {
|
||||
render(<MobileEditModal {...defaultProps} />);
|
||||
|
||||
const closeBtn = document.querySelector('.modal-close');
|
||||
expect(closeBtn).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onClose when close button clicked', () => {
|
||||
const onClose = vi.fn();
|
||||
const onResetForm = vi.fn();
|
||||
render(<MobileEditModal {...defaultProps} onClose={onClose} onResetForm={onResetForm} />);
|
||||
|
||||
const closeBtn = document.querySelector('.modal-close');
|
||||
if (closeBtn) {
|
||||
fireEvent.click(closeBtn);
|
||||
}
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
expect(onResetForm).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('renders form element', () => {
|
||||
render(<MobileEditModal {...defaultProps} />);
|
||||
|
||||
const form = document.querySelector('form');
|
||||
expect(form).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders name input', () => {
|
||||
render(<MobileEditModal {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText(/form\.commercialName/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders generic name input', () => {
|
||||
render(<MobileEditModal {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText(/form\.genericName/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders packs input', () => {
|
||||
render(<MobileEditModal {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText(/form\.packs/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders blisters per pack input', () => {
|
||||
render(<MobileEditModal {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText(/form\.blistersPerPack/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders pills per blister input', () => {
|
||||
render(<MobileEditModal {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText(/form\.pillsPerBlister/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders loose tablets input', () => {
|
||||
render(<MobileEditModal {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText(/form\.loose/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders intake schedules section', () => {
|
||||
render(<MobileEditModal {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText(/form\.blisters\.title/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders save button', () => {
|
||||
render(<MobileEditModal {...defaultProps} />);
|
||||
|
||||
const saveBtn = document.querySelector('button[type="submit"]');
|
||||
expect(saveBtn).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('disables save when saving', () => {
|
||||
render(<MobileEditModal {...defaultProps} saving={true} />);
|
||||
|
||||
const saveBtn = document.querySelector('button[type="submit"]') as HTMLButtonElement;
|
||||
expect(saveBtn).toBeDisabled();
|
||||
});
|
||||
|
||||
it('disables save when has validation errors', () => {
|
||||
render(<MobileEditModal {...defaultProps} hasValidationErrors={true} />);
|
||||
|
||||
const saveBtn = document.querySelector('button[type="submit"]') as HTMLButtonElement;
|
||||
expect(saveBtn).toBeDisabled();
|
||||
});
|
||||
|
||||
it('renders add intake button', () => {
|
||||
render(<MobileEditModal {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText(/form\.blisters\.addIntake/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onAddBlister when add intake clicked', () => {
|
||||
const onAddBlister = vi.fn();
|
||||
render(<MobileEditModal {...defaultProps} onAddBlister={onAddBlister} />);
|
||||
|
||||
const addBtn = screen.getByText(/form\.blisters\.addIntake/i);
|
||||
fireEvent.click(addBtn);
|
||||
|
||||
expect(onAddBlister).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('renders modal content', () => {
|
||||
render(<MobileEditModal {...defaultProps} />);
|
||||
|
||||
const content = document.querySelector('.modal-content.edit-modal');
|
||||
expect(content).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders edit modal header', () => {
|
||||
render(<MobileEditModal {...defaultProps} />);
|
||||
|
||||
const header = document.querySelector('.edit-modal-header');
|
||||
expect(header).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('MobileEditModal with existing people', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders modal with existing people prop', () => {
|
||||
render(<MobileEditModal {...defaultProps} existingPeople={['John', 'Jane']} />);
|
||||
|
||||
// Should render the modal - suggestions shown on input focus
|
||||
const modal = document.querySelector('.modal-overlay');
|
||||
expect(modal).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('MobileEditModal with form errors', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('shows name error when present', () => {
|
||||
render(<MobileEditModal {...defaultProps} fieldErrors={{ name: 'Name is required' }} />);
|
||||
|
||||
expect(screen.getByText('Name is required')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows notes error when present', () => {
|
||||
render(<MobileEditModal {...defaultProps} fieldErrors={{ notes: 'Notes too long' }} />);
|
||||
|
||||
expect(screen.getByText('Notes too long')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('MobileEditModal blister management', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders blister rows', () => {
|
||||
render(<MobileEditModal {...defaultProps} />);
|
||||
|
||||
const blisterRows = document.querySelectorAll('.blister-row');
|
||||
expect(blisterRows.length).toBe(1);
|
||||
});
|
||||
|
||||
it('renders remove button for each blister', () => {
|
||||
const form = {
|
||||
...defaultForm,
|
||||
blisters: [
|
||||
{ usage: '1', every: '1', startDate: '2024-01-01', startTime: '09:00' },
|
||||
{ usage: '2', every: '7', startDate: '2024-01-01', startTime: '10:00' }
|
||||
]
|
||||
};
|
||||
|
||||
render(<MobileEditModal {...defaultProps} form={form} />);
|
||||
|
||||
const blisterRows = document.querySelectorAll('.blister-row');
|
||||
expect(blisterRows.length).toBe(2);
|
||||
});
|
||||
|
||||
it('calls onRemoveBlister when remove button clicked', () => {
|
||||
const onRemoveBlister = vi.fn();
|
||||
const form = {
|
||||
...defaultForm,
|
||||
blisters: [
|
||||
{ usage: '1', every: '1', startDate: '2024-01-01', startTime: '09:00' },
|
||||
{ usage: '2', every: '7', startDate: '2024-01-01', startTime: '10:00' }
|
||||
]
|
||||
};
|
||||
|
||||
render(<MobileEditModal {...defaultProps} form={form} onRemoveBlister={onRemoveBlister} />);
|
||||
|
||||
const removeButtons = document.querySelectorAll('.blister-row button.danger');
|
||||
if (removeButtons.length > 0) {
|
||||
fireEvent.click(removeButtons[0]);
|
||||
expect(onRemoveBlister).toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it('calls onSetBlisterValue when changing blister field', () => {
|
||||
const onSetBlisterValue = vi.fn();
|
||||
|
||||
render(<MobileEditModal {...defaultProps} onSetBlisterValue={onSetBlisterValue} />);
|
||||
|
||||
const usageInputs = document.querySelectorAll('.blister-row input[type="number"]');
|
||||
if (usageInputs.length > 0) {
|
||||
fireEvent.change(usageInputs[0], { target: { value: '2' } });
|
||||
expect(onSetBlisterValue).toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('MobileEditModal form submission', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('calls onSaveMedication when form submitted', () => {
|
||||
const onSaveMedication = vi.fn((e: Event) => e.preventDefault());
|
||||
|
||||
render(<MobileEditModal {...defaultProps} onSaveMedication={onSaveMedication} />);
|
||||
|
||||
const form = document.querySelector('form');
|
||||
if (form) {
|
||||
fireEvent.submit(form);
|
||||
expect(onSaveMedication).toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it('shows saving state', () => {
|
||||
render(<MobileEditModal {...defaultProps} saving={true} />);
|
||||
|
||||
const saveBtn = document.querySelector('button[type="submit"]');
|
||||
expect(saveBtn).toBeDisabled();
|
||||
});
|
||||
|
||||
it('shows formSaved state', () => {
|
||||
render(<MobileEditModal {...defaultProps} formSaved={true} />);
|
||||
|
||||
// Form should still render
|
||||
const modal = document.querySelector('.modal-overlay');
|
||||
expect(modal).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('MobileEditModal with filled form', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('displays filled form values', () => {
|
||||
const form = {
|
||||
...defaultForm,
|
||||
name: 'Aspirin',
|
||||
genericName: 'Acetylsalicylic acid',
|
||||
packCount: '2',
|
||||
blistersPerPack: '3',
|
||||
pillsPerBlister: '10',
|
||||
looseTablets: '5'
|
||||
};
|
||||
|
||||
render(<MobileEditModal {...defaultProps} form={form} />);
|
||||
|
||||
// Find input with the value
|
||||
const nameInputs = document.querySelectorAll('input');
|
||||
const nameInput = Array.from(nameInputs).find(input =>
|
||||
(input as HTMLInputElement).value === 'Aspirin'
|
||||
);
|
||||
expect(nameInput).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('MobileEditModal takenBy', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('displays takenBy tags', () => {
|
||||
const form = {
|
||||
...defaultForm,
|
||||
takenBy: ['John', 'Jane']
|
||||
};
|
||||
|
||||
render(<MobileEditModal {...defaultProps} form={form} />);
|
||||
|
||||
expect(screen.getByText('John')).toBeInTheDocument();
|
||||
expect(screen.getByText('Jane')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onRemoveTakenByPerson when tag removed', () => {
|
||||
const onRemoveTakenByPerson = vi.fn();
|
||||
const form = {
|
||||
...defaultForm,
|
||||
takenBy: ['John']
|
||||
};
|
||||
|
||||
render(<MobileEditModal {...defaultProps} form={form} onRemoveTakenByPerson={onRemoveTakenByPerson} />);
|
||||
|
||||
const removeButtons = document.querySelectorAll('.tag-remove');
|
||||
if (removeButtons.length > 0) {
|
||||
fireEvent.click(removeButtons[0]);
|
||||
expect(onRemoveTakenByPerson).toHaveBeenCalledWith('John');
|
||||
}
|
||||
});
|
||||
|
||||
it('calls onTakenByInputChange when typing', () => {
|
||||
const onTakenByInputChange = vi.fn();
|
||||
|
||||
render(<MobileEditModal {...defaultProps} onTakenByInputChange={onTakenByInputChange} />);
|
||||
|
||||
// Find the takenBy input using the container class
|
||||
const tagInputContainer = document.querySelector('.tag-input-container input');
|
||||
if (tagInputContainer) {
|
||||
fireEvent.change(tagInputContainer, { target: { value: 'New Person' } });
|
||||
expect(onTakenByInputChange).toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it('calls onTakenByKeyDown on keydown', () => {
|
||||
const onTakenByKeyDown = vi.fn();
|
||||
|
||||
render(<MobileEditModal {...defaultProps} onTakenByKeyDown={onTakenByKeyDown} />);
|
||||
|
||||
const tagInputContainer = document.querySelector('.tag-input-container input');
|
||||
if (tagInputContainer) {
|
||||
fireEvent.keyDown(tagInputContainer, { key: 'Enter' });
|
||||
expect(onTakenByKeyDown).toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('MobileEditModal overlay interaction', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('calls onClose when clicking overlay', () => {
|
||||
const onClose = vi.fn();
|
||||
const onResetForm = vi.fn();
|
||||
|
||||
render(<MobileEditModal {...defaultProps} onClose={onClose} onResetForm={onResetForm} />);
|
||||
|
||||
const overlay = document.querySelector('.modal-overlay');
|
||||
if (overlay) {
|
||||
fireEvent.click(overlay);
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it('does not close when clicking modal content', () => {
|
||||
const onClose = vi.fn();
|
||||
const onResetForm = vi.fn();
|
||||
|
||||
render(<MobileEditModal {...defaultProps} onClose={onClose} onResetForm={onResetForm} />);
|
||||
|
||||
const content = document.querySelector('.modal-content');
|
||||
if (content) {
|
||||
fireEvent.click(content);
|
||||
}
|
||||
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('MobileEditModal optional fields', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders expiry date field', () => {
|
||||
render(<MobileEditModal {...defaultProps} />);
|
||||
|
||||
const dateInput = document.querySelector('input[type="date"]');
|
||||
expect(dateInput).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders notes field', () => {
|
||||
render(<MobileEditModal {...defaultProps} />);
|
||||
|
||||
const textarea = document.querySelector('textarea');
|
||||
expect(textarea).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders pill weight field', () => {
|
||||
render(<MobileEditModal {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText(/form\.pillWeight/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders intake reminders toggle', () => {
|
||||
render(<MobileEditModal {...defaultProps} />);
|
||||
|
||||
const toggle = document.querySelector('.toggle-switch input[type="checkbox"]');
|
||||
expect(toggle).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import ProfileModal from '../../components/ProfileModal';
|
||||
|
||||
// Mock Auth UserProfile component
|
||||
vi.mock('../../components/Auth', () => ({
|
||||
UserProfile: ({ onClose }: { onClose: () => void }) => (
|
||||
<div data-testid="user-profile">User Profile Content</div>
|
||||
)
|
||||
}));
|
||||
|
||||
describe('ProfileModal', () => {
|
||||
it('renders nothing when not open', () => {
|
||||
const onClose = vi.fn();
|
||||
render(<ProfileModal isOpen={false} onClose={onClose} />);
|
||||
|
||||
expect(screen.queryByTestId('user-profile')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders modal when open', () => {
|
||||
const onClose = vi.fn();
|
||||
render(<ProfileModal isOpen={true} onClose={onClose} />);
|
||||
|
||||
expect(screen.getByTestId('user-profile')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders close button', () => {
|
||||
const onClose = vi.fn();
|
||||
render(<ProfileModal isOpen={true} onClose={onClose} />);
|
||||
|
||||
const closeBtn = screen.getByText('×');
|
||||
expect(closeBtn).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onClose when close button clicked', () => {
|
||||
const onClose = vi.fn();
|
||||
render(<ProfileModal isOpen={true} onClose={onClose} />);
|
||||
|
||||
const closeBtn = screen.getByText('×');
|
||||
fireEvent.click(closeBtn);
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('calls onClose when overlay clicked', () => {
|
||||
const onClose = vi.fn();
|
||||
render(<ProfileModal isOpen={true} onClose={onClose} />);
|
||||
|
||||
const overlay = document.querySelector('.modal-overlay');
|
||||
if (overlay) {
|
||||
fireEvent.click(overlay);
|
||||
}
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not call onClose when modal content clicked', () => {
|
||||
const onClose = vi.fn();
|
||||
render(<ProfileModal isOpen={true} onClose={onClose} />);
|
||||
|
||||
const content = document.querySelector('.modal-content');
|
||||
if (content) {
|
||||
fireEvent.click(content);
|
||||
}
|
||||
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { ShareDialog } from '../../components/ShareDialog';
|
||||
|
||||
describe('ShareDialog', () => {
|
||||
const defaultProps = {
|
||||
show: true,
|
||||
sharePeople: ['Alice', 'Bob'],
|
||||
shareSelectedPerson: 'Alice',
|
||||
onShareSelectedPersonChange: vi.fn(),
|
||||
shareSelectedDays: 30,
|
||||
onShareSelectedDaysChange: vi.fn(),
|
||||
shareGenerating: false,
|
||||
shareLink: null,
|
||||
onShareLinkChange: vi.fn(),
|
||||
shareCopied: false,
|
||||
onShareCopiedChange: vi.fn(),
|
||||
onClose: vi.fn(),
|
||||
onGenerateShareLink: vi.fn(),
|
||||
onCopyShareLink: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('returns null when show is false', () => {
|
||||
const { container } = render(<ShareDialog {...defaultProps} show={false} />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it('renders dialog when show is true', () => {
|
||||
render(<ShareDialog {...defaultProps} />);
|
||||
expect(screen.getByText(/share\.title/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders no people message when sharePeople is empty', () => {
|
||||
render(<ShareDialog {...defaultProps} sharePeople={[]} />);
|
||||
expect(screen.getByText(/share\.noPeople/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders person selection dropdown', () => {
|
||||
render(<ShareDialog {...defaultProps} />);
|
||||
expect(screen.getByRole('option', { name: 'Alice' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('option', { name: 'Bob' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders period selection dropdown', () => {
|
||||
render(<ShareDialog {...defaultProps} />);
|
||||
// The dropdown renders with 3 options for time periods
|
||||
const options = screen.getAllByRole('option');
|
||||
expect(options.length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
it('calls onClose when close button is clicked', () => {
|
||||
render(<ShareDialog {...defaultProps} />);
|
||||
fireEvent.click(screen.getByText('×'));
|
||||
expect(defaultProps.onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls onClose when overlay is clicked', () => {
|
||||
const { container } = render(<ShareDialog {...defaultProps} />);
|
||||
const overlay = container.querySelector('.modal-overlay');
|
||||
fireEvent.click(overlay!);
|
||||
expect(defaultProps.onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows generated link', () => {
|
||||
render(<ShareDialog {...defaultProps} shareLink="http://example.com/share/abc123" />);
|
||||
const input = screen.getByRole('textbox');
|
||||
expect(input).toHaveValue('http://example.com/share/abc123');
|
||||
});
|
||||
|
||||
it('calls onCopyShareLink when copy button is clicked', () => {
|
||||
render(<ShareDialog {...defaultProps} shareLink="http://example.com/share/abc123" />);
|
||||
fireEvent.click(screen.getByText('📋'));
|
||||
expect(defaultProps.onCopyShareLink).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows copied indicator after copy', () => {
|
||||
render(<ShareDialog {...defaultProps} shareLink="http://example.com/share/abc123" shareCopied={true} />);
|
||||
expect(screen.getByText('✓')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('selects link text when input is clicked', () => {
|
||||
render(<ShareDialog {...defaultProps} shareLink="http://example.com/share/abc123" />);
|
||||
const input = screen.getByRole('textbox') as HTMLInputElement;
|
||||
const selectMock = vi.fn();
|
||||
input.select = selectMock;
|
||||
fireEvent.click(input);
|
||||
expect(selectMock).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,193 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { MemoryRouter, Routes, Route } from 'react-router-dom';
|
||||
import { SharedSchedule } from '../../components/SharedSchedule';
|
||||
|
||||
describe('SharedSchedule', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('shows loading state initially', () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/share/test-token']}>
|
||||
<Routes>
|
||||
<Route path="/share/:token" element={<SharedSchedule />} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
// Should show loading state - actual translation key is common.loading
|
||||
expect(screen.getByText(/common\.loading/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders app title during loading', () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/share/test-token']}>
|
||||
<Routes>
|
||||
<Route path="/share/:token" element={<SharedSchedule />} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
expect(screen.getByText(/MedAssist/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders shared schedule page container', () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/share/test-token']}>
|
||||
<Routes>
|
||||
<Route path="/share/:token" element={<SharedSchedule />} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
const container = document.querySelector('.shared-schedule-page');
|
||||
expect(container).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders loading state container', () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/share/test-token']}>
|
||||
<Routes>
|
||||
<Route path="/share/:token" element={<SharedSchedule />} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
const loading = document.querySelector('.shared-schedule-loading');
|
||||
expect(loading).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('has correct initial theme', () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/share/test-token']}>
|
||||
<Routes>
|
||||
<Route path="/share/:token" element={<SharedSchedule />} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
// Default theme should be dark
|
||||
expect(document.documentElement.getAttribute('data-theme')).toBe('dark');
|
||||
});
|
||||
|
||||
it('renders h1 heading', () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/share/test-token']}>
|
||||
<Routes>
|
||||
<Route path="/share/:token" element={<SharedSchedule />} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
const heading = document.querySelector('h1');
|
||||
expect(heading).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders paragraph element', () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/share/test-token']}>
|
||||
<Routes>
|
||||
<Route path="/share/:token" element={<SharedSchedule />} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
const paragraph = document.querySelector('p');
|
||||
expect(paragraph).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('SharedSchedule with different tokens', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('renders with different token', () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/share/another-token']}>
|
||||
<Routes>
|
||||
<Route path="/share/:token" element={<SharedSchedule />} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
expect(screen.getByText(/common\.loading/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders with uuid token', () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/share/550e8400-e29b-41d4-a716-446655440000']}>
|
||||
<Routes>
|
||||
<Route path="/share/:token" element={<SharedSchedule />} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
expect(screen.getByText(/MedAssist/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('SharedSchedule theme persistence', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
// Reset data-theme to ensure clean state
|
||||
document.documentElement.removeAttribute('data-theme');
|
||||
});
|
||||
|
||||
it('uses saved theme from localStorage', () => {
|
||||
// Set theme before rendering
|
||||
localStorage.setItem('theme', 'light');
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/share/test-token']}>
|
||||
<Routes>
|
||||
<Route path="/share/:token" element={<SharedSchedule />} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
// After rendering, theme should be applied
|
||||
// The component reads from localStorage and sets the theme
|
||||
const theme = document.documentElement.getAttribute('data-theme');
|
||||
// Theme should be set (either from localStorage or default)
|
||||
expect(theme).toBeTruthy();
|
||||
});
|
||||
|
||||
it('defaults to dark theme when no saved theme', () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/share/test-token']}>
|
||||
<Routes>
|
||||
<Route path="/share/:token" element={<SharedSchedule />} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
expect(document.documentElement.getAttribute('data-theme')).toBe('dark');
|
||||
});
|
||||
});
|
||||
|
||||
describe('SharedSchedule keyboard handling', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('handles Escape key without error', () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/share/test-token']}>
|
||||
<Routes>
|
||||
<Route path="/share/:token" element={<SharedSchedule />} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
fireEvent.keyDown(window, { key: 'Escape' });
|
||||
// No error should occur
|
||||
expect(document.querySelector('.shared-schedule-page')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,138 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { TagInput } from '../../components/TagInput';
|
||||
|
||||
describe('TagInput', () => {
|
||||
const defaultProps = {
|
||||
tags: [] as string[],
|
||||
inputValue: '',
|
||||
onInputChange: vi.fn(),
|
||||
onAddTag: vi.fn(),
|
||||
onRemoveTag: vi.fn()
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders input element', () => {
|
||||
render(<TagInput {...defaultProps} />);
|
||||
expect(screen.getByRole('combobox')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders existing tags', () => {
|
||||
render(<TagInput {...defaultProps} tags={['Tag1', 'Tag2']} />);
|
||||
expect(screen.getByText('Tag1')).toBeInTheDocument();
|
||||
expect(screen.getByText('Tag2')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onInputChange when typing', () => {
|
||||
render(<TagInput {...defaultProps} />);
|
||||
const input = screen.getByRole('combobox');
|
||||
fireEvent.change(input, { target: { value: 'new tag' } });
|
||||
expect(defaultProps.onInputChange).toHaveBeenCalledWith('new tag');
|
||||
});
|
||||
|
||||
it('calls onAddTag when Enter is pressed with value', () => {
|
||||
render(<TagInput {...defaultProps} inputValue="new tag" />);
|
||||
const input = screen.getByRole('combobox');
|
||||
fireEvent.keyDown(input, { key: 'Enter' });
|
||||
expect(defaultProps.onAddTag).toHaveBeenCalledWith('new tag');
|
||||
});
|
||||
|
||||
it('calls onAddTag when comma is pressed with value', () => {
|
||||
render(<TagInput {...defaultProps} inputValue="new tag" />);
|
||||
const input = screen.getByRole('combobox');
|
||||
fireEvent.keyDown(input, { key: ',' });
|
||||
expect(defaultProps.onAddTag).toHaveBeenCalledWith('new tag');
|
||||
});
|
||||
|
||||
it('does not call onAddTag when Enter pressed with empty value', () => {
|
||||
render(<TagInput {...defaultProps} inputValue="" />);
|
||||
const input = screen.getByRole('combobox');
|
||||
fireEvent.keyDown(input, { key: 'Enter' });
|
||||
expect(defaultProps.onAddTag).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls onRemoveTag when Backspace is pressed with empty input', () => {
|
||||
render(<TagInput {...defaultProps} tags={['Tag1', 'Tag2']} inputValue="" />);
|
||||
const input = screen.getByRole('combobox');
|
||||
fireEvent.keyDown(input, { key: 'Backspace' });
|
||||
expect(defaultProps.onRemoveTag).toHaveBeenCalledWith('Tag2');
|
||||
});
|
||||
|
||||
it('does not call onRemoveTag when Backspace pressed with value', () => {
|
||||
render(<TagInput {...defaultProps} tags={['Tag1']} inputValue="text" />);
|
||||
const input = screen.getByRole('combobox');
|
||||
fireEvent.keyDown(input, { key: 'Backspace' });
|
||||
expect(defaultProps.onRemoveTag).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls onRemoveTag when tag remove button is clicked', () => {
|
||||
render(<TagInput {...defaultProps} tags={['Tag1', 'Tag2']} />);
|
||||
const removeButtons = screen.getAllByText('×');
|
||||
fireEvent.click(removeButtons[0]);
|
||||
expect(defaultProps.onRemoveTag).toHaveBeenCalledWith('Tag1');
|
||||
});
|
||||
|
||||
it('calls onAddTag on blur when there is a value', () => {
|
||||
render(<TagInput {...defaultProps} inputValue="pending tag" />);
|
||||
const input = screen.getByRole('combobox');
|
||||
fireEvent.blur(input);
|
||||
expect(defaultProps.onAddTag).toHaveBeenCalledWith('pending tag');
|
||||
});
|
||||
|
||||
it('shows placeholder when no tags', () => {
|
||||
render(<TagInput {...defaultProps} placeholder="Enter tags" />);
|
||||
expect(screen.getByPlaceholderText('Enter tags')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows addPlaceholder when tags exist', () => {
|
||||
render(
|
||||
<TagInput
|
||||
{...defaultProps}
|
||||
tags={['Tag1']}
|
||||
placeholder="Enter tags"
|
||||
addPlaceholder="Add more"
|
||||
/>
|
||||
);
|
||||
expect(screen.getByPlaceholderText('Add more')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies maxLength attribute', () => {
|
||||
render(<TagInput {...defaultProps} maxLength={50} />);
|
||||
const input = screen.getByRole('combobox');
|
||||
expect(input).toHaveAttribute('maxLength', '50');
|
||||
});
|
||||
|
||||
it('shows error message when provided', () => {
|
||||
render(<TagInput {...defaultProps} error="This field is required" />);
|
||||
expect(screen.getByText('This field is required')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders datalist for suggestions', () => {
|
||||
const { container } = render(
|
||||
<TagInput
|
||||
{...defaultProps}
|
||||
suggestions={['Option1', 'Option2']}
|
||||
datalistId="test-datalist"
|
||||
/>
|
||||
);
|
||||
const datalist = container.querySelector('#test-datalist');
|
||||
expect(datalist).toBeInTheDocument();
|
||||
expect(datalist?.querySelectorAll('option').length).toBe(2);
|
||||
});
|
||||
|
||||
it('excludes already selected tags from suggestions', () => {
|
||||
const { container } = render(
|
||||
<TagInput
|
||||
{...defaultProps}
|
||||
tags={['Option1']}
|
||||
suggestions={['Option1', 'Option2', 'Option3']}
|
||||
datalistId="test-datalist"
|
||||
/>
|
||||
);
|
||||
const datalist = container.querySelector('#test-datalist');
|
||||
expect(datalist?.querySelectorAll('option').length).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,281 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { UserFilterModal } from '../../components/UserFilterModal';
|
||||
import type { Medication, Coverage, StockThresholds } from '../../types';
|
||||
|
||||
const defaultSettings: StockThresholds = {
|
||||
lowStockDays: 7,
|
||||
normalStockDays: 30,
|
||||
highStockDays: 90
|
||||
};
|
||||
|
||||
const mockMedication: Medication = {
|
||||
id: 1,
|
||||
name: 'Test Med',
|
||||
genericName: 'Generic Name',
|
||||
packCount: 1,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 30,
|
||||
looseTablets: 0,
|
||||
takenBy: ['John'],
|
||||
blisters: [{ usage: 1, every: 1, start: '2024-01-01T09:00:00' }],
|
||||
updatedAt: null
|
||||
};
|
||||
|
||||
const mockCoverage: Coverage = {
|
||||
name: 'Test Med',
|
||||
medsLeft: 25,
|
||||
daysLeft: 25,
|
||||
depletionDate: null,
|
||||
depletionTime: null,
|
||||
nextDose: null
|
||||
};
|
||||
|
||||
describe('UserFilterModal', () => {
|
||||
it('renders nothing when selectedUser is null', () => {
|
||||
const onClose = vi.fn();
|
||||
const onOpenMedDetail = vi.fn();
|
||||
|
||||
render(
|
||||
<UserFilterModal
|
||||
selectedUser={null}
|
||||
meds={[mockMedication]}
|
||||
coverage={{ all: [mockCoverage] }}
|
||||
settings={defaultSettings}
|
||||
onClose={onClose}
|
||||
onOpenMedDetail={onOpenMedDetail}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.queryByText(/modal\.userMedications/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders modal when selectedUser is provided', () => {
|
||||
const onClose = vi.fn();
|
||||
const onOpenMedDetail = vi.fn();
|
||||
|
||||
render(
|
||||
<UserFilterModal
|
||||
selectedUser="John"
|
||||
meds={[mockMedication]}
|
||||
coverage={{ all: [mockCoverage] }}
|
||||
settings={defaultSettings}
|
||||
onClose={onClose}
|
||||
onOpenMedDetail={onOpenMedDetail}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText(/modal\.userMedications/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays user avatar', () => {
|
||||
const onClose = vi.fn();
|
||||
const onOpenMedDetail = vi.fn();
|
||||
|
||||
render(
|
||||
<UserFilterModal
|
||||
selectedUser="John"
|
||||
meds={[mockMedication]}
|
||||
coverage={{ all: [mockCoverage] }}
|
||||
settings={defaultSettings}
|
||||
onClose={onClose}
|
||||
onOpenMedDetail={onOpenMedDetail}
|
||||
/>
|
||||
);
|
||||
|
||||
// Avatar should show first letter
|
||||
expect(screen.getByText('J')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays medications for selected user', () => {
|
||||
const onClose = vi.fn();
|
||||
const onOpenMedDetail = vi.fn();
|
||||
|
||||
render(
|
||||
<UserFilterModal
|
||||
selectedUser="John"
|
||||
meds={[mockMedication]}
|
||||
coverage={{ all: [mockCoverage] }}
|
||||
settings={defaultSettings}
|
||||
onClose={onClose}
|
||||
onOpenMedDetail={onOpenMedDetail}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Test Med')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays generic name when available', () => {
|
||||
const onClose = vi.fn();
|
||||
const onOpenMedDetail = vi.fn();
|
||||
|
||||
render(
|
||||
<UserFilterModal
|
||||
selectedUser="John"
|
||||
meds={[mockMedication]}
|
||||
coverage={{ all: [mockCoverage] }}
|
||||
settings={defaultSettings}
|
||||
onClose={onClose}
|
||||
onOpenMedDetail={onOpenMedDetail}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Generic Name')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows empty message when user has no medications', () => {
|
||||
const onClose = vi.fn();
|
||||
const onOpenMedDetail = vi.fn();
|
||||
|
||||
render(
|
||||
<UserFilterModal
|
||||
selectedUser="Jane"
|
||||
meds={[mockMedication]}
|
||||
coverage={{ all: [mockCoverage] }}
|
||||
settings={defaultSettings}
|
||||
onClose={onClose}
|
||||
onOpenMedDetail={onOpenMedDetail}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText(/modal\.noMedsForUser/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onClose when close button clicked', () => {
|
||||
const onClose = vi.fn();
|
||||
const onOpenMedDetail = vi.fn();
|
||||
|
||||
render(
|
||||
<UserFilterModal
|
||||
selectedUser="John"
|
||||
meds={[mockMedication]}
|
||||
coverage={{ all: [mockCoverage] }}
|
||||
settings={defaultSettings}
|
||||
onClose={onClose}
|
||||
onOpenMedDetail={onOpenMedDetail}
|
||||
/>
|
||||
);
|
||||
|
||||
const closeBtn = screen.getByText('×');
|
||||
fireEvent.click(closeBtn);
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('calls onClose when overlay clicked', () => {
|
||||
const onClose = vi.fn();
|
||||
const onOpenMedDetail = vi.fn();
|
||||
|
||||
render(
|
||||
<UserFilterModal
|
||||
selectedUser="John"
|
||||
meds={[mockMedication]}
|
||||
coverage={{ all: [mockCoverage] }}
|
||||
settings={defaultSettings}
|
||||
onClose={onClose}
|
||||
onOpenMedDetail={onOpenMedDetail}
|
||||
/>
|
||||
);
|
||||
|
||||
const overlay = document.querySelector('.modal-overlay');
|
||||
if (overlay) {
|
||||
fireEvent.click(overlay);
|
||||
}
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('calls onClose and onOpenMedDetail when medication clicked', () => {
|
||||
const onClose = vi.fn();
|
||||
const onOpenMedDetail = vi.fn();
|
||||
|
||||
render(
|
||||
<UserFilterModal
|
||||
selectedUser="John"
|
||||
meds={[mockMedication]}
|
||||
coverage={{ all: [mockCoverage] }}
|
||||
settings={defaultSettings}
|
||||
onClose={onClose}
|
||||
onOpenMedDetail={onOpenMedDetail}
|
||||
/>
|
||||
);
|
||||
|
||||
const medItem = document.querySelector('.user-med-item');
|
||||
if (medItem) {
|
||||
fireEvent.click(medItem);
|
||||
}
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
expect(onOpenMedDetail).toHaveBeenCalledWith(mockMedication);
|
||||
});
|
||||
|
||||
it('calls onClose when footer close button clicked', () => {
|
||||
const onClose = vi.fn();
|
||||
const onOpenMedDetail = vi.fn();
|
||||
|
||||
render(
|
||||
<UserFilterModal
|
||||
selectedUser="John"
|
||||
meds={[mockMedication]}
|
||||
coverage={{ all: [mockCoverage] }}
|
||||
settings={defaultSettings}
|
||||
onClose={onClose}
|
||||
onOpenMedDetail={onOpenMedDetail}
|
||||
/>
|
||||
);
|
||||
|
||||
const footerCloseBtn = screen.getByText(/common\.close/i);
|
||||
fireEvent.click(footerCloseBtn);
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not call onClose when modal content clicked', () => {
|
||||
const onClose = vi.fn();
|
||||
const onOpenMedDetail = vi.fn();
|
||||
|
||||
render(
|
||||
<UserFilterModal
|
||||
selectedUser="John"
|
||||
meds={[mockMedication]}
|
||||
coverage={{ all: [mockCoverage] }}
|
||||
settings={defaultSettings}
|
||||
onClose={onClose}
|
||||
onOpenMedDetail={onOpenMedDetail}
|
||||
/>
|
||||
);
|
||||
|
||||
const content = document.querySelector('.modal-content');
|
||||
if (content) {
|
||||
fireEvent.click(content);
|
||||
}
|
||||
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('filters medications by takenBy correctly', () => {
|
||||
const onClose = vi.fn();
|
||||
const onOpenMedDetail = vi.fn();
|
||||
|
||||
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'] }
|
||||
];
|
||||
|
||||
render(
|
||||
<UserFilterModal
|
||||
selectedUser="John"
|
||||
meds={meds}
|
||||
coverage={{ all: [] }}
|
||||
settings={defaultSettings}
|
||||
onClose={onClose}
|
||||
onOpenMedDetail={onOpenMedDetail}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Med1')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Med2')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('Med3')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { useCollapsedDays } from '../../hooks/useCollapsedDays';
|
||||
|
||||
describe('useCollapsedDays', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
(window.localStorage.getItem as ReturnType<typeof vi.fn>).mockReturnValue(null);
|
||||
(window.localStorage.setItem as ReturnType<typeof vi.fn>).mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('returns empty sets initially when no userId', () => {
|
||||
const { result } = renderHook(() => useCollapsedDays(undefined));
|
||||
|
||||
expect(result.current.manuallyCollapsedDays.size).toBe(0);
|
||||
expect(result.current.manuallyExpandedDays.size).toBe(0);
|
||||
});
|
||||
|
||||
it('loads from localStorage when userId is provided', () => {
|
||||
(window.localStorage.getItem as ReturnType<typeof vi.fn>).mockImplementation((key: string) => {
|
||||
if (key === 'collapsedDays_user_1') return JSON.stringify(['2024-01-01']);
|
||||
if (key === 'expandedDays_user_1') return JSON.stringify(['2024-01-02']);
|
||||
return null;
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useCollapsedDays(1));
|
||||
|
||||
expect(result.current.manuallyCollapsedDays.has('2024-01-01')).toBe(true);
|
||||
expect(result.current.manuallyExpandedDays.has('2024-01-02')).toBe(true);
|
||||
});
|
||||
|
||||
it('toggles collapsed day when not auto-collapsed', () => {
|
||||
const { result } = renderHook(() => useCollapsedDays(1));
|
||||
|
||||
act(() => {
|
||||
result.current.toggleDayCollapse('2024-01-01', false);
|
||||
});
|
||||
|
||||
expect(result.current.manuallyCollapsedDays.has('2024-01-01')).toBe(true);
|
||||
|
||||
act(() => {
|
||||
result.current.toggleDayCollapse('2024-01-01', false);
|
||||
});
|
||||
|
||||
expect(result.current.manuallyCollapsedDays.has('2024-01-01')).toBe(false);
|
||||
});
|
||||
|
||||
it('toggles expanded day when auto-collapsed', () => {
|
||||
const { result } = renderHook(() => useCollapsedDays(1));
|
||||
|
||||
act(() => {
|
||||
result.current.toggleDayCollapse('2024-01-01', true);
|
||||
});
|
||||
|
||||
expect(result.current.manuallyExpandedDays.has('2024-01-01')).toBe(true);
|
||||
|
||||
act(() => {
|
||||
result.current.toggleDayCollapse('2024-01-01', true);
|
||||
});
|
||||
|
||||
expect(result.current.manuallyExpandedDays.has('2024-01-01')).toBe(false);
|
||||
});
|
||||
|
||||
it('saves to localStorage when toggling with userId', () => {
|
||||
const { result } = renderHook(() => useCollapsedDays(1));
|
||||
|
||||
act(() => {
|
||||
result.current.toggleDayCollapse('2024-01-01', false);
|
||||
});
|
||||
|
||||
expect(window.localStorage.setItem).toHaveBeenCalledWith(
|
||||
'collapsedDays_user_1',
|
||||
expect.any(String)
|
||||
);
|
||||
});
|
||||
|
||||
it('does not save to localStorage without userId', () => {
|
||||
const { result } = renderHook(() => useCollapsedDays(undefined));
|
||||
|
||||
act(() => {
|
||||
result.current.toggleDayCollapse('2024-01-01', false);
|
||||
});
|
||||
|
||||
expect(window.localStorage.setItem).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,246 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { renderHook, act, waitFor } from '@testing-library/react';
|
||||
import { useDoses } from '../../hooks/useDoses';
|
||||
|
||||
describe('useDoses', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ doses: [] })
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('initializes with empty state', () => {
|
||||
const { result } = renderHook(() => useDoses());
|
||||
|
||||
expect(result.current.takenDoses.size).toBe(0);
|
||||
expect(result.current.dismissedDoses.size).toBe(0);
|
||||
expect(result.current.clearingMissed).toBe(false);
|
||||
expect(result.current.showClearMissedConfirm).toBe(false);
|
||||
});
|
||||
|
||||
it('loads taken doses from API on mount', async () => {
|
||||
const mockDoses = {
|
||||
doses: [
|
||||
{ doseId: 'dose-1', dismissed: false },
|
||||
{ doseId: 'dose-2', dismissed: true }
|
||||
]
|
||||
};
|
||||
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockDoses)
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useDoses());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.takenDoses.has('dose-1')).toBe(true);
|
||||
expect(result.current.dismissedDoses.has('dose-2')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('getDoseId returns correct ID format', () => {
|
||||
const { result } = renderHook(() => useDoses());
|
||||
|
||||
expect(result.current.getDoseId('dose-1', null)).toBe('dose-1');
|
||||
expect(result.current.getDoseId('dose-1', 'John')).toBe('dose-1-John');
|
||||
});
|
||||
|
||||
it('countTakenDoses calculates correctly', async () => {
|
||||
const mockDoses = {
|
||||
doses: [{ doseId: 'dose-1', dismissed: false }]
|
||||
};
|
||||
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockDoses)
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useDoses());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.takenDoses.has('dose-1')).toBe(true);
|
||||
});
|
||||
|
||||
const doses = [
|
||||
{ id: 'dose-1', takenBy: [] },
|
||||
{ id: 'dose-2', takenBy: [] }
|
||||
];
|
||||
|
||||
const count = result.current.countTakenDoses(doses);
|
||||
expect(count.total).toBe(2);
|
||||
expect(count.taken).toBe(1);
|
||||
});
|
||||
|
||||
it('countTakenDoses handles multiple people', async () => {
|
||||
const mockDoses = {
|
||||
doses: [
|
||||
{ doseId: 'dose-1-Alice', dismissed: false },
|
||||
{ doseId: 'dose-1-Bob', dismissed: false }
|
||||
]
|
||||
};
|
||||
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockDoses)
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useDoses());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.takenDoses.size).toBe(2);
|
||||
});
|
||||
|
||||
const doses = [{ id: 'dose-1', takenBy: ['Alice', 'Bob', 'Charlie'] }];
|
||||
const count = result.current.countTakenDoses(doses);
|
||||
expect(count.total).toBe(3);
|
||||
expect(count.taken).toBe(2);
|
||||
});
|
||||
|
||||
it('marks dose as taken optimistically', async () => {
|
||||
// First call for initial load, subsequent calls for marking dose
|
||||
(global.fetch as ReturnType<typeof vi.fn>)
|
||||
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ doses: [] }) })
|
||||
.mockResolvedValueOnce({ ok: true });
|
||||
|
||||
const { result } = renderHook(() => useDoses());
|
||||
|
||||
// Wait for initial load to complete
|
||||
await waitFor(() => {
|
||||
expect(result.current.takenDoses.size).toBe(0);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.markDoseTaken('new-dose');
|
||||
});
|
||||
|
||||
expect(result.current.takenDoses.has('new-dose')).toBe(true);
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
'/api/doses/taken',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ doseId: 'new-dose' })
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('reverts optimistic update on error', async () => {
|
||||
// First call for initial load, second for marking dose fails
|
||||
(global.fetch as ReturnType<typeof vi.fn>)
|
||||
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ doses: [] }) })
|
||||
.mockRejectedValueOnce(new Error('Network error'));
|
||||
|
||||
const { result } = renderHook(() => useDoses());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.takenDoses.size).toBe(0);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.markDoseTaken('new-dose');
|
||||
});
|
||||
|
||||
// After error, the dose should be removed
|
||||
await waitFor(() => {
|
||||
expect(result.current.takenDoses.has('new-dose')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('undoes dose taken optimistically', async () => {
|
||||
const mockDoses = {
|
||||
doses: [{ doseId: 'taken-dose', dismissed: false }]
|
||||
};
|
||||
|
||||
(global.fetch as ReturnType<typeof vi.fn>)
|
||||
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(mockDoses) })
|
||||
.mockResolvedValueOnce({ ok: true });
|
||||
|
||||
const { result } = renderHook(() => useDoses());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.takenDoses.has('taken-dose')).toBe(true);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.undoDoseTaken('taken-dose');
|
||||
});
|
||||
|
||||
expect(result.current.takenDoses.has('taken-dose')).toBe(false);
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
'/api/doses/taken/taken-dose',
|
||||
expect.objectContaining({ method: 'DELETE' })
|
||||
);
|
||||
});
|
||||
|
||||
it('dismisses missed doses', async () => {
|
||||
(global.fetch as ReturnType<typeof vi.fn>)
|
||||
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ doses: [] }) })
|
||||
.mockResolvedValueOnce({ ok: true });
|
||||
|
||||
const { result } = renderHook(() => useDoses());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.clearingMissed).toBe(false);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.dismissMissedDoses(['missed-1', 'missed-2']);
|
||||
});
|
||||
|
||||
expect(result.current.dismissedDoses.has('missed-1')).toBe(true);
|
||||
expect(result.current.dismissedDoses.has('missed-2')).toBe(true);
|
||||
});
|
||||
|
||||
it('does nothing when dismissing empty array', async () => {
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ doses: [] })
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useDoses());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.dismissMissedDoses([]);
|
||||
});
|
||||
|
||||
// Should not make a POST call for dismiss
|
||||
expect(fetch).not.toHaveBeenCalledWith(
|
||||
'/api/doses/dismiss',
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
it('setShowClearMissedConfirm works', () => {
|
||||
const { result } = renderHook(() => useDoses());
|
||||
|
||||
act(() => {
|
||||
result.current.setShowClearMissedConfirm(true);
|
||||
});
|
||||
|
||||
expect(result.current.showClearMissedConfirm).toBe(true);
|
||||
});
|
||||
|
||||
it('handles API error on dismiss gracefully', async () => {
|
||||
(global.fetch as ReturnType<typeof vi.fn>)
|
||||
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ doses: [] }) })
|
||||
.mockRejectedValueOnce(new Error('Network error'));
|
||||
|
||||
const { result } = renderHook(() => useDoses());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.clearingMissed).toBe(false);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.dismissMissedDoses(['missed-1']);
|
||||
});
|
||||
|
||||
expect(result.current.clearingMissed).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { defaultForm, defaultBlister } from '../../hooks/useMedicationForm';
|
||||
|
||||
// Note: Hook tests were causing memory issues due to complex dependencies
|
||||
// Testing only the exported utility functions to avoid heap overflow
|
||||
|
||||
describe('defaultBlister', () => {
|
||||
it('creates a blister with default values', () => {
|
||||
const blister = defaultBlister();
|
||||
expect(blister.usage).toBe('1');
|
||||
expect(blister.every).toBe('1');
|
||||
expect(blister.startDate).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
||||
expect(blister.startTime).toMatch(/^\d{2}:\d{2}$/);
|
||||
});
|
||||
|
||||
it('uses current date', () => {
|
||||
const before = new Date();
|
||||
const blister = defaultBlister();
|
||||
const after = new Date();
|
||||
|
||||
const blisterDate = new Date(blister.startDate);
|
||||
expect(blisterDate >= new Date(before.toISOString().slice(0, 10))).toBe(true);
|
||||
expect(blisterDate <= new Date(after.toISOString().slice(0, 10) + 'T23:59:59')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('defaultForm', () => {
|
||||
it('creates a form with default values', () => {
|
||||
const form = defaultForm();
|
||||
expect(form.name).toBe('');
|
||||
expect(form.genericName).toBe('');
|
||||
expect(form.takenBy).toEqual([]);
|
||||
expect(form.packCount).toBe('1');
|
||||
expect(form.blistersPerPack).toBe('1');
|
||||
expect(form.pillsPerBlister).toBe('1');
|
||||
expect(form.looseTablets).toBe('0');
|
||||
expect(form.pillWeightMg).toBe('');
|
||||
expect(form.expiryDate).toBe('');
|
||||
expect(form.notes).toBe('');
|
||||
expect(form.intakeRemindersEnabled).toBe(false);
|
||||
expect(form.blisters).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('creates a blister in the form', () => {
|
||||
const form = defaultForm();
|
||||
expect(form.blisters).toHaveLength(1);
|
||||
expect(form.blisters[0].usage).toBe('1');
|
||||
expect(form.blisters[0].every).toBe('1');
|
||||
});
|
||||
|
||||
it('creates independent forms', () => {
|
||||
const form1 = defaultForm();
|
||||
const form2 = defaultForm();
|
||||
|
||||
form1.name = 'Test';
|
||||
expect(form2.name).toBe('');
|
||||
});
|
||||
|
||||
it('creates independent blisters arrays', () => {
|
||||
const form1 = defaultForm();
|
||||
const form2 = defaultForm();
|
||||
|
||||
form1.blisters.push(defaultBlister());
|
||||
expect(form2.blisters).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('creates independent takenBy arrays', () => {
|
||||
const form1 = defaultForm();
|
||||
const form2 = defaultForm();
|
||||
|
||||
form1.takenBy.push('John');
|
||||
expect(form2.takenBy).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,197 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { renderHook, act, waitFor } from '@testing-library/react';
|
||||
import { useMedications } from '../../hooks/useMedications';
|
||||
|
||||
describe('useMedications', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve([])
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('initializes with empty state', () => {
|
||||
const { result } = renderHook(() => useMedications());
|
||||
|
||||
expect(result.current.meds).toEqual([]);
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(result.current.saving).toBe(false);
|
||||
expect(result.current.uploadingImage).toBe(false);
|
||||
});
|
||||
|
||||
it('loads medications from API', async () => {
|
||||
const mockMeds = [
|
||||
{ id: 1, name: 'TestMed', packCount: 1 }
|
||||
];
|
||||
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockMeds)
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useMedications());
|
||||
|
||||
act(() => {
|
||||
result.current.loadMeds();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.meds).toEqual(mockMeds);
|
||||
});
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith('/api/medications');
|
||||
});
|
||||
|
||||
it('handles API error gracefully', async () => {
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error('Network error'));
|
||||
|
||||
const { result } = renderHook(() => useMedications());
|
||||
|
||||
act(() => {
|
||||
result.current.loadMeds();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.meds).toEqual([]);
|
||||
});
|
||||
|
||||
it('handles non-array response', async () => {
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ not: 'array' })
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useMedications());
|
||||
|
||||
act(() => {
|
||||
result.current.loadMeds();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.meds).toEqual([]);
|
||||
});
|
||||
|
||||
it('deletes medication', async () => {
|
||||
const mockMeds = [{ id: 1, name: 'TestMed' }];
|
||||
(global.fetch as ReturnType<typeof vi.fn>)
|
||||
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(mockMeds) })
|
||||
.mockResolvedValueOnce({ ok: true })
|
||||
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve([]) });
|
||||
|
||||
const mockResetForm = vi.fn();
|
||||
const { result } = renderHook(() => useMedications());
|
||||
|
||||
// First load meds
|
||||
act(() => {
|
||||
result.current.loadMeds();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.meds).toEqual(mockMeds);
|
||||
});
|
||||
|
||||
// Then delete
|
||||
await act(async () => {
|
||||
await result.current.deleteMed(1, 1, mockResetForm);
|
||||
});
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith('/api/medications/1', { method: 'DELETE' });
|
||||
expect(mockResetForm).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not call resetForm if editingId does not match', async () => {
|
||||
(global.fetch as ReturnType<typeof vi.fn>)
|
||||
.mockResolvedValueOnce({ ok: true })
|
||||
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve([]) });
|
||||
|
||||
const mockResetForm = vi.fn();
|
||||
const { result } = renderHook(() => useMedications());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.deleteMed(1, 2, mockResetForm);
|
||||
});
|
||||
|
||||
expect(mockResetForm).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uploads medication image', async () => {
|
||||
(global.fetch as ReturnType<typeof vi.fn>)
|
||||
.mockResolvedValueOnce({ ok: true })
|
||||
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve([]) });
|
||||
|
||||
const { result } = renderHook(() => useMedications());
|
||||
const file = new File(['test'], 'test.jpg', { type: 'image/jpeg' });
|
||||
|
||||
await act(async () => {
|
||||
await result.current.uploadMedImage(1, file);
|
||||
});
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
'/api/medications/1/image',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: expect.any(FormData)
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('handles image upload error', async () => {
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error('Upload failed'));
|
||||
|
||||
const { result } = renderHook(() => useMedications());
|
||||
const file = new File(['test'], 'test.jpg', { type: 'image/jpeg' });
|
||||
|
||||
await act(async () => {
|
||||
await result.current.uploadMedImage(1, file);
|
||||
});
|
||||
|
||||
expect(result.current.uploadingImage).toBe(false);
|
||||
});
|
||||
|
||||
it('deletes medication image', async () => {
|
||||
(global.fetch as ReturnType<typeof vi.fn>)
|
||||
.mockResolvedValueOnce({ ok: true })
|
||||
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve([]) });
|
||||
|
||||
const { result } = renderHook(() => useMedications());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.deleteMedImage(1);
|
||||
});
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith('/api/medications/1/image', { method: 'DELETE' });
|
||||
});
|
||||
|
||||
it('allows setting meds directly', () => {
|
||||
const { result } = renderHook(() => useMedications());
|
||||
|
||||
const newMeds = [{ id: 1, name: 'NewMed' }] as any;
|
||||
|
||||
act(() => {
|
||||
result.current.setMeds(newMeds);
|
||||
});
|
||||
|
||||
expect(result.current.meds).toEqual(newMeds);
|
||||
});
|
||||
|
||||
it('allows setting saving state', () => {
|
||||
const { result } = renderHook(() => useMedications());
|
||||
|
||||
act(() => {
|
||||
result.current.setSaving(true);
|
||||
});
|
||||
|
||||
expect(result.current.saving).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,313 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { renderHook, act, waitFor } from '@testing-library/react';
|
||||
import { useRefill } from '../../hooks/useRefill';
|
||||
import type { Medication, Coverage } from '../../types';
|
||||
|
||||
describe('useRefill', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({})
|
||||
});
|
||||
vi.spyOn(window.history, 'pushState').mockImplementation(() => {});
|
||||
vi.spyOn(window.history, 'back').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('initializes with default state', () => {
|
||||
const { result } = renderHook(() => useRefill());
|
||||
|
||||
expect(result.current.showRefillModal).toBe(false);
|
||||
expect(result.current.refillPacks).toBe(1);
|
||||
expect(result.current.refillLoose).toBe(0);
|
||||
expect(result.current.refillSaving).toBe(false);
|
||||
expect(result.current.refillHistory).toEqual([]);
|
||||
expect(result.current.refillHistoryExpanded).toBe(false);
|
||||
expect(result.current.showEditStockModal).toBe(false);
|
||||
});
|
||||
|
||||
it('loads refill history', async () => {
|
||||
const mockHistory = [
|
||||
{ id: 1, packsAdded: 2, loosePillsAdded: 0, createdAt: '2024-03-15T10:00:00Z' }
|
||||
];
|
||||
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockHistory)
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useRefill());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.loadRefillHistory(1);
|
||||
});
|
||||
|
||||
expect(result.current.refillHistory).toEqual(mockHistory);
|
||||
});
|
||||
|
||||
it('handles refill history with refills wrapper', async () => {
|
||||
const mockHistory = {
|
||||
refills: [{ id: 1, packsAdded: 2, createdAt: '2024-03-15T10:00:00Z' }]
|
||||
};
|
||||
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockHistory)
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useRefill());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.loadRefillHistory(1);
|
||||
});
|
||||
|
||||
expect(result.current.refillHistory).toEqual(mockHistory.refills);
|
||||
});
|
||||
|
||||
it('handles refill history error', async () => {
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error('Network error'));
|
||||
|
||||
const { result } = renderHook(() => useRefill());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.loadRefillHistory(1);
|
||||
});
|
||||
|
||||
expect(result.current.refillHistory).toEqual([]);
|
||||
});
|
||||
|
||||
it('opens refill modal and pushes history', () => {
|
||||
const { result } = renderHook(() => useRefill());
|
||||
|
||||
act(() => {
|
||||
result.current.openRefillModal();
|
||||
});
|
||||
|
||||
expect(result.current.showRefillModal).toBe(true);
|
||||
expect(window.history.pushState).toHaveBeenCalledWith({ modal: 'refill' }, '');
|
||||
});
|
||||
|
||||
it('closes refill modal using history back', () => {
|
||||
const { result } = renderHook(() => useRefill());
|
||||
|
||||
act(() => {
|
||||
result.current.openRefillModal();
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.closeRefillModal();
|
||||
});
|
||||
|
||||
expect(window.history.back).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not call history back when refill modal not open', () => {
|
||||
const { result } = renderHook(() => useRefill());
|
||||
|
||||
act(() => {
|
||||
result.current.closeRefillModal();
|
||||
});
|
||||
|
||||
expect(window.history.back).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('submits refill successfully', async () => {
|
||||
(global.fetch as ReturnType<typeof vi.fn>)
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ newStock: { packCount: 3, looseTablets: 5 } })
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve([])
|
||||
});
|
||||
|
||||
const mockSetForm = vi.fn();
|
||||
const mockLoadMeds = vi.fn();
|
||||
|
||||
const { result } = renderHook(() => useRefill());
|
||||
|
||||
// Open modal first
|
||||
act(() => {
|
||||
result.current.openRefillModal();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.submitRefill(1, 1, mockSetForm, mockLoadMeds);
|
||||
});
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
'/api/medications/1/refill',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ packsAdded: 1, loosePillsAdded: 0 })
|
||||
})
|
||||
);
|
||||
expect(mockSetForm).toHaveBeenCalled();
|
||||
expect(mockLoadMeds).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not submit refill if both values are 0', async () => {
|
||||
const { result } = renderHook(() => useRefill());
|
||||
|
||||
act(() => {
|
||||
result.current.setRefillPacks(0);
|
||||
result.current.setRefillLoose(0);
|
||||
});
|
||||
|
||||
const mockSetForm = vi.fn();
|
||||
const mockLoadMeds = vi.fn();
|
||||
|
||||
await act(async () => {
|
||||
await result.current.submitRefill(1, 1, mockSetForm, mockLoadMeds);
|
||||
});
|
||||
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('opens edit stock modal', () => {
|
||||
const { result } = renderHook(() => useRefill());
|
||||
|
||||
const mockMed: Medication = {
|
||||
id: 1,
|
||||
name: 'Test Med',
|
||||
packCount: 1,
|
||||
blistersPerPack: 2,
|
||||
pillsPerBlister: 10,
|
||||
looseTablets: 5,
|
||||
takenBy: [],
|
||||
blisters: [],
|
||||
updatedAt: null
|
||||
};
|
||||
|
||||
const mockCoverage = {
|
||||
all: [{ name: 'Test Med', medsLeft: 20, daysLeft: 10 }] as Coverage[]
|
||||
};
|
||||
|
||||
act(() => {
|
||||
result.current.openEditStockModal(mockMed, mockCoverage);
|
||||
});
|
||||
|
||||
expect(result.current.showEditStockModal).toBe(true);
|
||||
expect(window.history.pushState).toHaveBeenCalledWith({ modal: 'editStock' }, '');
|
||||
expect(result.current.editStockFullBlisters).toBe(2); // 20 / 10 = 2
|
||||
expect(result.current.editStockPartialBlisterPills).toBe(0); // 20 % 10 = 0
|
||||
});
|
||||
|
||||
it('closes edit stock modal using history back', () => {
|
||||
const { result } = renderHook(() => useRefill());
|
||||
|
||||
const mockMed: Medication = {
|
||||
id: 1,
|
||||
name: 'Test Med',
|
||||
packCount: 1,
|
||||
blistersPerPack: 2,
|
||||
pillsPerBlister: 10,
|
||||
looseTablets: 5,
|
||||
takenBy: [],
|
||||
blisters: [],
|
||||
updatedAt: null
|
||||
};
|
||||
|
||||
act(() => {
|
||||
result.current.openEditStockModal(mockMed, { all: [] });
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.closeEditStockModal();
|
||||
});
|
||||
|
||||
expect(window.history.back).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('submits stock correction', async () => {
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ ok: true });
|
||||
|
||||
const mockMed: Medication = {
|
||||
id: 1,
|
||||
name: 'Test Med',
|
||||
packCount: 1,
|
||||
blistersPerPack: 2,
|
||||
pillsPerBlister: 10,
|
||||
looseTablets: 5,
|
||||
takenBy: [],
|
||||
blisters: [],
|
||||
updatedAt: null
|
||||
};
|
||||
|
||||
const mockLoadMeds = vi.fn();
|
||||
const { result } = renderHook(() => useRefill());
|
||||
|
||||
act(() => {
|
||||
result.current.openEditStockModal(mockMed, { all: [] });
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.submitStockCorrection(1, mockMed, mockLoadMeds);
|
||||
});
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
'/api/medications/1/stock-adjustment',
|
||||
expect.objectContaining({ method: 'PATCH' })
|
||||
);
|
||||
expect(mockLoadMeds).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles full blister conversion in stock correction', async () => {
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ ok: true });
|
||||
|
||||
const mockMed: Medication = {
|
||||
id: 1,
|
||||
name: 'Test Med',
|
||||
packCount: 1,
|
||||
blistersPerPack: 2,
|
||||
pillsPerBlister: 10,
|
||||
looseTablets: 5,
|
||||
takenBy: [],
|
||||
blisters: [],
|
||||
updatedAt: null
|
||||
};
|
||||
|
||||
const mockLoadMeds = vi.fn();
|
||||
const { result } = renderHook(() => useRefill());
|
||||
|
||||
act(() => {
|
||||
result.current.openEditStockModal(mockMed, { all: [] });
|
||||
// Set partial pills to equal a full blister
|
||||
result.current.setEditStockPartialBlisterPills(10);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.submitStockCorrection(1, mockMed, mockLoadMeds);
|
||||
});
|
||||
|
||||
expect(fetch).toHaveBeenCalled();
|
||||
expect(mockLoadMeds).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows setting state directly', () => {
|
||||
const { result } = renderHook(() => useRefill());
|
||||
|
||||
act(() => {
|
||||
result.current.setRefillPacks(5);
|
||||
result.current.setRefillLoose(3);
|
||||
result.current.setRefillHistoryExpanded(true);
|
||||
result.current.setShowRefillModal(true);
|
||||
result.current.setShowEditStockModal(true);
|
||||
result.current.setEditStockFullBlisters(10);
|
||||
result.current.setEditStockPartialBlisterPills(5);
|
||||
});
|
||||
|
||||
expect(result.current.refillPacks).toBe(5);
|
||||
expect(result.current.refillLoose).toBe(3);
|
||||
expect(result.current.refillHistoryExpanded).toBe(true);
|
||||
expect(result.current.showRefillModal).toBe(true);
|
||||
expect(result.current.showEditStockModal).toBe(true);
|
||||
expect(result.current.editStockFullBlisters).toBe(10);
|
||||
expect(result.current.editStockPartialBlisterPills).toBe(5);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,252 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { renderHook, act, waitFor } from '@testing-library/react';
|
||||
import { useSettings } from '../../hooks/useSettings';
|
||||
import React from 'react';
|
||||
|
||||
describe('useSettings', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({})
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('initializes with default settings', () => {
|
||||
const { result } = renderHook(() => useSettings());
|
||||
|
||||
expect(result.current.settings.emailEnabled).toBe(false);
|
||||
expect(result.current.settings.lowStockDays).toBe(30);
|
||||
expect(result.current.settings.reminderDaysBefore).toBe(7);
|
||||
expect(result.current.settingsLoading).toBe(true);
|
||||
});
|
||||
|
||||
it('loads settings from API on mount', async () => {
|
||||
const mockSettings = {
|
||||
emailEnabled: true,
|
||||
notificationEmail: 'test@example.com',
|
||||
lowStockDays: 14
|
||||
};
|
||||
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockSettings)
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useSettings());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.settingsLoading).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.settings.emailEnabled).toBe(true);
|
||||
expect(result.current.settings.notificationEmail).toBe('test@example.com');
|
||||
});
|
||||
|
||||
it('handles API error on load', async () => {
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error('Network error'));
|
||||
|
||||
const { result } = renderHook(() => useSettings());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.settingsLoading).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('saves settings to API', async () => {
|
||||
(global.fetch as ReturnType<typeof vi.fn>)
|
||||
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({}) })
|
||||
.mockResolvedValueOnce({ ok: true });
|
||||
|
||||
const { result } = renderHook(() => useSettings());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.settingsLoading).toBe(false);
|
||||
});
|
||||
|
||||
const mockEvent = { preventDefault: vi.fn() } as unknown as React.FormEvent;
|
||||
|
||||
await act(async () => {
|
||||
await result.current.saveSettings(mockEvent);
|
||||
});
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
'/api/settings',
|
||||
expect.objectContaining({
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
})
|
||||
);
|
||||
expect(result.current.settingsSaved).toBe(true);
|
||||
});
|
||||
|
||||
it('validates email before saving', async () => {
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({})
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useSettings());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.settingsLoading).toBe(false);
|
||||
});
|
||||
|
||||
// Set invalid email
|
||||
act(() => {
|
||||
result.current.setSettings(s => ({
|
||||
...s,
|
||||
emailEnabled: true,
|
||||
notificationEmail: 'invalid-email'
|
||||
}));
|
||||
});
|
||||
|
||||
const mockEvent = { preventDefault: vi.fn() } as unknown as React.FormEvent;
|
||||
|
||||
await act(async () => {
|
||||
await result.current.saveSettings(mockEvent);
|
||||
});
|
||||
|
||||
expect(result.current.testEmailResult?.success).toBe(false);
|
||||
expect(result.current.testEmailResult?.message).toContain('Invalid email');
|
||||
});
|
||||
|
||||
it('tests email notification', async () => {
|
||||
(global.fetch as ReturnType<typeof vi.fn>)
|
||||
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({}) })
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ message: 'Email sent!' })
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useSettings());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.settingsLoading).toBe(false);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.testEmail();
|
||||
});
|
||||
|
||||
expect(result.current.testEmailResult?.success).toBe(true);
|
||||
expect(result.current.testingEmail).toBe(false);
|
||||
});
|
||||
|
||||
it('handles test email failure', async () => {
|
||||
(global.fetch as ReturnType<typeof vi.fn>)
|
||||
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({}) })
|
||||
.mockRejectedValueOnce(new Error('Network error'));
|
||||
|
||||
const { result } = renderHook(() => useSettings());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.settingsLoading).toBe(false);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.testEmail();
|
||||
});
|
||||
|
||||
expect(result.current.testEmailResult?.success).toBe(false);
|
||||
});
|
||||
|
||||
it('tests shoutrrr notification', async () => {
|
||||
(global.fetch as ReturnType<typeof vi.fn>)
|
||||
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({}) })
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ message: 'Notification sent!' })
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useSettings());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.settingsLoading).toBe(false);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.testShoutrrr();
|
||||
});
|
||||
|
||||
expect(result.current.testShoutrrrResult?.success).toBe(true);
|
||||
expect(result.current.testingShoutrrr).toBe(false);
|
||||
});
|
||||
|
||||
it('tracks unsaved changes', async () => {
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ lowStockDays: 30 })
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useSettings());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.settingsLoading).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.hasUnsavedChanges).toBe(false);
|
||||
|
||||
act(() => {
|
||||
result.current.setSettings(s => ({ ...s, lowStockDays: 14 }));
|
||||
});
|
||||
|
||||
expect(result.current.hasUnsavedChanges).toBe(true);
|
||||
});
|
||||
|
||||
it('loadSettings can be called manually', async () => {
|
||||
(global.fetch as ReturnType<typeof vi.fn>)
|
||||
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({}) })
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ lowStockDays: 14 })
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useSettings());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.settingsLoading).toBe(false);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.loadSettings();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.settings.lowStockDays).toBe(14);
|
||||
});
|
||||
});
|
||||
|
||||
it('auto-disables email when no recipient', async () => {
|
||||
(global.fetch as ReturnType<typeof vi.fn>)
|
||||
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({}) })
|
||||
.mockResolvedValueOnce({ ok: true });
|
||||
|
||||
const { result } = renderHook(() => useSettings());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.settingsLoading).toBe(false);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.setSettings(s => ({
|
||||
...s,
|
||||
emailEnabled: true,
|
||||
notificationEmail: ''
|
||||
}));
|
||||
});
|
||||
|
||||
const mockEvent = { preventDefault: vi.fn() } as unknown as React.FormEvent;
|
||||
|
||||
await act(async () => {
|
||||
await result.current.saveSettings(mockEvent);
|
||||
});
|
||||
|
||||
// emailEnabled should be false in the saved state
|
||||
expect(result.current.settings.emailEnabled).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,298 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { renderHook, act, waitFor } from '@testing-library/react';
|
||||
import { useShare } from '../../hooks/useShare';
|
||||
import type { Medication } from '../../types';
|
||||
|
||||
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;
|
||||
|
||||
mockClipboard = { writeText: vi.fn() };
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
value: mockClipboard,
|
||||
writable: true
|
||||
});
|
||||
|
||||
// Mock window.history
|
||||
vi.spyOn(window.history, 'pushState').mockImplementation(() => {});
|
||||
vi.spyOn(window.history, 'back').mockImplementation(() => {});
|
||||
|
||||
// Mock window.location.origin
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { origin: 'http://localhost:5173' },
|
||||
writable: true
|
||||
});
|
||||
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ token: 'test-token' })
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('initializes with default state', () => {
|
||||
const { result } = renderHook(() => useShare());
|
||||
|
||||
expect(result.current.showShareDialog).toBe(false);
|
||||
expect(result.current.sharePeople).toEqual([]);
|
||||
expect(result.current.shareSelectedPerson).toBe('');
|
||||
expect(result.current.shareSelectedDays).toBe(30);
|
||||
expect(result.current.shareLink).toBeNull();
|
||||
});
|
||||
|
||||
it('opens share dialog with people from medications', () => {
|
||||
const { result } = renderHook(() => useShare());
|
||||
|
||||
const meds: Medication[] = [
|
||||
{
|
||||
id: 1, name: 'Med1', takenBy: ['Alice', 'Bob'],
|
||||
packCount: 1, blistersPerPack: 1, pillsPerBlister: 10,
|
||||
looseTablets: 0, blisters: [], updatedAt: null
|
||||
},
|
||||
{
|
||||
id: 2, name: 'Med2', takenBy: ['Bob', 'Charlie'],
|
||||
packCount: 1, blistersPerPack: 1, pillsPerBlister: 10,
|
||||
looseTablets: 0, blisters: [], updatedAt: null
|
||||
}
|
||||
];
|
||||
|
||||
act(() => {
|
||||
result.current.openShareDialog(meds);
|
||||
});
|
||||
|
||||
expect(result.current.showShareDialog).toBe(true);
|
||||
expect(result.current.sharePeople).toEqual(['Alice', 'Bob', 'Charlie']);
|
||||
expect(result.current.shareSelectedPerson).toBe('Alice');
|
||||
expect(window.history.pushState).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('resets state when opening dialog', () => {
|
||||
const { result } = renderHook(() => useShare());
|
||||
|
||||
// Set some state first
|
||||
act(() => {
|
||||
result.current.setShareLink('old-link');
|
||||
result.current.setShareCopied(true);
|
||||
});
|
||||
|
||||
const meds: Medication[] = [
|
||||
{
|
||||
id: 1, name: 'Med1', takenBy: ['Alice'],
|
||||
packCount: 1, blistersPerPack: 1, pillsPerBlister: 10,
|
||||
looseTablets: 0, blisters: [], updatedAt: null
|
||||
}
|
||||
];
|
||||
|
||||
act(() => {
|
||||
result.current.openShareDialog(meds);
|
||||
});
|
||||
|
||||
expect(result.current.shareLink).toBeNull();
|
||||
expect(result.current.shareCopied).toBe(false);
|
||||
});
|
||||
|
||||
it('generates share link', async () => {
|
||||
const { result } = renderHook(() => useShare());
|
||||
|
||||
const meds: Medication[] = [
|
||||
{
|
||||
id: 1, name: 'Med1', takenBy: ['Alice'],
|
||||
packCount: 1, blistersPerPack: 1, pillsPerBlister: 10,
|
||||
looseTablets: 0, blisters: [], updatedAt: null
|
||||
}
|
||||
];
|
||||
|
||||
act(() => {
|
||||
result.current.openShareDialog(meds);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.generateShareLink();
|
||||
});
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
'/api/share',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ takenBy: 'Alice', scheduleDays: 30 })
|
||||
})
|
||||
);
|
||||
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' })
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useShare());
|
||||
|
||||
const meds: Medication[] = [
|
||||
{
|
||||
id: 1, name: 'Med1', takenBy: ['Alice'],
|
||||
packCount: 1, blistersPerPack: 1, pillsPerBlister: 10,
|
||||
looseTablets: 0, blisters: [], updatedAt: null
|
||||
}
|
||||
];
|
||||
|
||||
act(() => {
|
||||
result.current.openShareDialog(meds);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.generateShareLink();
|
||||
});
|
||||
|
||||
expect(mockAlert).toHaveBeenCalled();
|
||||
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'));
|
||||
|
||||
const { result } = renderHook(() => useShare());
|
||||
|
||||
const meds: Medication[] = [
|
||||
{
|
||||
id: 1, name: 'Med1', takenBy: ['Alice'],
|
||||
packCount: 1, blistersPerPack: 1, pillsPerBlister: 10,
|
||||
looseTablets: 0, blisters: [], updatedAt: null
|
||||
}
|
||||
];
|
||||
|
||||
act(() => {
|
||||
result.current.openShareDialog(meds);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.generateShareLink();
|
||||
});
|
||||
|
||||
expect(mockAlert).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does nothing when generateShareLink called without selected person', async () => {
|
||||
const { result } = renderHook(() => useShare());
|
||||
|
||||
// Don't open dialog, so shareSelectedPerson is empty
|
||||
await act(async () => {
|
||||
await result.current.generateShareLink();
|
||||
});
|
||||
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('copies share link to clipboard', async () => {
|
||||
const { result } = renderHook(() => useShare());
|
||||
|
||||
act(() => {
|
||||
result.current.setShareLink('http://localhost:5173/share/test-token');
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.copyShareLink();
|
||||
});
|
||||
|
||||
expect(mockClipboard.writeText).toHaveBeenCalledWith('http://localhost:5173/share/test-token');
|
||||
expect(result.current.shareCopied).toBe(true);
|
||||
|
||||
// Should reset after 2 seconds
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(2000);
|
||||
});
|
||||
|
||||
expect(result.current.shareCopied).toBe(false);
|
||||
});
|
||||
|
||||
it('does nothing when copyShareLink called without link', () => {
|
||||
const { result } = renderHook(() => useShare());
|
||||
|
||||
act(() => {
|
||||
result.current.copyShareLink();
|
||||
});
|
||||
|
||||
expect(mockClipboard.writeText).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('closes share dialog with history back', () => {
|
||||
const { result } = renderHook(() => useShare());
|
||||
|
||||
const meds: Medication[] = [
|
||||
{
|
||||
id: 1, name: 'Med1', takenBy: ['Alice'],
|
||||
packCount: 1, blistersPerPack: 1, pillsPerBlister: 10,
|
||||
looseTablets: 0, blisters: [], updatedAt: null
|
||||
}
|
||||
];
|
||||
|
||||
act(() => {
|
||||
result.current.openShareDialog(meds);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.closeShareDialog();
|
||||
});
|
||||
|
||||
expect(window.history.back).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not call history back when dialog not open', () => {
|
||||
const { result } = renderHook(() => useShare());
|
||||
|
||||
act(() => {
|
||||
result.current.closeShareDialog();
|
||||
});
|
||||
|
||||
expect(window.history.back).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('resetShareDialogState clears state', () => {
|
||||
const { result } = renderHook(() => useShare());
|
||||
|
||||
const meds: Medication[] = [
|
||||
{
|
||||
id: 1, name: 'Med1', takenBy: ['Alice'],
|
||||
packCount: 1, blistersPerPack: 1, pillsPerBlister: 10,
|
||||
looseTablets: 0, blisters: [], updatedAt: null
|
||||
}
|
||||
];
|
||||
|
||||
act(() => {
|
||||
result.current.openShareDialog(meds);
|
||||
result.current.setShareLink('some-link');
|
||||
result.current.setShareCopied(true);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.resetShareDialogState();
|
||||
});
|
||||
|
||||
expect(result.current.showShareDialog).toBe(false);
|
||||
expect(result.current.shareLink).toBeNull();
|
||||
expect(result.current.shareCopied).toBe(false);
|
||||
});
|
||||
|
||||
it('allows changing selected person and days', () => {
|
||||
const { result } = renderHook(() => useShare());
|
||||
|
||||
act(() => {
|
||||
result.current.setShareSelectedPerson('Bob');
|
||||
result.current.setShareSelectedDays(90);
|
||||
});
|
||||
|
||||
expect(result.current.shareSelectedPerson).toBe('Bob');
|
||||
expect(result.current.shareSelectedDays).toBe(90);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { useTheme } from '../../hooks/useTheme';
|
||||
|
||||
describe('useTheme', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
(window.localStorage.getItem as ReturnType<typeof vi.fn>).mockReturnValue(null);
|
||||
// Reset mock to default behavior
|
||||
(window.localStorage.setItem as ReturnType<typeof vi.fn>).mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('returns dark as default theme', () => {
|
||||
const { result } = renderHook(() => useTheme());
|
||||
expect(result.current.theme).toBe('dark');
|
||||
});
|
||||
|
||||
it('reads theme from localStorage', () => {
|
||||
(window.localStorage.getItem as ReturnType<typeof vi.fn>).mockReturnValue('light');
|
||||
const { result } = renderHook(() => useTheme());
|
||||
expect(result.current.theme).toBe('light');
|
||||
});
|
||||
|
||||
it('toggles theme from dark to light', () => {
|
||||
const { result } = renderHook(() => useTheme());
|
||||
|
||||
expect(result.current.theme).toBe('dark');
|
||||
|
||||
act(() => {
|
||||
result.current.toggleTheme();
|
||||
});
|
||||
|
||||
expect(result.current.theme).toBe('light');
|
||||
});
|
||||
|
||||
it('toggles theme from light to dark', () => {
|
||||
(window.localStorage.getItem as ReturnType<typeof vi.fn>).mockReturnValue('light');
|
||||
const { result } = renderHook(() => useTheme());
|
||||
|
||||
expect(result.current.theme).toBe('light');
|
||||
|
||||
act(() => {
|
||||
result.current.toggleTheme();
|
||||
});
|
||||
|
||||
expect(result.current.theme).toBe('dark');
|
||||
});
|
||||
|
||||
it('saves theme to localStorage on change', () => {
|
||||
const { result } = renderHook(() => useTheme());
|
||||
|
||||
act(() => {
|
||||
result.current.toggleTheme();
|
||||
});
|
||||
|
||||
expect(window.localStorage.setItem).toHaveBeenCalledWith('theme', 'light');
|
||||
});
|
||||
|
||||
it('sets data-theme attribute on document', () => {
|
||||
const { result } = renderHook(() => useTheme());
|
||||
|
||||
expect(document.documentElement.getAttribute('data-theme')).toBe('dark');
|
||||
|
||||
act(() => {
|
||||
result.current.toggleTheme();
|
||||
});
|
||||
|
||||
expect(document.documentElement.getAttribute('data-theme')).toBe('light');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,767 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { DashboardPage } from '../../pages/DashboardPage';
|
||||
|
||||
// Mock data for tests with medications
|
||||
const mockMeds = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Aspirin',
|
||||
packCount: 1,
|
||||
blistersPerPack: 2,
|
||||
pillsPerBlister: 10,
|
||||
looseTablets: 5,
|
||||
takenBy: ['John'],
|
||||
blisters: [{ usage: 1, every: 1, start: '2024-01-01T09:00:00Z' }],
|
||||
intakeRemindersEnabled: true,
|
||||
notes: 'Take with food',
|
||||
expiryDate: '2025-12-31',
|
||||
imageUrl: null,
|
||||
updatedAt: null
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Vitamin D',
|
||||
packCount: 0,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 30,
|
||||
looseTablets: 3,
|
||||
takenBy: [],
|
||||
blisters: [{ usage: 1, every: 1, start: '2024-01-01T08:00:00Z' }],
|
||||
intakeRemindersEnabled: false,
|
||||
notes: null,
|
||||
expiryDate: null,
|
||||
imageUrl: null,
|
||||
updatedAt: null
|
||||
}
|
||||
];
|
||||
|
||||
const mockCoverage = {
|
||||
all: [
|
||||
{ name: 'Aspirin', medsLeft: 25, daysLeft: 25, depletionDate: '2025-02-15', depletionTime: Date.now() + 25 * 86400000, nextDose: null },
|
||||
{ name: 'Vitamin D', medsLeft: 3, daysLeft: 3, depletionDate: '2025-01-25', depletionTime: Date.now() + 3 * 86400000, nextDose: null }
|
||||
],
|
||||
low: [
|
||||
{ name: 'Vitamin D', medsLeft: 3, daysLeft: 3, depletionDate: '2025-01-25', depletionTime: Date.now() + 3 * 86400000, nextDose: null }
|
||||
]
|
||||
};
|
||||
|
||||
const mockFutureDays = [
|
||||
{
|
||||
dateStr: 'Mon, Jan 22',
|
||||
date: new Date(),
|
||||
isPast: false,
|
||||
meds: [
|
||||
{
|
||||
medName: 'Aspirin',
|
||||
total: 1,
|
||||
doses: [
|
||||
{ id: '1-0-' + Date.now(), timeStr: '09:00', when: Date.now(), usage: 1, takenBy: ['John'] }
|
||||
],
|
||||
lastWhen: Date.now()
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
const mockPastDays = [
|
||||
{
|
||||
dateStr: 'Sun, Jan 21',
|
||||
date: new Date(Date.now() - 86400000),
|
||||
isPast: true,
|
||||
meds: [
|
||||
{
|
||||
medName: 'Aspirin',
|
||||
total: 1,
|
||||
doses: [
|
||||
{ id: '1-0-' + (Date.now() - 86400000), timeStr: '09:00', when: Date.now() - 86400000, usage: 1, takenBy: ['John'] }
|
||||
],
|
||||
lastWhen: Date.now() - 86400000
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
// Default mock factory
|
||||
const createMockAppContext = (overrides = {}) => ({
|
||||
meds: [],
|
||||
settings: {
|
||||
lowStockThreshold: 30,
|
||||
criticalStockThreshold: 7,
|
||||
expiryWarningDays: 30,
|
||||
lowStockDays: 7,
|
||||
normalStockDays: 30,
|
||||
highStockDays: 90,
|
||||
emailEnabled: false,
|
||||
shoutrrrEnabled: false,
|
||||
reminderDaysBefore: 7,
|
||||
notificationEmail: '',
|
||||
lastAutoEmailSent: null,
|
||||
lastNotificationType: null,
|
||||
lastNotificationChannel: null
|
||||
},
|
||||
scheduleDays: 30,
|
||||
setScheduleDays: vi.fn(),
|
||||
showPastDays: false,
|
||||
setShowPastDays: vi.fn(),
|
||||
pastDays: [],
|
||||
futureDays: [],
|
||||
takenDoses: new Set(),
|
||||
dismissedDoses: new Set(),
|
||||
markDoseTaken: vi.fn(),
|
||||
undoDoseTaken: vi.fn(),
|
||||
coverage: { all: [], low: [] },
|
||||
coverageByMed: {},
|
||||
depletionByMed: {},
|
||||
manuallyExpandedDays: new Set(),
|
||||
manuallyCollapsedDays: new Set(),
|
||||
toggleDayCollapse: vi.fn(),
|
||||
openMedDetail: vi.fn(),
|
||||
openUserFilter: vi.fn(),
|
||||
openShareDialog: vi.fn(),
|
||||
openScheduleLightbox: vi.fn(),
|
||||
missedPastDoseIds: [],
|
||||
getDayStockStatus: vi.fn(() => 'success'),
|
||||
getDoseId: vi.fn((id, person) => person ? `${id}-${person}` : id),
|
||||
showClearMissedConfirm: false,
|
||||
setShowClearMissedConfirm: vi.fn(),
|
||||
clearingMissed: false,
|
||||
dismissMissedDoses: vi.fn(),
|
||||
...overrides
|
||||
});
|
||||
|
||||
let mockContextValue = createMockAppContext();
|
||||
|
||||
// Mock the context
|
||||
vi.mock('../../context', () => ({
|
||||
useAppContext: () => mockContextValue
|
||||
}));
|
||||
|
||||
vi.mock('../../components/Auth', () => ({
|
||||
useAuth: () => ({
|
||||
user: { id: 1, username: 'testuser' }
|
||||
})
|
||||
}));
|
||||
|
||||
describe('DashboardPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
mockContextValue = createMockAppContext();
|
||||
});
|
||||
|
||||
it('renders dashboard page', () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<DashboardPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
// Should render the dashboard section
|
||||
const section = document.querySelector('section.grid');
|
||||
expect(section).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders reorder section title', () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<DashboardPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
expect(screen.getByText(/dashboard\.reorder\.title/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders overview section title', () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<DashboardPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
expect(screen.getByText(/dashboard\.overview\.title/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders schedule section title', () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<DashboardPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
expect(screen.getByText(/dashboard\.schedules\.title/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders empty state when no medications', () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<DashboardPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
// With no meds, should show the dashboard cards
|
||||
const cards = document.querySelectorAll('.card');
|
||||
expect(cards.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('renders schedule days selector', () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<DashboardPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
// Should have schedule days select dropdown
|
||||
const select = document.querySelector('.schedule-days-select');
|
||||
expect(select).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders timeline section', () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<DashboardPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
// Should have timeline div
|
||||
const timeline = document.querySelector('.timeline');
|
||||
expect(timeline).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders table headers for overview', () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<DashboardPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
// Should have table headers
|
||||
expect(screen.getByText(/table\.name/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/table\.daysLeft/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders multiple cards', () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<DashboardPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
// Dashboard has multiple cards
|
||||
const cards = document.querySelectorAll('.card');
|
||||
expect(cards.length).toBeGreaterThan(2);
|
||||
});
|
||||
|
||||
it('renders card heads', () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<DashboardPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
// Should have card heads for each section
|
||||
const cardHeads = document.querySelectorAll('.card-head');
|
||||
expect(cardHeads.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('renders table headers', () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<DashboardPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
// Should have table head
|
||||
const tableHead = document.querySelector('.table-head');
|
||||
expect(tableHead).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders table structure', () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<DashboardPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
// Should have table class
|
||||
const table = document.querySelector('.table');
|
||||
expect(table).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders no meds message for reorder section', () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<DashboardPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
// When no meds, should show empty state
|
||||
expect(screen.getByText(/dashboard\.reorder\.noMeds/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('DashboardPage interactions', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
mockContextValue = createMockAppContext();
|
||||
});
|
||||
|
||||
it('has schedule days options', () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<DashboardPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
// Should have 30, 90, 180 day options
|
||||
const select = document.querySelector('.schedule-days-select');
|
||||
expect(select).toBeInTheDocument();
|
||||
|
||||
const options = select?.querySelectorAll('option');
|
||||
expect(options?.length).toBe(3);
|
||||
});
|
||||
|
||||
it('can change schedule days', () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<DashboardPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
const select = document.querySelector('.schedule-days-select') as HTMLSelectElement;
|
||||
expect(select).toBeInTheDocument();
|
||||
|
||||
fireEvent.change(select, { target: { value: '90' } });
|
||||
});
|
||||
});
|
||||
|
||||
describe('DashboardPage structure', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
mockContextValue = createMockAppContext();
|
||||
});
|
||||
|
||||
it('renders multiple section grids', () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<DashboardPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
const sections = document.querySelectorAll('section.grid');
|
||||
expect(sections.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('renders card head actions', () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<DashboardPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
const cardHeadActions = document.querySelector('.card-head-actions');
|
||||
expect(cardHeadActions).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders all table columns', () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<DashboardPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
// Should have all expected table columns
|
||||
expect(screen.getByText(/table\.name/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/table\.fullBlisters/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/table\.openBlister/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/table\.daysLeft/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/table\.runsOut/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/table\.expiry/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/table\.status/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('DashboardPage with medications', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
mockContextValue = createMockAppContext({
|
||||
meds: mockMeds,
|
||||
coverage: mockCoverage,
|
||||
coverageByMed: {
|
||||
'Aspirin': mockCoverage.all[0],
|
||||
'Vitamin D': mockCoverage.all[1]
|
||||
},
|
||||
depletionByMed: {
|
||||
'Aspirin': Date.now() + 25 * 86400000,
|
||||
'Vitamin D': Date.now() + 3 * 86400000
|
||||
},
|
||||
futureDays: mockFutureDays
|
||||
});
|
||||
});
|
||||
|
||||
it('renders medication rows in overview table', () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<DashboardPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
// Should show medication names (may appear in multiple places)
|
||||
const aspirinElements = screen.getAllByText('Aspirin');
|
||||
const vitaminDElements = screen.getAllByText('Vitamin D');
|
||||
expect(aspirinElements.length).toBeGreaterThan(0);
|
||||
expect(vitaminDElements.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('renders low stock section with low stock medications', () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<DashboardPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
// Should show the low stock medication name
|
||||
const vitaminDElements = screen.getAllByText('Vitamin D');
|
||||
expect(vitaminDElements.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('renders taken by badges', () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<DashboardPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
// Should show taken by badge for Aspirin
|
||||
const johnBadges = screen.getAllByText('John');
|
||||
expect(johnBadges.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('renders medication icons for reminders and notes', () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<DashboardPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
// Aspirin has intakeRemindersEnabled and notes
|
||||
const reminderIcons = document.querySelectorAll('.reminder-icon');
|
||||
expect(reminderIcons.length).toBeGreaterThan(0);
|
||||
|
||||
const notesIcons = document.querySelectorAll('.notes-icon');
|
||||
expect(notesIcons.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('renders schedule timeline with future doses', () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<DashboardPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
// Should show day block
|
||||
const dayBlocks = document.querySelectorAll('.day-block');
|
||||
expect(dayBlocks.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('calls openMedDetail when clicking medication row', () => {
|
||||
const openMedDetail = vi.fn();
|
||||
mockContextValue = createMockAppContext({
|
||||
meds: mockMeds,
|
||||
coverage: mockCoverage,
|
||||
coverageByMed: { 'Aspirin': mockCoverage.all[0] },
|
||||
openMedDetail
|
||||
});
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<DashboardPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
// Click on medication row
|
||||
const aspirinRow = screen.getAllByText('Aspirin')[0].closest('.table-row');
|
||||
if (aspirinRow) {
|
||||
fireEvent.click(aspirinRow);
|
||||
expect(openMedDetail).toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it('calls openUserFilter when clicking taken by badge', () => {
|
||||
const openUserFilter = vi.fn();
|
||||
mockContextValue = createMockAppContext({
|
||||
meds: mockMeds,
|
||||
coverage: mockCoverage,
|
||||
coverageByMed: { 'Aspirin': mockCoverage.all[0] },
|
||||
openUserFilter
|
||||
});
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<DashboardPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
// Click on taken by badge
|
||||
const johnBadge = screen.getAllByText('John')[0];
|
||||
fireEvent.click(johnBadge);
|
||||
expect(openUserFilter).toHaveBeenCalledWith('John');
|
||||
});
|
||||
});
|
||||
|
||||
describe('DashboardPage with email notifications', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
mockContextValue = createMockAppContext({
|
||||
meds: mockMeds,
|
||||
coverage: mockCoverage,
|
||||
settings: {
|
||||
...createMockAppContext().settings,
|
||||
emailEnabled: true,
|
||||
notificationEmail: 'test@example.com'
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('renders email status bar when email enabled', () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<DashboardPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
// Should show email status bar
|
||||
const statusBar = document.querySelector('.email-status-bar');
|
||||
expect(statusBar).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows reminder email button when there are low stock meds', () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<DashboardPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
// Should show send reminder button
|
||||
expect(screen.getByText(/dashboard\.reorder\.sendReminder/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('DashboardPage with shoutrrr notifications', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
mockContextValue = createMockAppContext({
|
||||
meds: mockMeds,
|
||||
coverage: mockCoverage,
|
||||
settings: {
|
||||
...createMockAppContext().settings,
|
||||
shoutrrrEnabled: true
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('renders notification status bar when shoutrrr enabled', () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<DashboardPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
// Should show status bar
|
||||
const statusBar = document.querySelector('.email-status-bar');
|
||||
expect(statusBar).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('DashboardPage with past days', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
mockContextValue = createMockAppContext({
|
||||
meds: mockMeds,
|
||||
coverage: mockCoverage,
|
||||
pastDays: mockPastDays,
|
||||
futureDays: mockFutureDays,
|
||||
showPastDays: false,
|
||||
missedPastDoseIds: ['1-0-' + (Date.now() - 86400000) + '-John']
|
||||
});
|
||||
});
|
||||
|
||||
it('renders past days toggle when past days exist', () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<DashboardPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
// Should show past days toggle
|
||||
const toggle = document.querySelector('.past-days-toggle');
|
||||
expect(toggle).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows missed dose warning count', () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<DashboardPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
// Should show warning with missed count
|
||||
const warning = document.querySelector('.past-days-warning');
|
||||
expect(warning).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('toggles past days visibility', () => {
|
||||
const setShowPastDays = vi.fn();
|
||||
mockContextValue = createMockAppContext({
|
||||
pastDays: mockPastDays,
|
||||
showPastDays: false,
|
||||
setShowPastDays,
|
||||
missedPastDoseIds: []
|
||||
});
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<DashboardPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
const toggle = document.querySelector('.past-days-toggle');
|
||||
if (toggle) {
|
||||
fireEvent.click(toggle);
|
||||
expect(setShowPastDays).toHaveBeenCalledWith(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('shows clear missed doses button when there are missed doses', () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<DashboardPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
// Should show clear missed button
|
||||
const clearBtn = document.querySelector('.clear-missed-btn');
|
||||
expect(clearBtn).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('DashboardPage with expanded past days', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
mockContextValue = createMockAppContext({
|
||||
meds: mockMeds,
|
||||
coverage: mockCoverage,
|
||||
coverageByMed: { 'Aspirin': mockCoverage.all[0] },
|
||||
pastDays: mockPastDays,
|
||||
futureDays: mockFutureDays,
|
||||
showPastDays: true,
|
||||
manuallyExpandedDays: new Set(['Sun, Jan 21']),
|
||||
getDayStockStatus: vi.fn(() => 'success')
|
||||
});
|
||||
});
|
||||
|
||||
it('renders past day blocks when showPastDays is true', () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<DashboardPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
// Should show past day block
|
||||
const pastDayBlocks = document.querySelectorAll('.day-block.past');
|
||||
expect(pastDayBlocks.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DashboardPage dose interactions', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('calls markDoseTaken when clicking take button', () => {
|
||||
const markDoseTaken = vi.fn();
|
||||
mockContextValue = createMockAppContext({
|
||||
meds: mockMeds,
|
||||
coverage: mockCoverage,
|
||||
coverageByMed: { 'Aspirin': mockCoverage.all[0] },
|
||||
depletionByMed: { 'Aspirin': Date.now() + 25 * 86400000 },
|
||||
futureDays: mockFutureDays,
|
||||
markDoseTaken
|
||||
});
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<DashboardPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
// Find and click take button
|
||||
const takeBtn = document.querySelector('.dose-btn.take');
|
||||
if (takeBtn) {
|
||||
fireEvent.click(takeBtn);
|
||||
expect(markDoseTaken).toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it('calls undoDoseTaken when clicking undo button', () => {
|
||||
const undoDoseTaken = vi.fn();
|
||||
const doseId = '1-0-' + Date.now() + '-John';
|
||||
mockContextValue = createMockAppContext({
|
||||
meds: mockMeds,
|
||||
coverage: mockCoverage,
|
||||
coverageByMed: { 'Aspirin': mockCoverage.all[0] },
|
||||
depletionByMed: { 'Aspirin': Date.now() + 25 * 86400000 },
|
||||
futureDays: mockFutureDays,
|
||||
takenDoses: new Set([doseId]),
|
||||
undoDoseTaken,
|
||||
getDoseId: vi.fn(() => doseId)
|
||||
});
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<DashboardPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
// Find and click undo button
|
||||
const undoBtn = document.querySelector('.dose-btn.undo');
|
||||
if (undoBtn) {
|
||||
fireEvent.click(undoBtn);
|
||||
expect(undoDoseTaken).toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('DashboardPage good stock state', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
mockContextValue = createMockAppContext({
|
||||
meds: mockMeds,
|
||||
coverage: {
|
||||
all: [{ name: 'Aspirin', medsLeft: 100, daysLeft: 100, depletionDate: '2025-05-01', depletionTime: Date.now() + 100 * 86400000, nextDose: null }],
|
||||
low: []
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('shows all good message when no low stock', () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<DashboardPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
// Should show all good message
|
||||
expect(screen.getByText(/dashboard\.reorder\.allGood/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,472 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { PlannerPage } from '../../pages/PlannerPage';
|
||||
|
||||
// Mock data
|
||||
const mockMeds = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Aspirin',
|
||||
packCount: 1,
|
||||
blistersPerPack: 2,
|
||||
pillsPerBlister: 10,
|
||||
looseTablets: 5,
|
||||
takenBy: ['John'],
|
||||
blisters: [{ usage: 1, every: 1, start: '2024-01-01T09:00:00Z' }],
|
||||
intakeRemindersEnabled: true,
|
||||
notes: 'Take with food',
|
||||
imageUrl: null,
|
||||
updatedAt: null
|
||||
}
|
||||
];
|
||||
|
||||
const mockPlannerRows = [
|
||||
{ medName: 'Aspirin', total: 30, currentStock: 25 }
|
||||
];
|
||||
|
||||
// Factory for mock context
|
||||
const createMockContext = (overrides = {}) => ({
|
||||
meds: [],
|
||||
settings: {
|
||||
lowStockThreshold: 30,
|
||||
criticalStockThreshold: 7,
|
||||
expiryWarningDays: 30,
|
||||
emailEnabled: false,
|
||||
shoutrrrEnabled: false,
|
||||
notificationEmail: ''
|
||||
},
|
||||
openMedDetail: vi.fn(),
|
||||
...overrides
|
||||
});
|
||||
|
||||
let mockContextValue = createMockContext();
|
||||
|
||||
// Mock the hooks and context
|
||||
vi.mock('../../context', () => ({
|
||||
useAppContext: () => mockContextValue
|
||||
}));
|
||||
|
||||
vi.mock('../../components/Auth', () => ({
|
||||
useAuth: () => ({
|
||||
user: { id: 1, username: 'testuser' }
|
||||
})
|
||||
}));
|
||||
|
||||
describe('PlannerPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
mockContextValue = createMockContext();
|
||||
});
|
||||
|
||||
it('renders planner page', () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<PlannerPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
// Should render the planner section
|
||||
expect(screen.getByText(/planner\.title/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders date range inputs', () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<PlannerPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
// Should have start and end date inputs (actual keys are planner.from and planner.until)
|
||||
expect(screen.getByText(/planner\.from/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/planner\.until/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders calculate button', () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<PlannerPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
const buttons = screen.getAllByRole('button');
|
||||
const calculateBtn = buttons.find(btn => btn.textContent?.includes('planner.calculate'));
|
||||
expect(calculateBtn).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders reset button', () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<PlannerPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
const buttons = screen.getAllByRole('button');
|
||||
const resetBtn = buttons.find(btn => btn.textContent?.includes('common.reset'));
|
||||
expect(resetBtn).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows empty state when no medications', () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<PlannerPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
// When no meds, should render the form at least
|
||||
const content = document.body.textContent;
|
||||
expect(content).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders datetime-local inputs', () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<PlannerPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
// Datetime-local inputs should be present
|
||||
expect(document.querySelectorAll('input[type="datetime-local"]').length).toBe(2);
|
||||
});
|
||||
|
||||
it('has form element', () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<PlannerPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
const form = document.querySelector('form.planner');
|
||||
expect(form).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders card with title', () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<PlannerPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
const card = document.querySelector('.card');
|
||||
expect(card).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders planner actions container', () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<PlannerPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
const actions = document.querySelector('.planner-actions');
|
||||
expect(actions).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders section grid', () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<PlannerPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
const grid = document.querySelector('section.grid');
|
||||
expect(grid).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('reset button has ghost class', () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<PlannerPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
const resetBtn = document.querySelector('button.ghost');
|
||||
expect(resetBtn).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calculate button is submit type', () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<PlannerPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
const submitBtn = document.querySelector('button[type="submit"]');
|
||||
expect(submitBtn).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('allows changing date input values', () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<PlannerPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
const inputs = document.querySelectorAll('input[type="datetime-local"]');
|
||||
expect(inputs.length).toBe(2);
|
||||
|
||||
// Should be able to change the value
|
||||
fireEvent.change(inputs[0], { target: { value: '2024-06-01T10:00' } });
|
||||
expect((inputs[0] as HTMLInputElement).value).toBe('2024-06-01T10:00');
|
||||
});
|
||||
});
|
||||
|
||||
describe('PlannerPage with localStorage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('loads saved range from localStorage', () => {
|
||||
// Set up saved data in localStorage
|
||||
localStorage.setItem('user_1_plannerRange', JSON.stringify({
|
||||
start: '2024-05-01T09:00',
|
||||
end: '2024-05-10T18:00'
|
||||
}));
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<PlannerPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
// Page should render
|
||||
expect(screen.getByText(/planner\.title/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('loads saved rows from localStorage', () => {
|
||||
// Set up saved data in localStorage
|
||||
localStorage.setItem('user_1_plannerRows', JSON.stringify([
|
||||
{ medName: 'Aspirin', total: 30 }
|
||||
]));
|
||||
localStorage.setItem('user_1_plannerRange', JSON.stringify({
|
||||
start: '2024-05-01T09:00',
|
||||
end: '2024-05-10T18:00'
|
||||
}));
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<PlannerPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
// Page should render with saved data
|
||||
expect(screen.getByText(/planner\.title/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles invalid localStorage data gracefully', () => {
|
||||
// Set up invalid data in localStorage
|
||||
localStorage.setItem('user_1_plannerRows', 'invalid-json');
|
||||
localStorage.setItem('user_1_plannerRange', 'invalid-json');
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<PlannerPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
// Page should still render
|
||||
expect(screen.getByText(/planner\.title/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PlannerPage with medications', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
mockContextValue = createMockContext({ meds: mockMeds });
|
||||
});
|
||||
|
||||
it('renders with medications', () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<PlannerPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
expect(screen.getByText(/planner\.title/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PlannerPage with saved results', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
localStorage.setItem('user_1_plannerRows', JSON.stringify(mockPlannerRows));
|
||||
localStorage.setItem('user_1_plannerRange', JSON.stringify({
|
||||
start: '2024-05-01T09:00',
|
||||
end: '2024-05-10T18:00'
|
||||
}));
|
||||
mockContextValue = createMockContext({ meds: mockMeds });
|
||||
});
|
||||
|
||||
it('loads saved planner range from localStorage', () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<PlannerPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
// Range should be loaded from localStorage
|
||||
const dateInputs = document.querySelectorAll('input[type="datetime-local"]');
|
||||
expect(dateInputs.length).toBe(2);
|
||||
// Range values should be set
|
||||
expect((dateInputs[0] as HTMLInputElement).value).toBeTruthy();
|
||||
expect((dateInputs[1] as HTMLInputElement).value).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders page with saved data', () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<PlannerPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
expect(screen.getByText(/planner\.title/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('preserves form after loading saved range', () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<PlannerPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
const form = document.querySelector('form.planner');
|
||||
expect(form).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows buttons after loading saved data', () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<PlannerPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
expect(document.querySelector('button[type="submit"]')).toBeInTheDocument();
|
||||
expect(document.querySelector('button.ghost')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('has planner actions section', () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<PlannerPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
const actions = document.querySelector('.planner-actions');
|
||||
expect(actions).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PlannerPage with email enabled', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
localStorage.setItem('user_1_plannerRows', JSON.stringify(mockPlannerRows));
|
||||
localStorage.setItem('user_1_plannerRange', JSON.stringify({
|
||||
start: '2024-05-01T09:00',
|
||||
end: '2024-05-10T18:00'
|
||||
}));
|
||||
mockContextValue = createMockContext({
|
||||
meds: mockMeds,
|
||||
settings: {
|
||||
...createMockContext().settings,
|
||||
emailEnabled: true,
|
||||
notificationEmail: 'test@example.com'
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('shows send email button when email is enabled', () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<PlannerPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
// Should have email send button
|
||||
const emailBtn = document.querySelector('.ghost');
|
||||
// Email button may be present
|
||||
});
|
||||
});
|
||||
|
||||
describe('PlannerPage form interactions', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
mockContextValue = createMockContext({ meds: mockMeds });
|
||||
// Mock fetch to avoid actual API calls
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve([])
|
||||
});
|
||||
});
|
||||
|
||||
it('can submit the form', () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<PlannerPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
const form = document.querySelector('form.planner');
|
||||
if (form) {
|
||||
fireEvent.submit(form);
|
||||
}
|
||||
|
||||
// Form should still be present after submit
|
||||
expect(document.querySelector('form.planner')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('can reset the form', () => {
|
||||
localStorage.setItem('user_1_plannerRows', JSON.stringify(mockPlannerRows));
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<PlannerPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
const resetBtn = document.querySelector('button.ghost');
|
||||
if (resetBtn) {
|
||||
fireEvent.click(resetBtn);
|
||||
}
|
||||
|
||||
// Form should be reset (no results table)
|
||||
expect(screen.getByText(/planner\.title/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PlannerPage medication detail', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
localStorage.setItem('user_1_plannerRows', JSON.stringify(mockPlannerRows));
|
||||
localStorage.setItem('user_1_plannerRange', JSON.stringify({
|
||||
start: '2024-05-01T09:00',
|
||||
end: '2024-05-10T18:00'
|
||||
}));
|
||||
});
|
||||
|
||||
it('calls openMedDetail when clicking medication row', () => {
|
||||
const openMedDetail = vi.fn();
|
||||
mockContextValue = createMockContext({
|
||||
meds: mockMeds,
|
||||
openMedDetail
|
||||
});
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<PlannerPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
const medRow = document.querySelector('.table-row.clickable');
|
||||
if (medRow) {
|
||||
fireEvent.click(medRow);
|
||||
expect(openMedDetail).toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,642 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { SchedulePage } from '../../pages/SchedulePage';
|
||||
|
||||
// Mock data
|
||||
const mockMeds = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Aspirin',
|
||||
packCount: 1,
|
||||
blistersPerPack: 2,
|
||||
pillsPerBlister: 10,
|
||||
looseTablets: 5,
|
||||
takenBy: ['John'],
|
||||
blisters: [{ usage: 1, every: 1, start: '2024-01-01T09:00:00Z' }],
|
||||
intakeRemindersEnabled: true,
|
||||
notes: 'Take with food',
|
||||
pillWeightMg: 500,
|
||||
imageUrl: null,
|
||||
updatedAt: null
|
||||
}
|
||||
];
|
||||
|
||||
// Fixed timestamp for consistent tests
|
||||
const FIXED_TIMESTAMP = 1706000000000; // Fixed date for testing
|
||||
|
||||
const mockCoverageByMed = {
|
||||
'Aspirin': { name: 'Aspirin', medsLeft: 25, daysLeft: 25, depletionDate: '2025-02-15', depletionTime: FIXED_TIMESTAMP + 25 * 86400000, nextDose: null }
|
||||
};
|
||||
|
||||
const mockFutureDays = [
|
||||
{
|
||||
dateStr: 'Mon, Jan 22',
|
||||
date: new Date(FIXED_TIMESTAMP),
|
||||
isPast: false,
|
||||
meds: [
|
||||
{
|
||||
medName: 'Aspirin',
|
||||
total: 1,
|
||||
doses: [
|
||||
{ id: '1-0-' + FIXED_TIMESTAMP, timeStr: '09:00', when: FIXED_TIMESTAMP, usage: 1, takenBy: ['John'] }
|
||||
],
|
||||
lastWhen: FIXED_TIMESTAMP
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
const mockPastDays = [
|
||||
{
|
||||
dateStr: 'Sun, Jan 21',
|
||||
date: new Date(FIXED_TIMESTAMP - 86400000),
|
||||
isPast: true,
|
||||
meds: [
|
||||
{
|
||||
medName: 'Aspirin',
|
||||
total: 1,
|
||||
doses: [
|
||||
{ id: '1-0-' + (FIXED_TIMESTAMP - 86400000), timeStr: '09:00', when: FIXED_TIMESTAMP - 86400000, usage: 1, takenBy: ['John'] }
|
||||
],
|
||||
lastWhen: FIXED_TIMESTAMP - 86400000
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
// Factory function for mock context
|
||||
const createMockContext = (overrides = {}) => ({
|
||||
meds: [],
|
||||
settings: {
|
||||
lowStockThreshold: 30,
|
||||
criticalStockThreshold: 7,
|
||||
expiryWarningDays: 30,
|
||||
lowStockDays: 7,
|
||||
normalStockDays: 30,
|
||||
highStockDays: 90
|
||||
},
|
||||
scheduleDays: 30,
|
||||
setScheduleDays: vi.fn(),
|
||||
showPastDays: false,
|
||||
setShowPastDays: vi.fn(),
|
||||
pastDays: [],
|
||||
futureDays: [],
|
||||
takenDoses: new Set(),
|
||||
markDoseTaken: vi.fn(),
|
||||
undoDoseTaken: vi.fn(),
|
||||
coverageByMed: {},
|
||||
depletionByMed: {},
|
||||
manuallyExpandedDays: new Set(),
|
||||
toggleDayCollapse: vi.fn(),
|
||||
openUserFilter: vi.fn(),
|
||||
...overrides
|
||||
});
|
||||
|
||||
let mockContextValue = createMockContext();
|
||||
|
||||
// Mock the context
|
||||
vi.mock('../../context', () => ({
|
||||
useAppContext: () => mockContextValue
|
||||
}));
|
||||
|
||||
vi.mock('../../components/Auth', () => ({
|
||||
useAuth: () => ({
|
||||
user: { id: 1, username: 'testuser' }
|
||||
})
|
||||
}));
|
||||
|
||||
describe('SchedulePage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
mockContextValue = createMockContext();
|
||||
});
|
||||
|
||||
it('renders schedule page', () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<SchedulePage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
// Should render the schedule section
|
||||
const section = document.querySelector('section.grid');
|
||||
expect(section).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders schedule title', () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<SchedulePage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
expect(screen.getByText(/dashboard\.schedules\.title/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders day range selector', () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<SchedulePage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
// Should have schedule days select dropdown
|
||||
const select = document.querySelector('.schedule-days-select');
|
||||
expect(select).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders timeline section', () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<SchedulePage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
// Should have timeline div
|
||||
const timeline = document.querySelector('.timeline');
|
||||
expect(timeline).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows empty state when no medications', () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<SchedulePage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
// With no meds, should show the schedule card but with empty timeline
|
||||
const card = document.querySelector('.card.schedule-full');
|
||||
expect(card).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders card head', () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<SchedulePage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
const cardHead = document.querySelector('.card-head');
|
||||
expect(cardHead).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders schedule days options', () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<SchedulePage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
const select = document.querySelector('.schedule-days-select');
|
||||
const options = select?.querySelectorAll('option');
|
||||
expect(options?.length).toBe(3);
|
||||
});
|
||||
|
||||
it('has 30, 90, 180 day options', () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<SchedulePage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
expect(screen.getByText(/dashboard\.schedules\.1month/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/dashboard\.schedules\.3months/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/dashboard\.schedules\.6months/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('can change schedule days', () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<SchedulePage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
const select = document.querySelector('.schedule-days-select') as HTMLSelectElement;
|
||||
expect(select).toBeInTheDocument();
|
||||
|
||||
fireEvent.change(select, { target: { value: '90' } });
|
||||
});
|
||||
});
|
||||
|
||||
describe('SchedulePage structure', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
mockContextValue = createMockContext();
|
||||
});
|
||||
|
||||
it('has heading element', () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<SchedulePage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
const heading = document.querySelector('h2');
|
||||
expect(heading).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders article element', () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<SchedulePage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
const article = document.querySelector('article');
|
||||
expect(article).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders section element', () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<SchedulePage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
const section = document.querySelector('section');
|
||||
expect(section).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders card with correct class', () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<SchedulePage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
const card = document.querySelector('.card.schedule-full');
|
||||
expect(card).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('SchedulePage with medications', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
mockContextValue = createMockContext({
|
||||
meds: mockMeds,
|
||||
futureDays: mockFutureDays,
|
||||
coverageByMed: mockCoverageByMed,
|
||||
depletionByMed: { 'Aspirin': Date.now() + 25 * 86400000 }
|
||||
});
|
||||
});
|
||||
|
||||
it('renders medication in timeline', () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<SchedulePage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Aspirin')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders day block', () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<SchedulePage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
const dayBlocks = document.querySelectorAll('.day-block');
|
||||
expect(dayBlocks.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('renders dose item', () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<SchedulePage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
const doseItems = document.querySelectorAll('.dose-item');
|
||||
expect(doseItems.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('renders take button', () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<SchedulePage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
const takeBtn = document.querySelector('.dose-btn.take');
|
||||
expect(takeBtn).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls markDoseTaken when clicking take button', () => {
|
||||
const markDoseTaken = vi.fn();
|
||||
mockContextValue = createMockContext({
|
||||
meds: mockMeds,
|
||||
futureDays: mockFutureDays,
|
||||
coverageByMed: mockCoverageByMed,
|
||||
markDoseTaken
|
||||
});
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<SchedulePage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
const takeBtn = document.querySelector('.dose-btn.take');
|
||||
if (takeBtn) {
|
||||
fireEvent.click(takeBtn);
|
||||
expect(markDoseTaken).toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it('renders person name for dose', () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<SchedulePage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
expect(screen.getByText('John')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls openUserFilter when clicking person name', () => {
|
||||
const openUserFilter = vi.fn();
|
||||
mockContextValue = createMockContext({
|
||||
meds: mockMeds,
|
||||
futureDays: mockFutureDays,
|
||||
coverageByMed: mockCoverageByMed,
|
||||
openUserFilter
|
||||
});
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<SchedulePage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
const personName = screen.getByText('John');
|
||||
fireEvent.click(personName);
|
||||
expect(openUserFilter).toHaveBeenCalledWith('John');
|
||||
});
|
||||
|
||||
it('renders pill weight when available', () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<SchedulePage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
// Aspirin has pillWeightMg of 500
|
||||
expect(screen.getByText(/500 mg/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders reminder icon when enabled', () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<SchedulePage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
// Aspirin has intakeRemindersEnabled
|
||||
const reminderIcon = document.querySelector('.reminder-icon');
|
||||
expect(reminderIcon).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders day blocks', () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<SchedulePage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
// Should have day blocks rendered
|
||||
const dayBlocks = document.querySelectorAll('.day-block');
|
||||
expect(dayBlocks.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SchedulePage with past days', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
mockContextValue = createMockContext({
|
||||
meds: mockMeds,
|
||||
pastDays: mockPastDays,
|
||||
futureDays: mockFutureDays,
|
||||
coverageByMed: mockCoverageByMed,
|
||||
showPastDays: false
|
||||
});
|
||||
});
|
||||
|
||||
it('renders past days toggle when past days exist', () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<SchedulePage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
const toggle = document.querySelector('.past-days-toggle');
|
||||
expect(toggle).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows missed doses warning', () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<SchedulePage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
const warning = document.querySelector('.past-days-warning');
|
||||
expect(warning).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('toggles past days visibility', () => {
|
||||
const setShowPastDays = vi.fn();
|
||||
mockContextValue = createMockContext({
|
||||
pastDays: mockPastDays,
|
||||
showPastDays: false,
|
||||
setShowPastDays
|
||||
});
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<SchedulePage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
const toggle = document.querySelector('.past-days-toggle');
|
||||
if (toggle) {
|
||||
fireEvent.click(toggle);
|
||||
expect(setShowPastDays).toHaveBeenCalledWith(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('SchedulePage with expanded past days', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
mockContextValue = createMockContext({
|
||||
meds: mockMeds,
|
||||
pastDays: mockPastDays,
|
||||
futureDays: mockFutureDays,
|
||||
coverageByMed: mockCoverageByMed,
|
||||
showPastDays: true,
|
||||
manuallyExpandedDays: new Set(['Sun, Jan 21'])
|
||||
});
|
||||
});
|
||||
|
||||
it('renders past day blocks when showPastDays is true', () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<SchedulePage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
const pastDayBlocks = document.querySelectorAll('.day-block.past');
|
||||
expect(pastDayBlocks.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('renders day divider for past days', () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<SchedulePage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
const dividers = document.querySelectorAll('.day-divider');
|
||||
expect(dividers.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('calls toggleDayCollapse when clicking day divider', () => {
|
||||
const toggleDayCollapse = vi.fn();
|
||||
mockContextValue = createMockContext({
|
||||
meds: mockMeds,
|
||||
pastDays: mockPastDays,
|
||||
showPastDays: true,
|
||||
manuallyExpandedDays: new Set(['Sun, Jan 21']),
|
||||
coverageByMed: mockCoverageByMed,
|
||||
toggleDayCollapse
|
||||
});
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<SchedulePage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
const divider = document.querySelector('.day-block.past .day-divider.clickable');
|
||||
if (divider) {
|
||||
fireEvent.click(divider);
|
||||
expect(toggleDayCollapse).toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('SchedulePage with taken doses', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
// Match the dose ID format exactly with the mockFutureDays dose
|
||||
// Since we can't predict Date.now(), we make the test check if takenDoses works
|
||||
});
|
||||
|
||||
it('marks doses as taken in UI', () => {
|
||||
// Create consistent timestamp for test
|
||||
const timestamp = Date.now();
|
||||
const doseId = `1-0-${timestamp}-John`;
|
||||
|
||||
const testFutureDays = [{
|
||||
dateStr: 'Mon, Jan 22',
|
||||
date: new Date(timestamp),
|
||||
isPast: false,
|
||||
meds: [{
|
||||
medName: 'Aspirin',
|
||||
total: 1,
|
||||
doses: [{ id: `1-0-${timestamp}`, timeStr: '09:00', when: timestamp, usage: 1, takenBy: ['John'] }],
|
||||
lastWhen: timestamp
|
||||
}]
|
||||
}];
|
||||
|
||||
mockContextValue = createMockContext({
|
||||
meds: mockMeds,
|
||||
futureDays: testFutureDays,
|
||||
coverageByMed: mockCoverageByMed,
|
||||
takenDoses: new Set([doseId])
|
||||
});
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<SchedulePage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
// When dose is taken, the undo button should appear
|
||||
const undoBtn = document.querySelector('.dose-btn.undo');
|
||||
expect(undoBtn).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls undoDoseTaken when clicking undo button', () => {
|
||||
const undoDoseTaken = vi.fn();
|
||||
const timestamp = Date.now();
|
||||
const doseId = `1-0-${timestamp}-John`;
|
||||
|
||||
const testFutureDays = [{
|
||||
dateStr: 'Mon, Jan 22',
|
||||
date: new Date(timestamp),
|
||||
isPast: false,
|
||||
meds: [{
|
||||
medName: 'Aspirin',
|
||||
total: 1,
|
||||
doses: [{ id: `1-0-${timestamp}`, timeStr: '09:00', when: timestamp, usage: 1, takenBy: ['John'] }],
|
||||
lastWhen: timestamp
|
||||
}]
|
||||
}];
|
||||
|
||||
mockContextValue = createMockContext({
|
||||
meds: mockMeds,
|
||||
futureDays: testFutureDays,
|
||||
coverageByMed: mockCoverageByMed,
|
||||
takenDoses: new Set([doseId]),
|
||||
undoDoseTaken
|
||||
});
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<SchedulePage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
const undoBtn = document.querySelector('.dose-btn.undo');
|
||||
if (undoBtn) {
|
||||
fireEvent.click(undoBtn);
|
||||
expect(undoDoseTaken).toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('SchedulePage with low stock', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
mockContextValue = createMockContext({
|
||||
meds: mockMeds,
|
||||
futureDays: mockFutureDays,
|
||||
coverageByMed: {
|
||||
'Aspirin': { name: 'Aspirin', medsLeft: 3, daysLeft: 3, depletionDate: '2025-01-25', depletionTime: Date.now() + 3 * 86400000, nextDose: null }
|
||||
},
|
||||
depletionByMed: { 'Aspirin': Date.now() + 3 * 86400000 }
|
||||
});
|
||||
});
|
||||
|
||||
it('shows status tag for medications', () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<SchedulePage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
const tags = document.querySelectorAll('.tag');
|
||||
expect(tags.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,85 @@
|
||||
import '@testing-library/jest-dom';
|
||||
import { vi } from 'vitest';
|
||||
|
||||
// Mock fetch globally
|
||||
global.fetch = vi.fn();
|
||||
|
||||
// Mock localStorage
|
||||
const localStorageMock = {
|
||||
getItem: vi.fn(),
|
||||
setItem: vi.fn(),
|
||||
removeItem: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
};
|
||||
Object.defineProperty(window, 'localStorage', { value: localStorageMock });
|
||||
|
||||
// Mock matchMedia
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: vi.fn().mockImplementation((query) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
});
|
||||
|
||||
// Mock navigator.clipboard
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
value: {
|
||||
writeText: vi.fn().mockResolvedValue(undefined),
|
||||
readText: vi.fn().mockResolvedValue(''),
|
||||
},
|
||||
writable: true,
|
||||
});
|
||||
|
||||
// Mock URL.createObjectURL and URL.revokeObjectURL
|
||||
global.URL.createObjectURL = vi.fn().mockReturnValue('blob:test-url');
|
||||
global.URL.revokeObjectURL = vi.fn();
|
||||
|
||||
// Mock window.history
|
||||
const mockHistoryPushState = vi.fn();
|
||||
const mockHistoryBack = vi.fn();
|
||||
Object.defineProperty(window, 'history', {
|
||||
value: {
|
||||
pushState: mockHistoryPushState,
|
||||
back: mockHistoryBack,
|
||||
replaceState: vi.fn(),
|
||||
state: null,
|
||||
length: 1,
|
||||
scrollRestoration: 'auto',
|
||||
go: vi.fn(),
|
||||
forward: vi.fn(),
|
||||
},
|
||||
writable: true,
|
||||
});
|
||||
|
||||
// Mock react-i18next globally
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, options?: Record<string, unknown>) => {
|
||||
if (options?.count !== undefined) return `${key}_${options.count}`;
|
||||
if (options?.max !== undefined) return `Max ${options.max} chars`;
|
||||
if (options?.days !== undefined) return `${key} (${options.days} days)`;
|
||||
return key;
|
||||
},
|
||||
i18n: {
|
||||
language: 'en',
|
||||
changeLanguage: vi.fn(),
|
||||
},
|
||||
}),
|
||||
I18nextProvider: ({ children }: { children: React.ReactNode }) => children,
|
||||
initReactI18next: { type: '3rdParty', init: vi.fn() },
|
||||
}));
|
||||
|
||||
// Reset mocks before each test
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorageMock.getItem.mockReturnValue(null);
|
||||
mockHistoryPushState.mockClear();
|
||||
mockHistoryBack.mockClear();
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { getMedTotal, getPackageSize, FIELD_LIMITS } from '../types';
|
||||
|
||||
describe('getMedTotal', () => {
|
||||
it('calculates total pills without stock adjustment', () => {
|
||||
const med = {
|
||||
packCount: 2,
|
||||
blistersPerPack: 3,
|
||||
pillsPerBlister: 10,
|
||||
looseTablets: 5
|
||||
};
|
||||
|
||||
expect(getMedTotal(med)).toBe(65); // 2*3*10 + 5 = 65
|
||||
});
|
||||
|
||||
it('includes positive stock adjustment', () => {
|
||||
const med = {
|
||||
packCount: 1,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 10,
|
||||
looseTablets: 0,
|
||||
stockAdjustment: 5
|
||||
};
|
||||
|
||||
expect(getMedTotal(med)).toBe(15); // 10 + 5 = 15
|
||||
});
|
||||
|
||||
it('includes negative stock adjustment', () => {
|
||||
const med = {
|
||||
packCount: 1,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 10,
|
||||
looseTablets: 0,
|
||||
stockAdjustment: -3
|
||||
};
|
||||
|
||||
expect(getMedTotal(med)).toBe(7); // 10 - 3 = 7
|
||||
});
|
||||
|
||||
it('handles undefined stock adjustment', () => {
|
||||
const med = {
|
||||
packCount: 1,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 10,
|
||||
looseTablets: 0,
|
||||
stockAdjustment: undefined
|
||||
};
|
||||
|
||||
expect(getMedTotal(med)).toBe(10);
|
||||
});
|
||||
|
||||
it('handles zero values', () => {
|
||||
const med = {
|
||||
packCount: 0,
|
||||
blistersPerPack: 0,
|
||||
pillsPerBlister: 0,
|
||||
looseTablets: 0
|
||||
};
|
||||
|
||||
expect(getMedTotal(med)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPackageSize', () => {
|
||||
it('calculates base package size', () => {
|
||||
const med = {
|
||||
packCount: 2,
|
||||
blistersPerPack: 3,
|
||||
pillsPerBlister: 10,
|
||||
looseTablets: 5
|
||||
};
|
||||
|
||||
expect(getPackageSize(med)).toBe(65);
|
||||
});
|
||||
|
||||
it('ignores stock adjustment', () => {
|
||||
const med = {
|
||||
packCount: 1,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 10,
|
||||
looseTablets: 0,
|
||||
stockAdjustment: 100 // Should be ignored
|
||||
};
|
||||
|
||||
expect(getPackageSize(med)).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe('FIELD_LIMITS', () => {
|
||||
it('has correct limits for name field', () => {
|
||||
expect(FIELD_LIMITS.name.min).toBe(1);
|
||||
expect(FIELD_LIMITS.name.max).toBe(100);
|
||||
});
|
||||
|
||||
it('has correct limits for genericName field', () => {
|
||||
expect(FIELD_LIMITS.genericName.max).toBe(100);
|
||||
});
|
||||
|
||||
it('has correct limits for takenBy field', () => {
|
||||
expect(FIELD_LIMITS.takenBy.max).toBe(100);
|
||||
});
|
||||
|
||||
it('has correct limits for notes field', () => {
|
||||
expect(FIELD_LIMITS.notes.max).toBe(2000);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,272 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import {
|
||||
formatNumber,
|
||||
formatDateTime,
|
||||
pad2,
|
||||
toIsoString,
|
||||
toDateValue,
|
||||
toTimeValue,
|
||||
combineDateAndTime,
|
||||
toInputValue,
|
||||
deriveTotal,
|
||||
getExpiryClass,
|
||||
getBlisterStock,
|
||||
formatFullBlisters,
|
||||
formatOpenBlisterAndLoose,
|
||||
compareSemver
|
||||
} from '../../utils/formatters';
|
||||
import type { Medication } from '../../types';
|
||||
|
||||
describe('formatNumber', () => {
|
||||
it('returns "—" for null', () => {
|
||||
expect(formatNumber(null)).toBe('—');
|
||||
});
|
||||
|
||||
it('returns "—" for undefined', () => {
|
||||
expect(formatNumber(undefined)).toBe('—');
|
||||
});
|
||||
|
||||
it('formats integer with no decimals', () => {
|
||||
expect(formatNumber(1234, 0)).toBe('1,234');
|
||||
});
|
||||
|
||||
it('formats number with specified decimals', () => {
|
||||
expect(formatNumber(1234.5678, 2)).toBe('1,234.57');
|
||||
});
|
||||
|
||||
it('formats zero correctly', () => {
|
||||
expect(formatNumber(0)).toBe('0');
|
||||
});
|
||||
|
||||
it('formats negative numbers correctly', () => {
|
||||
expect(formatNumber(-500)).toBe('-500');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatDateTime', () => {
|
||||
it('returns "-" for null', () => {
|
||||
expect(formatDateTime(null)).toBe('-');
|
||||
});
|
||||
|
||||
it('returns "-" for undefined', () => {
|
||||
expect(formatDateTime(undefined)).toBe('-');
|
||||
});
|
||||
|
||||
it('returns "-" for empty string', () => {
|
||||
expect(formatDateTime('')).toBe('-');
|
||||
});
|
||||
|
||||
it('returns "-" for invalid date string', () => {
|
||||
expect(formatDateTime('not-a-date')).toBe('-');
|
||||
});
|
||||
|
||||
it('formats valid ISO date string', () => {
|
||||
const result = formatDateTime('2024-03-15T10:30:00Z', 'en-US');
|
||||
expect(result).toMatch(/\d{2}\/\d{2}\/\d{4}/); // Contains date in some format
|
||||
expect(result).toMatch(/\d{1,2}:\d{2}/); // Contains time
|
||||
});
|
||||
});
|
||||
|
||||
describe('pad2', () => {
|
||||
it('pads single digit with leading zero', () => {
|
||||
expect(pad2(5)).toBe('05');
|
||||
});
|
||||
|
||||
it('keeps double digit as is', () => {
|
||||
expect(pad2(12)).toBe('12');
|
||||
});
|
||||
|
||||
it('pads zero correctly', () => {
|
||||
expect(pad2(0)).toBe('00');
|
||||
});
|
||||
});
|
||||
|
||||
describe('toIsoString', () => {
|
||||
it('converts Date to ISO string format', () => {
|
||||
const date = new Date(2024, 2, 15); // March 15, 2024
|
||||
expect(toIsoString(date)).toBe('2024-03-15');
|
||||
});
|
||||
|
||||
it('pads single digit months and days', () => {
|
||||
const date = new Date(2024, 0, 5); // January 5, 2024
|
||||
expect(toIsoString(date)).toBe('2024-01-05');
|
||||
});
|
||||
});
|
||||
|
||||
describe('toDateValue', () => {
|
||||
it('extracts date from ISO string', () => {
|
||||
expect(toDateValue('2024-03-15T10:30:00Z')).toBe('2024-03-15');
|
||||
});
|
||||
|
||||
it('converts Date to date string', () => {
|
||||
const date = new Date(2024, 2, 15);
|
||||
expect(toDateValue(date)).toBe('2024-03-15');
|
||||
});
|
||||
});
|
||||
|
||||
describe('toTimeValue', () => {
|
||||
it('extracts time from ISO string', () => {
|
||||
const result = toTimeValue('2024-03-15T10:30:00Z');
|
||||
// Time depends on timezone, just check format
|
||||
expect(result).toMatch(/^\d{2}:\d{2}$/);
|
||||
});
|
||||
|
||||
it('extracts time from Date object', () => {
|
||||
const date = new Date(2024, 2, 15, 14, 45);
|
||||
expect(toTimeValue(date)).toBe('14:45');
|
||||
});
|
||||
});
|
||||
|
||||
describe('combineDateAndTime', () => {
|
||||
it('combines date and time into ISO datetime', () => {
|
||||
expect(combineDateAndTime('2024-03-15', '10:30')).toBe('2024-03-15T10:30:00');
|
||||
});
|
||||
});
|
||||
|
||||
describe('toInputValue', () => {
|
||||
it('converts Date to datetime-local input format', () => {
|
||||
const date = new Date(2024, 2, 15, 14, 30);
|
||||
expect(toInputValue(date)).toBe('2024-03-15T14:30');
|
||||
});
|
||||
|
||||
it('converts ISO string to datetime-local input format', () => {
|
||||
const result = toInputValue('2024-03-15T14:30:00');
|
||||
// Format depends on timezone, but should be YYYY-MM-DDTHH:MM
|
||||
expect(result).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deriveTotal', () => {
|
||||
it('calculates total pills correctly', () => {
|
||||
expect(deriveTotal(2, 3, 10, 5)).toBe(65); // 2*3*10 + 5 = 65
|
||||
});
|
||||
|
||||
it('handles zero values', () => {
|
||||
expect(deriveTotal(0, 0, 0, 0)).toBe(0);
|
||||
});
|
||||
|
||||
it('handles only loose tablets', () => {
|
||||
expect(deriveTotal(0, 0, 0, 15)).toBe(15);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getExpiryClass', () => {
|
||||
let realDateNow: () => number;
|
||||
|
||||
beforeEach(() => {
|
||||
realDateNow = Date.now;
|
||||
// Mock current date to a fixed point
|
||||
const fixedDate = new Date('2024-03-15T12:00:00Z').getTime();
|
||||
vi.spyOn(Date, 'now').mockReturnValue(fixedDate);
|
||||
vi.setSystemTime(new Date('2024-03-15T12:00:00Z'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
Date.now = realDateNow;
|
||||
});
|
||||
|
||||
it('returns empty string for null', () => {
|
||||
expect(getExpiryClass(null, 30)).toBe('');
|
||||
});
|
||||
|
||||
it('returns empty string for undefined', () => {
|
||||
expect(getExpiryClass(undefined, 30)).toBe('');
|
||||
});
|
||||
|
||||
it('returns danger-text for past date', () => {
|
||||
expect(getExpiryClass('2024-03-10', 30)).toBe('danger-text');
|
||||
});
|
||||
|
||||
it('returns warning-text when within threshold', () => {
|
||||
expect(getExpiryClass('2024-03-25', 30)).toBe('warning-text');
|
||||
});
|
||||
|
||||
it('returns success-text when expiry is far away', () => {
|
||||
expect(getExpiryClass('2024-06-15', 30)).toBe('success-text');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getBlisterStock', () => {
|
||||
it('calculates blister stock correctly', () => {
|
||||
const med: Medication = {
|
||||
id: 1,
|
||||
name: 'Test Med',
|
||||
packCount: 1,
|
||||
blistersPerPack: 2,
|
||||
pillsPerBlister: 10,
|
||||
looseTablets: 5,
|
||||
takenBy: [],
|
||||
blisters: [],
|
||||
updatedAt: null
|
||||
};
|
||||
|
||||
const result = getBlisterStock(med);
|
||||
expect(result.fullBlisters).toBe(2); // 25 / 10 = 2
|
||||
expect(result.openBlisterPills).toBe(5); // 25 % 10 = 5
|
||||
expect(result.loosePills).toBe(5);
|
||||
});
|
||||
|
||||
it('includes stock adjustment in calculation', () => {
|
||||
const med: Medication = {
|
||||
id: 1,
|
||||
name: 'Test Med',
|
||||
packCount: 1,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 10,
|
||||
looseTablets: 0,
|
||||
stockAdjustment: -5,
|
||||
takenBy: [],
|
||||
blisters: [],
|
||||
updatedAt: null
|
||||
};
|
||||
|
||||
const result = getBlisterStock(med);
|
||||
expect(result.fullBlisters).toBe(0); // 5 / 10 = 0
|
||||
expect(result.openBlisterPills).toBe(5); // 5 % 10 = 5
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatFullBlisters', () => {
|
||||
it('formats count without pill info', () => {
|
||||
expect(formatFullBlisters({ fullBlisters: 5, openBlisterPills: 3, loosePills: 3 })).toBe('5');
|
||||
});
|
||||
|
||||
it('formats count with pill info', () => {
|
||||
expect(formatFullBlisters({ fullBlisters: 5, openBlisterPills: 3, loosePills: 3 }, 10)).toBe('5 (50)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatOpenBlisterAndLoose', () => {
|
||||
it('formats open blister pills count', () => {
|
||||
expect(formatOpenBlisterAndLoose({ fullBlisters: 5, openBlisterPills: 7, loosePills: 7 })).toBe('7');
|
||||
});
|
||||
});
|
||||
|
||||
describe('compareSemver', () => {
|
||||
it('returns 0 for equal versions', () => {
|
||||
expect(compareSemver('1.2.3', '1.2.3')).toBe(0);
|
||||
});
|
||||
|
||||
it('returns negative when a < b', () => {
|
||||
expect(compareSemver('1.2.3', '1.2.4')).toBeLessThan(0);
|
||||
expect(compareSemver('1.2.3', '1.3.0')).toBeLessThan(0);
|
||||
expect(compareSemver('1.2.3', '2.0.0')).toBeLessThan(0);
|
||||
});
|
||||
|
||||
it('returns positive when a > b', () => {
|
||||
expect(compareSemver('1.2.4', '1.2.3')).toBeGreaterThan(0);
|
||||
expect(compareSemver('1.3.0', '1.2.3')).toBeGreaterThan(0);
|
||||
expect(compareSemver('2.0.0', '1.2.3')).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('handles version prefixes', () => {
|
||||
expect(compareSemver('v1.2.3', 'v1.2.3')).toBe(0);
|
||||
expect(compareSemver('v1.2.3', '1.2.4')).toBeLessThan(0);
|
||||
});
|
||||
|
||||
it('handles versions with different segment counts', () => {
|
||||
expect(compareSemver('1.2', '1.2.0')).toBe(0);
|
||||
expect(compareSemver('1.2.3', '1.2')).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { generateICS } from '../../utils/ics';
|
||||
import type { Medication } from '../../types';
|
||||
|
||||
describe('generateICS', () => {
|
||||
let mockCreateObjectURL: ReturnType<typeof vi.fn>;
|
||||
let mockRevokeObjectURL: ReturnType<typeof vi.fn>;
|
||||
let mockAppendChild: ReturnType<typeof vi.fn>;
|
||||
let mockRemoveChild: ReturnType<typeof vi.fn>;
|
||||
let mockClick: ReturnType<typeof vi.fn>;
|
||||
let createdLink: HTMLAnchorElement | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
mockCreateObjectURL = vi.fn().mockReturnValue('blob:test-url');
|
||||
mockRevokeObjectURL = vi.fn();
|
||||
mockAppendChild = vi.fn();
|
||||
mockRemoveChild = vi.fn();
|
||||
mockClick = vi.fn();
|
||||
|
||||
global.URL.createObjectURL = mockCreateObjectURL;
|
||||
global.URL.revokeObjectURL = mockRevokeObjectURL;
|
||||
|
||||
vi.spyOn(document.body, 'appendChild').mockImplementation((node) => {
|
||||
mockAppendChild(node);
|
||||
createdLink = node as HTMLAnchorElement;
|
||||
return node;
|
||||
});
|
||||
vi.spyOn(document.body, 'removeChild').mockImplementation(mockRemoveChild);
|
||||
|
||||
// Mock createElement to track the created anchor
|
||||
const originalCreateElement = document.createElement.bind(document);
|
||||
vi.spyOn(document, 'createElement').mockImplementation((tag) => {
|
||||
const element = originalCreateElement(tag);
|
||||
if (tag === 'a') {
|
||||
element.click = mockClick;
|
||||
}
|
||||
return element;
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
createdLink = null;
|
||||
});
|
||||
|
||||
const createTestMed = (overrides?: Partial<Medication>): Medication => ({
|
||||
id: 1,
|
||||
name: 'TestMed',
|
||||
genericName: 'Generic Test',
|
||||
packCount: 1,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 30,
|
||||
looseTablets: 0,
|
||||
takenBy: ['John'],
|
||||
pillWeightMg: 100,
|
||||
blisters: [{
|
||||
usage: 1,
|
||||
every: 1,
|
||||
start: '2024-03-15T09:00:00'
|
||||
}],
|
||||
notes: 'Take with food',
|
||||
updatedAt: null,
|
||||
...overrides
|
||||
});
|
||||
|
||||
it('creates and downloads ICS file', () => {
|
||||
const med = createTestMed();
|
||||
|
||||
generateICS(med);
|
||||
|
||||
expect(mockCreateObjectURL).toHaveBeenCalledTimes(1);
|
||||
expect(mockAppendChild).toHaveBeenCalledTimes(1);
|
||||
expect(mockClick).toHaveBeenCalledTimes(1);
|
||||
expect(mockRemoveChild).toHaveBeenCalledTimes(1);
|
||||
expect(mockRevokeObjectURL).toHaveBeenCalledWith('blob:test-url');
|
||||
});
|
||||
|
||||
it('generates correct filename', () => {
|
||||
const med = createTestMed({ name: 'Test Med/Special' });
|
||||
|
||||
generateICS(med);
|
||||
|
||||
expect(createdLink?.download).toBe('Test_Med_Special_schedule.ics');
|
||||
});
|
||||
|
||||
it('creates blob with text/calendar content type', () => {
|
||||
const med = createTestMed();
|
||||
|
||||
generateICS(med);
|
||||
|
||||
expect(mockCreateObjectURL).toHaveBeenCalled();
|
||||
const blobArg = mockCreateObjectURL.mock.calls[0][0];
|
||||
expect(blobArg).toBeInstanceOf(Blob);
|
||||
expect(blobArg.type).toBe('text/calendar;charset=utf-8');
|
||||
});
|
||||
|
||||
it('handles medication with multiple blisters', () => {
|
||||
const med = createTestMed({
|
||||
blisters: [
|
||||
{ usage: 1, every: 1, start: '2024-03-15T09:00:00' },
|
||||
{ usage: 2, every: 7, start: '2024-03-15T21:00:00' }
|
||||
]
|
||||
});
|
||||
|
||||
expect(() => generateICS(med)).not.toThrow();
|
||||
expect(mockCreateObjectURL).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles medication without optional fields', () => {
|
||||
const med = createTestMed({
|
||||
genericName: undefined,
|
||||
pillWeightMg: undefined,
|
||||
takenBy: [],
|
||||
notes: undefined
|
||||
});
|
||||
|
||||
expect(() => generateICS(med)).not.toThrow();
|
||||
});
|
||||
|
||||
it('handles medication with empty blisters', () => {
|
||||
const med = createTestMed({ blisters: [] });
|
||||
|
||||
expect(() => generateICS(med)).not.toThrow();
|
||||
});
|
||||
|
||||
it('handles plural pills correctly', () => {
|
||||
const singlePillMed = createTestMed({
|
||||
blisters: [{ usage: 1, every: 1, start: '2024-03-15T09:00:00' }]
|
||||
});
|
||||
|
||||
const multiPillMed = createTestMed({
|
||||
blisters: [{ usage: 2, every: 1, start: '2024-03-15T09:00:00' }]
|
||||
});
|
||||
|
||||
expect(() => generateICS(singlePillMed)).not.toThrow();
|
||||
expect(() => generateICS(multiPillMed)).not.toThrow();
|
||||
});
|
||||
|
||||
it('handles different interval values', () => {
|
||||
const dailyMed = createTestMed({
|
||||
blisters: [{ usage: 1, every: 1, start: '2024-03-15T09:00:00' }]
|
||||
});
|
||||
|
||||
const weeklyMed = createTestMed({
|
||||
blisters: [{ usage: 1, every: 7, start: '2024-03-15T09:00:00' }]
|
||||
});
|
||||
|
||||
expect(() => generateICS(dailyMed)).not.toThrow();
|
||||
expect(() => generateICS(weeklyMed)).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,555 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import {
|
||||
buildSchedulePreview,
|
||||
calculateCoverage,
|
||||
getStockStatus,
|
||||
getNextReminderForMed,
|
||||
getReminderStatusText
|
||||
} from '../../utils/schedule';
|
||||
import type { Medication, Coverage, StockThresholds } from '../../types';
|
||||
|
||||
describe('buildSchedulePreview', () => {
|
||||
beforeEach(() => {
|
||||
vi.setSystemTime(new Date('2024-03-15T12:00:00Z'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('returns empty events for empty medications array', () => {
|
||||
const result = buildSchedulePreview([], 'en', false);
|
||||
expect(result.events).toEqual([]);
|
||||
expect(result.today).toBe(0);
|
||||
expect(result.totalBlisters).toBe(0);
|
||||
});
|
||||
|
||||
it('returns empty for non-array input', () => {
|
||||
const result = buildSchedulePreview(null as unknown as Medication[], 'en', false);
|
||||
expect(result.events).toEqual([]);
|
||||
});
|
||||
|
||||
it('builds events for medication with schedule', () => {
|
||||
const meds: Medication[] = [{
|
||||
id: 1,
|
||||
name: 'TestMed',
|
||||
packCount: 1,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 30,
|
||||
looseTablets: 0,
|
||||
takenBy: ['John'],
|
||||
blisters: [{
|
||||
usage: 1,
|
||||
every: 1,
|
||||
start: '2024-03-14T09:00:00'
|
||||
}],
|
||||
updatedAt: null
|
||||
}];
|
||||
|
||||
const result = buildSchedulePreview(meds, 'en', true);
|
||||
expect(result.events.length).toBeGreaterThan(0);
|
||||
expect(result.totalBlisters).toBe(1);
|
||||
});
|
||||
|
||||
it('filters out past events when includePast is false', () => {
|
||||
const meds: Medication[] = [{
|
||||
id: 1,
|
||||
name: 'TestMed',
|
||||
packCount: 1,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 30,
|
||||
looseTablets: 0,
|
||||
takenBy: [],
|
||||
blisters: [{
|
||||
usage: 1,
|
||||
every: 1,
|
||||
start: '2024-03-01T09:00:00'
|
||||
}],
|
||||
updatedAt: null
|
||||
}];
|
||||
|
||||
const withPast = buildSchedulePreview(meds, 'en', true);
|
||||
const withoutPast = buildSchedulePreview(meds, 'en', false);
|
||||
|
||||
expect(withPast.events.length).toBeGreaterThanOrEqual(withoutPast.events.length);
|
||||
});
|
||||
|
||||
it('handles invalid date in blister start', () => {
|
||||
const meds: Medication[] = [{
|
||||
id: 1,
|
||||
name: 'TestMed',
|
||||
packCount: 1,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 30,
|
||||
looseTablets: 0,
|
||||
takenBy: [],
|
||||
blisters: [{
|
||||
usage: 1,
|
||||
every: 1,
|
||||
start: 'invalid-date'
|
||||
}],
|
||||
updatedAt: null
|
||||
}];
|
||||
|
||||
const result = buildSchedulePreview(meds, 'en', true);
|
||||
// Should not crash, events for invalid dates are skipped
|
||||
expect(Array.isArray(result.events)).toBe(true);
|
||||
});
|
||||
|
||||
it('sorts events by time', () => {
|
||||
const meds: Medication[] = [{
|
||||
id: 1,
|
||||
name: 'Morning Med',
|
||||
packCount: 1,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 30,
|
||||
looseTablets: 0,
|
||||
takenBy: [],
|
||||
blisters: [{
|
||||
usage: 1,
|
||||
every: 1,
|
||||
start: '2024-03-15T09:00:00'
|
||||
}],
|
||||
updatedAt: null
|
||||
}, {
|
||||
id: 2,
|
||||
name: 'Evening Med',
|
||||
packCount: 1,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 30,
|
||||
looseTablets: 0,
|
||||
takenBy: [],
|
||||
blisters: [{
|
||||
usage: 1,
|
||||
every: 1,
|
||||
start: '2024-03-15T21:00:00'
|
||||
}],
|
||||
updatedAt: null
|
||||
}];
|
||||
|
||||
const result = buildSchedulePreview(meds, 'en', false);
|
||||
for (let i = 1; i < result.events.length; i++) {
|
||||
expect(result.events[i].when).toBeGreaterThanOrEqual(result.events[i - 1].when);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculateCoverage', () => {
|
||||
beforeEach(() => {
|
||||
vi.setSystemTime(new Date('2024-03-15T12:00:00Z'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('calculates coverage for medication with schedule', () => {
|
||||
const meds: Medication[] = [{
|
||||
id: 1,
|
||||
name: 'TestMed',
|
||||
packCount: 1,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 30,
|
||||
looseTablets: 0,
|
||||
takenBy: [],
|
||||
blisters: [{
|
||||
usage: 1,
|
||||
every: 1,
|
||||
start: '2024-03-15T09:00:00'
|
||||
}],
|
||||
updatedAt: null
|
||||
}];
|
||||
|
||||
const events = [{ medName: 'TestMed', when: Date.now() }];
|
||||
const result = calculateCoverage(meds, events, 'en', 7, 'automatic', new Set());
|
||||
|
||||
expect(result.all).toHaveLength(1);
|
||||
expect(result.all[0].name).toBe('TestMed');
|
||||
expect(result.all[0].daysLeft).toBeDefined();
|
||||
});
|
||||
|
||||
it('handles medication with no schedule', () => {
|
||||
const meds: Medication[] = [{
|
||||
id: 1,
|
||||
name: 'NoSchedule',
|
||||
packCount: 1,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 30,
|
||||
looseTablets: 0,
|
||||
takenBy: [],
|
||||
blisters: [],
|
||||
updatedAt: null
|
||||
}];
|
||||
|
||||
const result = calculateCoverage(meds, [], 'en', 7, 'automatic', new Set());
|
||||
|
||||
expect(result.all).toHaveLength(1);
|
||||
expect(result.all[0].daysLeft).toBeNull();
|
||||
});
|
||||
|
||||
it('filters low stock medications', () => {
|
||||
const meds: Medication[] = [{
|
||||
id: 1,
|
||||
name: 'LowStock',
|
||||
packCount: 0,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 30,
|
||||
looseTablets: 5,
|
||||
takenBy: [],
|
||||
blisters: [{
|
||||
usage: 1,
|
||||
every: 1,
|
||||
start: '2024-03-15T09:00:00'
|
||||
}],
|
||||
updatedAt: null
|
||||
}];
|
||||
|
||||
const result = calculateCoverage(meds, [], 'en', 7, 'automatic', new Set());
|
||||
expect(result.low.length).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it('respects manual stock calculation mode', () => {
|
||||
const meds: Medication[] = [{
|
||||
id: 1,
|
||||
name: 'TestMed',
|
||||
packCount: 1,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 30,
|
||||
looseTablets: 0,
|
||||
takenBy: [],
|
||||
blisters: [{
|
||||
usage: 1,
|
||||
every: 1,
|
||||
start: '2024-03-10T09:00:00'
|
||||
}],
|
||||
updatedAt: null
|
||||
}];
|
||||
|
||||
const takenDoses = new Set(['1-0-1710061200000']);
|
||||
const result = calculateCoverage(meds, [], 'en', 7, 'manual', takenDoses);
|
||||
|
||||
expect(result.all).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('handles multiple takenBy people', () => {
|
||||
const meds: Medication[] = [{
|
||||
id: 1,
|
||||
name: 'SharedMed',
|
||||
packCount: 1,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 30,
|
||||
looseTablets: 0,
|
||||
takenBy: ['Alice', 'Bob'],
|
||||
blisters: [{
|
||||
usage: 1,
|
||||
every: 1,
|
||||
start: '2024-03-15T09:00:00'
|
||||
}],
|
||||
updatedAt: null
|
||||
}];
|
||||
|
||||
const result = calculateCoverage(meds, [], 'en', 7, 'automatic', new Set());
|
||||
expect(result.all).toHaveLength(1);
|
||||
// Daily rate should be doubled for 2 people
|
||||
});
|
||||
});
|
||||
|
||||
describe('getStockStatus', () => {
|
||||
const thresholds: StockThresholds = {
|
||||
lowStockDays: 30,
|
||||
normalStockDays: 90,
|
||||
highStockDays: 180
|
||||
};
|
||||
|
||||
it('returns out-of-stock when medsLeft is 0', () => {
|
||||
const result = getStockStatus(5, 0, thresholds);
|
||||
expect(result.level).toBe('out-of-stock');
|
||||
expect(result.className).toBe('danger');
|
||||
});
|
||||
|
||||
it('returns out-of-stock when daysLeft is 0', () => {
|
||||
const result = getStockStatus(0, 5, thresholds);
|
||||
expect(result.level).toBe('out-of-stock');
|
||||
expect(result.className).toBe('danger');
|
||||
});
|
||||
|
||||
it('returns high when daysLeft > highStockDays', () => {
|
||||
const result = getStockStatus(200, 100, thresholds);
|
||||
expect(result.level).toBe('high');
|
||||
expect(result.className).toBe('high');
|
||||
});
|
||||
|
||||
it('returns normal when daysLeft >= lowStockDays', () => {
|
||||
const result = getStockStatus(50, 100, thresholds);
|
||||
expect(result.level).toBe('normal');
|
||||
expect(result.className).toBe('success');
|
||||
});
|
||||
|
||||
it('returns low when daysLeft < lowStockDays', () => {
|
||||
const result = getStockStatus(20, 100, thresholds);
|
||||
expect(result.level).toBe('low');
|
||||
expect(result.className).toBe('warning');
|
||||
});
|
||||
|
||||
it('returns normal when daysLeft is null but medsLeft > 0', () => {
|
||||
const result = getStockStatus(null, 100, thresholds);
|
||||
expect(result.level).toBe('normal');
|
||||
expect(result.label).toBe('status.noSchedule');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getNextReminderForMed', () => {
|
||||
beforeEach(() => {
|
||||
vi.setSystemTime(new Date('2024-03-15T12:00:00Z'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('returns "—" when no depletion time', () => {
|
||||
const med: Coverage = {
|
||||
name: 'Test',
|
||||
medsLeft: 100,
|
||||
daysLeft: null,
|
||||
depletionDate: null,
|
||||
depletionTime: null,
|
||||
nextDose: null
|
||||
};
|
||||
|
||||
expect(getNextReminderForMed(med, 7, 'en')).toBe('—');
|
||||
});
|
||||
|
||||
it('returns "Due now" when reminder time is past', () => {
|
||||
const now = Date.now();
|
||||
const med: Coverage = {
|
||||
name: 'Test',
|
||||
medsLeft: 5,
|
||||
daysLeft: 3,
|
||||
depletionDate: null,
|
||||
depletionTime: now + 3 * 86400000,
|
||||
nextDose: null
|
||||
};
|
||||
|
||||
// Reminder 7 days before = already past
|
||||
expect(getNextReminderForMed(med, 7, 'en')).toBe('Due now');
|
||||
});
|
||||
|
||||
it('returns formatted date for future reminder', () => {
|
||||
const now = Date.now();
|
||||
const med: Coverage = {
|
||||
name: 'Test',
|
||||
medsLeft: 100,
|
||||
daysLeft: 30,
|
||||
depletionDate: null,
|
||||
depletionTime: now + 30 * 86400000,
|
||||
nextDose: null
|
||||
};
|
||||
|
||||
const result = getNextReminderForMed(med, 7, 'en-US');
|
||||
expect(result).not.toBe('—');
|
||||
expect(result).not.toBe('Due now');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getReminderStatusText', () => {
|
||||
const mockT = (key: string, options?: Record<string, unknown>) => {
|
||||
if (options?.count) return `${key} (${options.count})`;
|
||||
if (options?.days) return `${key} (${options.days})`;
|
||||
return key;
|
||||
};
|
||||
|
||||
it('shows empty stock warning first', () => {
|
||||
const emptyMed: Coverage = {
|
||||
name: 'Empty',
|
||||
medsLeft: 0,
|
||||
daysLeft: 0,
|
||||
depletionDate: null,
|
||||
depletionTime: null,
|
||||
nextDose: null
|
||||
};
|
||||
|
||||
const result = getReminderStatusText(7, 30, [], [emptyMed], null, null, null, mockT, 'en');
|
||||
expect(result.lines[0].text).toContain('dashboard.reminders.emptyStock');
|
||||
expect(result.lines[0].className).toBe('danger-text');
|
||||
});
|
||||
|
||||
it('shows all ok when everything is fine', () => {
|
||||
const healthyMed: Coverage = {
|
||||
name: 'Healthy',
|
||||
medsLeft: 100,
|
||||
daysLeft: 60,
|
||||
depletionDate: null,
|
||||
depletionTime: Date.now() + 60 * 86400000,
|
||||
nextDose: null
|
||||
};
|
||||
|
||||
const result = getReminderStatusText(7, 30, [], [healthyMed], null, null, null, mockT, 'en');
|
||||
expect(result.lines[0].text).toContain('dashboard.reminders.allOk');
|
||||
});
|
||||
|
||||
it('includes last sent info if available', () => {
|
||||
// For healthy meds with no upcoming reminders, it goes to the final fallback
|
||||
// which returns allStockOk and includes lastReminder info
|
||||
const healthyMed: Coverage = {
|
||||
name: 'Healthy',
|
||||
medsLeft: 100,
|
||||
daysLeft: 200,
|
||||
depletionDate: null,
|
||||
depletionTime: Date.now() + 200 * 86400000,
|
||||
nextDose: null
|
||||
};
|
||||
|
||||
const result = getReminderStatusText(
|
||||
7, 30, [], [healthyMed],
|
||||
'2024-03-10T10:00:00Z',
|
||||
'stock',
|
||||
'email',
|
||||
mockT,
|
||||
'en'
|
||||
);
|
||||
// Either allOk or allStockOk includes last reminder info
|
||||
const hasLastReminder = result.lines.some(l =>
|
||||
l.text.includes('lastReminder') ||
|
||||
l.text.includes('allOk') ||
|
||||
l.text.includes('allStockOk')
|
||||
);
|
||||
expect(hasLastReminder).toBe(true);
|
||||
});
|
||||
|
||||
it('shows low warning for medications running low', () => {
|
||||
const lowMed: Coverage = {
|
||||
name: 'RunningLow',
|
||||
medsLeft: 20,
|
||||
daysLeft: 20,
|
||||
depletionDate: null,
|
||||
depletionTime: Date.now() + 20 * 86400000,
|
||||
nextDose: null
|
||||
};
|
||||
|
||||
const result = getReminderStatusText(7, 30, [], [lowMed], null, null, null, mockT, 'en');
|
||||
expect(result.lines.some(l => l.text.includes('lowWarning') || l.text.includes('needReorder'))).toBe(true);
|
||||
});
|
||||
|
||||
it('handles intake reminder type with push channel', () => {
|
||||
const emptyMed: Coverage = {
|
||||
name: 'Empty',
|
||||
medsLeft: 0,
|
||||
daysLeft: 0,
|
||||
depletionDate: null,
|
||||
depletionTime: null,
|
||||
nextDose: null
|
||||
};
|
||||
|
||||
const result = getReminderStatusText(
|
||||
7, 30, [], [emptyMed],
|
||||
'2024-03-10T10:00:00Z',
|
||||
'intake',
|
||||
'push',
|
||||
mockT,
|
||||
'en'
|
||||
);
|
||||
expect(result.lines[0].className).toBe('danger-text');
|
||||
});
|
||||
|
||||
it('handles both channel type', () => {
|
||||
const emptyMed: Coverage = {
|
||||
name: 'Empty',
|
||||
medsLeft: 0,
|
||||
daysLeft: 0,
|
||||
depletionDate: null,
|
||||
depletionTime: null,
|
||||
nextDose: null
|
||||
};
|
||||
|
||||
const result = getReminderStatusText(
|
||||
7, 30, [], [emptyMed],
|
||||
'2024-03-10T10:00:00Z',
|
||||
'stock',
|
||||
'both',
|
||||
mockT,
|
||||
'en'
|
||||
);
|
||||
expect(result.lines[0].className).toBe('danger-text');
|
||||
});
|
||||
|
||||
it('shows needReorder when below critical threshold', () => {
|
||||
const criticalMed: Coverage = {
|
||||
name: 'Critical',
|
||||
medsLeft: 5,
|
||||
daysLeft: 5,
|
||||
depletionDate: null,
|
||||
depletionTime: Date.now() + 5 * 86400000,
|
||||
nextDose: null
|
||||
};
|
||||
|
||||
const result = getReminderStatusText(
|
||||
7, 30, [criticalMed], [criticalMed],
|
||||
null, null, null, mockT, 'en'
|
||||
);
|
||||
expect(result.lines.some(l => l.text.includes('needReorder'))).toBe(true);
|
||||
});
|
||||
|
||||
it('shows low warning when below low threshold but above critical', () => {
|
||||
const lowMed: Coverage = {
|
||||
name: 'Low',
|
||||
medsLeft: 20,
|
||||
daysLeft: 20,
|
||||
depletionDate: null,
|
||||
depletionTime: Date.now() + 20 * 86400000,
|
||||
nextDose: null
|
||||
};
|
||||
|
||||
const result = getReminderStatusText(
|
||||
7, 30, [], [lowMed],
|
||||
null, null, null, mockT, 'en'
|
||||
);
|
||||
expect(result.lines.some(l => l.text.includes('lowWarning'))).toBe(true);
|
||||
});
|
||||
|
||||
it('returns noRemindersNeeded when all ok and no last sent', () => {
|
||||
const result = getReminderStatusText(
|
||||
7, 30, [], [],
|
||||
null, null, null, mockT, 'en'
|
||||
);
|
||||
expect(result.lines.some(l =>
|
||||
l.text.includes('noRemindersNeeded') || l.text.includes('allStockOk')
|
||||
)).toBe(true);
|
||||
});
|
||||
|
||||
it('handles empty and critical meds together', () => {
|
||||
const emptyMed: Coverage = {
|
||||
name: 'Empty',
|
||||
medsLeft: 0,
|
||||
daysLeft: 0,
|
||||
depletionDate: null,
|
||||
depletionTime: null,
|
||||
nextDose: null
|
||||
};
|
||||
|
||||
const criticalMed: Coverage = {
|
||||
name: 'Critical',
|
||||
medsLeft: 5,
|
||||
daysLeft: 5,
|
||||
depletionDate: null,
|
||||
depletionTime: Date.now() + 5 * 86400000,
|
||||
nextDose: null
|
||||
};
|
||||
|
||||
const lowMed: Coverage = {
|
||||
name: 'Low',
|
||||
medsLeft: 20,
|
||||
daysLeft: 20,
|
||||
depletionDate: null,
|
||||
depletionTime: Date.now() + 20 * 86400000,
|
||||
nextDose: null
|
||||
};
|
||||
|
||||
const result = getReminderStatusText(
|
||||
7, 30, [criticalMed], [emptyMed, criticalMed, lowMed],
|
||||
null, null, null, mockT, 'en'
|
||||
);
|
||||
expect(result.lines[0].text).toContain('emptyStock');
|
||||
expect(result.lines.length).toBeGreaterThan(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,183 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import {
|
||||
userStorageKey,
|
||||
todayIso,
|
||||
plusDaysIso,
|
||||
loadCollapsedDaysFromStorage,
|
||||
saveCollapsedDaysToStorage,
|
||||
getStoredTheme,
|
||||
saveTheme
|
||||
} from '../../utils/storage';
|
||||
|
||||
describe('userStorageKey', () => {
|
||||
it('generates user-specific storage key', () => {
|
||||
expect(userStorageKey(123, 'testKey')).toBe('testKey_user_123');
|
||||
});
|
||||
|
||||
it('works with string userId', () => {
|
||||
expect(userStorageKey('456', 'myKey')).toBe('myKey_user_456');
|
||||
});
|
||||
});
|
||||
|
||||
describe('todayIso', () => {
|
||||
it('returns today date in ISO format', () => {
|
||||
const result = todayIso();
|
||||
expect(result).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
||||
|
||||
const today = new Date();
|
||||
const year = today.getFullYear();
|
||||
const month = String(today.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(today.getDate()).padStart(2, '0');
|
||||
expect(result).toBe(`${year}-${month}-${day}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('plusDaysIso', () => {
|
||||
it('returns date N days from today', () => {
|
||||
const today = new Date();
|
||||
const expectedDate = new Date(today);
|
||||
expectedDate.setDate(expectedDate.getDate() + 7);
|
||||
|
||||
const result = plusDaysIso(7);
|
||||
expect(result).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
||||
|
||||
const year = expectedDate.getFullYear();
|
||||
const month = String(expectedDate.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(expectedDate.getDate()).padStart(2, '0');
|
||||
expect(result).toBe(`${year}-${month}-${day}`);
|
||||
});
|
||||
|
||||
it('handles zero days', () => {
|
||||
expect(plusDaysIso(0)).toBe(todayIso());
|
||||
});
|
||||
|
||||
it('handles negative days', () => {
|
||||
const today = new Date();
|
||||
const expectedDate = new Date(today);
|
||||
expectedDate.setDate(expectedDate.getDate() - 3);
|
||||
|
||||
const result = plusDaysIso(-3);
|
||||
const year = expectedDate.getFullYear();
|
||||
const month = String(expectedDate.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(expectedDate.getDate()).padStart(2, '0');
|
||||
expect(result).toBe(`${year}-${month}-${day}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadCollapsedDaysFromStorage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
(window.localStorage.getItem as ReturnType<typeof vi.fn>).mockReturnValue(null);
|
||||
});
|
||||
|
||||
it('returns empty sets when no data in storage', () => {
|
||||
const result = loadCollapsedDaysFromStorage('collapsed', 'expanded');
|
||||
expect(result.collapsed.size).toBe(0);
|
||||
expect(result.expanded.size).toBe(0);
|
||||
});
|
||||
|
||||
it('loads collapsed days from localStorage', () => {
|
||||
(window.localStorage.getItem as ReturnType<typeof vi.fn>)
|
||||
.mockImplementation((key: string) => {
|
||||
if (key === 'collapsed') return JSON.stringify(['2024-01-01', '2024-01-02']);
|
||||
return null;
|
||||
});
|
||||
|
||||
const result = loadCollapsedDaysFromStorage('collapsed', 'expanded');
|
||||
expect(result.collapsed.has('2024-01-01')).toBe(true);
|
||||
expect(result.collapsed.has('2024-01-02')).toBe(true);
|
||||
expect(result.collapsed.size).toBe(2);
|
||||
});
|
||||
|
||||
it('loads expanded days from localStorage', () => {
|
||||
(window.localStorage.getItem as ReturnType<typeof vi.fn>)
|
||||
.mockImplementation((key: string) => {
|
||||
if (key === 'expanded') return JSON.stringify(['2024-01-03']);
|
||||
return null;
|
||||
});
|
||||
|
||||
const result = loadCollapsedDaysFromStorage('collapsed', 'expanded');
|
||||
expect(result.expanded.has('2024-01-03')).toBe(true);
|
||||
expect(result.expanded.size).toBe(1);
|
||||
});
|
||||
|
||||
it('handles invalid JSON gracefully', () => {
|
||||
(window.localStorage.getItem as ReturnType<typeof vi.fn>)
|
||||
.mockReturnValue('invalid-json');
|
||||
|
||||
const result = loadCollapsedDaysFromStorage('collapsed', 'expanded');
|
||||
expect(result.collapsed.size).toBe(0);
|
||||
expect(result.expanded.size).toBe(0);
|
||||
});
|
||||
|
||||
it('handles non-array JSON gracefully', () => {
|
||||
(window.localStorage.getItem as ReturnType<typeof vi.fn>)
|
||||
.mockReturnValue('{"not": "array"}');
|
||||
|
||||
const result = loadCollapsedDaysFromStorage('collapsed', 'expanded');
|
||||
expect(result.collapsed.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('saveCollapsedDaysToStorage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('saves state to localStorage', () => {
|
||||
const state = { '2024-01-01': true, '2024-01-02': false };
|
||||
saveCollapsedDaysToStorage('testKey', state);
|
||||
|
||||
expect(window.localStorage.setItem).toHaveBeenCalledWith(
|
||||
'testKey',
|
||||
JSON.stringify(state)
|
||||
);
|
||||
});
|
||||
|
||||
it('handles storage errors gracefully', () => {
|
||||
(window.localStorage.setItem as ReturnType<typeof vi.fn>)
|
||||
.mockImplementation(() => {
|
||||
throw new Error('Storage full');
|
||||
});
|
||||
|
||||
// Should not throw
|
||||
expect(() => {
|
||||
saveCollapsedDaysToStorage('testKey', { key: true });
|
||||
}).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getStoredTheme', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
(window.localStorage.getItem as ReturnType<typeof vi.fn>).mockReturnValue(null);
|
||||
});
|
||||
|
||||
it('returns "dark" as default', () => {
|
||||
expect(getStoredTheme()).toBe('dark');
|
||||
});
|
||||
|
||||
it('returns stored theme', () => {
|
||||
(window.localStorage.getItem as ReturnType<typeof vi.fn>)
|
||||
.mockReturnValue('light');
|
||||
expect(getStoredTheme()).toBe('light');
|
||||
});
|
||||
});
|
||||
|
||||
describe('saveTheme', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Reset mock to default behavior
|
||||
(window.localStorage.setItem as ReturnType<typeof vi.fn>).mockImplementation(() => {});
|
||||
});
|
||||
|
||||
it('saves theme to localStorage', () => {
|
||||
saveTheme('light');
|
||||
expect(window.localStorage.setItem).toHaveBeenCalledWith('theme', 'light');
|
||||
});
|
||||
|
||||
it('saves dark theme', () => {
|
||||
saveTheme('dark');
|
||||
expect(window.localStorage.setItem).toHaveBeenCalledWith('theme', 'dark');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,173 @@
|
||||
// =============================================================================
|
||||
// Core Types for MedAssist
|
||||
// =============================================================================
|
||||
|
||||
export type Blister = {
|
||||
usage: number;
|
||||
every: number;
|
||||
start: string;
|
||||
};
|
||||
|
||||
export type Medication = {
|
||||
id: number;
|
||||
name: string;
|
||||
genericName?: string | null;
|
||||
takenBy: string[];
|
||||
packCount: number;
|
||||
blistersPerPack: number;
|
||||
pillsPerBlister: number;
|
||||
looseTablets: number;
|
||||
stockAdjustment?: number;
|
||||
lastStockCorrectionAt?: string | null;
|
||||
pillWeightMg?: number | null;
|
||||
blisters: Blister[];
|
||||
imageUrl?: string | null;
|
||||
expiryDate?: string | null;
|
||||
notes?: string | null;
|
||||
intakeRemindersEnabled?: boolean;
|
||||
updatedAt: string | number | null;
|
||||
};
|
||||
|
||||
export type PlannerRow = {
|
||||
medicationId: number;
|
||||
medicationName: string;
|
||||
totalPills: number;
|
||||
plannerUsage: number;
|
||||
blisterSize: number;
|
||||
blistersNeeded: number;
|
||||
fullBlisters: number;
|
||||
loosePills: number;
|
||||
enough: boolean;
|
||||
};
|
||||
|
||||
export type RefillEntry = {
|
||||
id: number;
|
||||
packsAdded: number;
|
||||
loosePillsAdded: number;
|
||||
refillDate: string;
|
||||
};
|
||||
|
||||
export type FormBlister = {
|
||||
usage: string;
|
||||
every: string;
|
||||
startDate: string;
|
||||
startTime: string;
|
||||
};
|
||||
|
||||
export type FormState = {
|
||||
name: string;
|
||||
genericName: string;
|
||||
takenBy: string[];
|
||||
packCount: string;
|
||||
blistersPerPack: string;
|
||||
pillsPerBlister: string;
|
||||
looseTablets: string;
|
||||
pillWeightMg: string;
|
||||
expiryDate: string;
|
||||
notes: string;
|
||||
intakeRemindersEnabled: boolean;
|
||||
blisters: FormBlister[];
|
||||
};
|
||||
|
||||
export type FieldErrors = {
|
||||
name?: string;
|
||||
genericName?: string;
|
||||
takenBy?: string;
|
||||
notes?: string;
|
||||
};
|
||||
|
||||
export type Coverage = {
|
||||
name: string;
|
||||
medsLeft: number;
|
||||
daysLeft: number | null;
|
||||
depletionDate: string | null;
|
||||
depletionTime: number | null;
|
||||
nextDose: string | null;
|
||||
};
|
||||
|
||||
export type StockStatus = {
|
||||
level: "out-of-stock" | "low" | "normal" | "high";
|
||||
className: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
export type StockThresholds = {
|
||||
lowStockDays: number;
|
||||
normalStockDays: number;
|
||||
highStockDays: number;
|
||||
};
|
||||
|
||||
export type ScheduleEvent = {
|
||||
id: string;
|
||||
medName: string;
|
||||
timeStr: string;
|
||||
dateStr: string;
|
||||
usage: number;
|
||||
when: number;
|
||||
isPast: boolean;
|
||||
takenBy: string[];
|
||||
};
|
||||
|
||||
export type BlisterStock = {
|
||||
fullBlisters: number;
|
||||
openBlisterPills: number;
|
||||
loosePills: number;
|
||||
};
|
||||
|
||||
// Shared schedule types
|
||||
export type SharedMedication = {
|
||||
id: number;
|
||||
name: string;
|
||||
genericName?: string | null;
|
||||
pillWeightMg?: number | null;
|
||||
imageUrl?: string | null;
|
||||
totalPills: number;
|
||||
packCount: number;
|
||||
blistersPerPack: number;
|
||||
looseTablets: number;
|
||||
pillsPerBlister: number;
|
||||
takenBy: string[];
|
||||
blisters: Blister[];
|
||||
};
|
||||
|
||||
export type SharedScheduleData = {
|
||||
takenBy: string;
|
||||
sharedBy: string | null;
|
||||
scheduleDays: number;
|
||||
medications: SharedMedication[];
|
||||
stockThresholds?: {
|
||||
lowStockDays: number;
|
||||
};
|
||||
};
|
||||
|
||||
export type ExpiredLinkData = {
|
||||
ownerUsername: string;
|
||||
takenBy: string;
|
||||
expiredAt: string;
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// Field Validation Limits (must match backend)
|
||||
// =============================================================================
|
||||
export const FIELD_LIMITS = {
|
||||
name: { min: 1, max: 100 },
|
||||
genericName: { max: 100 },
|
||||
takenBy: { max: 100 },
|
||||
notes: { max: 2000 }
|
||||
} as const;
|
||||
|
||||
// =============================================================================
|
||||
// Helper Functions for Medication Calculations
|
||||
// =============================================================================
|
||||
|
||||
type MedLike = Pick<Medication, 'packCount' | 'blistersPerPack' | 'pillsPerBlister' | 'looseTablets'> & { stockAdjustment?: number };
|
||||
|
||||
/** Calculate total pills including stockAdjustment */
|
||||
export function getMedTotal(med: MedLike): number {
|
||||
return med.packCount * med.blistersPerPack * med.pillsPerBlister + med.looseTablets + (med.stockAdjustment ?? 0);
|
||||
}
|
||||
|
||||
/** Get the base package size (without stockAdjustment) */
|
||||
export function getPackageSize(med: MedLike): number {
|
||||
return med.packCount * med.blistersPerPack * med.pillsPerBlister + med.looseTablets;
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
// =============================================================================
|
||||
// Formatting Utilities
|
||||
// =============================================================================
|
||||
|
||||
import type { Medication, BlisterStock } from "../types";
|
||||
|
||||
/**
|
||||
* Map timezone to region code (ISO 3166-1 alpha-2).
|
||||
* This allows combining app language with regional formatting.
|
||||
*/
|
||||
const TIMEZONE_TO_REGION: Record<string, string> = {
|
||||
// Europe
|
||||
"Europe/Berlin": "DE",
|
||||
"Europe/Vienna": "AT",
|
||||
"Europe/Zurich": "CH",
|
||||
"Europe/London": "GB",
|
||||
"Europe/Dublin": "IE",
|
||||
"Europe/Paris": "FR",
|
||||
"Europe/Madrid": "ES",
|
||||
"Europe/Rome": "IT",
|
||||
"Europe/Amsterdam": "NL",
|
||||
"Europe/Brussels": "BE",
|
||||
"Europe/Warsaw": "PL",
|
||||
"Europe/Prague": "CZ",
|
||||
"Europe/Stockholm": "SE",
|
||||
"Europe/Oslo": "NO",
|
||||
"Europe/Copenhagen": "DK",
|
||||
"Europe/Helsinki": "FI",
|
||||
"Europe/Athens": "GR",
|
||||
"Europe/Lisbon": "PT",
|
||||
"Europe/Moscow": "RU",
|
||||
"Europe/Kiev": "UA",
|
||||
"Europe/Kyiv": "UA",
|
||||
"Europe/Budapest": "HU",
|
||||
"Europe/Bucharest": "RO",
|
||||
// Americas
|
||||
"America/New_York": "US",
|
||||
"America/Chicago": "US",
|
||||
"America/Denver": "US",
|
||||
"America/Los_Angeles": "US",
|
||||
"America/Phoenix": "US",
|
||||
"America/Toronto": "CA",
|
||||
"America/Vancouver": "CA",
|
||||
"America/Mexico_City": "MX",
|
||||
"America/Sao_Paulo": "BR",
|
||||
"America/Buenos_Aires": "AR",
|
||||
// Asia/Pacific
|
||||
"Asia/Tokyo": "JP",
|
||||
"Asia/Shanghai": "CN",
|
||||
"Asia/Hong_Kong": "HK",
|
||||
"Asia/Singapore": "SG",
|
||||
"Asia/Seoul": "KR",
|
||||
"Asia/Dubai": "AE",
|
||||
"Asia/Kolkata": "IN",
|
||||
"Australia/Sydney": "AU",
|
||||
"Australia/Melbourne": "AU",
|
||||
"Pacific/Auckland": "NZ",
|
||||
};
|
||||
|
||||
/**
|
||||
* Get region code from timezone.
|
||||
* Returns undefined if timezone is not mapped.
|
||||
*/
|
||||
export function getRegionFromTimezone(): string | undefined {
|
||||
try {
|
||||
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
return TIMEZONE_TO_REGION[timezone];
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get locale for formatting based on app language and timezone region.
|
||||
* Combines app language (en/de) with region from timezone (DE/US/etc.)
|
||||
* Example: app=en + timezone=Europe/Berlin → en-DE (English text, German format)
|
||||
*
|
||||
* @param appLanguage - The app's UI language (e.g., 'en', 'de')
|
||||
*/
|
||||
export function getSystemLocale(appLanguage?: string): string {
|
||||
const region = getRegionFromTimezone();
|
||||
const lang = appLanguage || navigator.language?.split('-')[0] || 'en';
|
||||
|
||||
if (region) {
|
||||
return `${lang}-${region}`;
|
||||
}
|
||||
|
||||
// Fallback: use browser language, or en-US as last resort
|
||||
return navigator.language || 'en-US';
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a number using the current locale with optional decimal places
|
||||
*/
|
||||
export function formatNumber(n: number | null | undefined, decimals = 0): string {
|
||||
if (n === null || n === undefined) return "—";
|
||||
return n.toLocaleString(undefined, {
|
||||
minimumFractionDigits: decimals,
|
||||
maximumFractionDigits: decimals
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a date/time string for display
|
||||
* Extracts date and time directly from string to avoid timezone conversion
|
||||
* Uses system locale by default for consistent regional formatting
|
||||
*/
|
||||
export function formatDateTime(iso: string | null | undefined, locale?: string): string {
|
||||
if (!iso) return "-";
|
||||
|
||||
// Extract date and time components directly from ISO string
|
||||
// Format: YYYY-MM-DDTHH:MM:SS or YYYY-MM-DDTHH:MM:SS.sssZ
|
||||
const match = iso.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/);
|
||||
if (!match) return "-";
|
||||
|
||||
const [, year, month, day, hour, minute] = match;
|
||||
const effectiveLocale = locale ?? getSystemLocale();
|
||||
|
||||
// Create a date object for formatting, but use local timezone interpretation
|
||||
// by creating the date without the Z suffix
|
||||
const localDateStr = `${year}-${month}-${day}T${hour}:${minute}:00`;
|
||||
const d = new Date(localDateStr);
|
||||
if (isNaN(d.getTime())) return "-";
|
||||
|
||||
const dateOpts: Intl.DateTimeFormatOptions = {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit"
|
||||
};
|
||||
const timeOpts: Intl.DateTimeFormatOptions = {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit"
|
||||
};
|
||||
const dateStr = d.toLocaleDateString(effectiveLocale, dateOpts);
|
||||
const timeStr = d.toLocaleTimeString(effectiveLocale, timeOpts);
|
||||
return `${dateStr} ${timeStr}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pad a number to 2 digits with leading zero
|
||||
*/
|
||||
export function pad2(n: number): string {
|
||||
return n.toString().padStart(2, "0");
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a Date to ISO date string (YYYY-MM-DD)
|
||||
*/
|
||||
export function toIsoString(d: Date): string {
|
||||
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the date portion (YYYY-MM-DD) from an ISO datetime string or Date
|
||||
*/
|
||||
export function toDateValue(input: string | Date): string {
|
||||
if (input instanceof Date) {
|
||||
return toIsoString(input);
|
||||
}
|
||||
return input.slice(0, 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the time portion (HH:MM) from an ISO datetime string or Date
|
||||
* For strings, extracts HH:MM directly without timezone conversion
|
||||
*/
|
||||
export function toTimeValue(input: string | Date): string {
|
||||
if (input instanceof Date) {
|
||||
return `${pad2(input.getHours())}:${pad2(input.getMinutes())}`;
|
||||
}
|
||||
// Extract HH:MM directly from string (position 11-16 in YYYY-MM-DDTHH:MM...)
|
||||
// This avoids timezone conversion issues with Z suffix
|
||||
const timeMatch = input.match(/T(\d{2}):(\d{2})/);
|
||||
if (timeMatch) {
|
||||
return `${timeMatch[1]}:${timeMatch[2]}`;
|
||||
}
|
||||
// Fallback to Date parsing if format doesn't match
|
||||
const d = new Date(input);
|
||||
return `${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Combine a date string (YYYY-MM-DD) and time string (HH:MM) into ISO datetime
|
||||
*/
|
||||
export function combineDateAndTime(dateStr: string, timeStr: string): string {
|
||||
return `${dateStr}T${timeStr}:00`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a Date or ISO string to datetime-local input value (YYYY-MM-DDTHH:MM)
|
||||
*/
|
||||
export function toInputValue(input: Date | string): string {
|
||||
const d = input instanceof Date ? input : new Date(input);
|
||||
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}T${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive total pills from medication inventory
|
||||
*/
|
||||
export function deriveTotal(
|
||||
packCount: number,
|
||||
blistersPerPack: number,
|
||||
pillsPerBlister: number,
|
||||
looseTablets: number
|
||||
): number {
|
||||
return packCount * blistersPerPack * pillsPerBlister + looseTablets;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get CSS class for expiry date status
|
||||
* Returns: danger-text (expired), warning-text (within threshold), success-text (OK)
|
||||
*/
|
||||
export function getExpiryClass(expiryDate: string | null | undefined, thresholdDays: number): string {
|
||||
if (!expiryDate) return "";
|
||||
const exp = new Date(expiryDate);
|
||||
const now = new Date();
|
||||
const diff = (exp.getTime() - now.getTime()) / (1000 * 60 * 60 * 24);
|
||||
if (diff < 0) return "danger-text";
|
||||
if (diff <= thresholdDays) return "warning-text";
|
||||
return "success-text";
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate blister stock breakdown for a medication
|
||||
*/
|
||||
export function getBlisterStock(med: Medication): BlisterStock {
|
||||
const total = med.packCount * med.blistersPerPack * med.pillsPerBlister + med.looseTablets + (med.stockAdjustment ?? 0);
|
||||
const bSize = med.pillsPerBlister;
|
||||
const fullBlisters = Math.floor(total / bSize);
|
||||
const openBlisterPills = total % bSize;
|
||||
return { fullBlisters, openBlisterPills, loosePills: openBlisterPills };
|
||||
}
|
||||
|
||||
/**
|
||||
* Format full blisters count with optional pills per blister
|
||||
*/
|
||||
export function formatFullBlisters(stock: BlisterStock, pillsPerBlister?: number): string {
|
||||
const count = stock.fullBlisters;
|
||||
if (pillsPerBlister !== undefined) {
|
||||
return `${count} (${count * pillsPerBlister})`;
|
||||
}
|
||||
return String(count);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format open blister and loose pills
|
||||
*/
|
||||
export function formatOpenBlisterAndLoose(stock: BlisterStock): string {
|
||||
return String(stock.openBlisterPills);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare semantic version strings
|
||||
* Returns: negative if a < b, positive if a > b, 0 if equal
|
||||
*/
|
||||
export function compareSemver(a: string, b: string): number {
|
||||
const pa = a.replace(/^v/, "").split(".").map(Number);
|
||||
const pb = b.replace(/^v/, "").split(".").map(Number);
|
||||
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
||||
const na = pa[i] ?? 0;
|
||||
const nb = pb[i] ?? 0;
|
||||
if (na !== nb) return na - nb;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
// =============================================================================
|
||||
// ICS Calendar Generation
|
||||
// =============================================================================
|
||||
|
||||
import type { Medication } from "../types";
|
||||
|
||||
/**
|
||||
* Format a Date for ICS format (YYYYMMDDTHHMMSSZ)
|
||||
*/
|
||||
function formatICSDate(date: Date): string {
|
||||
return date.toISOString().replace(/[-:]/g, "").replace(/\.\d{3}/, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate and download an ICS calendar file for a medication's schedule
|
||||
*/
|
||||
export function generateICS(med: Medication): void {
|
||||
const events = med.blisters
|
||||
.map((blister, idx) => {
|
||||
const start = new Date(blister.start);
|
||||
const end = new Date(start.getTime() + 15 * 60 * 1000); // 15 min duration
|
||||
const interval = blister.every;
|
||||
|
||||
const pillInfo = `${blister.usage} pill${blister.usage !== 1 ? "s" : ""}${med.pillWeightMg ? ` (${blister.usage * med.pillWeightMg} mg)` : ""}`;
|
||||
const summary = `💊 ${med.name} - ${pillInfo}`;
|
||||
const description = [
|
||||
`Medication: ${med.name}`,
|
||||
med.genericName ? `Generic: ${med.genericName}` : "",
|
||||
med.takenBy && med.takenBy.length > 0 ? `For: ${med.takenBy.join(", ")}` : "",
|
||||
`Dosage: ${pillInfo}`,
|
||||
`Frequency: every ${interval} day${interval !== 1 ? "s" : ""}`,
|
||||
med.notes ? `Notes: ${med.notes}` : ""
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\\n");
|
||||
|
||||
return `BEGIN:VEVENT
|
||||
UID:medassist-ng-${med.id}-${idx}@medassist-ng
|
||||
DTSTAMP:${formatICSDate(new Date())}
|
||||
DTSTART:${formatICSDate(start)}
|
||||
DTEND:${formatICSDate(end)}
|
||||
RRULE:FREQ=DAILY;INTERVAL=${interval}
|
||||
SUMMARY:${summary}
|
||||
DESCRIPTION:${description}
|
||||
BEGIN:VALARM
|
||||
TRIGGER:-PT5M
|
||||
ACTION:DISPLAY
|
||||
DESCRIPTION:Time to take ${med.name}
|
||||
END:VALARM
|
||||
END:VEVENT`;
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
const ics = `BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID:-//MedAssist-ng//Medication Schedule//EN
|
||||
CALSCALE:GREGORIAN
|
||||
METHOD:PUBLISH
|
||||
X-WR-CALNAME:${med.name} Schedule
|
||||
${events}
|
||||
END:VCALENDAR`;
|
||||
|
||||
const blob = new Blob([ics], { type: "text/calendar;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = `${med.name.replace(/[^a-zA-Z0-9]/g, "_")}_schedule.ics`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
// =============================================================================
|
||||
// Utility Functions - Barrel Export
|
||||
// =============================================================================
|
||||
|
||||
export * from "./formatters";
|
||||
export * from "./schedule";
|
||||
export * from "./storage";
|
||||
export * from "./ics";
|
||||
@@ -0,0 +1,275 @@
|
||||
// =============================================================================
|
||||
// Schedule Building and Coverage Calculations
|
||||
// =============================================================================
|
||||
|
||||
import type { Medication, Coverage, StockStatus, StockThresholds, ScheduleEvent } from "../types";
|
||||
import { getMedTotal } from "../types";
|
||||
|
||||
/**
|
||||
* Build schedule preview events for medications
|
||||
*/
|
||||
export function buildSchedulePreview(
|
||||
meds: Medication[],
|
||||
locale: string,
|
||||
includePast: boolean = false
|
||||
): { events: ScheduleEvent[]; today: number; nextThree: number; totalBlisters: number } {
|
||||
const events: ScheduleEvent[] = [];
|
||||
if (!Array.isArray(meds)) return { events, today: 0, nextThree: 0, totalBlisters: 0 };
|
||||
|
||||
const now = new Date();
|
||||
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
const end = new Date();
|
||||
end.setDate(end.getDate() + 180); // 6 months horizon
|
||||
|
||||
meds.forEach((med) => {
|
||||
med.blisters.forEach((blister, idx) => {
|
||||
const start = new Date(blister.start);
|
||||
if (Number.isNaN(start.getTime())) return;
|
||||
for (let d = new Date(start); d <= end; d.setDate(d.getDate() + blister.every)) {
|
||||
const isPast = d < todayStart;
|
||||
if (isPast && !includePast) continue;
|
||||
const whenMs = d.getTime();
|
||||
events.push({
|
||||
id: `${med.id}-${idx}-${whenMs}`,
|
||||
medName: med.name,
|
||||
takenBy: med.takenBy || [],
|
||||
usage: blister.usage,
|
||||
when: whenMs,
|
||||
isPast,
|
||||
timeStr: d.toLocaleTimeString(locale, { hour: "2-digit", minute: "2-digit" }),
|
||||
dateStr: d.toLocaleDateString(locale, { weekday: "short", day: "2-digit", month: "short" })
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
events.sort((a, b) => a.when - b.when);
|
||||
|
||||
const todayCount = events.filter((e) => {
|
||||
const t = new Date(e.when);
|
||||
const n = new Date();
|
||||
return t.getFullYear() === n.getFullYear() && t.getMonth() === n.getMonth() && t.getDate() === n.getDate();
|
||||
}).length;
|
||||
|
||||
return {
|
||||
events,
|
||||
today: todayCount,
|
||||
nextThree: events.length,
|
||||
totalBlisters: meds.reduce((acc, m) => acc + m.blisters.length, 0)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate coverage information for medications
|
||||
*/
|
||||
export function calculateCoverage(
|
||||
meds: Medication[],
|
||||
events: Array<{ medName: string; when: number }>,
|
||||
locale: string,
|
||||
reminderDaysBefore: number,
|
||||
stockCalculationMode: "automatic" | "manual",
|
||||
takenDoses: Set<string>
|
||||
): { low: Coverage[]; all: Coverage[] } {
|
||||
const MS_PER_DAY = 86_400_000;
|
||||
const now = Date.now();
|
||||
|
||||
const coverage: Coverage[] = meds.map((m) => {
|
||||
const personCount = Math.max(1, m.takenBy?.length || 1);
|
||||
const dailyRate = m.blisters.reduce((sum, s) => sum + (s.every > 0 ? s.usage / s.every : 0), 0) * personCount;
|
||||
|
||||
let consumed = 0;
|
||||
const stockCorrectionCutoff = m.lastStockCorrectionAt ? new Date(m.lastStockCorrectionAt).getTime() : 0;
|
||||
|
||||
if (stockCalculationMode === "automatic") {
|
||||
m.blisters.forEach((s) => {
|
||||
const blisterStart = new Date(s.start).getTime();
|
||||
const effectiveStart = Math.max(blisterStart, stockCorrectionCutoff);
|
||||
if (Number.isNaN(effectiveStart) || effectiveStart > now) return;
|
||||
const period = Math.max(1, s.every) * MS_PER_DAY;
|
||||
const occurrences = Math.floor((now - effectiveStart) / period) + 1;
|
||||
consumed += occurrences * s.usage * personCount;
|
||||
});
|
||||
} else {
|
||||
takenDoses.forEach((doseId) => {
|
||||
const parts = doseId.split("-");
|
||||
if (parts.length >= 3) {
|
||||
const medId = parseInt(parts[0], 10);
|
||||
const blisterIdx = parseInt(parts[1], 10);
|
||||
const doseTimestamp = parseInt(parts[2], 10);
|
||||
if (medId === m.id && m.blisters[blisterIdx]) {
|
||||
const blisterStart = new Date(m.blisters[blisterIdx].start).getTime();
|
||||
if (!Number.isNaN(blisterStart) && doseTimestamp >= blisterStart && doseTimestamp > stockCorrectionCutoff) {
|
||||
consumed += m.blisters[blisterIdx].usage;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const totalPills = getMedTotal(m);
|
||||
const medsLeft = Math.max(0, totalPills - consumed);
|
||||
const rawDaysLeft = dailyRate > 0 ? medsLeft / dailyRate : null;
|
||||
const daysLeft = rawDaysLeft !== null ? Math.max(0, Math.floor(rawDaysLeft)) : null;
|
||||
const depletionMs = daysLeft !== null ? now + daysLeft * MS_PER_DAY : null;
|
||||
const depletionDate = depletionMs !== null
|
||||
? new Date(depletionMs).toLocaleDateString(locale, { weekday: "short", day: "2-digit", month: "short" })
|
||||
: null;
|
||||
const nextEvent = events.find((e) => e.medName === m.name);
|
||||
|
||||
return {
|
||||
name: m.name,
|
||||
medsLeft: Number(medsLeft.toFixed(1)),
|
||||
daysLeft,
|
||||
depletionDate,
|
||||
depletionTime: depletionMs,
|
||||
nextDose: nextEvent
|
||||
? new Date(nextEvent.when).toLocaleString(locale, { weekday: "short", day: "2-digit", month: "short", hour: "2-digit", minute: "2-digit" })
|
||||
: null
|
||||
};
|
||||
});
|
||||
|
||||
const low = coverage.filter((c) => c.medsLeft <= 0 || (c.daysLeft !== null && c.daysLeft <= reminderDaysBefore));
|
||||
return { low, all: coverage };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get stock status based on days left and thresholds
|
||||
*/
|
||||
export function getStockStatus(daysLeft: number | null, medsLeft: number, thresholds: StockThresholds): StockStatus {
|
||||
if (medsLeft <= 0 || daysLeft === 0) {
|
||||
return { level: "out-of-stock", className: "danger", label: "status.outOfStock" };
|
||||
}
|
||||
|
||||
if (daysLeft === null) {
|
||||
return { level: "normal", className: "success", label: "status.noSchedule" };
|
||||
}
|
||||
|
||||
if (daysLeft > thresholds.highStockDays) {
|
||||
return { level: "high", className: "high", label: "status.highStock" };
|
||||
}
|
||||
|
||||
if (daysLeft >= thresholds.lowStockDays) {
|
||||
return { level: "normal", className: "success", label: "status.normal" };
|
||||
}
|
||||
|
||||
return { level: "low", className: "warning", label: "status.lowStock" };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get next reminder date for a medication
|
||||
*/
|
||||
export function getNextReminderForMed(med: Coverage, reminderDaysBefore: number, locale: string): string {
|
||||
if (!med.depletionTime) return "—";
|
||||
|
||||
const reminderTime = med.depletionTime - reminderDaysBefore * 86_400_000;
|
||||
const now = Date.now();
|
||||
|
||||
if (reminderTime <= now) {
|
||||
return "Due now";
|
||||
}
|
||||
|
||||
return new Date(reminderTime).toLocaleDateString(locale, {
|
||||
day: "2-digit",
|
||||
month: "short"
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get reminder status text for dashboard display
|
||||
*/
|
||||
export function getReminderStatusText(
|
||||
reminderDaysBefore: number,
|
||||
lowStockDays: number,
|
||||
_lowStock: Coverage[],
|
||||
allCoverage: Coverage[],
|
||||
lastSent: string | null,
|
||||
lastType: "stock" | "intake" | null,
|
||||
lastChannel: "email" | "push" | "both" | null,
|
||||
t: (key: string, options?: Record<string, unknown>) => string,
|
||||
locale: string
|
||||
): { lines: Array<{ text: string; className?: string; strong?: boolean }> } {
|
||||
const emptyMeds = allCoverage.filter((c) => c.medsLeft <= 0);
|
||||
const medsNeedingReminder = allCoverage
|
||||
.filter((c) => c.medsLeft > 0 && c.daysLeft !== null && c.daysLeft <= reminderDaysBefore)
|
||||
.sort((a, b) => (a.daysLeft ?? 0) - (b.daysLeft ?? 0));
|
||||
const lowStockNotYetCritical = allCoverage.filter(
|
||||
(c) => c.medsLeft > 0 && c.daysLeft !== null && c.daysLeft > reminderDaysBefore && c.daysLeft < lowStockDays
|
||||
);
|
||||
|
||||
const formatLastSent = (iso: string) => {
|
||||
const date = new Date(iso);
|
||||
return date.toLocaleDateString(locale, { day: "2-digit", month: "short", hour: "2-digit", minute: "2-digit" });
|
||||
};
|
||||
|
||||
const getTypeLabel = () => (lastType === "intake" ? t("dashboard.reminders.typeIntake") : t("dashboard.reminders.typeStock"));
|
||||
const getChannelLabel = () => {
|
||||
if (lastChannel === "both") return t("dashboard.reminders.channelBoth");
|
||||
if (lastChannel === "push") return t("dashboard.reminders.channelPush");
|
||||
return t("dashboard.reminders.channelEmail");
|
||||
};
|
||||
|
||||
const formatLastInfo = (iso: string) => {
|
||||
const dateStr = formatLastSent(iso);
|
||||
if (lastType && lastChannel) {
|
||||
return `${dateStr} (${getTypeLabel()}, ${getChannelLabel()})`;
|
||||
}
|
||||
return dateStr;
|
||||
};
|
||||
|
||||
const lines: Array<{ text: string; className?: string; strong?: boolean }> = [];
|
||||
|
||||
if (emptyMeds.length > 0) {
|
||||
lines.push({ text: `🚨 ${t("dashboard.reminders.emptyStock", { count: emptyMeds.length })}`, className: "danger-text", strong: true });
|
||||
if (medsNeedingReminder.length > 0) {
|
||||
lines.push({ text: `⚠ ${t("dashboard.reminders.needReorder", { count: medsNeedingReminder.length })}`, className: "danger-text" });
|
||||
}
|
||||
if (lowStockNotYetCritical.length > 0) {
|
||||
lines.push({ text: t("dashboard.reminders.lowWarning", { count: lowStockNotYetCritical.length }), className: "warning-text" });
|
||||
}
|
||||
if (lastSent) {
|
||||
lines.push({ text: `${t("dashboard.reminders.lastReminder")}: ${formatLastInfo(lastSent)}` });
|
||||
}
|
||||
return { lines };
|
||||
}
|
||||
|
||||
if (medsNeedingReminder.length > 0) {
|
||||
lines.push({ text: `⚠ ${t("dashboard.reminders.needReorder", { count: medsNeedingReminder.length })}`, className: "danger-text", strong: true });
|
||||
if (lowStockNotYetCritical.length > 0) {
|
||||
lines.push({ text: t("dashboard.reminders.lowWarning", { count: lowStockNotYetCritical.length }), className: "warning-text" });
|
||||
}
|
||||
if (lastSent) {
|
||||
lines.push({ text: `${t("dashboard.reminders.lastReminder")}: ${formatLastInfo(lastSent)}` });
|
||||
}
|
||||
return { lines };
|
||||
}
|
||||
|
||||
if (lowStockNotYetCritical.length > 0) {
|
||||
const nextMed = lowStockNotYetCritical.sort((a, b) => (a.daysLeft ?? 0) - (b.daysLeft ?? 0))[0];
|
||||
const daysUntilReminder = Math.max(0, (nextMed.daysLeft ?? 0) - reminderDaysBefore);
|
||||
lines.push({ text: t("dashboard.reminders.lowWarning", { count: lowStockNotYetCritical.length }), className: "warning-text" });
|
||||
lines.push({ text: `${t("dashboard.reminders.nextIn")}: ${nextMed.name} ${t("dashboard.reminders.inDays", { days: daysUntilReminder })}` });
|
||||
return { lines };
|
||||
}
|
||||
|
||||
const allWithDepletion = allCoverage
|
||||
.filter((c) => c.depletionTime !== null && c.daysLeft !== null && c.medsLeft > 0)
|
||||
.sort((a, b) => (a.daysLeft ?? Infinity) - (b.daysLeft ?? Infinity));
|
||||
|
||||
if (allWithDepletion.length > 0) {
|
||||
const nextMed = allWithDepletion[0];
|
||||
const daysUntilReminder = (nextMed.daysLeft ?? 0) - reminderDaysBefore;
|
||||
if (daysUntilReminder > 0) {
|
||||
lines.push({ text: `✓ ${t("dashboard.reminders.allOk")}`, className: "success-text" });
|
||||
lines.push({ text: `${t("dashboard.reminders.nextIn")}: ${nextMed.name} ${t("dashboard.reminders.inDays", { days: daysUntilReminder })}` });
|
||||
return { lines };
|
||||
}
|
||||
}
|
||||
|
||||
lines.push({ text: `✓ ${t("dashboard.reminders.allStockOk")}`, className: "success-text" });
|
||||
if (lastSent) {
|
||||
lines.push({ text: `${t("dashboard.reminders.lastReminder")}: ${formatLastInfo(lastSent)}` });
|
||||
} else {
|
||||
lines.push({ text: t("dashboard.reminders.noRemindersNeeded") });
|
||||
}
|
||||
return { lines };
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
// =============================================================================
|
||||
// Local Storage Utilities
|
||||
// =============================================================================
|
||||
|
||||
import { pad2 } from "./formatters";
|
||||
|
||||
/**
|
||||
* Generate a user-specific storage key
|
||||
* @param userId - The user ID
|
||||
* @param key - The storage key name
|
||||
*/
|
||||
export function userStorageKey(userId: number | string, key: string): string {
|
||||
return `${key}_user_${userId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get today's date as ISO string (YYYY-MM-DD)
|
||||
*/
|
||||
export function todayIso(): string {
|
||||
const d = new Date();
|
||||
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a date N days from today as ISO string (YYYY-MM-DD)
|
||||
*/
|
||||
export function plusDaysIso(days: number): string {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() + days);
|
||||
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load collapsed days state from localStorage
|
||||
*/
|
||||
export function loadCollapsedDaysFromStorage(
|
||||
collapsedKey: string,
|
||||
expandedKey: string
|
||||
): { collapsed: Set<string>; expanded: Set<string> } {
|
||||
const collapsed = new Set<string>();
|
||||
const expanded = new Set<string>();
|
||||
try {
|
||||
const storedCollapsed = localStorage.getItem(collapsedKey);
|
||||
if (storedCollapsed) {
|
||||
const arr = JSON.parse(storedCollapsed);
|
||||
if (Array.isArray(arr)) arr.forEach((s: string) => collapsed.add(s));
|
||||
}
|
||||
const storedExpanded = localStorage.getItem(expandedKey);
|
||||
if (storedExpanded) {
|
||||
const arr = JSON.parse(storedExpanded);
|
||||
if (Array.isArray(arr)) arr.forEach((s: string) => expanded.add(s));
|
||||
}
|
||||
} catch {
|
||||
// Ignore parse errors
|
||||
}
|
||||
return { collapsed, expanded };
|
||||
}
|
||||
|
||||
/**
|
||||
* Save collapsed days state to localStorage
|
||||
*/
|
||||
export function saveCollapsedDaysToStorage(storageKey: string, state: Record<string, boolean>): void {
|
||||
try {
|
||||
localStorage.setItem(storageKey, JSON.stringify(state));
|
||||
} catch {
|
||||
// Ignore storage errors
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get theme from localStorage or default
|
||||
*/
|
||||
export function getStoredTheme(): "light" | "dark" {
|
||||
if (typeof window !== "undefined") {
|
||||
return (localStorage.getItem("theme") as "light" | "dark") || "dark";
|
||||
}
|
||||
return "dark";
|
||||
}
|
||||
|
||||
/**
|
||||
* Save theme to localStorage
|
||||
*/
|
||||
export function saveTheme(theme: "light" | "dark"): void {
|
||||
localStorage.setItem("theme", theme);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
setupFiles: ['./src/test/setup.ts'],
|
||||
include: ['src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'json', 'html'],
|
||||
include: ['src/**/*.{ts,tsx}'],
|
||||
exclude: [
|
||||
'src/main.tsx',
|
||||
'src/test/**',
|
||||
'src/**/*.d.ts',
|
||||
'src/**/index.ts',
|
||||
],
|
||||
thresholds: {
|
||||
global: {
|
||||
lines: 75,
|
||||
functions: 75,
|
||||
branches: 75,
|
||||
statements: 75,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
+99
-11
@@ -7,6 +7,9 @@
|
||||
# ./scripts/release.sh minor # 1.0.0 -> 1.1.0 (new features)
|
||||
# ./scripts/release.sh major # 1.0.0 -> 2.0.0 (breaking changes)
|
||||
# ./scripts/release.sh 1.2.3 # explicit version
|
||||
#
|
||||
# This script creates a PR for the version bump (required due to branch protection),
|
||||
# waits for CI, merges it, and then creates a signed tag for the release.
|
||||
# =============================================================================
|
||||
|
||||
set -e
|
||||
@@ -18,11 +21,28 @@ YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# GitHub repo
|
||||
GITHUB_REPO="DanielVolz/medassist-ng"
|
||||
|
||||
# Get script directory and project root
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
# Check for gh CLI
|
||||
if ! command -v gh &> /dev/null; then
|
||||
echo -e "${RED}Error: GitHub CLI (gh) is required but not installed.${NC}"
|
||||
echo "Install it with: brew install gh"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check gh authentication
|
||||
if ! gh auth status &> /dev/null; then
|
||||
echo -e "${RED}Error: Not authenticated with GitHub CLI.${NC}"
|
||||
echo "Run: gh auth login"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check for uncommitted changes
|
||||
if [[ -n $(git status --porcelain) ]]; then
|
||||
echo -e "${RED}Error: You have uncommitted changes. Commit or stash them first.${NC}"
|
||||
@@ -30,6 +50,11 @@ if [[ -n $(git status --porcelain) ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Make sure we're on main and up to date
|
||||
echo -e "${BLUE}Updating main branch...${NC}"
|
||||
git checkout main
|
||||
git pull origin main 2>/dev/null || git pull github main 2>/dev/null || true
|
||||
|
||||
# Get current version from backend/package.json
|
||||
CURRENT_VERSION=$(grep '"version"' backend/package.json | sed 's/.*"version": "\(.*\)".*/\1/')
|
||||
echo -e "${BLUE}Current version: ${YELLOW}v${CURRENT_VERSION}${NC}"
|
||||
@@ -74,6 +99,19 @@ if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Branch name for the release
|
||||
RELEASE_BRANCH="chore/release-${NEW_VERSION}"
|
||||
|
||||
# Check if branch already exists
|
||||
if git show-ref --verify --quiet "refs/heads/${RELEASE_BRANCH}"; then
|
||||
echo -e "${YELLOW}Branch ${RELEASE_BRANCH} already exists locally. Deleting...${NC}"
|
||||
git branch -D "${RELEASE_BRANCH}"
|
||||
fi
|
||||
|
||||
# Create release branch
|
||||
echo -e "${BLUE}Creating release branch...${NC}"
|
||||
git checkout -b "${RELEASE_BRANCH}"
|
||||
|
||||
# Update version in package.json files
|
||||
echo -e "${BLUE}Updating package.json files...${NC}"
|
||||
sed -i '' "s/\"version\": \"${CURRENT_VERSION}\"/\"version\": \"${NEW_VERSION}\"/" backend/package.json
|
||||
@@ -84,23 +122,73 @@ echo -e "${BLUE}Committing version bump...${NC}"
|
||||
git add backend/package.json frontend/package.json 2>/dev/null || git add backend/package.json
|
||||
git commit -m "chore: release v${NEW_VERSION}"
|
||||
|
||||
# Check if tag exists
|
||||
if git rev-parse "v${NEW_VERSION}" >/dev/null 2>&1; then
|
||||
echo -e "${YELLOW}Tag v${NEW_VERSION} already exists. Overwriting...${NC}"
|
||||
git tag -d "v${NEW_VERSION}"
|
||||
git push origin ":refs/tags/v${NEW_VERSION}" 2>/dev/null || true
|
||||
# Push branch to GitHub
|
||||
echo -e "${BLUE}Pushing release branch to GitHub...${NC}"
|
||||
git push -u origin "${RELEASE_BRANCH}" 2>/dev/null || git push -u github "${RELEASE_BRANCH}"
|
||||
|
||||
# Create PR
|
||||
echo -e "${BLUE}Creating Pull Request...${NC}"
|
||||
PR_URL=$(gh pr create \
|
||||
--repo "${GITHUB_REPO}" \
|
||||
--head "${RELEASE_BRANCH}" \
|
||||
--title "chore: release v${NEW_VERSION}" \
|
||||
--body "## Release v${NEW_VERSION}
|
||||
|
||||
Automated version bump for release v${NEW_VERSION}.
|
||||
|
||||
This PR was created by the release script." \
|
||||
2>&1)
|
||||
|
||||
echo -e "${GREEN}PR created: ${YELLOW}${PR_URL}${NC}"
|
||||
|
||||
# Extract PR number
|
||||
PR_NUMBER=$(echo "$PR_URL" | grep -oE '[0-9]+$')
|
||||
|
||||
# Wait for CI checks
|
||||
echo -e "${BLUE}Waiting for CI checks to complete...${NC}"
|
||||
if ! gh pr checks "${PR_NUMBER}" --repo "${GITHUB_REPO}" --watch; then
|
||||
echo -e "${RED}CI checks failed! Please fix the issues and try again.${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Create and push tag
|
||||
echo -e "${GREEN}CI checks passed!${NC}"
|
||||
|
||||
# Merge PR
|
||||
echo -e "${BLUE}Merging PR...${NC}"
|
||||
gh pr merge "${PR_NUMBER}" --repo "${GITHUB_REPO}" --squash --delete-branch
|
||||
|
||||
# Switch back to main and pull
|
||||
echo -e "${BLUE}Updating main branch with merged changes...${NC}"
|
||||
git checkout main
|
||||
git pull origin main 2>/dev/null || git pull github main 2>/dev/null || true
|
||||
|
||||
# Check if tag exists and delete it
|
||||
if git rev-parse "v${NEW_VERSION}" >/dev/null 2>&1; then
|
||||
echo -e "${YELLOW}Tag v${NEW_VERSION} already exists locally. Deleting...${NC}"
|
||||
git tag -d "v${NEW_VERSION}"
|
||||
fi
|
||||
|
||||
# Check if remote tag exists
|
||||
if git ls-remote --tags origin "v${NEW_VERSION}" 2>/dev/null | grep -q "v${NEW_VERSION}" || \
|
||||
git ls-remote --tags github "v${NEW_VERSION}" 2>/dev/null | grep -q "v${NEW_VERSION}"; then
|
||||
echo -e "${YELLOW}Tag v${NEW_VERSION} exists on remote. Deleting...${NC}"
|
||||
git push origin ":refs/tags/v${NEW_VERSION}" 2>/dev/null || true
|
||||
git push github ":refs/tags/v${NEW_VERSION}" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Create signed tag
|
||||
echo -e "${BLUE}Creating signed tag v${NEW_VERSION}...${NC}"
|
||||
git tag -s "v${NEW_VERSION}" -m "Release v${NEW_VERSION}"
|
||||
|
||||
# Push
|
||||
echo -e "${BLUE}Pushing to origin (GitHub)...${NC}"
|
||||
git push origin main
|
||||
git push origin "v${NEW_VERSION}"
|
||||
# Push tag
|
||||
echo -e "${BLUE}Pushing tag to GitHub...${NC}"
|
||||
git push origin "v${NEW_VERSION}" 2>/dev/null || git push github "v${NEW_VERSION}"
|
||||
|
||||
echo ""
|
||||
echo -e "${GREEN}════════════════════════════════════════════════════════════${NC}"
|
||||
echo -e "${GREEN}✓ Released v${NEW_VERSION}${NC}"
|
||||
echo -e "${GREEN}════════════════════════════════════════════════════════════${NC}"
|
||||
echo ""
|
||||
echo -e "${BLUE}GitHub Actions will now build and publish Docker images.${NC}"
|
||||
echo -e "Track progress: ${YELLOW}https://github.com/DanielVolz/medassist-ng/actions${NC}"
|
||||
echo -e "Track progress: ${YELLOW}https://github.com/${GITHUB_REPO}/actions${NC}"
|
||||
echo -e "Release page: ${YELLOW}https://github.com/${GITHUB_REPO}/releases/tag/v${NEW_VERSION}${NC}"
|
||||
|
||||
Reference in New Issue
Block a user