Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b2026637db | |||
| 99ef5bd622 | |||
| 1dcd333fde | |||
| 9ed039724e | |||
| 156e54f0ea | |||
| 47e8dfe9bc | |||
| aed0b20875 | |||
| fcd1b79c56 | |||
| e725700d10 | |||
| 8685e802cd | |||
| 1793f636bf | |||
| 9cf931f243 | |||
| 85f4d2dd21 | |||
| 01283ebd15 | |||
| 18bcb96869 | |||
| d516bdea7d |
@@ -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!)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -71,4 +71,7 @@ Thumbs.db
|
||||
*.local
|
||||
.cache/
|
||||
.turbo/
|
||||
.roo/
|
||||
.roomodes
|
||||
AGENTS.md
|
||||
docs/TECH_STACK.md
|
||||
@@ -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!"
|
||||
Generated
+2
-2
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "medassist-ng-backend",
|
||||
"version": "1.5.0",
|
||||
"version": "1.6.5",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Generated
+2
-2
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -17,11 +17,16 @@
|
||||
},
|
||||
"dashboard": {
|
||||
"reorder": {
|
||||
"title": "Nachbestell-Erinnerung",
|
||||
"title": "Nachfüll-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 nachfüllen",
|
||||
"needRefill_other": "{{count}} Medikamente nachfüllen",
|
||||
"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,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": {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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" : ""}>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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'],
|
||||
|
||||
Reference in New Issue
Block a user