Compare commits

...

16 Commits

Author SHA1 Message Date
Daniel Volz b2026637db chore: release v1.6.5 (#87) 2026-01-30 22:27:41 +01:00
Daniel Volz 99ef5bd622 feat: streamline dashboard UI and improve refill reminder (#86)
- Hide Reorder Reminder card when reminders are enabled (avoids redundancy with Reminder Bar)
- Show all low stock medications in Reminder Bar instead of just the next one
- Rename 'Reorder' to 'Refill' throughout the app
- Make medication names clickable in Refill Reminder card (opens detail modal)
- Add daysLeft display for each low stock medication
- Update translations (EN + DE)
2026-01-30 22:21:05 +01:00
Daniel Volz 1dcd333fde feat: add account deletion feature (#85)
* feat: add account deletion feature

- Add DELETE /auth/me endpoint to delete user account and all data
- Add deleteAccount() method to AuthContext
- Add Delete Account button with confirmation modal in UserProfile
- Add danger zone styling (.btn-danger, .profile-danger-zone)
- Add i18n translations for EN and DE
- Add backend tests for account deletion endpoint
- Add timeout settings to frontend vitest.config.ts
- Reduce CI timeout for frontend tests (10min -> 5min)

* fix: improve delete account section layout

- Make profile modal scrollable with max-height
- Add proper horizontal margin to danger zone
- Align delete section with form content

* fix: use ConfirmModal component for delete account dialog

- Replace inline modal with existing ConfirmModal component
- Ensures consistent button styling across all modals
- Add UI consistency rule to AGENTS.md and copilot-instructions.md

* fix: consistent styling for delete account section

- Remove warning text (users know what delete means)
- Remove border-bottom from danger zone title (section has border-top)
- Update copilot-instructions and AGENTS.md with stricter UI consistency rules
- Remove unused deleteAccountHint i18n keys

* chore: remove pre-push test hook (CI handles tests)

Tests were running twice - in pre-push hook and GitHub CI.
Removing local pre-push tests since CI provides authoritative test results.
Use 'npm test' manually before pushing if you want local feedback.
2026-01-30 21:13:11 +01:00
Daniel Volz 9ed039724e fix: use test:run script and add timeouts to badge workflow (#84)
- Add test:run script to frontend package.json (consistent with backend)
- Use npm run test:run instead of npm run test -- --run
- Add timeout-minutes to prevent infinite hangs
2026-01-30 19:30:07 +01:00
Daniel Volz 156e54f0ea fix: add CI=true to test badge workflow (#83)
Frontend tests were running in watch mode without CI=true env var,
causing the workflow to hang for 30+ minutes.
2026-01-30 19:15:54 +01:00
Daniel Volz 47e8dfe9bc fix: use date-only timestamp for stable dose IDs (#82)
- Use date-only timestamp instead of full timestamp for dose ID generation
- Ensures changing intake times doesn't invalidate past dose tracking
- IDs are now immune to time configuration changes
2026-01-30 19:12:25 +01:00
Daniel Volz aed0b20875 refactor: deduplicate formatters and improve test mocks (#81)
- Consolidate duplicate date formatting utilities
- Use shared formatters across backend and frontend
- Clean up test mocks to use consistent test data
- Remove redundant formatting functions
2026-01-30 18:37:24 +01:00
Daniel Volz fcd1b79c56 chore: add .roo/, .roomodes, and AGENTS.md to .gitignore (#80)
* chore: add .roo/ to gitignore

* chore: add .roo/, .roomodes, and AGENTS.md to .gitignore
2026-01-30 18:35:00 +01:00
Daniel Volz e725700d10 fix: only count missed doses scheduled after medication update (#79)
When medication intake times change, dose IDs change (they include
timestamps). Previously, this caused all past doses to appear as
'missed' because the old 'taken' markers no longer matched.

Now doses are only counted as 'missed' if they were scheduled AFTER
the medication's last update (updatedAt). This means:
- Legitimately missed doses still show as missed (e.g., yesterday's
  dose not taken)
- Doses from before a schedule change are NOT counted as missed
  (they were from a previous schedule configuration)

Changes:
- AppContext: Add isDoseFromPreviousSchedule helper
- SchedulePage: Use context's missedPastDoseIds instead of local calc
- Update tests to include missedPastDoseIds in mocks
2026-01-25 20:45:11 +01:00
Daniel Volz 8685e802cd fix: add frontend tests to pre-push hook (#78) 2026-01-25 20:04:03 +01:00
Daniel Volz 1793f636bf docs: update release workflow instructions (#77)
- Remove reference to release script (not used)
- Document automatic version bump via GitHub Action
- Simplify release process description
2026-01-25 19:52:10 +01:00
Daniel Volz 9cf931f243 ci: add automatic version bump on GitHub release (#76)
Creates a workflow that triggers when a release is published and
automatically updates package.json versions in backend/ and frontend/
to match the release tag version.
2026-01-25 19:49:01 +01:00
Daniel Volz 85f4d2dd21 chore: update package.json versions to 1.6.0 (#75)
The release script created tag v1.6.0 but did not update the version
numbers in package.json files. This fix ensures the About modal
displays the correct version.
2026-01-25 19:36:19 +01:00
Daniel Volz 01283ebd15 chore: rename MedAssist to MedAssist-ng in all frontend UI (#74)
Update all visible text from 'MedAssist' to 'MedAssist-ng':
- Auth page titles (login, register)
- Loading/error/initializing states
- SharedSchedule page (loading, expired, error, footer)
- AboutModal fallback text
- i18n strings for export file validation (EN/DE)
- Related test expectations
2026-01-25 19:32:17 +01:00
Daniel Volz 18bcb96869 fix: add automatic retry for auth state fetch on connection errors (#73)
When the server is restarting (e.g., during tsx watch hot reload), the
initial auth state fetch may fail. This change adds automatic retry
logic (up to 3 attempts with 1s delay) to handle transient connection
errors gracefully instead of immediately showing the error screen.
2026-01-25 19:16:24 +01:00
Daniel Volz d516bdea7d fix: add credentials to all fetch calls for auth cookie support (#72)
* fix: add credentials to all fetch calls for auth cookie support

- Add credentials: include to useMedications.ts fetch calls
- Add credentials: include to MedicationsPage.tsx save function
- Add credentials: include to useSettings.ts settings update
- Add credentials: include to useShare.ts share generation
- Add credentials: include to DashboardPage.tsx reminder email
- Add credentials: include to PlannerPage.tsx usage calculation
- Make create-release workflow skip if release already exists

* fix: default to ntfy-style notifications for HTTP URLs

- Change notification logic to use plain text format by default
- Only use JSON format for known webhook services (Discord, Slack, Telegram, Gotify)
- This fixes ntfy URLs not being recognized when hostname doesn't contain 'ntfy'

* feat: highlight medication being edited

- Add blue border and background to the medication row being edited
- Show medication avatar and name in the edit form header
- Makes it easy to identify which medication is being edited when there are many

* fix: use proper URL parsing for webhook detection (CodeQL security fix)

Replace vulnerable .includes() URL checks with proper URL hostname
parsing to prevent bypass attacks (e.g., evil.com?hooks.slack.com).

Fixes CodeQL alerts #33 and #34 (js/incomplete-url-substring-sanitization)
2026-01-25 19:10:41 +01:00
45 changed files with 816 additions and 1961 deletions
+7 -11
View File
@@ -195,19 +195,14 @@ gh pr merge --squash --delete-branch
### Release Workflow (MANDATORY for minor/major releases) ### Release Workflow (MANDATORY for minor/major releases)
The `main` branch is protected - releases must go through the automated release script. The `main` branch is protected - releases are created via GitHub's release UI or API.
**Release Process:** **Release Process:**
```bash 1. Create a new release on GitHub with tag `vX.Y.Z`
# 1. Run release script (creates PR, waits for CI, merges, creates tag) 2. **Automatic Version Bump**: A GitHub Action (`version-bump.yml`) automatically updates `package.json` versions to match the release tag
./scripts/release.sh [patch|minor|major] 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
# 2. GitHub Actions creates a DRAFT release automatically 5. User publishes the release with the written notes
# 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! > ⚠️ **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. > Do NOT just publish the auto-generated commit list. Follow the process above.
@@ -505,6 +500,7 @@ Example: `5-0-1735344000000` = Medication 5, Blister 0, timestamp
- **API responses**: Return objects directly, Fastify serializes to JSON - **API responses**: Return objects directly, Fastify serializes to JSON
- **Environment**: Copy `.env.example``.env`, secrets must be 10+ chars - **Environment**: Copy `.env.example``.env`, secrets must be 10+ chars
- **i18n**: All UI text via `t('key')` function, translations in `frontend/src/i18n/*.json` - **i18n**: All UI text via `t('key')` function, translations in `frontend/src/i18n/*.json`
- **UI Consistency**: Always use existing components for modals, buttons, and forms. For confirmation dialogs, use `ConfirmModal` component. Never create inline modals with custom button styling - all UI elements must match the existing design system. When adding new sections to existing components, ensure font sizes, spacing, margins, and button styles match exactly with other sections. Check existing CSS classes before creating new ones.
## Database Schema Changes (IMPORTANT: Backward Compatibility!) ## Database Schema Changes (IMPORTANT: Backward Compatibility!)
+16
View File
@@ -137,13 +137,28 @@ jobs:
with: with:
fetch-depth: 0 # Fetch all history for changelog generation fetch-depth: 0 # Fetch all history for changelog generation
- name: Check if release exists
id: check_release
run: |
CURRENT_TAG=${GITHUB_REF#refs/tags/}
if gh release view "$CURRENT_TAG" &>/dev/null; then
echo "exists=true" >> $GITHUB_OUTPUT
echo "Release $CURRENT_TAG already exists, skipping creation"
else
echo "exists=false" >> $GITHUB_OUTPUT
fi
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Get previous tag - name: Get previous tag
if: steps.check_release.outputs.exists == 'false'
id: prev_tag id: prev_tag
run: | run: |
PREV_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "") PREV_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "")
echo "tag=${PREV_TAG}" >> $GITHUB_OUTPUT echo "tag=${PREV_TAG}" >> $GITHUB_OUTPUT
- name: Generate changelog - name: Generate changelog
if: steps.check_release.outputs.exists == 'false'
id: changelog id: changelog
run: | run: |
CURRENT_TAG=${GITHUB_REF#refs/tags/} CURRENT_TAG=${GITHUB_REF#refs/tags/}
@@ -172,6 +187,7 @@ jobs:
echo "**Full Changelog**: https://github.com/${{ github.repository }}/compare/${PREV_TAG}...${CURRENT_TAG}" >> changelog.md echo "**Full Changelog**: https://github.com/${{ github.repository }}/compare/${PREV_TAG}...${CURRENT_TAG}" >> changelog.md
- name: Create GitHub Release - name: Create GitHub Release
if: steps.check_release.outputs.exists == 'false'
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v2
with: with:
body_path: changelog.md body_path: changelog.md
+7 -1
View File
@@ -41,6 +41,9 @@ jobs:
- name: Run backend tests and capture count - name: Run backend tests and capture count
id: backend-tests id: backend-tests
working-directory: backend working-directory: backend
timeout-minutes: 5
env:
CI: true
run: | run: |
OUTPUT=$(npm run test:run 2>&1) || true OUTPUT=$(npm run test:run 2>&1) || true
echo "$OUTPUT" echo "$OUTPUT"
@@ -51,8 +54,11 @@ jobs:
- name: Run frontend tests and capture count - name: Run frontend tests and capture count
id: frontend-tests id: frontend-tests
working-directory: frontend working-directory: frontend
timeout-minutes: 5
env:
CI: true
run: | run: |
OUTPUT=$(npm run test -- --run 2>&1) || true OUTPUT=$(npm run test:run 2>&1) || true
echo "$OUTPUT" echo "$OUTPUT"
# Extract "Tests X passed" from output # Extract "Tests X passed" from output
PASSED=$(echo "$OUTPUT" | grep -oP 'Tests\s+\K\d+(?=\s+passed)' | tail -1) PASSED=$(echo "$OUTPUT" | grep -oP 'Tests\s+\K\d+(?=\s+passed)' | tail -1)
+57
View File
@@ -0,0 +1,57 @@
name: Version Bump on Release
on:
release:
types: [published]
permissions:
contents: write
jobs:
version-bump:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: main
token: ${{ secrets.GITHUB_TOKEN }}
- name: Get version from tag
id: version
run: |
# Extract version from tag (e.g., v1.6.0 -> 1.6.0)
VERSION="${GITHUB_REF_NAME#v}"
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "Extracted version: $VERSION"
- name: Update package.json versions
run: |
VERSION="${{ steps.version.outputs.version }}"
# Update backend/package.json
jq --arg v "$VERSION" '.version = $v' backend/package.json > backend/package.json.tmp
mv backend/package.json.tmp backend/package.json
# Update frontend/package.json
jq --arg v "$VERSION" '.version = $v' frontend/package.json > frontend/package.json.tmp
mv frontend/package.json.tmp frontend/package.json
echo "Updated versions to $VERSION"
cat backend/package.json | head -5
cat frontend/package.json | head -5
- name: Commit and push
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add backend/package.json frontend/package.json
# Only commit if there are changes
if git diff --staged --quiet; then
echo "No version changes needed"
else
git commit -m "chore: bump version to ${{ steps.version.outputs.version }} [skip ci]"
git push origin main
fi
+3
View File
@@ -71,4 +71,7 @@ Thumbs.db
*.local *.local
.cache/ .cache/
.turbo/ .turbo/
.roo/
.roomodes
AGENTS.md
docs/TECH_STACK.md docs/TECH_STACK.md
-12
View File
@@ -1,12 +0,0 @@
#!/bin/sh
echo "Running backend tests before push..."
cd backend && CI=true npm test
if [ $? -ne 0 ]; then
echo "❌ Backend tests failed. Push aborted."
echo "Use 'git push --no-verify' to skip tests if needed."
exit 1
fi
echo "✅ Backend tests passed!"
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "medassist-ng-backend", "name": "medassist-ng-backend",
"version": "1.5.0", "version": "1.6.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "medassist-ng-backend", "name": "medassist-ng-backend",
"version": "1.5.0", "version": "1.6.0",
"dependencies": { "dependencies": {
"@fastify/cookie": "^10.0.1", "@fastify/cookie": "^10.0.1",
"@fastify/cors": "^10.0.1", "@fastify/cors": "^10.0.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "medassist-ng-backend", "name": "medassist-ng-backend",
"version": "1.5.0", "version": "1.6.5",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
+40
View File
@@ -534,4 +534,44 @@ export async function authRoutes(app: FastifyInstance) {
return { ok: true }; return { ok: true };
} }
); );
// ---------------------------------------------------------------------------
// DELETE /auth/me - Delete user account and all data
// ---------------------------------------------------------------------------
app.delete(
"/auth/me",
{
preHandler: requireAuth,
config: { rateLimit: sensitiveRateLimitConfig },
},
async (request, reply) => {
const authUser = request.user as unknown as AuthUser | null;
if (!authUser) {
return reply.status(401).send({ error: "Not authenticated" });
}
// Delete avatar file if exists
const [user] = await db.select().from(users).where(eq(users.id, authUser.id));
if (user?.avatarUrl) {
const fs = await import("node:fs/promises");
const path = await import("node:path");
try {
await fs.unlink(path.join(process.cwd(), "data", "images", user.avatarUrl));
} catch {
// Ignore if file doesn't exist
}
}
// Delete user - cascade delete handles all related data
await db.delete(users).where(eq(users.id, authUser.id));
app.log.info(`User deleted account: ${authUser.username} (ID: ${authUser.id})`);
// Clear auth cookies
return reply
.clearCookie("access_token", app.config.cookieOptions)
.clearCookie("refresh_token", app.config.refreshCookieOptions)
.send({ ok: true, message: "Account deleted" });
}
);
} }
+1 -11
View File
@@ -9,6 +9,7 @@ import { doseTracking, medications, shareTokens, userSettings } from "../db/sche
import { getAnonymousUserId, requireAuth } from "../plugins/auth.js"; import { getAnonymousUserId, requireAuth } from "../plugins/auth.js";
import { env } from "../plugins/env.js"; import { env } from "../plugins/env.js";
import type { AuthUser } from "../types/fastify.js"; import type { AuthUser } from "../types/fastify.js";
import { parseTakenByJson } from "../utils/scheduler-utils.js";
const IMAGES_DIR = resolve(process.cwd(), "data/images"); const IMAGES_DIR = resolve(process.cwd(), "data/images");
@@ -125,17 +126,6 @@ async function getUserId(request: any, reply: any): Promise<number> {
return authUser.id; return authUser.id;
} }
// Parse takenByJson safely
function parseTakenByJson(takenByJson: string | null | undefined): string[] {
if (!takenByJson) return [];
try {
const parsed = JSON.parse(takenByJson);
return Array.isArray(parsed) ? parsed.filter((s: unknown) => typeof s === "string" && s.trim()) : [];
} catch {
return [];
}
}
// Parse blisters from DB format to export format // Parse blisters from DB format to export format
function parseBlistersForExport( function parseBlistersForExport(
row: typeof medications.$inferSelect row: typeof medications.$inferSelect
+1 -31
View File
@@ -9,7 +9,7 @@ import { doseTracking, medications } from "../db/schema.js";
import { getAnonymousUserId, requireAuth } from "../plugins/auth.js"; import { getAnonymousUserId, requireAuth } from "../plugins/auth.js";
import { env } from "../plugins/env.js"; import { env } from "../plugins/env.js";
import type { AuthUser } from "../types/fastify.js"; import type { AuthUser } from "../types/fastify.js";
import { parseLocalDateTime } from "../utils/scheduler-utils.js"; import { parseBlisters, parseLocalDateTime, parseTakenByJson } from "../utils/scheduler-utils.js";
const IMAGES_DIR = resolve(process.cwd(), "data/images"); const IMAGES_DIR = resolve(process.cwd(), "data/images");
@@ -34,36 +34,6 @@ const medicationSchema = z.object({
blisters: z.array(blisterSchema).min(1).max(12), blisters: z.array(blisterSchema).min(1).max(12),
}); });
function zipBlisters(usage: number[], every: number[], start: string[]) {
const len = Math.min(usage.length, every.length, start.length);
const blisters: Array<{ usage: number; every: number; start: string }> = [];
for (let i = 0; i < len; i++) {
blisters.push({ usage: usage[i], every: every[i], start: start[i] });
}
return blisters;
}
function parseBlisters(row: typeof medications.$inferSelect) {
try {
const usage = JSON.parse(row.usageJson) as number[];
const every = JSON.parse(row.everyJson) as number[];
const start = JSON.parse(row.startJson) as string[];
return zipBlisters(usage, every, start);
} catch (_err) {
return [];
}
}
function parseTakenByJson(takenByJson: string | null | undefined): string[] {
if (!takenByJson) return [];
try {
const parsed = JSON.parse(takenByJson);
return Array.isArray(parsed) ? parsed.filter((s: unknown) => typeof s === "string" && s.trim()) : [];
} catch {
return [];
}
}
export async function medicationRoutes(app: FastifyInstance) { export async function medicationRoutes(app: FastifyInstance) {
// All medication routes require auth // All medication routes require auth
app.addHook("preHandler", requireAuth); app.addHook("preHandler", requireAuth);
+26 -3
View File
@@ -482,10 +482,33 @@ export async function sendShoutrrrNotification(
) )
.trim(); .trim();
// Determine notification type based on validation result and URL pattern // Determine notification type based on URL hostname
const isNtfyUrl = isNtfy || sanitizedUrl.includes("ntfy.sh") || sanitizedUrl.includes("/ntfy/"); // Use JSON format only for known webhook services that require it
// Use proper URL parsing to prevent bypass attacks (e.g., evil.com?hooks.slack.com)
let isJsonWebhook = false;
try {
const parsedUrl = new URL(sanitizedUrl);
const hostname = parsedUrl.hostname.toLowerCase();
const pathname = parsedUrl.pathname.toLowerCase();
if (isNtfyUrl) { isJsonWebhook =
// Discord webhooks
((hostname === "discord.com" || hostname === "discordapp.com") && pathname.startsWith("/api/webhooks")) ||
// Slack webhooks
hostname === "hooks.slack.com" ||
hostname.endsWith(".hooks.slack.com") ||
// Telegram API
hostname === "api.telegram.org" ||
// Gotify (can be self-hosted, so check if "gotify" is in hostname)
hostname.includes("gotify");
} catch {
// If URL parsing fails, default to ntfy-style
isJsonWebhook = false;
}
// Default to ntfy-style (plain text with Title header) for all other HTTP URLs
// This works for ntfy, Apprise, and most simple push services
if (!isJsonWebhook) {
targetUrl = sanitizedUrl; targetUrl = sanitizedUrl;
headers = { Title: cleanTitle, Tags: "pill" }; headers = { Title: cleanTitle, Tags: "pill" };
body = message; body = message;
+1 -11
View File
@@ -7,6 +7,7 @@ import { medications, shareTokens, userSettings, users } from "../db/schema.js";
import { getAnonymousUserId, requireAuth } from "../plugins/auth.js"; import { getAnonymousUserId, requireAuth } from "../plugins/auth.js";
import { env } from "../plugins/env.js"; import { env } from "../plugins/env.js";
import type { AuthUser } from "../types/fastify.js"; import type { AuthUser } from "../types/fastify.js";
import { parseTakenByJson } from "../utils/scheduler-utils.js";
// Share token validity: 1 year in milliseconds // Share token validity: 1 year in milliseconds
const SHARE_TOKEN_VALIDITY_MS = 365 * 24 * 60 * 60 * 1000; const SHARE_TOKEN_VALIDITY_MS = 365 * 24 * 60 * 60 * 1000;
@@ -35,17 +36,6 @@ async function getUserId(request: FastifyRequest, reply: FastifyReply): Promise<
return authUser.id; return authUser.id;
} }
// Helper to parse takenByJson
function parseTakenByJson(takenByJson: string | null | undefined): string[] {
if (!takenByJson) return [];
try {
const parsed = JSON.parse(takenByJson);
return Array.isArray(parsed) ? parsed.filter((s: unknown) => typeof s === "string" && s.trim()) : [];
} catch {
return [];
}
}
// ============================================================================= // =============================================================================
// Share Routes // Share Routes
// ============================================================================= // =============================================================================
@@ -42,10 +42,6 @@ function saveIntakeReminderState(state: IntakeReminderState): void {
writeFileSync(intakeReminderStateFile, JSON.stringify(state, null, 2)); writeFileSync(intakeReminderStateFile, JSON.stringify(state, null, 2));
} }
function parseBlistersFromRow(row: { usageJson: string; everyJson: string; startJson: string }): Blister[] {
return parseBlisters(row);
}
async function sendIntakeReminderEmail( async function sendIntakeReminderEmail(
email: string, email: string,
intakes: UpcomingIntake[], intakes: UpcomingIntake[],
@@ -295,7 +291,7 @@ async function checkAndSendIntakeRemindersForUser(
// Find intakes: upcoming ones in reminder window + past ones for repeat reminders // Find intakes: upcoming ones in reminder window + past ones for repeat reminders
for (const med of medsWithReminders) { for (const med of medsWithReminders) {
const blisters = parseBlistersFromRow(med); const blisters = parseBlisters(med);
const takenByArray = parseTakenByJson(med.takenByJson); const takenByArray = parseTakenByJson(med.takenByJson);
logger.info( logger.info(
+58
View File
@@ -682,4 +682,62 @@ describe("Auth Routes (AUTH_ENABLED=true)", () => {
expect(response.statusCode).toBe(401); expect(response.statusCode).toBe(401);
}); });
}); });
describe("DELETE /auth/me - Delete Account", () => {
it("should delete user account and all data", async () => {
// Register and login
await app.inject({
method: "POST",
url: "/auth/register",
payload: {
username: "deleteuser",
password: "TestPassword123",
},
});
const login = await app.inject({
method: "POST",
url: "/auth/login",
payload: {
username: "deleteuser",
password: "TestPassword123",
},
});
const accessToken = login.cookies.find((c: any) => c.name === "access_token");
// Delete account
const response = await app.inject({
method: "DELETE",
url: "/auth/me",
cookies: {
access_token: accessToken?.value ?? "",
},
});
expect(response.statusCode).toBe(200);
expect(response.json().ok).toBe(true);
// Verify can't login anymore
const loginAgain = await app.inject({
method: "POST",
url: "/auth/login",
payload: {
username: "deleteuser",
password: "TestPassword123",
},
});
expect(loginAgain.statusCode).toBe(401);
});
it("should reject delete without auth", async () => {
const response = await app.inject({
method: "DELETE",
url: "/auth/me",
});
expect(response.statusCode).toBe(401);
});
});
}); });
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "medassist-ng-frontend", "name": "medassist-ng-frontend",
"version": "1.5.0", "version": "1.6.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "medassist-ng-frontend", "name": "medassist-ng-frontend",
"version": "1.5.0", "version": "1.6.0",
"dependencies": { "dependencies": {
"i18next": "^24.2.2", "i18next": "^24.2.2",
"i18next-browser-languagedetector": "^8.0.4", "i18next-browser-languagedetector": "^8.0.4",
+2 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "medassist-ng-frontend", "name": "medassist-ng-frontend",
"private": true, "private": true,
"version": "1.5.0", "version": "1.6.5",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
@@ -12,6 +12,7 @@
"format": "npx biome format --write .", "format": "npx biome format --write .",
"check": "npx biome check . && tsc --noEmit", "check": "npx biome check . && tsc --noEmit",
"test": "vitest", "test": "vitest",
"test:run": "vitest run",
"test:coverage": "vitest run --coverage" "test:coverage": "vitest run --coverage"
}, },
"dependencies": { "dependencies": {
+3 -3
View File
@@ -44,7 +44,7 @@ function AppRouter() {
return ( return (
<div className="auth-container"> <div className="auth-container">
<div className="auth-card" style={{ textAlign: "center" }}> <div className="auth-card" style={{ textAlign: "center" }}>
<h1 className="auth-title">💊 MedAssist</h1> <h1 className="auth-title">💊 MedAssist-ng</h1>
<p>Loading...</p> <p>Loading...</p>
</div> </div>
</div> </div>
@@ -56,7 +56,7 @@ function AppRouter() {
return ( return (
<div className="auth-container"> <div className="auth-container">
<div className="auth-card" style={{ textAlign: "center" }}> <div className="auth-card" style={{ textAlign: "center" }}>
<h1 className="auth-title">💊 MedAssist</h1> <h1 className="auth-title">💊 MedAssist-ng</h1>
<div className="auth-error" style={{ marginBottom: "1rem" }}> <div className="auth-error" style={{ marginBottom: "1rem" }}>
<strong>Connection Error</strong> <strong>Connection Error</strong>
<br /> <br />
@@ -78,7 +78,7 @@ function AppRouter() {
return ( return (
<div className="auth-container"> <div className="auth-container">
<div className="auth-card" style={{ textAlign: "center" }}> <div className="auth-card" style={{ textAlign: "center" }}>
<h1 className="auth-title">💊 MedAssist</h1> <h1 className="auth-title">💊 MedAssist-ng</h1>
<p>Initializing...</p> <p>Initializing...</p>
</div> </div>
</div> </div>
+1 -1
View File
@@ -80,7 +80,7 @@ export default function AboutModal({ isOpen, onClose }: AboutModalProps) {
<path d="M9 2h6M12 2v2" /> <path d="M9 2h6M12 2v2" />
</svg> </svg>
</div> </div>
<h2>{t("about.appName", "MedAssist")}</h2> <h2>{t("about.appName", "MedAssist-ng")}</h2>
<p className="about-tagline">{t("about.description", "Personal medication tracking and reminder app")}</p> <p className="about-tagline">{t("about.description", "Personal medication tracking and reminder app")}</p>
</div> </div>
<div className="about-versions"> <div className="about-versions">
+80 -6
View File
@@ -1,5 +1,6 @@
import { createContext, type ReactNode, useCallback, useContext, useEffect, useRef, useState } from "react"; import { createContext, type ReactNode, useCallback, useContext, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ConfirmModal } from "./ConfirmModal";
// ============================================================================= // =============================================================================
// Types (no roles - all users are equal) // Types (no roles - all users are equal)
@@ -32,6 +33,7 @@ interface AuthContextType {
updateProfile: (data: { currentPassword?: string; newPassword?: string }) => Promise<void>; updateProfile: (data: { currentPassword?: string; newPassword?: string }) => Promise<void>;
uploadAvatar: (file: File) => Promise<void>; uploadAvatar: (file: File) => Promise<void>;
deleteAvatar: () => Promise<void>; deleteAvatar: () => Promise<void>;
deleteAccount: () => Promise<void>;
authFetch: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>; authFetch: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
} }
@@ -87,7 +89,10 @@ export function AuthProvider({ children }: { children: ReactNode }) {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [user, authState?.authEnabled]); }, [user, authState?.authEnabled]);
async function fetchAuthState() { async function fetchAuthState(retryCount = 0) {
const maxRetries = 3;
const retryDelay = 1000; // 1 second
try { try {
setAuthError(null); setAuthError(null);
const res = await fetch("/api/auth/state"); const res = await fetch("/api/auth/state");
@@ -101,10 +106,17 @@ export function AuthProvider({ children }: { children: ReactNode }) {
if (state.authEnabled) { if (state.authEnabled) {
await refreshUser(); await refreshUser();
} }
setLoading(false);
} catch (err) { } catch (err) {
console.error("Failed to fetch auth state:", err); console.error(`Failed to fetch auth state (attempt ${retryCount + 1}/${maxRetries + 1}):`, err);
// Retry on connection errors or 5xx errors (server might be restarting)
if (retryCount < maxRetries) {
await new Promise((resolve) => setTimeout(resolve, retryDelay));
return fetchAuthState(retryCount + 1);
}
setAuthError(err instanceof Error ? err.message : "Failed to connect to server"); setAuthError(err instanceof Error ? err.message : "Failed to connect to server");
} finally {
setLoading(false); setLoading(false);
} }
} }
@@ -244,6 +256,21 @@ export function AuthProvider({ children }: { children: ReactNode }) {
await refreshUser(); await refreshUser();
} }
// Delete account
async function deleteAccount() {
const res = await fetch("/api/auth/me", {
method: "DELETE",
credentials: "include",
});
if (!res.ok) {
const err = await res.json().catch(() => ({ error: "Delete failed" }));
throw new Error(err.error || "Delete failed");
}
setUser(null);
}
// Fetch wrapper that automatically refreshes token on 401 // Fetch wrapper that automatically refreshes token on 401
const authFetch = useCallback( const authFetch = useCallback(
async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => { async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
@@ -285,6 +312,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
updateProfile, updateProfile,
uploadAvatar, uploadAvatar,
deleteAvatar, deleteAvatar,
deleteAccount,
authFetch, authFetch,
}} }}
> >
@@ -329,7 +357,7 @@ export function LoginForm({
return ( return (
<div className="auth-container"> <div className="auth-container">
<div className="auth-card"> <div className="auth-card">
<h1 className="auth-title">💊 MedAssist</h1> <h1 className="auth-title">💊 MedAssist-ng</h1>
<h2 className="auth-subtitle">{t("auth.login", "Login")}</h2> <h2 className="auth-subtitle">{t("auth.login", "Login")}</h2>
{/* SSO Login Button */} {/* SSO Login Button */}
@@ -445,7 +473,7 @@ export function RegisterForm({ onSuccess, onSwitchToLogin }: { onSuccess?: () =>
return ( return (
<div className="auth-container"> <div className="auth-container">
<div className="auth-card"> <div className="auth-card">
<h1 className="auth-title">💊 MedAssist</h1> <h1 className="auth-title">💊 MedAssist-ng</h1>
<h2 className="auth-subtitle">{t("auth.register", "Create Account")}</h2> <h2 className="auth-subtitle">{t("auth.register", "Create Account")}</h2>
{/* SSO Login Button - also show on registration */} {/* SSO Login Button - also show on registration */}
@@ -541,7 +569,7 @@ export function RegisterForm({ onSuccess, onSwitchToLogin }: { onSuccess?: () =>
// ============================================================================= // =============================================================================
export function UserProfile({ onClose }: { onClose?: () => void }) { export function UserProfile({ onClose }: { onClose?: () => void }) {
const { t } = useTranslation(); const { t } = useTranslation();
const { user, updateProfile, uploadAvatar, deleteAvatar } = useAuth(); const { user, updateProfile, uploadAvatar, deleteAvatar, deleteAccount } = useAuth();
const [currentPassword, setCurrentPassword] = useState(""); const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState(""); const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState("");
@@ -549,6 +577,8 @@ export function UserProfile({ onClose }: { onClose?: () => void }) {
const [success, setSuccess] = useState(""); const [success, setSuccess] = useState("");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [avatarLoading, setAvatarLoading] = useState(false); const [avatarLoading, setAvatarLoading] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [deleteLoading, setDeleteLoading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
// Close on Escape key // Close on Escape key
@@ -625,6 +655,18 @@ export function UserProfile({ onClose }: { onClose?: () => void }) {
} }
} }
async function handleDeleteAccount() {
setDeleteLoading(true);
setError("");
try {
await deleteAccount();
// User will be logged out automatically
} catch (err) {
setError(err instanceof Error ? err.message : "Delete failed");
setDeleteLoading(false);
}
}
if (!user) return null; if (!user) return null;
const hasChanges = currentPassword || newPassword || confirmPassword; const hasChanges = currentPassword || newPassword || confirmPassword;
@@ -725,6 +767,38 @@ export function UserProfile({ onClose }: { onClose?: () => void }) {
</button> </button>
</div> </div>
</form> </form>
{/* Delete Account Section */}
<div className="profile-section profile-danger-zone">
<h3 className="profile-section-title">{t("auth.deleteAccount", "Delete Account")}</h3>
<button type="button" className="btn btn-danger" onClick={() => setShowDeleteConfirm(true)}>
{t("auth.deleteAccount", "Delete Account")}
</button>
</div>
{/* Delete Confirmation Modal */}
{showDeleteConfirm && (
<ConfirmModal
title={t("auth.deleteAccountConfirmTitle", "Delete Account?")}
message={
<>
<p>
{t(
"auth.deleteAccountConfirmText",
"This will permanently delete your account and all your data (medications, settings, history). This action cannot be undone."
)}
</p>
{error && <div className="auth-error">{error}</div>}
</>
}
confirmLabel={t("auth.deleteAccountButton", "Yes, delete my account")}
cancelLabel={t("common.cancel", "Cancel")}
onConfirm={handleDeleteAccount}
onCancel={() => setShowDeleteConfirm(false)}
isLoading={deleteLoading}
confirmVariant="danger"
/>
)}
</div> </div>
); );
} }
+5 -3
View File
@@ -4,6 +4,7 @@
*/ */
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { FieldErrors, FormBlister, FormState, Medication } from "../types"; import type { FieldErrors, FormBlister, FormState, Medication } from "../types";
import { deriveTotal } from "../utils";
// Field limits for validation // Field limits for validation
const FIELD_LIMITS = { const FIELD_LIMITS = {
@@ -53,12 +54,13 @@ export interface MobileEditModalProps {
onSaveMedication: (e: React.FormEvent) => void; onSaveMedication: (e: React.FormEvent) => void;
} }
function deriveTotal(form: FormState) { /** Calculate total pills from form state */
function deriveTotalFromForm(form: FormState) {
const packCount = Number(form.packCount) || 0; const packCount = Number(form.packCount) || 0;
const blistersPerPack = Number(form.blistersPerPack) || 0; const blistersPerPack = Number(form.blistersPerPack) || 0;
const pillsPerBlister = Number(form.pillsPerBlister) || 1; const pillsPerBlister = Number(form.pillsPerBlister) || 1;
const looseTablets = Number(form.looseTablets) || 0; const looseTablets = Number(form.looseTablets) || 0;
return packCount * blistersPerPack * pillsPerBlister + looseTablets; return deriveTotal(packCount, blistersPerPack, pillsPerBlister, looseTablets);
} }
export function MobileEditModal({ export function MobileEditModal({
@@ -216,7 +218,7 @@ export function MobileEditModal({
</label> </label>
<div className="full"> <div className="full">
<p className="sub"> <p className="sub">
<strong>{t("form.total")}:</strong> {deriveTotal(form)} {t("common.pills")} <strong>{t("form.total")}:</strong> {deriveTotalFromForm(form)} {t("common.pills")}
</p> </p>
</div> </div>
<label className="full"> <label className="full">
+36 -11
View File
@@ -19,6 +19,7 @@ export function SharedSchedule() {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [expiredData, setExpiredData] = useState<ExpiredLinkData | null>(null); const [expiredData, setExpiredData] = useState<ExpiredLinkData | null>(null);
const [takenDoses, setTakenDoses] = useState<Set<string>>(new Set()); const [takenDoses, setTakenDoses] = useState<Set<string>>(new Set());
const [dismissedDoses, setDismissedDoses] = useState<Set<string>>(new Set());
const [lightboxImage, setLightboxImage] = useState<{ url: string; name: string } | null>(null); const [lightboxImage, setLightboxImage] = useState<{ url: string; name: string } | null>(null);
const [showPastDays, setShowPastDays] = useState(false); const [showPastDays, setShowPastDays] = useState(false);
const [showFutureDays, setShowFutureDays] = useState(false); const [showFutureDays, setShowFutureDays] = useState(false);
@@ -116,6 +117,7 @@ export function SharedSchedule() {
}, [lightboxImage]); }, [lightboxImage]);
// Load taken doses from server with polling for real-time sync // Load taken doses from server with polling for real-time sync
// Separates taken and dismissed doses (like main app's useDoses hook)
useEffect(() => { useEffect(() => {
if (token) { if (token) {
async function loadTakenDoses() { async function loadTakenDoses() {
@@ -123,12 +125,24 @@ export function SharedSchedule() {
const res = await fetch(`/api/share/${token}/doses`); const res = await fetch(`/api/share/${token}/doses`);
if (res.ok) { if (res.ok) {
const data = await res.json(); const data = await res.json();
setTakenDoses(new Set(data.doses.map((d: { doseId: string }) => d.doseId))); const taken = new Set<string>();
const dismissed = new Set<string>();
for (const d of data.doses as Array<{ doseId: string; dismissed?: boolean }>) {
if (d.dismissed) {
dismissed.add(d.doseId);
} else {
taken.add(d.doseId);
}
}
setTakenDoses(taken);
setDismissedDoses(dismissed);
} else { } else {
setTakenDoses(new Set()); setTakenDoses(new Set());
setDismissedDoses(new Set());
} }
} catch { } catch {
setTakenDoses(new Set()); setTakenDoses(new Set());
setDismissedDoses(new Set());
} }
} }
loadTakenDoses(); loadTakenDoses();
@@ -274,8 +288,11 @@ export function SharedSchedule() {
for (let d = new Date(startDate); d <= end; d.setDate(d.getDate() + blister.every)) { for (let d = new Date(startDate); d <= end; d.setDate(d.getDate() + blister.every)) {
const t = d.getTime(); const t = d.getTime();
const isPast = d < todayStart; const isPast = d < todayStart;
// Generate dose ID matching Dashboard format: ${med.id}-${blisterIdx}-${whenMs} // Use date-only timestamp for stable ID (immune to time changes)
const doseId = `${med.id}-${blisterIdx}-${t}`; // This ensures changing intake times doesn't invalidate past dose tracking
// Must match buildSchedulePreview in schedule.ts exactly
const dateOnlyMs = new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
const doseId = `${med.id}-${blisterIdx}-${dateOnlyMs}`;
doses.push({ doses.push({
id: doseId, id: doseId,
when: t, when: t,
@@ -455,7 +472,7 @@ export function SharedSchedule() {
return ( return (
<div className="shared-schedule-page"> <div className="shared-schedule-page">
<div className="shared-schedule-loading"> <div className="shared-schedule-loading">
<h1>💊 MedAssist</h1> <h1>💊 MedAssist-ng</h1>
<p>{t("common.loading")}</p> <p>{t("common.loading")}</p>
</div> </div>
</div> </div>
@@ -466,7 +483,7 @@ export function SharedSchedule() {
return ( return (
<div className="shared-schedule-page"> <div className="shared-schedule-page">
<div className="shared-schedule-error expired"> <div className="shared-schedule-error expired">
<h1>💊 MedAssist</h1> <h1>💊 MedAssist-ng</h1>
<div className="expired-icon"></div> <div className="expired-icon"></div>
<h2>{t("share.expired.title")}</h2> <h2>{t("share.expired.title")}</h2>
<p className="expired-message">{t("share.expired.message", { takenBy: expiredData.takenBy })}</p> <p className="expired-message">{t("share.expired.message", { takenBy: expiredData.takenBy })}</p>
@@ -485,7 +502,7 @@ export function SharedSchedule() {
return ( return (
<div className="shared-schedule-page"> <div className="shared-schedule-page">
<div className="shared-schedule-error"> <div className="shared-schedule-error">
<h1>💊 MedAssist</h1> <h1>💊 MedAssist-ng</h1>
<p className="error-message">{error || "Unknown error"}</p> <p className="error-message">{error || "Unknown error"}</p>
</div> </div>
</div> </div>
@@ -535,9 +552,12 @@ export function SharedSchedule() {
) )
); );
// Count missed doses (not taken AND not dismissed) // Count missed doses (not taken AND not dismissed)
// Check both: per-dose dismissed flag from API AND medication-level dismissedUntil
const missedPastDoses = totalPastDoses.filter((id) => { const missedPastDoses = totalPastDoses.filter((id) => {
if (takenDoses.has(id)) return false; if (takenDoses.has(id)) return false;
// Check if this dose is dismissed // Check if this dose is dismissed via per-dose flag from API
if (dismissedDoses.has(id)) return false;
// Check if dismissed via medication-level dismissedUntil date
const parts = id.split("-"); const parts = id.split("-");
if (parts.length >= 3) { if (parts.length >= 3) {
const timestamp = parseInt(parts[2], 10); const timestamp = parseInt(parts[2], 10);
@@ -580,9 +600,12 @@ export function SharedSchedule() {
{showPastDays && {showPastDays &&
pastDays.map((day) => { pastDays.map((day) => {
// Helper to check if a dose ID is "done" (taken or dismissed) // Helper to check if a dose ID is "done" (taken or dismissed)
// Checks both: per-dose dismissed flag from API AND medication-level dismissedUntil
const isDoseIdDone = (doseId: string) => { const isDoseIdDone = (doseId: string) => {
if (takenDoses.has(doseId)) return true; if (takenDoses.has(doseId)) return true;
// Check if dismissed // Check if this dose is dismissed via per-dose flag from API
if (dismissedDoses.has(doseId)) return true;
// Check if dismissed via medication-level dismissedUntil date
const parts = doseId.split("-"); const parts = doseId.split("-");
if (parts.length >= 3) { if (parts.length >= 3) {
const timestamp = parseInt(parts[2], 10); const timestamp = parseInt(parts[2], 10);
@@ -693,10 +716,11 @@ export function SharedSchedule() {
<div className="doses-col"> <div className="doses-col">
{item.doses.map((dose) => { {item.doses.map((dose) => {
const people = (dose.takenBy || []).length > 0 ? dose.takenBy : [null]; const people = (dose.takenBy || []).length > 0 ? dose.takenBy : [null];
const isDismissed = isDoseDismissed(dose.when, dose.medName); // Check both: medication-level dismissedUntil AND per-dose dismissed flag
const isMedLevelDismissed = isDoseDismissed(dose.when, dose.medName);
const allDone = people.every((person) => { const allDone = people.every((person) => {
const doseId = getDoseId(dose.id, person); const doseId = getDoseId(dose.id, person);
return takenDoses.has(doseId) || isDismissed; return takenDoses.has(doseId) || dismissedDoses.has(doseId) || isMedLevelDismissed;
}); });
return ( return (
<div key={dose.id} className={`dose-item past ${allDone ? "all-taken" : ""}`}> <div key={dose.id} className={`dose-item past ${allDone ? "all-taken" : ""}`}>
@@ -709,7 +733,8 @@ export function SharedSchedule() {
{people.map((person) => { {people.map((person) => {
const doseId = getDoseId(dose.id, person); const doseId = getDoseId(dose.id, person);
const isTaken = takenDoses.has(doseId); const isTaken = takenDoses.has(doseId);
const isDone = isTaken || isDismissed; const isPerDoseDismissed = dismissedDoses.has(doseId);
const isDone = isTaken || isPerDoseDismissed || isMedLevelDismissed;
return ( return (
<div key={doseId} className={`dose-person ${isDone ? "taken" : ""}`}> <div key={doseId} className={`dose-person ${isDone ? "taken" : ""}`}>
{person && <span className="person-name">{person}</span>} {person && <span className="person-name">{person}</span>}
+31 -2
View File
@@ -419,12 +419,35 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
return doseDateStr <= dismissedUntilDate; return doseDateStr <= dismissedUntilDate;
}, []); }, []);
// Helper to check if a dose was scheduled BEFORE the medication was last updated
// If so, it's from a previous schedule configuration and shouldn't count as "missed"
const isDoseFromPreviousSchedule = useCallback(
(doseId: string, medUpdatedAt: string | number | null | undefined): boolean => {
if (!medUpdatedAt) return false; // No updatedAt means it was never changed, all doses are valid
// Extract timestamp from dose ID (format: medId-blisterIdx-timestamp or medId-blisterIdx-timestamp-person)
const parts = doseId.split("-");
if (parts.length < 3) return false;
const doseTimestamp = parseInt(parts[2], 10);
if (Number.isNaN(doseTimestamp)) return false;
// Convert updatedAt to timestamp
const updatedAtTimestamp = typeof medUpdatedAt === "number" ? medUpdatedAt : new Date(medUpdatedAt).getTime();
if (Number.isNaN(updatedAtTimestamp)) return false;
// If the dose was scheduled before the medication was updated, it's from a previous schedule
return doseTimestamp < updatedAtTimestamp;
},
[]
);
const missedPastDoseIds = useMemo(() => { const missedPastDoseIds = useMemo(() => {
const totalPastDoses = pastDays.flatMap((d) => const totalPastDoses = pastDays.flatMap((d) =>
d.meds.flatMap((m) => { d.meds.flatMap((m) => {
// Find the medication to get its dismissedUntil // Find the medication to get its dismissedUntil and updatedAt
const med = medications.meds.find((med) => med.name === m.medName); const med = medications.meds.find((med) => med.name === m.medName);
const dismissedUntilDate = med?.dismissedUntil ?? undefined; const dismissedUntilDate = med?.dismissedUntil ?? undefined;
const medUpdatedAt = med?.updatedAt;
return m.doses.flatMap((dose) => { return m.doses.flatMap((dose) => {
// Check if this dose is on or before the dismissed date for this medication // Check if this dose is on or before the dismissed date for this medication
@@ -432,13 +455,19 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
return []; return [];
} }
// Check if this dose is from a previous schedule configuration
// (scheduled before the medication was last updated)
if (isDoseFromPreviousSchedule(dose.id, medUpdatedAt)) {
return [];
}
return (dose.takenBy || []).length > 0 ? dose.takenBy.map((p: string) => `${dose.id}-${p}`) : [dose.id]; return (dose.takenBy || []).length > 0 ? dose.takenBy.map((p: string) => `${dose.id}-${p}`) : [dose.id];
}); });
}) })
); );
// Also filter out doses that are marked as taken or individually dismissed (legacy) // Also filter out doses that are marked as taken or individually dismissed (legacy)
return totalPastDoses.filter((id) => !doses.takenDoses.has(id) && !doses.dismissedDoses.has(id)); return totalPastDoses.filter((id) => !doses.takenDoses.has(id) && !doses.dismissedDoses.has(id));
}, [pastDays, medications.meds, doses.takenDoses, doses.dismissedDoses, isDoseDismissed]); }, [pastDays, medications.meds, doses.takenDoses, doses.dismissedDoses, isDoseDismissed, isDoseFromPreviousSchedule]);
// Modal helpers with browser history support // Modal helpers with browser history support
const openMedDetail = useCallback( const openMedDetail = useCallback(
+4 -3
View File
@@ -22,7 +22,7 @@ export function useMedications(): UseMedicationsReturn {
const loadMeds = useCallback(() => { const loadMeds = useCallback(() => {
setLoading(true); setLoading(true);
fetch("/api/medications") fetch("/api/medications", { credentials: "include" })
.then((res) => res.json()) .then((res) => res.json())
.then((data) => setMeds(Array.isArray(data) ? data : [])) .then((data) => setMeds(Array.isArray(data) ? data : []))
.catch(() => setMeds([])) .catch(() => setMeds([]))
@@ -31,7 +31,7 @@ export function useMedications(): UseMedicationsReturn {
const deleteMed = useCallback( const deleteMed = useCallback(
async (id: number, editingId: number | null, resetForm: () => void) => { async (id: number, editingId: number | null, resetForm: () => void) => {
await fetch(`/api/medications/${id}`, { method: "DELETE" }).catch(() => null); await fetch(`/api/medications/${id}`, { method: "DELETE", credentials: "include" }).catch(() => null);
if (editingId === id) resetForm(); if (editingId === id) resetForm();
loadMeds(); loadMeds();
}, },
@@ -48,6 +48,7 @@ export function useMedications(): UseMedicationsReturn {
const res = await fetch(`/api/medications/${medId}/image`, { const res = await fetch(`/api/medications/${medId}/image`, {
method: "POST", method: "POST",
body: formData, body: formData,
credentials: "include",
}); });
if (res.ok) { if (res.ok) {
loadMeds(); loadMeds();
@@ -62,7 +63,7 @@ export function useMedications(): UseMedicationsReturn {
const deleteMedImage = useCallback( const deleteMedImage = useCallback(
async (medId: number) => { async (medId: number) => {
await fetch(`/api/medications/${medId}/image`, { method: "DELETE" }).catch(() => null); await fetch(`/api/medications/${medId}/image`, { method: "DELETE", credentials: "include" }).catch(() => null);
loadMeds(); loadMeds();
}, },
[loadMeds] [loadMeds]
+3
View File
@@ -210,6 +210,7 @@ export function useSettings(): UseSettingsReturn {
await fetch("/api/settings", { await fetch("/api/settings", {
method: "PUT", method: "PUT",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify(payload), body: JSON.stringify(payload),
}).catch(() => null); }).catch(() => null);
@@ -233,6 +234,7 @@ export function useSettings(): UseSettingsReturn {
const res = await fetch("/api/settings/test-email", { const res = await fetch("/api/settings/test-email", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ email: settings.notificationEmail }), body: JSON.stringify({ email: settings.notificationEmail }),
}); });
const data = await res.json(); const data = await res.json();
@@ -254,6 +256,7 @@ export function useSettings(): UseSettingsReturn {
const res = await fetch("/api/settings/test-shoutrrr", { const res = await fetch("/api/settings/test-shoutrrr", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ url: settings.shoutrrrUrl }), body: JSON.stringify({ url: settings.shoutrrrUrl }),
}); });
const data = await res.json(); const data = await res.json();
+1
View File
@@ -59,6 +59,7 @@ export function useShare(): UseShareReturn {
const res = await fetch("/api/share", { const res = await fetch("/api/share", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ body: JSON.stringify({
takenBy: shareSelectedPerson, takenBy: shareSelectedPerson,
scheduleDays: shareSelectedDays, scheduleDays: shareSelectedDays,
+19 -8
View File
@@ -17,11 +17,16 @@
}, },
"dashboard": { "dashboard": {
"reorder": { "reorder": {
"title": "Nachbestell-Erinnerung", "title": "Nachll-Erinnerung",
"badge": "Bestandsüberwachung", "badge": "Bestandsüberwachung",
"noMeds": "Noch keine Medikamente konfiguriert.", "noMeds": "Noch keine Medikamente konfiguriert.",
"allGood": "Alles in Ordnung, genug Vorrat.", "lowWarning": "Genug Vorrat, aber {{count}} Medikament wird knapp.", "allGood": "Alles in Ordnung, genug Vorrat.",
"lowWarning_other": "Genug Vorrat, aber {{count}} Medikamente werden knapp.", "sendReminder": "🔔 Erinnerung jetzt senden" "lowWarning": "Genug Vorrat, aber {{meds}} wird knapp.",
"lowWarning_other": "Genug Vorrat, aber {{meds}} werden knapp.",
"lowWarningPrefix": "Genug Vorrat, aber",
"lowWarningSuffix": "wird knapp.",
"lowWarningSuffix_other": "werden knapp.",
"sendReminder": "🔔 Erinnerung jetzt senden"
}, },
"overview": { "overview": {
"title": "Medikamentenübersicht", "title": "Medikamentenübersicht",
@@ -64,8 +69,8 @@
"inDays_one": "in {{days}} Tag", "inDays_one": "in {{days}} Tag",
"inDays_other": "in {{days}} Tagen", "inDays_other": "in {{days}} Tagen",
"noRemindersNeeded": "Keine Erinnerungen nötig", "noRemindersNeeded": "Keine Erinnerungen nötig",
"needReorder": "{{count}} Medikament nachbestellen", "needRefill": "{{count}} Medikament nachllen",
"needReorder_other": "{{count}} Medikamente nachbestellen", "needRefill_other": "{{count}} Medikamente nachllen",
"emptyStock": "{{count}} Medikament leer", "emptyStock": "{{count}} Medikament leer",
"emptyStock_other": "{{count}} Medikamente leer", "emptyStock_other": "{{count}} Medikamente leer",
"lowWarning": "{{count}} Medikament wird knapp", "lowWarning": "{{count}} Medikament wird knapp",
@@ -81,7 +86,9 @@
"criticalMeds": "{{count}} Medikament kritisch", "criticalMeds": "{{count}} Medikament kritisch",
"criticalMeds_other": "{{count}} Medikamente kritisch", "criticalMeds_other": "{{count}} Medikamente kritisch",
"lowMeds": "{{count}} Medikament knapp", "lowMeds": "{{count}} Medikament knapp",
"lowMeds_other": "{{count}} Medikamente knapp" "lowMeds_other": "{{count}} Medikamente knapp",
"daysLeft": "{{days}} Tag übrig",
"daysLeft_other": "{{days}} Tage übrig"
} }
}, },
"table": { "table": {
@@ -309,7 +316,11 @@
"avatarUpdated": "Avatar aktualisiert", "avatarUpdated": "Avatar aktualisiert",
"avatarRemoved": "Avatar entfernt", "avatarRemoved": "Avatar entfernt",
"loginWithSSO": "Mit {{provider}} anmelden", "loginWithSSO": "Mit {{provider}} anmelden",
"or": "oder" "or": "oder",
"deleteAccount": "Konto löschen",
"deleteAccountConfirmTitle": "Konto löschen?",
"deleteAccountConfirmText": "Dadurch werden dein Konto und alle deine Daten (Medikamente, Einstellungen, Verlauf) dauerhaft gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.",
"deleteAccountButton": "Ja, mein Konto löschen"
}, },
"common": { "common": {
"loading": "Wird geladen...", "loading": "Wird geladen...",
@@ -410,7 +421,7 @@
"importSuccess": "Daten erfolgreich importiert", "importSuccess": "Daten erfolgreich importiert",
"importSuccessDetails": "Importiert: {{medications}} Medikamente, {{doses}} Dosen, {{shares}} Teilen-Links", "importSuccessDetails": "Importiert: {{medications}} Medikamente, {{doses}} Dosen, {{shares}} Teilen-Links",
"importError": "Daten konnten nicht importiert werden", "importError": "Daten konnten nicht importiert werden",
"invalidFile": "Ungültiges Dateiformat. Bitte wähle eine gültige MedAssist-Exportdatei.", "invalidFile": "Ungültiges Dateiformat. Bitte wähle eine gültige MedAssist-ng-Exportdatei.",
"downloadFilename": "medassist-export" "downloadFilename": "medassist-export"
}, },
"refill": { "refill": {
+17 -8
View File
@@ -17,12 +17,15 @@
}, },
"dashboard": { "dashboard": {
"reorder": { "reorder": {
"title": "Reorder Reminder", "title": "Refill Reminder",
"badge": "Stock watch", "badge": "Stock watch",
"noMeds": "No medications configured yet.", "noMeds": "No medications configured yet.",
"allGood": "All good, enough stock.", "allGood": "All good, enough stock.",
"lowWarning": "Enough stock for now, but {{count}} medication is running low.", "lowWarning": "Enough stock for now, but {{meds}} is running low.",
"lowWarning_other": "Enough stock for now, but {{count}} medications are running low.", "lowWarning_other": "Enough stock for now, but {{meds}} are running low.",
"lowWarningPrefix": "Enough stock for now, but",
"lowWarningSuffix": "is running low.",
"lowWarningSuffix_other": "are running low.",
"sendReminder": "🔔 Send Reminder Now" "sendReminder": "🔔 Send Reminder Now"
}, },
"overview": { "overview": {
@@ -66,8 +69,8 @@
"inDays_one": "in {{days}} day", "inDays_one": "in {{days}} day",
"inDays_other": "in {{days}} days", "inDays_other": "in {{days}} days",
"noRemindersNeeded": "No reminders needed", "noRemindersNeeded": "No reminders needed",
"needReorder": "{{count}} med needs reorder", "needRefill": "{{count}} med needs refill",
"needReorder_other": "{{count}} meds need reorder", "needRefill_other": "{{count}} meds need refill",
"emptyStock": "{{count}} med is empty", "emptyStock": "{{count}} med is empty",
"emptyStock_other": "{{count}} meds are empty", "emptyStock_other": "{{count}} meds are empty",
"lowWarning": "{{count}} medication running low", "lowWarning": "{{count}} medication running low",
@@ -83,7 +86,9 @@
"criticalMeds": "{{count}} medication critical", "criticalMeds": "{{count}} medication critical",
"criticalMeds_other": "{{count}} medications critical", "criticalMeds_other": "{{count}} medications critical",
"lowMeds": "{{count}} medication low", "lowMeds": "{{count}} medication low",
"lowMeds_other": "{{count}} medications low" "lowMeds_other": "{{count}} medications low",
"daysLeft": "{{days}} day left",
"daysLeft_other": "{{days}} days left"
} }
}, },
"table": { "table": {
@@ -311,7 +316,11 @@
"avatarUpdated": "Avatar updated", "avatarUpdated": "Avatar updated",
"avatarRemoved": "Avatar removed", "avatarRemoved": "Avatar removed",
"loginWithSSO": "Login with {{provider}}", "loginWithSSO": "Login with {{provider}}",
"or": "or" "or": "or",
"deleteAccount": "Delete Account",
"deleteAccountConfirmTitle": "Delete Account?",
"deleteAccountConfirmText": "This will permanently delete your account and all your data (medications, settings, history). This action cannot be undone.",
"deleteAccountButton": "Yes, delete my account"
}, },
"common": { "common": {
"loading": "Loading...", "loading": "Loading...",
@@ -412,7 +421,7 @@
"importSuccess": "Data imported successfully", "importSuccess": "Data imported successfully",
"importSuccessDetails": "Imported: {{medications}} medications, {{doses}} doses, {{shares}} share links", "importSuccessDetails": "Imported: {{medications}} medications, {{doses}} doses, {{shares}} share links",
"importError": "Failed to import data", "importError": "Failed to import data",
"invalidFile": "Invalid file format. Please select a valid MedAssist export file.", "invalidFile": "Invalid file format. Please select a valid MedAssist-ng export file.",
"downloadFilename": "medassist-export" "downloadFilename": "medassist-export"
}, },
"refill": { "refill": {
+208 -165
View File
@@ -105,7 +105,7 @@ function getReminderStatusData(
locale: string locale: string
): { ): {
status: { text: string; className: string }; status: { text: string; className: string };
next: { name: string; days: number } | null; lowStockMeds: { name: string; daysLeft: number; isCritical: boolean }[];
lastSent: { date: string; medName: string | null; takenBy: string | null } | null; lastSent: { date: string; medName: string | null; takenBy: string | null } | null;
} { } {
const criticalCount = lowCoverage.length; const criticalCount = lowCoverage.length;
@@ -134,17 +134,28 @@ function getReminderStatusData(
}; };
} }
// Find next medication to hit reminder threshold // Collect all low stock medications (critical + low)
const nextToRunOut = allCoverage const lowStockMeds: { name: string; daysLeft: number; isCritical: boolean }[] = [];
.filter((c) => c.daysLeft !== null && c.daysLeft > reminderDaysBefore)
.sort((a, b) => (a.daysLeft ?? Infinity) - (b.daysLeft ?? Infinity))[0];
let next: { name: string; days: number } | null = null; // Add critical meds (from lowCoverage - these are ≤3 days)
if (nextToRunOut && nextToRunOut.daysLeft !== null) { for (const c of lowCoverage) {
const daysUntilReminder = Math.round(nextToRunOut.daysLeft - reminderDaysBefore); if (c.daysLeft !== null) {
next = { name: nextToRunOut.name, days: daysUntilReminder }; lowStockMeds.push({ name: c.name, daysLeft: Math.round(c.daysLeft), isCritical: true });
}
} }
// Add low but not critical meds
for (const c of allCoverage) {
if (c.medsLeft <= 0) continue;
if (c.daysLeft === null) continue;
if (c.daysLeft < lowStockDays && c.daysLeft > 3) {
lowStockMeds.push({ name: c.name, daysLeft: Math.round(c.daysLeft), isCritical: false });
}
}
// Sort by days left (most urgent first)
lowStockMeds.sort((a, b) => a.daysLeft - b.daysLeft);
// Parse last sent info // Parse last sent info
let lastSent: { date: string; medName: string | null; takenBy: string | null } | null = null; let lastSent: { date: string; medName: string | null; takenBy: string | null } | null = null;
if (lastAutoEmailSent) { if (lastAutoEmailSent) {
@@ -163,7 +174,7 @@ function getReminderStatusData(
}; };
} }
return { status, next, lastSent }; return { status, lowStockMeds, lastSent };
} }
export function DashboardPage() { export function DashboardPage() {
@@ -217,6 +228,7 @@ export function DashboardPage() {
const res = await fetch("/api/reminder/send-email", { const res = await fetch("/api/reminder/send-email", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ body: JSON.stringify({
email: settings.notificationEmail, email: settings.notificationEmail,
lowStock: coverage.low, lowStock: coverage.low,
@@ -272,167 +284,198 @@ export function DashboardPage() {
{reminderData.status.text} {reminderData.status.text}
</span> </span>
</div> </div>
<div className="reminder-status-details"> {(reminderData.lowStockMeds.length > 0 || (intakeRemindersEnabled && reminderData.lastSent)) && (
{stockRemindersEnabled && reminderData.next && ( <div className="reminder-status-details">
<div className="reminder-status-row"> {stockRemindersEnabled && reminderData.lowStockMeds.length > 0 && (
<span className="reminder-status-label">{t("dashboard.reminders.next")}:</span> <div className="reminder-low-stock-list">
<span className="reminder-status-value"> {reminderData.lowStockMeds.map((med) => (
{reminderData.next.name}{" "} <div key={med.name} className={`reminder-low-stock-item ${med.isCritical ? "critical" : ""}`}>
{t("dashboard.reminders.inDays", { count: reminderData.next.days, days: reminderData.next.days })} <span className="reminder-med-name">{med.name}</span>
</span> <span className="reminder-days-left">
</div> {t("dashboard.reminders.daysLeft", { count: med.daysLeft, days: med.daysLeft })}
)} </span>
{intakeRemindersEnabled && reminderData.lastSent && ( </div>
<div className="reminder-status-row"> ))}
<span className="reminder-status-label">{t("dashboard.reminders.lastSent")}:</span> </div>
<span className="reminder-status-value"> )}
{reminderData.lastSent.medName && ( {intakeRemindersEnabled && reminderData.lastSent && (
<span className="reminder-med-name">{reminderData.lastSent.medName}</span> <div className="reminder-status-row">
)} <span className="reminder-status-label">{t("dashboard.reminders.lastSent")}:</span>
{reminderData.lastSent.takenBy && ( <span className="reminder-status-value">
<span className="reminder-taken-by">({reminderData.lastSent.takenBy})</span> {reminderData.lastSent.medName && (
)} <span className="reminder-med-name">{reminderData.lastSent.medName}</span>
<span className="reminder-date">{reminderData.lastSent.date}</span> )}
</span> {reminderData.lastSent.takenBy && (
</div> <span className="reminder-taken-by">({reminderData.lastSent.takenBy})</span>
)} )}
</div> <span className="reminder-date">{reminderData.lastSent.date}</span>
</span>
</div>
)}
</div>
)}
</section> </section>
)} )}
<section className="grid"> {/* Reorder Reminder card: Only show when reminders are NOT enabled (otherwise Reminder Bar shows the same info) */}
<article className="card"> {!anyRemindersEnabled && (
<div className="card-head"> <section className="grid">
<h2>{t("dashboard.reorder.title")}</h2> <article className="card">
</div> <div className="card-head">
{(() => { <h2>{t("dashboard.reorder.title")}</h2>
if (meds.length === 0) { </div>
return <p className="muted">{t("dashboard.reorder.noMeds")}</p>; {(() => {
} 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 ( // Count medications with "Low" stock status (based on lowStockDays setting)
<> const lowStockMeds = coverage.all.filter((c) => {
<div className="table table-7"> if (c.medsLeft <= 0) return true; // out of stock
<div className="table-head"> if (c.daysLeft === null) return false; // no schedule
<span>{t("table.name")}</span> return c.daysLeft < settings.lowStockDays;
<span>{t("table.fullBlisters")}</span> });
<span>{t("table.openBlister")}</span> const lowStockCount = lowStockMeds.length;
<span>{t("table.daysLeft")}</span> const lowStockNames = lowStockMeds.map((c) => c.name).join(", ");
<span>{t("table.status")}</span>
<span>{t("table.runsOut")}</span> if (coverage.low.length === 0) {
<span>{t("table.autoRemind")}</span> // No critical meds (≤3 days)
</div> if (lowStockCount === 0) {
{coverage.low.map((row) => { // All good - everything is Normal or High
const status = getStockStatus(row.daysLeft, row.medsLeft, settings); return <p className="success-text">{t("dashboard.reorder.allGood")}</p>;
const med = meds.find((m) => m.name === row.name); } else {
const textClass = // Some meds are Low but not critical - render with clickable med names
status.className === "danger" return (
? "danger-text" <p className="warning-text">
: status.className === "warning" {t("dashboard.reorder.lowWarningPrefix")}{" "}
? "warning-text" {lowStockMeds.map((c, idx) => {
: "success-text"; const med = meds.find((m) => m.name === c.name);
const stock = getBlisterStock( return (
Math.round(row.medsLeft), <span key={c.name}>
med?.pillsPerBlister ?? 1, {idx > 0 && ", "}
med?.looseTablets ?? 0, <span className="med-link clickable" onClick={() => med && openMedDetail(med)}>
med ? getMedTotal(med) : Math.round(row.medsLeft) {c.name}
);
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>
</span> );
<span data-label={t("table.fullBlisters")} className={textClass}> })}{" "}
{formatFullBlisters(stock.fullBlisters, t)} {t("dashboard.reorder.lowWarningSuffix", { count: lowStockCount })}
</span> </p>
<span data-label={t("table.openBlister")} className={textClass}> );
{formatOpenBlisterAndLoose( }
stock.openBlisterPills, }
stock.loosePills,
med?.pillsPerBlister ?? 1, return (
t <>
)} <div className="table table-7">
</span> <div className="table-head">
<span data-label={t("table.days")} className={textClass}> <span>{t("table.name")}</span>
{formatNumber(row.daysLeft)} <span>{t("table.fullBlisters")}</span>
</span> <span>{t("table.openBlister")}</span>
<span data-label={t("table.status")} className={`status-chip ${status.className}`}> <span>{t("table.daysLeft")}</span>
{t(status.label)} <span>{t("table.status")}</span>
</span> <span>{t("table.runsOut")}</span>
<span data-label={t("table.runsOut")}>{row.depletionDate ?? "-"}</span> <span>{t("table.autoRemind")}</span>
<span data-label={t("table.autoRemind")} className="next-reminder-date"> </div>
{getNextReminderForMed(row, settings.reminderDaysBefore, getSystemLocale(i18n.language))} {coverage.low.map((row) => {
</span> const status = getStockStatus(row.daysLeft, row.medsLeft, settings);
</div> const med = meds.find((m) => m.name === row.name);
); const textClass =
})} status.className === "danger"
</div> ? "danger-text"
{(settings.emailEnabled || settings.shoutrrrEnabled) && ( : status.className === "warning"
<div className="email-send-action"> ? "warning-text"
<button type="button" className="ghost" onClick={sendReminderEmail} disabled={sendingReminderEmail}> : "success-text";
{sendingReminderEmail ? t("common.sending") : t("dashboard.reorder.sendReminder")} const stock = getBlisterStock(
</button> Math.round(row.medsLeft),
{reminderEmailResult && ( med?.pillsPerBlister ?? 1,
<span className={reminderEmailResult.success ? "success-text" : "danger-text"}> med?.looseTablets ?? 0,
{reminderEmailResult.message} med ? getMedTotal(med) : Math.round(row.medsLeft)
</span> );
)} 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> </div>
)} {(settings.emailEnabled || settings.shoutrrrEnabled) && (
</> <div className="email-send-action">
); <button
})()} type="button"
</article> className="ghost"
</section> 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"> <section className="grid">
<article className="card"> <article className="card">
+16 -2
View File
@@ -192,6 +192,7 @@ export function MedicationsPage() {
method, method,
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(body), body: JSON.stringify(body),
credentials: "include",
}); });
if (!res.ok) { if (!res.ok) {
@@ -308,7 +309,7 @@ export function MedicationsPage() {
</div> </div>
<div className="med-list"> <div className="med-list">
{meds.map((med) => ( {meds.map((med) => (
<div key={med.id} className="med-row"> <div key={med.id} className={`med-row${editingId === med.id ? " editing" : ""}`}>
<div className="med-header"> <div className="med-header">
<div className="med-info"> <div className="med-info">
<div className="med-name-row"> <div className="med-name-row">
@@ -358,7 +359,20 @@ export function MedicationsPage() {
<article className="card form desktop-only"> <article className="card form desktop-only">
<div className="card-head"> <div className="card-head">
<h2>{editingId ? t("form.editEntry") : t("form.newEntry")}</h2> {editingId ? (
<div className="edit-header">
<MedicationAvatar
name={meds.find((m) => m.id === editingId)?.name || ""}
imageUrl={meds.find((m) => m.id === editingId)?.imageUrl}
size="md"
/>
<h2>
{t("form.editEntry")}: {meds.find((m) => m.id === editingId)?.name}
</h2>
</div>
) : (
<h2>{t("form.newEntry")}</h2>
)}
</div> </div>
<form className="form-grid" onSubmit={saveMedication}> <form className="form-grid" onSubmit={saveMedication}>
<label className={fieldErrors.name ? "has-error" : ""}> <label className={fieldErrors.name ? "has-error" : ""}>
+2
View File
@@ -82,6 +82,7 @@ export function PlannerPage() {
const rows = (await fetch("/api/medications/usage", { const rows = (await fetch("/api/medications/usage", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify(body), body: JSON.stringify(body),
}) })
.then((res) => res.json()) .then((res) => res.json())
@@ -113,6 +114,7 @@ export function PlannerPage() {
const res = await fetch("/api/planner/send-email", { const res = await fetch("/api/planner/send-email", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ body: JSON.stringify({
email: settings.notificationEmail, email: settings.notificationEmail,
from: range.start, from: range.start,
+7 -12
View File
@@ -63,6 +63,7 @@ export function SchedulePage() {
manuallyExpandedDays, manuallyExpandedDays,
toggleDayCollapse, toggleDayCollapse,
openUserFilter, openUserFilter,
missedPastDoseIds,
} = useAppContext(); } = useAppContext();
return ( return (
@@ -88,17 +89,11 @@ export function SchedulePage() {
{/* Past days toggle */} {/* Past days toggle */}
{pastDays.length > 0 && {pastDays.length > 0 &&
(() => { (() => {
const totalPastDoses = pastDays.flatMap((d) => // Use context's missedPastDoseIds which handles dismissed doses and previous schedule detection
d.meds.flatMap((m) => const missedCount = missedPastDoseIds.length;
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 ( return (
<div <div
className={`past-days-toggle ${showPastDays ? "expanded" : ""} ${missedPastDoses > 0 ? "has-missed" : ""}`} className={`past-days-toggle ${showPastDays ? "expanded" : ""} ${missedCount > 0 ? "has-missed" : ""}`}
onClick={() => setShowPastDays(!showPastDays)} onClick={() => setShowPastDays(!showPastDays)}
> >
<span className="past-days-icon">{showPastDays ? "▼" : "▶"}</span> <span className="past-days-icon">{showPastDays ? "▼" : "▶"}</span>
@@ -108,12 +103,12 @@ export function SchedulePage() {
<span className="past-days-count"> <span className="past-days-count">
({t("dashboard.schedules.pastDaysCount", { count: pastDays.length })}) ({t("dashboard.schedules.pastDaysCount", { count: pastDays.length })})
</span> </span>
{missedPastDoses > 0 && ( {missedCount > 0 && (
<span <span
className="past-days-warning" className="past-days-warning"
title={t("dashboard.schedules.missedDoses", { count: missedPastDoses })} title={t("dashboard.schedules.missedDoses", { count: missedCount })}
> >
{missedPastDoses} {missedCount}
</span> </span>
)} )}
</div> </div>
+105 -5
View File
@@ -195,7 +195,7 @@ body.modal-open {
.reminder-status-header { .reminder-status-header {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.75rem;
flex-wrap: wrap; flex-wrap: wrap;
} }
@@ -212,11 +212,10 @@ body.modal-open {
} }
.reminder-status-badge { .reminder-status-badge {
padding: 0.2rem 0.5rem; padding: 0.25rem 0.6rem;
border-radius: 4px; border-radius: 6px;
font-size: 0.75rem; font-size: 0.75rem;
font-weight: 500; font-weight: 500;
margin-left: auto;
} }
.reminder-status-badge.success { .reminder-status-badge.success {
@@ -282,6 +281,52 @@ body.modal-open {
color: var(--text-secondary); color: var(--text-secondary);
} }
.reminder-low-stock-list {
display: flex;
flex-direction: column;
gap: 0.375rem;
padding-left: 1.75rem;
}
.reminder-low-stock-item {
display: flex;
align-items: center;
gap: 0.75rem;
font-size: 0.8rem;
color: var(--text-secondary);
}
.reminder-low-stock-item .reminder-med-name {
font-weight: 500;
color: var(--text-primary);
}
.reminder-low-stock-item .reminder-days-left {
color: var(--warning);
font-size: 0.75rem;
}
.reminder-low-stock-item.critical .reminder-days-left {
color: var(--danger);
font-weight: 500;
}
.med-link {
font-weight: 600;
text-decoration: underline;
text-decoration-style: dotted;
text-underline-offset: 2px;
}
.med-link.clickable {
cursor: pointer;
}
.med-link.clickable:hover {
color: var(--accent);
text-decoration-style: solid;
}
@media (max-width: 600px) { @media (max-width: 600px) {
.reminder-status-bar { .reminder-status-bar {
padding: 0.75rem; padding: 0.75rem;
@@ -300,6 +345,9 @@ body.modal-open {
flex-direction: column; flex-direction: column;
gap: 0.15rem; gap: 0.15rem;
} }
.reminder-low-stock-list {
padding-left: 0;
}
} }
.tabs { .tabs {
@@ -387,6 +435,19 @@ body.modal-open {
color: var(--accent-light); color: var(--accent-light);
} }
.edit-header {
display: flex;
align-items: center;
gap: 0.75rem;
}
.edit-header h2 {
margin: 0;
font-size: 1.1rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.schedule-full { .schedule-full {
max-width: 100%; max-width: 100%;
} }
@@ -468,6 +529,11 @@ body.modal-open {
background 200ms ease, background 200ms ease,
border-color 200ms ease; border-color 200ms ease;
} }
.med-row.editing {
border-color: var(--accent);
background: color-mix(in srgb, var(--accent) 8%, var(--bg-tertiary));
box-shadow: 0 0 0 1px var(--accent);
}
.med-header { .med-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@@ -4164,7 +4230,8 @@ h3 .reminder-icon.info-tooltip {
.profile-modal { .profile-modal {
max-width: 420px; max-width: 420px;
padding: 0; padding: 0;
overflow: hidden; overflow-y: auto;
max-height: 90vh;
} }
.profile-container { .profile-container {
@@ -4348,6 +4415,39 @@ h3 .reminder-icon.info-tooltip {
cursor: not-allowed; cursor: not-allowed;
} }
/* Profile danger zone */
.profile-danger-zone {
margin: 0 1.5rem 1.5rem;
padding-top: 1.5rem;
border-top: 1px solid var(--border-primary);
}
.profile-danger-zone .profile-section-title {
border-bottom: none;
padding-bottom: 0;
margin-bottom: 0.75rem;
}
.btn-danger {
background: #dc2626;
color: white;
border: none;
padding: 0.5rem 1rem;
border-radius: 6px;
font-weight: 500;
cursor: pointer;
transition: background 0.15s ease;
}
.btn-danger:hover:not(:disabled) {
background: #b91c1c;
}
.btn-danger:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* ============================================================================= /* =============================================================================
About Modal About Modal
============================================================================= */ ============================================================================= */
@@ -14,6 +14,15 @@ vi.mock("react-router-dom", async () => {
}; };
}); });
// Mock useUnsavedChanges
vi.mock("../../context", () => ({
useUnsavedChanges: () => ({
setHasUnsavedChanges: vi.fn(),
hasUnsavedChanges: false,
confirmNavigation: vi.fn().mockReturnValue(true),
}),
}));
describe("AppHeader", () => { describe("AppHeader", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
@@ -99,8 +99,7 @@ describe("MobileEditModal", () => {
it("calls onClose when close button clicked", () => { it("calls onClose when close button clicked", () => {
const onClose = vi.fn(); const onClose = vi.fn();
const onResetForm = vi.fn(); render(<MobileEditModal {...defaultProps} onClose={onClose} />);
render(<MobileEditModal {...defaultProps} onClose={onClose} onResetForm={onResetForm} />);
const closeBtn = document.querySelector(".modal-close"); const closeBtn = document.querySelector(".modal-close");
if (closeBtn) { if (closeBtn) {
@@ -108,7 +107,6 @@ describe("MobileEditModal", () => {
} }
expect(onClose).toHaveBeenCalledTimes(1); expect(onClose).toHaveBeenCalledTimes(1);
expect(onResetForm).toHaveBeenCalledTimes(1);
}); });
it("renders form element", () => { it("renders form element", () => {
@@ -310,8 +308,9 @@ describe("MobileEditModal form submission", () => {
it("calls onSaveMedication when form submitted", () => { it("calls onSaveMedication when form submitted", () => {
const onSaveMedication = vi.fn((e: Event) => e.preventDefault()); const onSaveMedication = vi.fn((e: Event) => e.preventDefault());
const validForm = { ...defaultForm, name: "TestMed" };
render(<MobileEditModal {...defaultProps} onSaveMedication={onSaveMedication} />); render(<MobileEditModal {...defaultProps} form={validForm} onSaveMedication={onSaveMedication} />);
const form = document.querySelector("form"); const form = document.querySelector("form");
if (form) { if (form) {
File diff suppressed because it is too large Load Diff
@@ -42,7 +42,7 @@ describe("useMedications", () => {
expect(result.current.meds).toEqual(mockMeds); expect(result.current.meds).toEqual(mockMeds);
}); });
expect(fetch).toHaveBeenCalledWith("/api/medications"); expect(fetch).toHaveBeenCalledWith("/api/medications", { credentials: "include" });
}); });
it("handles API error gracefully", async () => { it("handles API error gracefully", async () => {
@@ -104,7 +104,7 @@ describe("useMedications", () => {
await result.current.deleteMed(1, 1, mockResetForm); await result.current.deleteMed(1, 1, mockResetForm);
}); });
expect(fetch).toHaveBeenCalledWith("/api/medications/1", { method: "DELETE" }); expect(fetch).toHaveBeenCalledWith("/api/medications/1", { method: "DELETE", credentials: "include" });
expect(mockResetForm).toHaveBeenCalled(); expect(mockResetForm).toHaveBeenCalled();
}); });
@@ -168,7 +168,7 @@ describe("useMedications", () => {
await result.current.deleteMedImage(1); await result.current.deleteMedImage(1);
}); });
expect(fetch).toHaveBeenCalledWith("/api/medications/1/image", { method: "DELETE" }); expect(fetch).toHaveBeenCalledWith("/api/medications/1/image", { method: "DELETE", credentials: "include" });
}); });
it("allows setting meds directly", () => { it("allows setting meds directly", () => {
+16 -3
View File
@@ -482,6 +482,17 @@ describe("DashboardPage with medications", () => {
}); });
it("renders schedule timeline with future doses", () => { it("renders schedule timeline with future doses", () => {
// Need showFutureDays: true for day-blocks to render
mockContextValue = createMockAppContext({
meds: mockMeds,
coverage: mockCoverage,
coverageByMed: {
Aspirin: mockCoverage.all[0],
},
futureDays: mockFutureDays,
showFutureDays: true,
});
render( render(
<MemoryRouter> <MemoryRouter>
<DashboardPage /> <DashboardPage />
@@ -548,6 +559,7 @@ describe("DashboardPage with email notifications", () => {
settings: { settings: {
...createMockAppContext().settings, ...createMockAppContext().settings,
emailEnabled: true, emailEnabled: true,
emailStockReminders: true,
notificationEmail: "test@example.com", notificationEmail: "test@example.com",
}, },
}); });
@@ -565,15 +577,15 @@ describe("DashboardPage with email notifications", () => {
expect(statusBar).toBeInTheDocument(); expect(statusBar).toBeInTheDocument();
}); });
it("shows reminder email button when there are low stock meds", () => { it("hides reorder reminder card when reminders are enabled (to avoid redundancy)", () => {
render( render(
<MemoryRouter> <MemoryRouter>
<DashboardPage /> <DashboardPage />
</MemoryRouter> </MemoryRouter>
); );
// Should show send reminder button // Reorder card should NOT be shown when reminders are active (Reminder Bar shows the info instead)
expect(screen.getByText(/dashboard\.reorder\.sendReminder/i)).toBeInTheDocument(); expect(screen.queryByText(/dashboard\.reorder\.sendReminder/i)).not.toBeInTheDocument();
}); });
}); });
@@ -587,6 +599,7 @@ describe("DashboardPage with shoutrrr notifications", () => {
settings: { settings: {
...createMockAppContext().settings, ...createMockAppContext().settings,
shoutrrrEnabled: true, shoutrrrEnabled: true,
shoutrrrStockReminders: true,
}, },
}); });
}); });
@@ -110,11 +110,17 @@ let mockFormHookValue = createMockFormHook();
// Mock the hooks // Mock the hooks
vi.mock("../../hooks", () => ({ vi.mock("../../hooks", () => ({
useMedicationForm: () => mockFormHookValue, useMedicationForm: () => mockFormHookValue,
useUnsavedChangesWarning: () => ({}),
})); }));
// Mock the context // Mock the context
vi.mock("../../context", () => ({ vi.mock("../../context", () => ({
useAppContext: () => mockContextValue, useAppContext: () => mockContextValue,
useUnsavedChanges: () => ({
setHasUnsavedChanges: vi.fn(),
hasUnsavedChanges: false,
confirmNavigation: vi.fn().mockReturnValue(true),
}),
})); }));
describe("MedicationsPage", () => { describe("MedicationsPage", () => {
@@ -101,6 +101,7 @@ const createMockContext = (overrides = {}) => ({
manuallyExpandedDays: new Set(), manuallyExpandedDays: new Set(),
toggleDayCollapse: vi.fn(), toggleDayCollapse: vi.fn(),
openUserFilter: vi.fn(), openUserFilter: vi.fn(),
missedPastDoseIds: [],
...overrides, ...overrides,
}); });
@@ -436,6 +437,7 @@ describe("SchedulePage with past days", () => {
futureDays: mockFutureDays, futureDays: mockFutureDays,
coverageByMed: mockCoverageByMed, coverageByMed: mockCoverageByMed,
showPastDays: false, showPastDays: false,
missedPastDoseIds: [`1-0-${Date.now() - 86400000}-John`], // One missed dose
}); });
}); });
@@ -467,6 +469,7 @@ describe("SchedulePage with past days", () => {
pastDays: mockPastDays, pastDays: mockPastDays,
showPastDays: false, showPastDays: false,
setShowPastDays, setShowPastDays,
missedPastDoseIds: [],
}); });
render( render(
@@ -5,9 +5,7 @@ import {
compareSemver, compareSemver,
deriveTotal, deriveTotal,
formatDateTime, formatDateTime,
formatFullBlisters,
formatNumber, formatNumber,
formatOpenBlisterAndLoose,
getBlisterStock, getBlisterStock,
getExpiryClass, getExpiryClass,
pad2, pad2,
@@ -227,22 +225,6 @@ describe("getBlisterStock", () => {
}); });
}); });
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", () => { describe("compareSemver", () => {
it("returns 0 for equal versions", () => { it("returns 0 for equal versions", () => {
expect(compareSemver("1.2.3", "1.2.3")).toBe(0); expect(compareSemver("1.2.3", "1.2.3")).toBe(0);
+3 -3
View File
@@ -466,7 +466,7 @@ describe("getReminderStatusText", () => {
}; };
const result = getReminderStatusText(7, 30, [], [lowMed], null, null, null, mockT, "en"); 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); expect(result.lines.some((l) => l.text.includes("lowWarning") || l.text.includes("needRefill"))).toBe(true);
}); });
it("handles intake reminder type with push channel", () => { it("handles intake reminder type with push channel", () => {
@@ -497,7 +497,7 @@ describe("getReminderStatusText", () => {
expect(result.lines[0].className).toBe("danger-text"); expect(result.lines[0].className).toBe("danger-text");
}); });
it("shows needReorder when below critical threshold", () => { it("shows needRefill when below critical threshold", () => {
const criticalMed: Coverage = { const criticalMed: Coverage = {
name: "Critical", name: "Critical",
medsLeft: 5, medsLeft: 5,
@@ -508,7 +508,7 @@ describe("getReminderStatusText", () => {
}; };
const result = getReminderStatusText(7, 30, [criticalMed], [criticalMed], null, null, null, mockT, "en"); const result = getReminderStatusText(7, 30, [criticalMed], [criticalMed], null, null, null, mockT, "en");
expect(result.lines.some((l) => l.text.includes("needReorder"))).toBe(true); expect(result.lines.some((l) => l.text.includes("needRefill"))).toBe(true);
}); });
it("shows low warning when below low threshold but above critical", () => { it("shows low warning when below low threshold but above critical", () => {
-18
View File
@@ -232,24 +232,6 @@ export function getBlisterStock(med: Medication): BlisterStock {
return { fullBlisters, openBlisterPills, loosePills: openBlisterPills }; 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 * Compare semantic version strings
* Returns: negative if a < b, positive if a > b, 0 if equal * Returns: negative if a < b, positive if a > b, 0 if equal
+6 -3
View File
@@ -29,8 +29,11 @@ export function buildSchedulePreview(
const isPast = d < todayStart; const isPast = d < todayStart;
if (isPast && !includePast) continue; if (isPast && !includePast) continue;
const whenMs = d.getTime(); const whenMs = d.getTime();
// Use date-only timestamp for stable ID (immune to time changes)
// This ensures changing intake times doesn't invalidate past dose tracking
const dateOnlyMs = new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
events.push({ events.push({
id: `${med.id}-${idx}-${whenMs}`, id: `${med.id}-${idx}-${dateOnlyMs}`,
medName: med.name, medName: med.name,
takenBy: med.takenBy || [], takenBy: med.takenBy || [],
usage: blister.usage, usage: blister.usage,
@@ -234,7 +237,7 @@ export function getReminderStatusText(
}); });
if (medsNeedingReminder.length > 0) { if (medsNeedingReminder.length > 0) {
lines.push({ lines.push({
text: `${t("dashboard.reminders.needReorder", { count: medsNeedingReminder.length })}`, text: `${t("dashboard.reminders.needRefill", { count: medsNeedingReminder.length })}`,
className: "danger-text", className: "danger-text",
}); });
} }
@@ -252,7 +255,7 @@ export function getReminderStatusText(
if (medsNeedingReminder.length > 0) { if (medsNeedingReminder.length > 0) {
lines.push({ lines.push({
text: `${t("dashboard.reminders.needReorder", { count: medsNeedingReminder.length })}`, text: `${t("dashboard.reminders.needRefill", { count: medsNeedingReminder.length })}`,
className: "danger-text", className: "danger-text",
strong: true, strong: true,
}); });
+4
View File
@@ -8,6 +8,10 @@ export default defineConfig({
environment: 'jsdom', environment: 'jsdom',
setupFiles: ['./src/test/setup.ts'], setupFiles: ['./src/test/setup.ts'],
include: ['src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], include: ['src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
// Timeout settings to prevent CI hanging
testTimeout: 10000,
hookTimeout: 10000,
teardownTimeout: 5000,
coverage: { coverage: {
provider: 'v8', provider: 'v8',
reporter: ['text', 'json', 'html'], reporter: ['text', 'json', 'html'],