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)
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:**
```bash
# 1. Run release script (creates PR, waits for CI, merges, creates tag)
./scripts/release.sh [patch|minor|major]
# 2. GitHub Actions creates a DRAFT release automatically
# 3. User asks AI to write release notes:
# "Write the release notes for vX.Y.Z"
# 4. AI writes descriptive release notes following the style guide below
# 5. User publishes the draft release with the written notes
```
1. Create a new release on GitHub with tag `vX.Y.Z`
2. **Automatic Version Bump**: A GitHub Action (`version-bump.yml`) automatically updates `package.json` versions to match the release tag
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 release with the written notes
> ⚠️ **MANDATORY for minor and major releases**: The AI assistant MUST write proper descriptive release notes!
> Do NOT just publish the auto-generated commit list. Follow the process above.
@@ -505,6 +500,7 @@ Example: `5-0-1735344000000` = Medication 5, Blister 0, timestamp
- **API responses**: Return objects directly, Fastify serializes to JSON
- **Environment**: Copy `.env.example``.env`, secrets must be 10+ chars
- **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!)
+16
View File
@@ -137,13 +137,28 @@ jobs:
with:
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
if: steps.check_release.outputs.exists == 'false'
id: prev_tag
run: |
PREV_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "")
echo "tag=${PREV_TAG}" >> $GITHUB_OUTPUT
- name: Generate changelog
if: steps.check_release.outputs.exists == 'false'
id: changelog
run: |
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
- name: Create GitHub Release
if: steps.check_release.outputs.exists == 'false'
uses: softprops/action-gh-release@v2
with:
body_path: changelog.md
+7 -1
View File
@@ -41,6 +41,9 @@ jobs:
- name: Run backend tests and capture count
id: backend-tests
working-directory: backend
timeout-minutes: 5
env:
CI: true
run: |
OUTPUT=$(npm run test:run 2>&1) || true
echo "$OUTPUT"
@@ -51,8 +54,11 @@ jobs:
- name: Run frontend tests and capture count
id: frontend-tests
working-directory: frontend
timeout-minutes: 5
env:
CI: true
run: |
OUTPUT=$(npm run test -- --run 2>&1) || true
OUTPUT=$(npm run test:run 2>&1) || true
echo "$OUTPUT"
# Extract "Tests X passed" from output
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
.cache/
.turbo/
.roo/
.roomodes
AGENTS.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",
"version": "1.5.0",
"version": "1.6.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "medassist-ng-backend",
"version": "1.5.0",
"version": "1.6.0",
"dependencies": {
"@fastify/cookie": "^10.0.1",
"@fastify/cors": "^10.0.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "medassist-ng-backend",
"version": "1.5.0",
"version": "1.6.5",
"private": true,
"type": "module",
"scripts": {
+40
View File
@@ -534,4 +534,44 @@ export async function authRoutes(app: FastifyInstance) {
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 { env } from "../plugins/env.js";
import type { AuthUser } from "../types/fastify.js";
import { parseTakenByJson } from "../utils/scheduler-utils.js";
const IMAGES_DIR = resolve(process.cwd(), "data/images");
@@ -125,17 +126,6 @@ async function getUserId(request: any, reply: any): Promise<number> {
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
function parseBlistersForExport(
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 { env } from "../plugins/env.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");
@@ -34,36 +34,6 @@ const medicationSchema = z.object({
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) {
// All medication routes require auth
app.addHook("preHandler", requireAuth);
+26 -3
View File
@@ -482,10 +482,33 @@ export async function sendShoutrrrNotification(
)
.trim();
// Determine notification type based on validation result and URL pattern
const isNtfyUrl = isNtfy || sanitizedUrl.includes("ntfy.sh") || sanitizedUrl.includes("/ntfy/");
// Determine notification type based on URL hostname
// 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;
headers = { Title: cleanTitle, Tags: "pill" };
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 { env } from "../plugins/env.js";
import type { AuthUser } from "../types/fastify.js";
import { parseTakenByJson } from "../utils/scheduler-utils.js";
// Share token validity: 1 year in milliseconds
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;
}
// 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
// =============================================================================
@@ -42,10 +42,6 @@ function saveIntakeReminderState(state: IntakeReminderState): void {
writeFileSync(intakeReminderStateFile, JSON.stringify(state, null, 2));
}
function parseBlistersFromRow(row: { usageJson: string; everyJson: string; startJson: string }): Blister[] {
return parseBlisters(row);
}
async function sendIntakeReminderEmail(
email: string,
intakes: UpcomingIntake[],
@@ -295,7 +291,7 @@ async function checkAndSendIntakeRemindersForUser(
// Find intakes: upcoming ones in reminder window + past ones for repeat reminders
for (const med of medsWithReminders) {
const blisters = parseBlistersFromRow(med);
const blisters = parseBlisters(med);
const takenByArray = parseTakenByJson(med.takenByJson);
logger.info(
+58
View File
@@ -682,4 +682,62 @@ describe("Auth Routes (AUTH_ENABLED=true)", () => {
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",
"version": "1.5.0",
"version": "1.6.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "medassist-ng-frontend",
"version": "1.5.0",
"version": "1.6.0",
"dependencies": {
"i18next": "^24.2.2",
"i18next-browser-languagedetector": "^8.0.4",
+2 -1
View File
@@ -1,7 +1,7 @@
{
"name": "medassist-ng-frontend",
"private": true,
"version": "1.5.0",
"version": "1.6.5",
"type": "module",
"scripts": {
"dev": "vite",
@@ -12,6 +12,7 @@
"format": "npx biome format --write .",
"check": "npx biome check . && tsc --noEmit",
"test": "vitest",
"test:run": "vitest run",
"test:coverage": "vitest run --coverage"
},
"dependencies": {
+3 -3
View File
@@ -44,7 +44,7 @@ function AppRouter() {
return (
<div className="auth-container">
<div className="auth-card" style={{ textAlign: "center" }}>
<h1 className="auth-title">💊 MedAssist</h1>
<h1 className="auth-title">💊 MedAssist-ng</h1>
<p>Loading...</p>
</div>
</div>
@@ -56,7 +56,7 @@ function AppRouter() {
return (
<div className="auth-container">
<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" }}>
<strong>Connection Error</strong>
<br />
@@ -78,7 +78,7 @@ function AppRouter() {
return (
<div className="auth-container">
<div className="auth-card" style={{ textAlign: "center" }}>
<h1 className="auth-title">💊 MedAssist</h1>
<h1 className="auth-title">💊 MedAssist-ng</h1>
<p>Initializing...</p>
</div>
</div>
+1 -1
View File
@@ -80,7 +80,7 @@ export default function AboutModal({ isOpen, onClose }: AboutModalProps) {
<path d="M9 2h6M12 2v2" />
</svg>
</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>
</div>
<div className="about-versions">
+80 -6
View File
@@ -1,5 +1,6 @@
import { createContext, type ReactNode, useCallback, useContext, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { ConfirmModal } from "./ConfirmModal";
// =============================================================================
// Types (no roles - all users are equal)
@@ -32,6 +33,7 @@ interface AuthContextType {
updateProfile: (data: { currentPassword?: string; newPassword?: string }) => Promise<void>;
uploadAvatar: (file: File) => Promise<void>;
deleteAvatar: () => Promise<void>;
deleteAccount: () => Promise<void>;
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
}, [user, authState?.authEnabled]);
async function fetchAuthState() {
async function fetchAuthState(retryCount = 0) {
const maxRetries = 3;
const retryDelay = 1000; // 1 second
try {
setAuthError(null);
const res = await fetch("/api/auth/state");
@@ -101,10 +106,17 @@ export function AuthProvider({ children }: { children: ReactNode }) {
if (state.authEnabled) {
await refreshUser();
}
setLoading(false);
} 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");
} finally {
setLoading(false);
}
}
@@ -244,6 +256,21 @@ export function AuthProvider({ children }: { children: ReactNode }) {
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
const authFetch = useCallback(
async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
@@ -285,6 +312,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
updateProfile,
uploadAvatar,
deleteAvatar,
deleteAccount,
authFetch,
}}
>
@@ -329,7 +357,7 @@ export function LoginForm({
return (
<div className="auth-container">
<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>
{/* SSO Login Button */}
@@ -445,7 +473,7 @@ export function RegisterForm({ onSuccess, onSwitchToLogin }: { onSuccess?: () =>
return (
<div className="auth-container">
<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>
{/* SSO Login Button - also show on registration */}
@@ -541,7 +569,7 @@ export function RegisterForm({ onSuccess, onSwitchToLogin }: { onSuccess?: () =>
// =============================================================================
export function UserProfile({ onClose }: { onClose?: () => void }) {
const { t } = useTranslation();
const { user, updateProfile, uploadAvatar, deleteAvatar } = useAuth();
const { user, updateProfile, uploadAvatar, deleteAvatar, deleteAccount } = useAuth();
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
@@ -549,6 +577,8 @@ export function UserProfile({ onClose }: { onClose?: () => void }) {
const [success, setSuccess] = useState("");
const [loading, setLoading] = useState(false);
const [avatarLoading, setAvatarLoading] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [deleteLoading, setDeleteLoading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
// 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;
const hasChanges = currentPassword || newPassword || confirmPassword;
@@ -725,6 +767,38 @@ export function UserProfile({ onClose }: { onClose?: () => void }) {
</button>
</div>
</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>
);
}
+5 -3
View File
@@ -4,6 +4,7 @@
*/
import { useTranslation } from "react-i18next";
import type { FieldErrors, FormBlister, FormState, Medication } from "../types";
import { deriveTotal } from "../utils";
// Field limits for validation
const FIELD_LIMITS = {
@@ -53,12 +54,13 @@ export interface MobileEditModalProps {
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 blistersPerPack = Number(form.blistersPerPack) || 0;
const pillsPerBlister = Number(form.pillsPerBlister) || 1;
const looseTablets = Number(form.looseTablets) || 0;
return packCount * blistersPerPack * pillsPerBlister + looseTablets;
return deriveTotal(packCount, blistersPerPack, pillsPerBlister, looseTablets);
}
export function MobileEditModal({
@@ -216,7 +218,7 @@ export function MobileEditModal({
</label>
<div className="full">
<p className="sub">
<strong>{t("form.total")}:</strong> {deriveTotal(form)} {t("common.pills")}
<strong>{t("form.total")}:</strong> {deriveTotalFromForm(form)} {t("common.pills")}
</p>
</div>
<label className="full">
+36 -11
View File
@@ -19,6 +19,7 @@ export function SharedSchedule() {
const [error, setError] = useState<string | null>(null);
const [expiredData, setExpiredData] = useState<ExpiredLinkData | null>(null);
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 [showPastDays, setShowPastDays] = useState(false);
const [showFutureDays, setShowFutureDays] = useState(false);
@@ -116,6 +117,7 @@ export function SharedSchedule() {
}, [lightboxImage]);
// Load taken doses from server with polling for real-time sync
// Separates taken and dismissed doses (like main app's useDoses hook)
useEffect(() => {
if (token) {
async function loadTakenDoses() {
@@ -123,12 +125,24 @@ export function SharedSchedule() {
const res = await fetch(`/api/share/${token}/doses`);
if (res.ok) {
const data = await res.json();
setTakenDoses(new Set(data.doses.map((d: { doseId: string }) => d.doseId)));
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 {
setTakenDoses(new Set());
setDismissedDoses(new Set());
}
} catch {
setTakenDoses(new Set());
setDismissedDoses(new Set());
}
}
loadTakenDoses();
@@ -274,8 +288,11 @@ export function SharedSchedule() {
for (let d = new Date(startDate); d <= end; d.setDate(d.getDate() + blister.every)) {
const t = d.getTime();
const isPast = d < todayStart;
// Generate dose ID matching Dashboard format: ${med.id}-${blisterIdx}-${whenMs}
const doseId = `${med.id}-${blisterIdx}-${t}`;
// Use date-only timestamp for stable ID (immune to time changes)
// 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({
id: doseId,
when: t,
@@ -455,7 +472,7 @@ export function SharedSchedule() {
return (
<div className="shared-schedule-page">
<div className="shared-schedule-loading">
<h1>💊 MedAssist</h1>
<h1>💊 MedAssist-ng</h1>
<p>{t("common.loading")}</p>
</div>
</div>
@@ -466,7 +483,7 @@ export function SharedSchedule() {
return (
<div className="shared-schedule-page">
<div className="shared-schedule-error expired">
<h1>💊 MedAssist</h1>
<h1>💊 MedAssist-ng</h1>
<div className="expired-icon"></div>
<h2>{t("share.expired.title")}</h2>
<p className="expired-message">{t("share.expired.message", { takenBy: expiredData.takenBy })}</p>
@@ -485,7 +502,7 @@ export function SharedSchedule() {
return (
<div className="shared-schedule-page">
<div className="shared-schedule-error">
<h1>💊 MedAssist</h1>
<h1>💊 MedAssist-ng</h1>
<p className="error-message">{error || "Unknown error"}</p>
</div>
</div>
@@ -535,9 +552,12 @@ export function SharedSchedule() {
)
);
// 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) => {
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("-");
if (parts.length >= 3) {
const timestamp = parseInt(parts[2], 10);
@@ -580,9 +600,12 @@ export function SharedSchedule() {
{showPastDays &&
pastDays.map((day) => {
// 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) => {
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("-");
if (parts.length >= 3) {
const timestamp = parseInt(parts[2], 10);
@@ -693,10 +716,11 @@ export function SharedSchedule() {
<div className="doses-col">
{item.doses.map((dose) => {
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 doseId = getDoseId(dose.id, person);
return takenDoses.has(doseId) || isDismissed;
return takenDoses.has(doseId) || dismissedDoses.has(doseId) || isMedLevelDismissed;
});
return (
<div key={dose.id} className={`dose-item past ${allDone ? "all-taken" : ""}`}>
@@ -709,7 +733,8 @@ export function SharedSchedule() {
{people.map((person) => {
const doseId = getDoseId(dose.id, person);
const isTaken = takenDoses.has(doseId);
const isDone = isTaken || isDismissed;
const isPerDoseDismissed = dismissedDoses.has(doseId);
const isDone = isTaken || isPerDoseDismissed || isMedLevelDismissed;
return (
<div key={doseId} className={`dose-person ${isDone ? "taken" : ""}`}>
{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;
}, []);
// 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 totalPastDoses = pastDays.flatMap((d) =>
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 dismissedUntilDate = med?.dismissedUntil ?? undefined;
const medUpdatedAt = med?.updatedAt;
return m.doses.flatMap((dose) => {
// 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 [];
}
// 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];
});
})
);
// 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));
}, [pastDays, medications.meds, doses.takenDoses, doses.dismissedDoses, isDoseDismissed]);
}, [pastDays, medications.meds, doses.takenDoses, doses.dismissedDoses, isDoseDismissed, isDoseFromPreviousSchedule]);
// Modal helpers with browser history support
const openMedDetail = useCallback(
+4 -3
View File
@@ -22,7 +22,7 @@ export function useMedications(): UseMedicationsReturn {
const loadMeds = useCallback(() => {
setLoading(true);
fetch("/api/medications")
fetch("/api/medications", { credentials: "include" })
.then((res) => res.json())
.then((data) => setMeds(Array.isArray(data) ? data : []))
.catch(() => setMeds([]))
@@ -31,7 +31,7 @@ export function useMedications(): UseMedicationsReturn {
const deleteMed = useCallback(
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();
loadMeds();
},
@@ -48,6 +48,7 @@ export function useMedications(): UseMedicationsReturn {
const res = await fetch(`/api/medications/${medId}/image`, {
method: "POST",
body: formData,
credentials: "include",
});
if (res.ok) {
loadMeds();
@@ -62,7 +63,7 @@ export function useMedications(): UseMedicationsReturn {
const deleteMedImage = useCallback(
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]
+3
View File
@@ -210,6 +210,7 @@ export function useSettings(): UseSettingsReturn {
await fetch("/api/settings", {
method: "PUT",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify(payload),
}).catch(() => null);
@@ -233,6 +234,7 @@ export function useSettings(): UseSettingsReturn {
const res = await fetch("/api/settings/test-email", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ email: settings.notificationEmail }),
});
const data = await res.json();
@@ -254,6 +256,7 @@ export function useSettings(): UseSettingsReturn {
const res = await fetch("/api/settings/test-shoutrrr", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ url: settings.shoutrrrUrl }),
});
const data = await res.json();
+1
View File
@@ -59,6 +59,7 @@ export function useShare(): UseShareReturn {
const res = await fetch("/api/share", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
takenBy: shareSelectedPerson,
scheduleDays: shareSelectedDays,
+19 -8
View File
@@ -17,11 +17,16 @@
},
"dashboard": {
"reorder": {
"title": "Nachbestell-Erinnerung",
"title": "Nachll-Erinnerung",
"badge": "Bestandsüberwachung",
"noMeds": "Noch keine Medikamente konfiguriert.",
"allGood": "Alles in Ordnung, genug Vorrat.", "lowWarning": "Genug Vorrat, aber {{count}} Medikament wird knapp.",
"lowWarning_other": "Genug Vorrat, aber {{count}} Medikamente werden knapp.", "sendReminder": "🔔 Erinnerung jetzt senden"
"allGood": "Alles in Ordnung, genug Vorrat.",
"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": {
"title": "Medikamentenübersicht",
@@ -64,8 +69,8 @@
"inDays_one": "in {{days}} Tag",
"inDays_other": "in {{days}} Tagen",
"noRemindersNeeded": "Keine Erinnerungen nötig",
"needReorder": "{{count}} Medikament nachbestellen",
"needReorder_other": "{{count}} Medikamente nachbestellen",
"needRefill": "{{count}} Medikament nachllen",
"needRefill_other": "{{count}} Medikamente nachllen",
"emptyStock": "{{count}} Medikament leer",
"emptyStock_other": "{{count}} Medikamente leer",
"lowWarning": "{{count}} Medikament wird knapp",
@@ -81,7 +86,9 @@
"criticalMeds": "{{count}} Medikament kritisch",
"criticalMeds_other": "{{count}} Medikamente kritisch",
"lowMeds": "{{count}} Medikament knapp",
"lowMeds_other": "{{count}} Medikamente knapp"
"lowMeds_other": "{{count}} Medikamente knapp",
"daysLeft": "{{days}} Tag übrig",
"daysLeft_other": "{{days}} Tage übrig"
}
},
"table": {
@@ -309,7 +316,11 @@
"avatarUpdated": "Avatar aktualisiert",
"avatarRemoved": "Avatar entfernt",
"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": {
"loading": "Wird geladen...",
@@ -410,7 +421,7 @@
"importSuccess": "Daten erfolgreich importiert",
"importSuccessDetails": "Importiert: {{medications}} Medikamente, {{doses}} Dosen, {{shares}} Teilen-Links",
"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"
},
"refill": {
+17 -8
View File
@@ -17,12 +17,15 @@
},
"dashboard": {
"reorder": {
"title": "Reorder Reminder",
"title": "Refill Reminder",
"badge": "Stock watch",
"noMeds": "No medications configured yet.",
"allGood": "All good, enough stock.",
"lowWarning": "Enough stock for now, but {{count}} medication is running low.",
"lowWarning_other": "Enough stock for now, but {{count}} medications are running low.",
"lowWarning": "Enough stock for now, but {{meds}} is 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"
},
"overview": {
@@ -66,8 +69,8 @@
"inDays_one": "in {{days}} day",
"inDays_other": "in {{days}} days",
"noRemindersNeeded": "No reminders needed",
"needReorder": "{{count}} med needs reorder",
"needReorder_other": "{{count}} meds need reorder",
"needRefill": "{{count}} med needs refill",
"needRefill_other": "{{count}} meds need refill",
"emptyStock": "{{count}} med is empty",
"emptyStock_other": "{{count}} meds are empty",
"lowWarning": "{{count}} medication running low",
@@ -83,7 +86,9 @@
"criticalMeds": "{{count}} medication critical",
"criticalMeds_other": "{{count}} medications critical",
"lowMeds": "{{count}} medication low",
"lowMeds_other": "{{count}} medications low"
"lowMeds_other": "{{count}} medications low",
"daysLeft": "{{days}} day left",
"daysLeft_other": "{{days}} days left"
}
},
"table": {
@@ -311,7 +316,11 @@
"avatarUpdated": "Avatar updated",
"avatarRemoved": "Avatar removed",
"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": {
"loading": "Loading...",
@@ -412,7 +421,7 @@
"importSuccess": "Data imported successfully",
"importSuccessDetails": "Imported: {{medications}} medications, {{doses}} doses, {{shares}} share links",
"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"
},
"refill": {
+208 -165
View File
@@ -105,7 +105,7 @@ function getReminderStatusData(
locale: 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;
} {
const criticalCount = lowCoverage.length;
@@ -134,17 +134,28 @@ function getReminderStatusData(
};
}
// Find next medication to hit reminder threshold
const nextToRunOut = allCoverage
.filter((c) => c.daysLeft !== null && c.daysLeft > reminderDaysBefore)
.sort((a, b) => (a.daysLeft ?? Infinity) - (b.daysLeft ?? Infinity))[0];
// Collect all low stock medications (critical + low)
const lowStockMeds: { name: string; daysLeft: number; isCritical: boolean }[] = [];
let next: { name: string; days: number } | null = null;
if (nextToRunOut && nextToRunOut.daysLeft !== null) {
const daysUntilReminder = Math.round(nextToRunOut.daysLeft - reminderDaysBefore);
next = { name: nextToRunOut.name, days: daysUntilReminder };
// Add critical meds (from lowCoverage - these are ≤3 days)
for (const c of lowCoverage) {
if (c.daysLeft !== null) {
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
let lastSent: { date: string; medName: string | null; takenBy: string | null } | null = null;
if (lastAutoEmailSent) {
@@ -163,7 +174,7 @@ function getReminderStatusData(
};
}
return { status, next, lastSent };
return { status, lowStockMeds, lastSent };
}
export function DashboardPage() {
@@ -217,6 +228,7 @@ export function DashboardPage() {
const res = await fetch("/api/reminder/send-email", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
email: settings.notificationEmail,
lowStock: coverage.low,
@@ -272,167 +284,198 @@ export function DashboardPage() {
{reminderData.status.text}
</span>
</div>
<div className="reminder-status-details">
{stockRemindersEnabled && reminderData.next && (
<div className="reminder-status-row">
<span className="reminder-status-label">{t("dashboard.reminders.next")}:</span>
<span className="reminder-status-value">
{reminderData.next.name}{" "}
{t("dashboard.reminders.inDays", { count: reminderData.next.days, days: reminderData.next.days })}
</span>
</div>
)}
{intakeRemindersEnabled && reminderData.lastSent && (
<div className="reminder-status-row">
<span className="reminder-status-label">{t("dashboard.reminders.lastSent")}:</span>
<span className="reminder-status-value">
{reminderData.lastSent.medName && (
<span className="reminder-med-name">{reminderData.lastSent.medName}</span>
)}
{reminderData.lastSent.takenBy && (
<span className="reminder-taken-by">({reminderData.lastSent.takenBy})</span>
)}
<span className="reminder-date">{reminderData.lastSent.date}</span>
</span>
</div>
)}
</div>
{(reminderData.lowStockMeds.length > 0 || (intakeRemindersEnabled && reminderData.lastSent)) && (
<div className="reminder-status-details">
{stockRemindersEnabled && reminderData.lowStockMeds.length > 0 && (
<div className="reminder-low-stock-list">
{reminderData.lowStockMeds.map((med) => (
<div key={med.name} className={`reminder-low-stock-item ${med.isCritical ? "critical" : ""}`}>
<span className="reminder-med-name">{med.name}</span>
<span className="reminder-days-left">
{t("dashboard.reminders.daysLeft", { count: med.daysLeft, days: med.daysLeft })}
</span>
</div>
))}
</div>
)}
{intakeRemindersEnabled && reminderData.lastSent && (
<div className="reminder-status-row">
<span className="reminder-status-label">{t("dashboard.reminders.lastSent")}:</span>
<span className="reminder-status-value">
{reminderData.lastSent.medName && (
<span className="reminder-med-name">{reminderData.lastSent.medName}</span>
)}
{reminderData.lastSent.takenBy && (
<span className="reminder-taken-by">({reminderData.lastSent.takenBy})</span>
)}
<span className="reminder-date">{reminderData.lastSent.date}</span>
</span>
</div>
)}
</div>
)}
</section>
)}
<section className="grid">
<article className="card">
<div className="card-head">
<h2>{t("dashboard.reorder.title")}</h2>
</div>
{(() => {
if (meds.length === 0) {
return <p className="muted">{t("dashboard.reorder.noMeds")}</p>;
}
// Count medications with "Low" stock status (based on lowStockDays setting)
const lowStockCount = coverage.all.filter((c) => {
if (c.medsLeft <= 0) return true; // out of stock
if (c.daysLeft === null) return false; // no schedule
return c.daysLeft < settings.lowStockDays;
}).length;
if (coverage.low.length === 0) {
// No critical meds (≤3 days)
if (lowStockCount === 0) {
// All good - everything is Normal or High
return <p className="success-text">{t("dashboard.reorder.allGood")}</p>;
} else {
// Some meds are Low but not critical
return <p className="warning-text">{t("dashboard.reorder.lowWarning", { count: lowStockCount })}</p>;
{/* Reorder Reminder card: Only show when reminders are NOT enabled (otherwise Reminder Bar shows the same info) */}
{!anyRemindersEnabled && (
<section className="grid">
<article className="card">
<div className="card-head">
<h2>{t("dashboard.reorder.title")}</h2>
</div>
{(() => {
if (meds.length === 0) {
return <p className="muted">{t("dashboard.reorder.noMeds")}</p>;
}
}
return (
<>
<div className="table table-7">
<div className="table-head">
<span>{t("table.name")}</span>
<span>{t("table.fullBlisters")}</span>
<span>{t("table.openBlister")}</span>
<span>{t("table.daysLeft")}</span>
<span>{t("table.status")}</span>
<span>{t("table.runsOut")}</span>
<span>{t("table.autoRemind")}</span>
</div>
{coverage.low.map((row) => {
const status = getStockStatus(row.daysLeft, row.medsLeft, settings);
const med = meds.find((m) => m.name === row.name);
const textClass =
status.className === "danger"
? "danger-text"
: status.className === "warning"
? "warning-text"
: "success-text";
const stock = getBlisterStock(
Math.round(row.medsLeft),
med?.pillsPerBlister ?? 1,
med?.looseTablets ?? 0,
med ? getMedTotal(med) : Math.round(row.medsLeft)
);
return (
<div key={row.name} className="table-row clickable" onClick={() => med && openMedDetail(med)}>
<span data-label={t("table.name")} className="cell-with-avatar">
<MedicationAvatar name={row.name} imageUrl={med?.imageUrl} />
<span className="med-name-text">{row.name}</span>
{med?.takenBy &&
med.takenBy.length > 0 &&
med.takenBy.map((person) => (
<span
key={person}
className="taken-by-badge clickable"
onClick={(e) => {
e.stopPropagation();
openUserFilter(person);
}}
>
{person}
</span>
))}
{(med?.intakeRemindersEnabled || med?.notes) && (
<span className="med-icons">
{med?.intakeRemindersEnabled && (
<span
className="reminder-icon info-tooltip"
data-tooltip={t("tooltips.intakeReminders")}
>
🔔
</span>
)}
{med?.notes && (
<span className="notes-icon info-tooltip" data-tooltip={t("tooltips.hasNotes")}>
📝
</span>
)}
// Count medications with "Low" stock status (based on lowStockDays setting)
const lowStockMeds = 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;
});
const lowStockCount = lowStockMeds.length;
const lowStockNames = lowStockMeds.map((c) => c.name).join(", ");
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 - render with clickable med names
return (
<p className="warning-text">
{t("dashboard.reorder.lowWarningPrefix")}{" "}
{lowStockMeds.map((c, idx) => {
const med = meds.find((m) => m.name === c.name);
return (
<span key={c.name}>
{idx > 0 && ", "}
<span className="med-link clickable" onClick={() => med && openMedDetail(med)}>
{c.name}
</span>
)}
</span>
<span data-label={t("table.fullBlisters")} className={textClass}>
{formatFullBlisters(stock.fullBlisters, t)}
</span>
<span data-label={t("table.openBlister")} className={textClass}>
{formatOpenBlisterAndLoose(
stock.openBlisterPills,
stock.loosePills,
med?.pillsPerBlister ?? 1,
t
)}
</span>
<span data-label={t("table.days")} className={textClass}>
{formatNumber(row.daysLeft)}
</span>
<span data-label={t("table.status")} className={`status-chip ${status.className}`}>
{t(status.label)}
</span>
<span data-label={t("table.runsOut")}>{row.depletionDate ?? "-"}</span>
<span data-label={t("table.autoRemind")} className="next-reminder-date">
{getNextReminderForMed(row, settings.reminderDaysBefore, getSystemLocale(i18n.language))}
</span>
</div>
);
})}
</div>
{(settings.emailEnabled || settings.shoutrrrEnabled) && (
<div className="email-send-action">
<button type="button" className="ghost" onClick={sendReminderEmail} disabled={sendingReminderEmail}>
{sendingReminderEmail ? t("common.sending") : t("dashboard.reorder.sendReminder")}
</button>
{reminderEmailResult && (
<span className={reminderEmailResult.success ? "success-text" : "danger-text"}>
{reminderEmailResult.message}
</span>
)}
</span>
);
})}{" "}
{t("dashboard.reorder.lowWarningSuffix", { count: lowStockCount })}
</p>
);
}
}
return (
<>
<div className="table table-7">
<div className="table-head">
<span>{t("table.name")}</span>
<span>{t("table.fullBlisters")}</span>
<span>{t("table.openBlister")}</span>
<span>{t("table.daysLeft")}</span>
<span>{t("table.status")}</span>
<span>{t("table.runsOut")}</span>
<span>{t("table.autoRemind")}</span>
</div>
{coverage.low.map((row) => {
const status = getStockStatus(row.daysLeft, row.medsLeft, settings);
const med = meds.find((m) => m.name === row.name);
const textClass =
status.className === "danger"
? "danger-text"
: status.className === "warning"
? "warning-text"
: "success-text";
const stock = getBlisterStock(
Math.round(row.medsLeft),
med?.pillsPerBlister ?? 1,
med?.looseTablets ?? 0,
med ? getMedTotal(med) : Math.round(row.medsLeft)
);
return (
<div key={row.name} className="table-row clickable" onClick={() => med && openMedDetail(med)}>
<span data-label={t("table.name")} className="cell-with-avatar">
<MedicationAvatar name={row.name} imageUrl={med?.imageUrl} />
<span className="med-name-text">{row.name}</span>
{med?.takenBy &&
med.takenBy.length > 0 &&
med.takenBy.map((person) => (
<span
key={person}
className="taken-by-badge clickable"
onClick={(e) => {
e.stopPropagation();
openUserFilter(person);
}}
>
{person}
</span>
))}
{(med?.intakeRemindersEnabled || med?.notes) && (
<span className="med-icons">
{med?.intakeRemindersEnabled && (
<span
className="reminder-icon info-tooltip"
data-tooltip={t("tooltips.intakeReminders")}
>
🔔
</span>
)}
{med?.notes && (
<span className="notes-icon info-tooltip" data-tooltip={t("tooltips.hasNotes")}>
📝
</span>
)}
</span>
)}
</span>
<span data-label={t("table.fullBlisters")} className={textClass}>
{formatFullBlisters(stock.fullBlisters, t)}
</span>
<span data-label={t("table.openBlister")} className={textClass}>
{formatOpenBlisterAndLoose(
stock.openBlisterPills,
stock.loosePills,
med?.pillsPerBlister ?? 1,
t
)}
</span>
<span data-label={t("table.days")} className={textClass}>
{formatNumber(row.daysLeft)}
</span>
<span data-label={t("table.status")} className={`status-chip ${status.className}`}>
{t(status.label)}
</span>
<span data-label={t("table.runsOut")}>{row.depletionDate ?? "-"}</span>
<span data-label={t("table.autoRemind")} className="next-reminder-date">
{getNextReminderForMed(row, settings.reminderDaysBefore, getSystemLocale(i18n.language))}
</span>
</div>
);
})}
</div>
)}
</>
);
})()}
</article>
</section>
{(settings.emailEnabled || settings.shoutrrrEnabled) && (
<div className="email-send-action">
<button
type="button"
className="ghost"
onClick={sendReminderEmail}
disabled={sendingReminderEmail}
>
{sendingReminderEmail ? t("common.sending") : t("dashboard.reorder.sendReminder")}
</button>
{reminderEmailResult && (
<span className={reminderEmailResult.success ? "success-text" : "danger-text"}>
{reminderEmailResult.message}
</span>
)}
</div>
)}
</>
);
})()}
</article>
</section>
)}
<section className="grid">
<article className="card">
+16 -2
View File
@@ -192,6 +192,7 @@ export function MedicationsPage() {
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
credentials: "include",
});
if (!res.ok) {
@@ -308,7 +309,7 @@ export function MedicationsPage() {
</div>
<div className="med-list">
{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-info">
<div className="med-name-row">
@@ -358,7 +359,20 @@ export function MedicationsPage() {
<article className="card form desktop-only">
<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>
<form className="form-grid" onSubmit={saveMedication}>
<label className={fieldErrors.name ? "has-error" : ""}>
+2
View File
@@ -82,6 +82,7 @@ export function PlannerPage() {
const rows = (await fetch("/api/medications/usage", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify(body),
})
.then((res) => res.json())
@@ -113,6 +114,7 @@ export function PlannerPage() {
const res = await fetch("/api/planner/send-email", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
email: settings.notificationEmail,
from: range.start,
+7 -12
View File
@@ -63,6 +63,7 @@ export function SchedulePage() {
manuallyExpandedDays,
toggleDayCollapse,
openUserFilter,
missedPastDoseIds,
} = useAppContext();
return (
@@ -88,17 +89,11 @@ export function SchedulePage() {
{/* Past days toggle */}
{pastDays.length > 0 &&
(() => {
const totalPastDoses = pastDays.flatMap((d) =>
d.meds.flatMap((m) =>
m.doses.flatMap((dose) =>
(dose.takenBy || []).length > 0 ? dose.takenBy.map((p) => `${dose.id}-${p}`) : [dose.id]
)
)
);
const missedPastDoses = totalPastDoses.filter((id) => !takenDoses.has(id)).length;
// Use context's missedPastDoseIds which handles dismissed doses and previous schedule detection
const missedCount = missedPastDoseIds.length;
return (
<div
className={`past-days-toggle ${showPastDays ? "expanded" : ""} ${missedPastDoses > 0 ? "has-missed" : ""}`}
className={`past-days-toggle ${showPastDays ? "expanded" : ""} ${missedCount > 0 ? "has-missed" : ""}`}
onClick={() => setShowPastDays(!showPastDays)}
>
<span className="past-days-icon">{showPastDays ? "▼" : "▶"}</span>
@@ -108,12 +103,12 @@ export function SchedulePage() {
<span className="past-days-count">
({t("dashboard.schedules.pastDaysCount", { count: pastDays.length })})
</span>
{missedPastDoses > 0 && (
{missedCount > 0 && (
<span
className="past-days-warning"
title={t("dashboard.schedules.missedDoses", { count: missedPastDoses })}
title={t("dashboard.schedules.missedDoses", { count: missedCount })}
>
{missedPastDoses}
{missedCount}
</span>
)}
</div>
+105 -5
View File
@@ -195,7 +195,7 @@ body.modal-open {
.reminder-status-header {
display: flex;
align-items: center;
gap: 0.5rem;
gap: 0.75rem;
flex-wrap: wrap;
}
@@ -212,11 +212,10 @@ body.modal-open {
}
.reminder-status-badge {
padding: 0.2rem 0.5rem;
border-radius: 4px;
padding: 0.25rem 0.6rem;
border-radius: 6px;
font-size: 0.75rem;
font-weight: 500;
margin-left: auto;
}
.reminder-status-badge.success {
@@ -282,6 +281,52 @@ body.modal-open {
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) {
.reminder-status-bar {
padding: 0.75rem;
@@ -300,6 +345,9 @@ body.modal-open {
flex-direction: column;
gap: 0.15rem;
}
.reminder-low-stock-list {
padding-left: 0;
}
}
.tabs {
@@ -387,6 +435,19 @@ body.modal-open {
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 {
max-width: 100%;
}
@@ -468,6 +529,11 @@ body.modal-open {
background 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 {
display: flex;
justify-content: space-between;
@@ -4164,7 +4230,8 @@ h3 .reminder-icon.info-tooltip {
.profile-modal {
max-width: 420px;
padding: 0;
overflow: hidden;
overflow-y: auto;
max-height: 90vh;
}
.profile-container {
@@ -4348,6 +4415,39 @@ h3 .reminder-icon.info-tooltip {
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
============================================================================= */
@@ -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", () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -99,8 +99,7 @@ describe("MobileEditModal", () => {
it("calls onClose when close button clicked", () => {
const onClose = vi.fn();
const onResetForm = vi.fn();
render(<MobileEditModal {...defaultProps} onClose={onClose} onResetForm={onResetForm} />);
render(<MobileEditModal {...defaultProps} onClose={onClose} />);
const closeBtn = document.querySelector(".modal-close");
if (closeBtn) {
@@ -108,7 +107,6 @@ describe("MobileEditModal", () => {
}
expect(onClose).toHaveBeenCalledTimes(1);
expect(onResetForm).toHaveBeenCalledTimes(1);
});
it("renders form element", () => {
@@ -310,8 +308,9 @@ describe("MobileEditModal form submission", () => {
it("calls onSaveMedication when form submitted", () => {
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");
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(fetch).toHaveBeenCalledWith("/api/medications");
expect(fetch).toHaveBeenCalledWith("/api/medications", { credentials: "include" });
});
it("handles API error gracefully", async () => {
@@ -104,7 +104,7 @@ describe("useMedications", () => {
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();
});
@@ -168,7 +168,7 @@ describe("useMedications", () => {
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", () => {
+16 -3
View File
@@ -482,6 +482,17 @@ describe("DashboardPage with medications", () => {
});
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(
<MemoryRouter>
<DashboardPage />
@@ -548,6 +559,7 @@ describe("DashboardPage with email notifications", () => {
settings: {
...createMockAppContext().settings,
emailEnabled: true,
emailStockReminders: true,
notificationEmail: "test@example.com",
},
});
@@ -565,15 +577,15 @@ describe("DashboardPage with email notifications", () => {
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(
<MemoryRouter>
<DashboardPage />
</MemoryRouter>
);
// Should show send reminder button
expect(screen.getByText(/dashboard\.reorder\.sendReminder/i)).toBeInTheDocument();
// Reorder card should NOT be shown when reminders are active (Reminder Bar shows the info instead)
expect(screen.queryByText(/dashboard\.reorder\.sendReminder/i)).not.toBeInTheDocument();
});
});
@@ -587,6 +599,7 @@ describe("DashboardPage with shoutrrr notifications", () => {
settings: {
...createMockAppContext().settings,
shoutrrrEnabled: true,
shoutrrrStockReminders: true,
},
});
});
@@ -110,11 +110,17 @@ let mockFormHookValue = createMockFormHook();
// Mock the hooks
vi.mock("../../hooks", () => ({
useMedicationForm: () => mockFormHookValue,
useUnsavedChangesWarning: () => ({}),
}));
// Mock the context
vi.mock("../../context", () => ({
useAppContext: () => mockContextValue,
useUnsavedChanges: () => ({
setHasUnsavedChanges: vi.fn(),
hasUnsavedChanges: false,
confirmNavigation: vi.fn().mockReturnValue(true),
}),
}));
describe("MedicationsPage", () => {
@@ -101,6 +101,7 @@ const createMockContext = (overrides = {}) => ({
manuallyExpandedDays: new Set(),
toggleDayCollapse: vi.fn(),
openUserFilter: vi.fn(),
missedPastDoseIds: [],
...overrides,
});
@@ -436,6 +437,7 @@ describe("SchedulePage with past days", () => {
futureDays: mockFutureDays,
coverageByMed: mockCoverageByMed,
showPastDays: false,
missedPastDoseIds: [`1-0-${Date.now() - 86400000}-John`], // One missed dose
});
});
@@ -467,6 +469,7 @@ describe("SchedulePage with past days", () => {
pastDays: mockPastDays,
showPastDays: false,
setShowPastDays,
missedPastDoseIds: [],
});
render(
@@ -5,9 +5,7 @@ import {
compareSemver,
deriveTotal,
formatDateTime,
formatFullBlisters,
formatNumber,
formatOpenBlisterAndLoose,
getBlisterStock,
getExpiryClass,
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", () => {
it("returns 0 for equal versions", () => {
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");
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", () => {
@@ -497,7 +497,7 @@ describe("getReminderStatusText", () => {
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 = {
name: "Critical",
medsLeft: 5,
@@ -508,7 +508,7 @@ describe("getReminderStatusText", () => {
};
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", () => {
-18
View File
@@ -232,24 +232,6 @@ export function getBlisterStock(med: Medication): BlisterStock {
return { fullBlisters, openBlisterPills, loosePills: openBlisterPills };
}
/**
* Format full blisters count with optional pills per blister
*/
export function formatFullBlisters(stock: BlisterStock, pillsPerBlister?: number): string {
const count = stock.fullBlisters;
if (pillsPerBlister !== undefined) {
return `${count} (${count * pillsPerBlister})`;
}
return String(count);
}
/**
* Format open blister and loose pills
*/
export function formatOpenBlisterAndLoose(stock: BlisterStock): string {
return String(stock.openBlisterPills);
}
/**
* Compare semantic version strings
* Returns: negative if a < b, positive if a > b, 0 if equal
+6 -3
View File
@@ -29,8 +29,11 @@ export function buildSchedulePreview(
const isPast = d < todayStart;
if (isPast && !includePast) continue;
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({
id: `${med.id}-${idx}-${whenMs}`,
id: `${med.id}-${idx}-${dateOnlyMs}`,
medName: med.name,
takenBy: med.takenBy || [],
usage: blister.usage,
@@ -234,7 +237,7 @@ export function getReminderStatusText(
});
if (medsNeedingReminder.length > 0) {
lines.push({
text: `${t("dashboard.reminders.needReorder", { count: medsNeedingReminder.length })}`,
text: `${t("dashboard.reminders.needRefill", { count: medsNeedingReminder.length })}`,
className: "danger-text",
});
}
@@ -252,7 +255,7 @@ export function getReminderStatusText(
if (medsNeedingReminder.length > 0) {
lines.push({
text: `${t("dashboard.reminders.needReorder", { count: medsNeedingReminder.length })}`,
text: `${t("dashboard.reminders.needRefill", { count: medsNeedingReminder.length })}`,
className: "danger-text",
strong: true,
});
+4
View File
@@ -8,6 +8,10 @@ export default defineConfig({
environment: 'jsdom',
setupFiles: ['./src/test/setup.ts'],
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: {
provider: 'v8',
reporter: ['text', 'json', 'html'],