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