Compare commits

...

15 Commits

Author SHA1 Message Date
Daniel Volz 29f4c4e48d chore: release v1.20.2 (#462) 2026-03-17 05:55:29 +01:00
Daniel Volz 934519767a fix: restore obsolete actions in timeline views 2026-03-16 21:39:22 +01:00
Daniel Volz 9e224c0441 fix: improve shared schedule stock overview display 2026-03-16 21:33:55 +01:00
github-actions[bot] a0b0febe85 chore: update test count badges [skip ci] 2026-03-16 20:33:15 +00:00
Daniel Volz 5138d784cd chore: improve intake reminder observability 2026-03-16 21:28:53 +01:00
Daniel Volz 5b019f942d chore: clean up repo automation governance 2026-03-16 21:23:58 +01:00
Daniel Volz 14e783f111 fix: exclude obsolete medications from share flows 2026-03-16 21:21:41 +01:00
dependabot[bot] fb62227154 build(deps-dev): bump jsdom from 28.1.0 to 29.0.0 in /frontend
Bumps [jsdom](https://github.com/jsdom/jsdom) from 28.1.0 to 29.0.0.
- [Release notes](https://github.com/jsdom/jsdom/releases)
- [Changelog](https://github.com/jsdom/jsdom/blob/v29.0.0/Changelog.md)
- [Commits](https://github.com/jsdom/jsdom/compare/v28.1.0...v29.0.0)

---
updated-dependencies:
- dependency-name: jsdom
  dependency-version: 29.0.0
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Daniel Volz <mail@danielvolz.org>
2026-03-16 08:36:53 +01:00
dependabot[bot] 9b95be851c build(deps): bump dorny/paths-filter from 3 to 4
Bumps [dorny/paths-filter](https://github.com/dorny/paths-filter) from 3 to 4.
- [Release notes](https://github.com/dorny/paths-filter/releases)
- [Changelog](https://github.com/dorny/paths-filter/blob/master/CHANGELOG.md)
- [Commits](https://github.com/dorny/paths-filter/compare/v3...v4)

---
updated-dependencies:
- dependency-name: dorny/paths-filter
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Daniel Volz <mail@danielvolz.org>
2026-03-16 08:32:03 +01:00
Daniel Volz 0f9458b7cb chore: align vite 8 and plugin-react 6 stack
* chore: align vite 8 and plugin-react 6 stack

* fix: remove array index keys from intake rows

* chore: format shared schedule test fix
2026-03-16 08:26:50 +01:00
dependabot[bot] 01b59e66ca build(deps-dev): bump the minor-and-patch group with 2 updates
Squash merge Dependabot root minor-and-patch dependency updates from PR #438 after rebasing onto updated main.
2026-03-16 07:53:18 +01:00
dependabot[bot] 9180783c42 build(deps): bump the minor-and-patch group in /backend with 5 updates
Squash merge Dependabot backend minor-and-patch dependency updates from PR #443.
2026-03-16 07:51:28 +01:00
Daniel Volz cc636eb98b chore: release v1.20.1 (#437) 2026-03-15 20:01:58 +01:00
github-actions[bot] 8c77a87bc5 chore: update test count badges [skip ci] 2026-03-15 18:31:55 +00:00
Daniel Volz 908e4e724f fix: remove dead shareStockStatus gating from shared medication overview (#436)
The shareStockStatus UI toggle was replaced by shareMedicationOverview in
commit e0fb77d, but the backend gating logic was left intact. Users who
had previously set shareStockStatus=false were stuck with empty stock
values ('-') on the shared medication overview with no UI to change it.

- Remove showStockStatus parameter from buildSharedMedicationOverview()
- Remove visibility gating that nullified stock fields
- Remove shareStockStatus from settings API responses and PUT schema
- Remove shareStockStatus from frontend types, hooks, and context
- Clean up all related test fixtures and dead test cases
- DB column share_stock_status retained (never remove columns)
2026-03-15 19:27:39 +01:00
43 changed files with 2858 additions and 2948 deletions
+20 -5
View File
@@ -15,7 +15,8 @@ You are the release manager for **MedAssist-ng**. Your job is to guide code from
- **Do EXACTLY what the user asks — nothing more.** If the user says "create a PR and merge to main", do only that. Do NOT also start a release. If the user says "do a release", do only the release. Never chain additional steps the user did not request.
- **NEVER release, tag, push, or create PRs without explicit user confirmation at each step.** Always present your plan and wait for approval.
- **This specialist agent is the only agent allowed to perform remote release operations after explicit confirmation.**
- **Use GitHub MCP for all GitHub remote operations. Never use `gh` CLI.** Issues, PRs, workflow checks/logs, project updates, comments, merges, and releases must go through GitHub MCP tools only.
- **Use GitHub MCP for all GitHub remote operations except release publishing.** Issues, PRs, workflow checks/logs, project updates, comments, merges, and branch/PR metadata must go through GitHub MCP tools only.
- **Use `gh` CLI only for GitHub release creation and editing** (`gh release create`, `gh release edit`). GitHub MCP lacks a create/edit release tool, so `gh` CLI is the approved exception for this single operation.
- **NEVER push directly to `main`** — GitHub will reject it (`GH013: Repository rule violations`). All changes go through Pull Requests.
- **NEVER skip CI checks.** Wait for all status checks to pass before merging.
- **Testing ownership belongs to `@testing-manager`**. Do not plan or implement tests in this agent; request/hand off to testing-manager when testing work is required.
@@ -51,10 +52,11 @@ This repository intentionally uses only two operational agents for CI/CD handoff
- During active PR/release work, `@release-manager` must keep all relevant current workflows in view until completion.
- If a failing workflow is testing-related (`test.yml` or `e2e.yml`), immediately hand off diagnosis/fix to `@testing-manager`.
## GitHub Operations (GitHub MCP Only)
## GitHub Operations (GitHub MCP + gh CLI Exception)
- Never use `gh` CLI in this agent.
- Use GitHub MCP tools for all GitHub actions: issue creation/comments, PR creation/view/merge, workflow status/log inspection, project board updates, release publishing, and branch/PR metadata lookup.
- Use GitHub MCP tools for: issue creation/comments, PR creation/view/merge, workflow status/log inspection, project board updates, and branch/PR metadata lookup.
- **Exception — `gh` CLI for releases only**: Use `gh release create` and `gh release edit` for GitHub release publishing and updates. GitHub MCP does not provide a create/edit release tool.
- Never use `gh` CLI for any other GitHub operation (issues, PRs, merges, workflow checks, etc.).
- Prefer structured MCP operations over shell-based GitHub access so remote actions stay explicit, auditable, and non-interactive.
## Workspace Hygiene And Source-Of-Truth Rules
@@ -69,6 +71,7 @@ This repository intentionally uses only two operational agents for CI/CD handoff
- When mixed local changes must be split into multiple PRs, do the classification first: `already upstream`, `intended for current PR`, or `unrelated/local-only`.
- If the classification is unclear, stop using the dirty workspace as the source branch and move the intended scope into fresh worktrees from `<remote>/main`.
- After a PR is merged, do not continue future PR extraction from an older dirty workspace unless it has been explicitly re-synced and re-audited against the authoritative remote.
- **Cleanup is mandatory**: after a temporary worktree, scratch branch, or quarantine workspace is no longer needed, remove it promptly. Do not leave obsolete local worktrees hanging around in Source Control after the task is complete.
---
@@ -399,7 +402,17 @@ Existing installations need to:
### Step 3: Publish
Present the release notes to the user. They will copy them to the GitHub release page or ask you to publish the release via GitHub MCP.
Publish the release via `gh` CLI:
```bash
# Write notes to a temp file first, then:
gh release create vX.Y.Z --title "vX.Y.Z" --notes-file /tmp/release-notes-vX.Y.Z.md
# If the release was already auto-created (e.g. by pushing a tag), update it:
gh release edit vX.Y.Z --title "vX.Y.Z" --notes-file /tmp/release-notes-vX.Y.Z.md
```
**Present the published release URL to the user for verification.**
---
@@ -452,6 +465,8 @@ All work is tracked in the [GitHub Project board](https://github.com/users/Danie
1. **Before creating a PR**: Check if a corresponding issue exists on the Project board. If not, create one via GitHub MCP with the appropriate label.
Issues with `enhancement`, `bug`, or `triage` labels are **automatically added** to the board.
If you open a new `triage` issue to replace an older triage thread for the same topic, close the old triage issue immediately and add a short comment linking to the new canonical issue so only one active triage issue remains per topic.
2. **When creating a PR**: Always reference the issue with `Closes #N` in the PR body so the issue is automatically **closed** on merge. Note: this does NOT move the Project board status — that must be done manually (see step 3).
Also add a direct issue comment with the PR link and a one-line summary for clear issue-thread traceability.
-4
View File
@@ -11,7 +11,6 @@ updates:
open-pull-requests-limit: 10
labels:
- "dependencies"
- "backend"
groups:
minor-and-patch:
update-types:
@@ -28,7 +27,6 @@ updates:
open-pull-requests-limit: 10
labels:
- "dependencies"
- "frontend"
groups:
minor-and-patch:
update-types:
@@ -45,7 +43,6 @@ updates:
open-pull-requests-limit: 5
labels:
- "dependencies"
- "root"
groups:
minor-and-patch:
update-types:
@@ -62,7 +59,6 @@ updates:
open-pull-requests-limit: 5
labels:
- "dependencies"
- "ci"
groups:
minor-and-patch:
update-types:
+1 -1
View File
@@ -18,7 +18,7 @@ jobs:
outputs:
e2e_relevant: ${{ steps.filter.outputs.e2e_relevant }}
steps:
- uses: dorny/paths-filter@v3
- uses: dorny/paths-filter@v4
id: filter
with:
filters: |
+1 -1
View File
@@ -22,7 +22,7 @@ jobs:
backend: ${{ steps.filter.outputs.backend }}
frontend: ${{ steps.filter.outputs.frontend }}
steps:
- uses: dorny/paths-filter@v3
- uses: dorny/paths-filter@v4
id: filter
with:
filters: |
+1 -1
View File
@@ -18,7 +18,7 @@
</p>
<p align="center">
<img src="https://img.shields.io/badge/Backend_Tests-618%2F618-brightgreen?logo=vitest" alt="Backend Tests 454/454" />
<img src="https://img.shields.io/badge/Backend_Tests-615%2F615-brightgreen?logo=vitest" alt="Backend Tests 454/454" />
<img src="https://img.shields.io/badge/Frontend_Tests-807%2F807-brightgreen?logo=vitest" alt="Frontend Tests 611/611" />
</p>
+654 -394
View File
File diff suppressed because it is too large Load Diff
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "medassist-ng-backend",
"version": "1.20.0",
"version": "1.20.2",
"private": true,
"type": "module",
"scripts": {
@@ -32,17 +32,17 @@
"dotenv": "^17.3.1",
"drizzle-orm": "^0.45.1",
"fastify": "^5.8.2",
"nodemailer": "^8.0.1",
"nodemailer": "^8.0.2",
"openid-client": "^6.8.2",
"sharp": "^0.34.5",
"zod": "^3.23.8"
},
"devDependencies": {
"@biomejs/biome": "^2.4.6",
"@types/node": "^25.3.5",
"@biomejs/biome": "^2.4.7",
"@types/node": "^25.5.0",
"@types/nodemailer": "^7.0.11",
"@types/supertest": "^7.2.0",
"@vitest/coverage-v8": "^4.0.18",
"@vitest/coverage-v8": "^4.1.0",
"drizzle-kit": "^0.31.9",
"pino-pretty": "^13.1.3",
"supertest": "^7.2.2",
+3 -3
View File
@@ -247,7 +247,7 @@ export async function authRoutes(app: FastifyInstance) {
})
.returning();
app.log.info(`User registered: ${username}`);
app.log.info(`[Auth] Account registered: username=${newUser.username}, userId=${newUser.id}`);
return reply.status(201).send({
ok: true,
@@ -376,7 +376,7 @@ export async function authRoutes(app: FastifyInstance) {
{ expiresIn: `${refreshTtlDays}d`, key: app.config.refreshSecret }
);
app.log.info(`User logged in: ${username} (rememberMe: ${rememberMe})`);
app.log.info(`[Auth] Login succeeded: username=${user.username}, userId=${user.id}, rememberMe=${rememberMe}`);
// Cookie options: with maxAge for "remember me", without for session cookie
const accessCookieOptions = rememberMe
@@ -807,7 +807,7 @@ export async function authRoutes(app: FastifyInstance) {
// 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})`);
app.log.info(`[Auth] Account deleted: username=${authUser.username}, userId=${authUser.id}`);
// Clear auth cookies
return reply
+14 -15
View File
@@ -61,11 +61,6 @@ const doseReadResponseSchema = {
},
} as const;
function maskToken(token: string): string {
if (token.length <= 8) return token;
return `${token.slice(0, 4)}...${token.slice(-4)}`;
}
// Helper to get user ID from request
// Returns anonymous user ID when auth is disabled
async function getUserId(request: FastifyRequest, reply: FastifyReply): Promise<number> {
@@ -545,7 +540,7 @@ export async function doseRoutes(app: FastifyInstance) {
const { share, reason } = await getActiveShareToken(token);
if (!share) {
request.log.warn(`[ShareDose] Rejected read for token ${maskToken(token)} (reason=${reason})`);
request.log.warn(`[ShareDose] Rejected read: token=${token}, reason=${reason}`);
return reply.notFound("Share link not found");
}
@@ -603,14 +598,14 @@ export async function doseRoutes(app: FastifyInstance) {
const { share, reason } = await getActiveShareToken(token);
if (!share) {
request.log.warn(`[ShareDose] Rejected mark for token ${maskToken(token)} (reason=${reason})`);
request.log.warn(`[ShareDose] Rejected mark: token=${token}, doseId=${doseId}, reason=${reason}`);
return reply.notFound("Share link not found");
}
const isValidShareDoseId = await validateShareDoseId(share, doseId);
if (!isValidShareDoseId) {
request.log.warn(
`[ShareDose] Rejected invalid doseId in mark request (owner=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId})`
`[ShareDose] Rejected invalid doseId in mark request: token=${token}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}`
);
return reply.status(400).send({ error: "Invalid or unauthorized doseId" });
}
@@ -622,7 +617,9 @@ export async function doseRoutes(app: FastifyInstance) {
.where(and(eq(doseTracking.userId, share.userId), eq(doseTracking.doseId, doseId)));
if (existing) {
request.log.debug(`[ShareDose] Duplicate mark ignored (owner=${share.userId}, doseId=${doseId})`);
request.log.debug(
`[ShareDose] Duplicate mark ignored: token=${token}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}`
);
return { success: true, message: "Already marked" };
}
@@ -634,7 +631,7 @@ export async function doseRoutes(app: FastifyInstance) {
});
if (outOfStock) {
request.log.info(
`[ShareDose] Rejected out-of-stock mark request (owner=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId})`
`[ShareDose] Rejected out-of-stock mark request: token=${token}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}`
);
return reply.status(409).send({ error: "Medication is out of stock", code: "OUT_OF_STOCK" });
}
@@ -651,7 +648,7 @@ export async function doseRoutes(app: FastifyInstance) {
});
request.log.info(
`[ShareDose] Dose marked via share link (owner=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId})`
`[ShareDose] Dose marked via share link: token=${token}, ownerUserId=${share.userId}, shareTakenBy=${share.takenBy}, markedBy=${markedBy}, doseId=${doseId}`
);
return { success: true };
@@ -685,14 +682,14 @@ export async function doseRoutes(app: FastifyInstance) {
const { share, reason } = await getActiveShareToken(token);
if (!share) {
request.log.warn(`[ShareDose] Rejected unmark for token ${maskToken(token)} (reason=${reason})`);
request.log.warn(`[ShareDose] Rejected unmark: token=${token}, doseId=${doseId}, reason=${reason}`);
return reply.notFound("Share link not found");
}
const isValidShareDoseId = await validateShareDoseId(share, doseId);
if (!isValidShareDoseId) {
request.log.warn(
`[ShareDose] Rejected invalid doseId in unmark request (owner=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId})`
`[ShareDose] Rejected invalid doseId in unmark request: token=${token}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}`
);
return reply.status(400).send({ error: "Invalid or unauthorized doseId" });
}
@@ -705,14 +702,16 @@ export async function doseRoutes(app: FastifyInstance) {
if (existing?.dismissed) {
// Already dismissed - keep the record as-is
request.log.debug(`[ShareDose] Unmark ignored for dismissed dose (owner=${share.userId}, doseId=${doseId})`);
request.log.debug(
`[ShareDose] Unmark ignored for dismissed dose: token=${token}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}`
);
} else {
// Not dismissed - delete the record entirely
await db
.delete(doseTracking)
.where(and(eq(doseTracking.userId, share.userId), eq(doseTracking.doseId, doseId)));
request.log.info(
`[ShareDose] Dose unmarked via share link (owner=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId})`
`[ShareDose] Dose unmarked via share link: token=${token}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}`
);
}
+1 -1
View File
@@ -238,7 +238,7 @@ export async function oidcRoutes(app: FastifyInstance) {
// Set cookies (use app's centralized cookie options)
request.log.debug(
`[OIDC] Setting cookies for user ${user.username}, NODE_ENV=${env.NODE_ENV}, secure=${app.config.cookieOptions.secure}`
`[OIDC] Setting auth cookies for username=${user.username}, userId=${user.id}, NODE_ENV=${env.NODE_ENV}, secure=${app.config.cookieOptions.secure}`
);
setAuthCookies(app, reply, accessToken, refreshToken);
+49 -28
View File
@@ -40,13 +40,6 @@ function escapeHtml(text: string): string {
return text.replace(/[&<>"']/g, (char) => htmlEscapes[char] || char);
}
function maskEmail(email: string): string {
const [localPart, domain] = email.split("@");
if (!domain) return "invalid-email";
if (localPart.length <= 2) return `${localPart[0] ?? "*"}*@${domain}`;
return `${localPart.slice(0, 2)}***@${domain}`;
}
type MailDeliveryInfo = {
accepted?: unknown;
rejected?: unknown;
@@ -256,10 +249,7 @@ export async function plannerRoutes(app: FastifyInstance) {
},
async (request, reply) => {
const { email, from, until, rows, language: bodyLanguage } = request.body;
request.log.info(
{ hasEmail: Boolean(email), rowCount: rows?.length ?? 0 },
"[Planner] Demand notification request received"
);
request.log.info({ email, rowCount: rows?.length ?? 0 }, "[Planner] Demand notification request received");
if (!rows || rows.length === 0) {
return reply.status(400).send({ error: "Missing planner data" });
@@ -277,6 +267,7 @@ export async function plannerRoutes(app: FastifyInstance) {
request.log.warn("[Planner] Demand notification skipped: no active medications in request");
return reply.status(400).send({ error: "No active medications to notify" });
}
const activeMedicationNames = activeRows.map((row) => row.medicationName);
const userSettings = await loadUserSettings(userId);
const notificationSettings = {
@@ -291,6 +282,8 @@ export async function plannerRoutes(app: FastifyInstance) {
pushEnabled: notificationSettings.shoutrrrEnabled,
hasPushUrl: Boolean(notificationSettings.shoutrrrUrl),
activeRowCount: activeRows.length,
recipientEmail: email,
medications: activeMedicationNames,
},
"[Planner] Demand notification channel state"
);
@@ -377,13 +370,14 @@ ${getFooterPlain(language)}`;
request.log.info(
{
userId,
hasSmtpHost: Boolean(smtpHost),
hasSmtpUser: Boolean(smtpUser),
hasSmtpPass: Boolean(smtpPass),
smtpPort,
smtpSecure,
hasSmtpFrom: Boolean(smtpFrom),
to: maskEmail(email),
recipientEmail: email,
},
"[Planner] Demand email path selected"
);
@@ -494,7 +488,7 @@ ${getFooterPlain(language)}`;
},
});
request.log.info({ to: maskEmail(email) }, "[Planner] Sending demand email");
request.log.info({ userId, recipientEmail: email }, "[Planner] Sending demand email");
const mailResult = await transporter.sendMail({
from: smtpFrom,
@@ -509,19 +503,23 @@ ${getFooterPlain(language)}`;
throw new Error(deliveryError);
}
request.log.info({ to: maskEmail(email), messageId: mailResult.messageId }, "[Planner] Demand email sent");
request.log.info(
{ userId, recipientEmail: email, messageId: mailResult.messageId },
"[Planner] Demand email sent"
);
results.email = true;
} catch (error) {
request.log.error({ error, to: maskEmail(email) }, "[Planner] Demand email failed");
request.log.error({ userId, recipientEmail: email, error }, "[Planner] Demand email failed");
const errorMessage = error instanceof Error ? error.message : "Unknown error";
results.errors.push(`Email: ${errorMessage}`);
}
} else {
request.log.warn(
{
userId,
hasSmtpHost: Boolean(smtpHost),
hasSmtpUser: Boolean(smtpUser),
to: maskEmail(email),
recipientEmail: email,
},
"[Planner] Demand email skipped: SMTP not configured"
);
@@ -612,7 +610,7 @@ ${getFooterPlain(language)}`;
async (request, reply) => {
const { email, lowStock } = request.body;
request.log.info(
{ hasEmail: Boolean(email), lowStockCount: lowStock?.length ?? 0 },
{ email, lowStockCount: lowStock?.length ?? 0 },
"[ReminderManual] Stock reminder request received"
);
@@ -641,6 +639,7 @@ ${getFooterPlain(language)}`;
request.log.warn("[ReminderManual] Stock reminder skipped: no active medications after filtering");
return reply.status(400).send({ error: "No active medications to notify" });
}
const filteredMedicationNames = filteredLowStock.map((item) => item.name);
const userSettings = await loadUserSettings(userId);
const notificationSettings = {
@@ -655,6 +654,8 @@ ${getFooterPlain(language)}`;
pushEnabled: notificationSettings.shoutrrrEnabled,
hasPushUrl: Boolean(notificationSettings.shoutrrrUrl),
filteredLowStockCount: filteredLowStock.length,
recipientEmail: email,
medications: filteredMedicationNames,
},
"[ReminderManual] Stock reminder channel state"
);
@@ -731,13 +732,14 @@ ${getFooterPlain(language)}`;
request.log.info(
{
userId,
hasSmtpHost: Boolean(smtpHost),
hasSmtpUser: Boolean(smtpUser),
hasSmtpPass: Boolean(smtpPass),
smtpPort,
smtpSecure,
hasSmtpFrom: Boolean(smtpFrom),
to: maskEmail(email),
recipientEmail: email,
},
"[ReminderManual] Stock email path selected"
);
@@ -855,7 +857,7 @@ ${getFooterPlain(language)}`;
},
});
request.log.info({ to: maskEmail(email) }, "[ReminderManual] Sending stock reminder email");
request.log.info({ userId, recipientEmail: email }, "[ReminderManual] Sending stock reminder email");
const mailResult = await transporter.sendMail({
from: smtpFrom,
@@ -871,21 +873,22 @@ ${getFooterPlain(language)}`;
}
request.log.info(
{ to: maskEmail(email), messageId: mailResult.messageId },
{ userId, recipientEmail: email, messageId: mailResult.messageId },
"[ReminderManual] Stock reminder email sent"
);
results.email = true;
} catch (error) {
request.log.error({ error, to: maskEmail(email) }, "[ReminderManual] Stock reminder email failed");
request.log.error({ userId, recipientEmail: email, error }, "[ReminderManual] Stock reminder email failed");
const errorMessage = error instanceof Error ? error.message : "Unknown error";
results.errors.push(`Email: ${errorMessage}`);
}
} else {
request.log.warn(
{
userId,
hasSmtpHost: Boolean(smtpHost),
hasSmtpUser: Boolean(smtpUser),
to: maskEmail(email),
recipientEmail: email,
},
"[ReminderManual] Stock reminder email skipped: SMTP not configured"
);
@@ -983,7 +986,7 @@ ${getFooterPlain(language)}`;
async (request, reply) => {
const { email, prescriptionLow } = request.body;
request.log.info(
{ hasEmail: Boolean(email), prescriptionCount: prescriptionLow?.length ?? 0 },
{ email, prescriptionCount: prescriptionLow?.length ?? 0 },
"[ReminderManual] Prescription reminder request received"
);
@@ -1002,10 +1005,23 @@ ${getFooterPlain(language)}`;
request.log.warn("[ReminderManual] Prescription reminder skipped: no active medications after filtering");
return reply.status(400).send({ error: "No active medications to notify" });
}
const filteredMedicationNames = filteredPrescriptionLow.map((item) => item.name);
const userSettings = await loadUserSettings(userId);
const language = (userSettings.language as Language) || "en";
const tr = getTranslations(language);
request.log.info(
{
userId,
emailEnabled: userSettings.emailEnabled,
pushEnabled: userSettings.shoutrrrEnabled,
hasPushUrl: Boolean(userSettings.shoutrrrUrl),
prescriptionCount: filteredPrescriptionLow.length,
recipientEmail: email,
medications: filteredMedicationNames,
},
"[ReminderManual] Prescription reminder channel state"
);
const emptyRx = filteredPrescriptionLow.filter((item) => item.remainingRefills <= 0);
const lowRx = filteredPrescriptionLow.filter((item) => item.remainingRefills > 0);
@@ -1039,13 +1055,14 @@ ${getFooterPlain(language)}`;
request.log.info(
{
userId,
hasSmtpHost: Boolean(smtpHost),
hasSmtpUser: Boolean(smtpUser),
hasSmtpPass: Boolean(smtpPass),
smtpPort,
smtpSecure,
hasSmtpFrom: Boolean(smtpFrom),
to: maskEmail(email),
recipientEmail: email,
},
"[ReminderManual] Prescription email path selected"
);
@@ -1133,7 +1150,7 @@ ${getFooterPlain(language)}`;
</div>
`;
request.log.info({ to: maskEmail(email) }, "[ReminderManual] Sending prescription reminder email");
request.log.info({ userId, recipientEmail: email }, "[ReminderManual] Sending prescription reminder email");
const mailResult = await transporter.sendMail({
from: smtpFrom,
@@ -1149,21 +1166,25 @@ ${getFooterPlain(language)}`;
}
request.log.info(
{ to: maskEmail(email), messageId: mailResult.messageId },
{ userId, recipientEmail: email, messageId: mailResult.messageId },
"[ReminderManual] Prescription reminder email sent"
);
results.email = true;
} catch (error) {
request.log.error({ error, to: maskEmail(email) }, "[ReminderManual] Prescription reminder email failed");
request.log.error(
{ userId, recipientEmail: email, error },
"[ReminderManual] Prescription reminder email failed"
);
const errorMessage = error instanceof Error ? error.message : "Unknown error";
results.errors.push(`Email: ${errorMessage}`);
}
} else {
request.log.warn(
{
userId,
hasSmtpHost: Boolean(smtpHost),
hasSmtpUser: Boolean(smtpUser),
to: maskEmail(email),
recipientEmail: email,
},
"[ReminderManual] Prescription reminder email skipped: SMTP not configured"
);
+5 -21
View File
@@ -32,7 +32,6 @@ export type UserSettings = {
highStockDays: number;
language: Language;
stockCalculationMode: "automatic" | "manual";
shareStockStatus: boolean;
shareMedicationOverview: boolean;
upcomingTodayOnly: boolean;
shareScheduleTodayOnly: boolean;
@@ -72,7 +71,6 @@ type SettingsBody = {
maxNaggingReminders: number;
language: string;
stockCalculationMode: "automatic" | "manual";
shareStockStatus: boolean;
shareMedicationOverview: boolean;
upcomingTodayOnly: boolean;
shareScheduleTodayOnly: boolean;
@@ -99,13 +97,6 @@ const settingsErrorSchema = {
},
};
function maskEmail(email: string): string {
const [localPart, domain] = email.split("@");
if (!domain) return "invalid-email";
if (localPart.length <= 2) return `${localPart[0] ?? "*"}*@${domain}`;
return `${localPart.slice(0, 2)}***@${domain}`;
}
type MailDeliveryInfo = {
accepted?: unknown;
rejected?: unknown;
@@ -222,7 +213,6 @@ function getDefaultSettings() {
highStockDays: envInt("DEFAULT_HIGH_STOCK_DAYS", 180),
language: (process.env.DEFAULT_LANGUAGE as "en" | "de") || "en",
stockCalculationMode: (process.env.DEFAULT_STOCK_CALCULATION_MODE as "automatic" | "manual") || "automatic",
shareStockStatus: envBool("DEFAULT_SHARE_STOCK_STATUS", true),
shareMedicationOverview: envBool("DEFAULT_SHARE_MEDICATION_OVERVIEW", false),
upcomingTodayOnly: envBool("DEFAULT_UPCOMING_TODAY_ONLY", false),
shareScheduleTodayOnly: envBool("DEFAULT_SHARE_SCHEDULE_TODAY_ONLY", false),
@@ -285,7 +275,6 @@ export async function loadUserSettings(userId: number): Promise<UserSettings> {
highStockDays: settings.highStockDays,
language: settings.language as Language,
stockCalculationMode: (settings.stockCalculationMode as "automatic" | "manual") ?? "automatic",
shareStockStatus: settings.shareStockStatus ?? true,
shareMedicationOverview: settings.shareMedicationOverview ?? false,
upcomingTodayOnly: settings.upcomingTodayOnly ?? false,
shareScheduleTodayOnly: settings.shareScheduleTodayOnly ?? false,
@@ -330,7 +319,6 @@ export async function getAllUserSettings(): Promise<UserSettings[]> {
highStockDays: settings.highStockDays,
language: settings.language as Language,
stockCalculationMode: (settings.stockCalculationMode as "automatic" | "manual") ?? "automatic",
shareStockStatus: settings.shareStockStatus ?? true,
shareMedicationOverview: settings.shareMedicationOverview ?? false,
upcomingTodayOnly: settings.upcomingTodayOnly ?? false,
shareScheduleTodayOnly: settings.shareScheduleTodayOnly ?? false,
@@ -415,7 +403,6 @@ export async function settingsRoutes(app: FastifyInstance) {
maxNaggingReminders: settings.maxNaggingReminders ?? 5,
language: settings.language,
stockCalculationMode: settings.stockCalculationMode ?? "automatic",
shareStockStatus: settings.shareStockStatus ?? true,
shareMedicationOverview: settings.shareMedicationOverview ?? false,
upcomingTodayOnly: settings.upcomingTodayOnly ?? false,
shareScheduleTodayOnly: settings.shareScheduleTodayOnly ?? false,
@@ -482,7 +469,6 @@ export async function settingsRoutes(app: FastifyInstance) {
maxNaggingReminders: { type: "number" },
language: { type: "string", enum: ["en", "de"] },
stockCalculationMode: { type: "string", enum: ["automatic", "manual"] },
shareStockStatus: { type: "boolean" },
shareMedicationOverview: { type: "boolean" },
upcomingTodayOnly: { type: "boolean" },
shareScheduleTodayOnly: { type: "boolean" },
@@ -510,7 +496,6 @@ export async function settingsRoutes(app: FastifyInstance) {
maxNaggingReminders: 5,
language: "en",
stockCalculationMode: "automatic",
shareStockStatus: true,
shareMedicationOverview: false,
upcomingTodayOnly: false,
shareScheduleTodayOnly: false,
@@ -561,7 +546,6 @@ export async function settingsRoutes(app: FastifyInstance) {
highStockDays: body.highStockDays ?? 180,
language: body.language ?? "en",
stockCalculationMode: body.stockCalculationMode ?? "automatic",
shareStockStatus: body.shareStockStatus ?? true,
shareMedicationOverview: body.shareMedicationOverview ?? false,
upcomingTodayOnly: body.upcomingTodayOnly ?? false,
shareScheduleTodayOnly: body.shareScheduleTodayOnly ?? false,
@@ -677,7 +661,7 @@ export async function settingsRoutes(app: FastifyInstance) {
request.log.info(
{
to: maskEmail(email),
to: email,
hasSmtpHost: Boolean(smtpHost),
hasSmtpUser: Boolean(smtpUser),
hasSmtpPass: Boolean(smtpPass),
@@ -690,7 +674,7 @@ export async function settingsRoutes(app: FastifyInstance) {
if (!smtpHost || !smtpUser) {
request.log.warn(
{ to: maskEmail(email), hasSmtpHost: Boolean(smtpHost), hasSmtpUser: Boolean(smtpUser) },
{ to: email, hasSmtpHost: Boolean(smtpHost), hasSmtpUser: Boolean(smtpUser) },
"[Settings] Test email skipped: SMTP not configured"
);
return reply.status(400).send({ error: "SMTP not configured" });
@@ -707,7 +691,7 @@ export async function settingsRoutes(app: FastifyInstance) {
},
});
request.log.info({ to: maskEmail(email) }, "[Settings] Sending test email");
request.log.info({ to: email }, "[Settings] Sending test email");
const mailResult = await transporter.sendMail({
from: smtpFrom,
@@ -730,11 +714,11 @@ export async function settingsRoutes(app: FastifyInstance) {
throw new Error(deliveryError);
}
request.log.info({ to: maskEmail(email), messageId: mailResult.messageId }, "[Settings] Test email sent");
request.log.info({ to: email, messageId: mailResult.messageId }, "[Settings] Test email sent");
return reply.send({ success: true, message: "Test email sent successfully" });
} catch (error) {
request.log.error({ error, to: maskEmail(email) }, "[Settings] Test email failed");
request.log.error({ to: email, error }, "[Settings] Test email failed");
const failure = classifyTestEmailFailure(error);
return reply.status(failure.status).send({ error: failure.message, code: failure.code });
}
+20 -20
View File
@@ -62,7 +62,6 @@ const shareReadResponseSchema = {
},
stockThresholds: { type: "object", additionalProperties: { type: "number" } },
stockCalculationMode: { type: "string", enum: ["automatic", "manual"] },
shareStockStatus: { type: "boolean" },
upcomingTodayOnly: { type: "boolean" },
shareScheduleTodayOnly: { type: "boolean" },
},
@@ -97,11 +96,6 @@ const shareOverviewResponseSchema = {
},
} as const;
function maskToken(token: string): string {
if (token.length <= 8) return token;
return `${token.slice(0, 4)}...${token.slice(-4)}`;
}
// Helper to get user ID from request
// Returns anonymous user ID when auth is disabled
async function getUserId(request: FastifyRequest, reply: FastifyReply): Promise<number> {
@@ -156,7 +150,7 @@ export async function shareRoutes(app: FastifyInstance) {
// Find share token
const [share] = await db.select().from(shareTokens).where(eq(shareTokens.token, token));
if (!share) {
request.log.warn(`[Share] Invalid share token requested: ${maskToken(token)}`);
request.log.warn(`[Share] Invalid share token requested: token=${token}`);
return reply.status(404).send({
error: "Share link not found",
code: "NOT_FOUND",
@@ -166,7 +160,7 @@ export async function shareRoutes(app: FastifyInstance) {
// Check if token has expired
if (share.expiresAt && share.expiresAt.getTime() < Date.now()) {
request.log.warn(
`[Share] Expired token requested: ${maskToken(token)} (owner=${share.userId}, takenBy=${share.takenBy})`
`[Share] Expired token requested: token=${token}, ownerUserId=${share.userId}, takenBy=${share.takenBy}`
);
// Get the username of the owner to show in the expired message
const [owner] = await db.select({ username: users.username }).from(users).where(eq(users.id, share.userId));
@@ -187,7 +181,10 @@ export async function shareRoutes(app: FastifyInstance) {
// Get medications for this user filtered by takenBy (search in JSON array)
// Use SQLite JSON function to check if takenBy is in the array
const allMeds = await db.select().from(medications).where(eq(medications.userId, share.userId));
const allMeds = await db
.select()
.from(medications)
.where(and(eq(medications.userId, share.userId), eq(medications.isObsolete, false)));
// Filter medications where takenBy matches either medication-level OR any intake-level takenBy
const meds = allMeds.filter((med) => {
@@ -251,7 +248,6 @@ export async function shareRoutes(app: FastifyInstance) {
medications: meds,
doses: await db.select().from(doseTracking).where(eq(doseTracking.userId, share.userId)),
thresholdDays: settings?.lowStockDays ?? 30,
showStockStatus: settings?.shareStockStatus ?? true,
})
: null;
@@ -270,7 +266,6 @@ export async function shareRoutes(app: FastifyInstance) {
expiryWarningDays: settings?.expiryWarningDays ?? 90,
},
stockCalculationMode: (settings?.stockCalculationMode as "automatic" | "manual") ?? "automatic",
shareStockStatus: settings?.shareStockStatus ?? true,
upcomingTodayOnly: settings?.upcomingTodayOnly ?? false,
shareScheduleTodayOnly: settings?.shareScheduleTodayOnly ?? false,
};
@@ -304,19 +299,19 @@ export async function shareRoutes(app: FastifyInstance) {
const { token } = request.params;
if (!shareTokenPattern.test(token)) {
request.log.warn(`[ShareOverview] Rejected invalid token format: ${maskToken(token)}`);
request.log.warn(`[ShareOverview] Rejected invalid token format: token=${token}`);
return reply.status(404).send({ error: "not_found" });
}
const [share] = await db.select().from(shareTokens).where(eq(shareTokens.token, token));
if (!share) {
request.log.warn(`[ShareOverview] Unknown token requested: ${maskToken(token)}`);
request.log.warn(`[ShareOverview] Unknown token requested: token=${token}`);
return reply.status(404).send({ error: "not_found" });
}
if (share.expiresAt && share.expiresAt.getTime() < Date.now()) {
request.log.warn(
`[ShareOverview] Expired token requested: ${maskToken(token)} (owner=${share.userId}, takenBy=${share.takenBy})`
`[ShareOverview] Expired token requested: token=${token}, ownerUserId=${share.userId}, takenBy=${share.takenBy}`
);
return reply.status(410).send({
error: "expired",
@@ -327,7 +322,10 @@ export async function shareRoutes(app: FastifyInstance) {
const [settings] = await db.select().from(userSettings).where(eq(userSettings.userId, share.userId));
const [owner] = await db.select({ username: users.username }).from(users).where(eq(users.id, share.userId));
const allMeds = await db.select().from(medications).where(eq(medications.userId, share.userId));
const allMeds = await db
.select()
.from(medications)
.where(and(eq(medications.userId, share.userId), eq(medications.isObsolete, false)));
const meds = allMeds.filter((med) => {
const takenByArray = parseTakenByJson(med.takenByJson);
const intakes = parseIntakesJson(
@@ -344,7 +342,6 @@ export async function shareRoutes(app: FastifyInstance) {
medications: meds,
doses,
thresholdDays: settings?.lowStockDays ?? 30,
showStockStatus: settings?.shareStockStatus ?? true,
});
return {
@@ -396,7 +393,10 @@ export async function shareRoutes(app: FastifyInstance) {
const { takenBy, scheduleDays } = parsed.data;
// Check if user has medications for this takenBy (search in both medication-level and intake-level)
const allMeds = await db.select().from(medications).where(eq(medications.userId, userId));
const allMeds = await db
.select()
.from(medications)
.where(and(eq(medications.userId, userId), eq(medications.isObsolete, false)));
const medsForPerson = allMeds.filter((med) => {
const takenByArray = parseTakenByJson(med.takenByJson);
const intakes = parseIntakesJson(
@@ -425,7 +425,7 @@ export async function shareRoutes(app: FastifyInstance) {
await db.update(shareTokens).set({ scheduleDays, expiresAt: null }).where(eq(shareTokens.id, existingShare.id));
request.log.info(
`[Share] Reused existing share token (owner=${userId}, takenBy=${takenBy}, scheduleDays=${scheduleDays})`
`[Share] Reused existing share token: token=${existingShare.token}, ownerUserId=${userId}, takenBy=${takenBy}, scheduleDays=${scheduleDays}`
);
return {
@@ -447,7 +447,7 @@ export async function shareRoutes(app: FastifyInstance) {
});
request.log.info(
`[Share] Created new share token (owner=${userId}, takenBy=${takenBy}, scheduleDays=${scheduleDays})`
`[Share] Created new share token: token=${token}, ownerUserId=${userId}, takenBy=${takenBy}, scheduleDays=${scheduleDays}`
);
return {
@@ -494,7 +494,7 @@ export async function shareRoutes(app: FastifyInstance) {
intakeRemindersEnabled: medications.intakeRemindersEnabled,
})
.from(medications)
.where(eq(medications.userId, userId));
.where(and(eq(medications.userId, userId), eq(medications.isObsolete, false)));
// Collect all unique person names from medication-level AND intake-level takenBy
const allPeople = new Set<string>();
+13 -13
View File
@@ -20,6 +20,8 @@ export type SharedMedicationOverviewItem = {
imageUrl: string | null;
packageType: string;
packCount: number;
packageAmountValue: number | null;
packageAmountUnit: "ml" | "g" | null;
blistersPerPack: number;
pillsPerBlister: number;
totalPills: number | null;
@@ -149,9 +151,8 @@ export function buildSharedMedicationOverview(options: {
medications: MedicationRow[];
doses: DoseRow[];
thresholdDays: number;
showStockStatus?: boolean;
}): SharedMedicationOverviewItem[] {
const { medications: medicationRows, doses, thresholdDays, showStockStatus = true } = options;
const { medications: medicationRows, doses, thresholdDays } = options;
const dosesByMedication = new Map<number, DoseRow[]>();
for (const dose of doses) {
@@ -189,28 +190,27 @@ export function buildSharedMedicationOverview(options: {
const depletionDate =
daysLeft === null ? null : toDateOnlyString(new Date(todayDate.getTime() + daysLeft * MS_PER_DAY));
const priority = computeOverviewPriority(currentStock, daysLeft, thresholdDays);
const visibleCurrentStock = showStockStatus ? currentStock : null;
const visibleCapacity = showStockStatus ? capacity : null;
const visibleDaysLeft = showStockStatus ? daysLeft : null;
const visibleDepletionDate = showStockStatus ? depletionDate : null;
const visiblePriority = showStockStatus ? priority : null;
return {
name: medication.name,
genericName: medication.genericName,
imageUrl: medication.imageUrl,
packageType: medication.packageType,
packCount: medication.packCount,
packageAmountValue: medication.packageAmountValue,
packageAmountUnit:
medication.packageAmountUnit === "g" || medication.packageAmountUnit === "ml"
? medication.packageAmountUnit
: null,
blistersPerPack: medication.blistersPerPack,
pillsPerBlister: medication.pillsPerBlister,
totalPills: medication.totalPills,
looseTablets: medication.looseTablets,
currentStock: visibleCurrentStock,
capacity: visibleCapacity,
daysLeft: visibleDaysLeft,
currentStock,
capacity,
daysLeft,
nextIntakeDate: computeNextIntakeDate(intakes, todayDateOnly),
depletionDate: visibleDepletionDate,
priority: visiblePriority,
depletionDate,
priority,
expiryDate: toNullableDate(medication.expiryDate),
medicationStartDate: toNullableDate(medication.medicationStartDate),
prescriptionEnabled: medication.prescriptionEnabled ?? false,
+125 -80
View File
@@ -91,19 +91,25 @@ function buildDoseIdForIntake(intake: UpcomingIntake & { medicationId: number; b
return `${intake.medicationId}-${intake.blisterIndex}-${dateOnlyMs}`;
}
async function resolveSchedulerUserDisplayName(userId: number): Promise<string> {
const [userRow] = await db.select({ username: users.username }).from(users).where(eq(users.id, userId)).limit(1);
return userRow?.username?.trim() || `unknown-user-${userId}`;
async function getUsernameForLog(userId: number): Promise<string> {
const user = await db.select({ username: users.username }).from(users).where(eq(users.id, userId));
const username = user[0]?.username?.trim();
return username && username.length > 0 ? username : `unknown-user-${userId}`;
}
function formatIntakeDescriptor(
definitionIndex: number,
medicationName: string,
medicationId: number,
intake: { every: number; usage: number; start: string; intakeRemindersEnabled: boolean; takenBy: string | null }
): string {
const takenByPart = intake.takenBy ? `, takenBy=${intake.takenBy}` : "";
return `Intake #${definitionIndex + 1} (index=${definitionIndex}, medication=${medicationName}, medicationId=${medicationId}, start=${intake.start}, every=${intake.every}d, usage=${intake.usage}, reminderEnabled=${intake.intakeRemindersEnabled}${takenByPart})`;
function formatIntakeLog(intake: {
medName: string;
medicationId: number;
blisterIndex: number;
intakeTime: Date;
intakeTimeStr: string;
usage: number;
doseUnit?: string;
takenBy?: string | null;
}): string {
const takenBy = intake.takenBy ? intake.takenBy : "none";
const doseUnit = intake.doseUnit ?? "mg";
return `${intake.medName} (medId=${intake.medicationId}, intakeIndex=${intake.blisterIndex}, time=${intake.intakeTime.toISOString()}, localTime=${intake.intakeTimeStr}, usage=${intake.usage} ${doseUnit}, takenBy=${takenBy})`;
}
async function autoMarkDueIntakesAsTaken(
@@ -114,6 +120,9 @@ async function autoMarkDueIntakesAsTaken(
logger: ServiceLogger
): Promise<number> {
if (settings.stockCalculationMode !== "automatic") {
logger.debug(
`[IntakeReminder] Auto-mark disabled for userId=${settings.userId} because stockCalculationMode=${settings.stockCalculationMode}`
);
return 0;
}
@@ -214,6 +223,19 @@ async function autoMarkDueIntakesAsTaken(
dismissed: false,
});
logger.info(
`[IntakeReminder] Auto-marked intake for userId=${settings.userId}: ${formatIntakeLog({
medName: intake.medName,
medicationId: intake.medicationId,
blisterIndex: intake.blisterIndex,
intakeTime: intake.intakeTime,
intakeTimeStr: intake.intakeTimeStr,
usage: intake.usage,
doseUnit: intake.doseUnit,
takenBy: intake.takenBy,
})}`
);
existingDoseIds.add(doseId);
trackedDoses.push({
id: 0,
@@ -229,8 +251,10 @@ async function autoMarkDueIntakesAsTaken(
}
}
if (inserted > 0) {
logger.info(`[IntakeReminder] Auto-marked ${inserted} due intake dose(s) as taken`);
if (inserted === 0) {
logger.debug(`[IntakeReminder] Auto-mark completed for userId=${settings.userId}: no due intakes`);
} else {
logger.info(`[IntakeReminder] Auto-mark completed for userId=${settings.userId}: inserted=${inserted}`);
}
return inserted;
@@ -417,55 +441,70 @@ async function checkAndSendIntakeReminders(logger: ServiceLogger): Promise<void>
// Get all user settings to iterate over each user
const allUserSettings = await getAllUserSettings();
logger.debug(`[IntakeReminder] Scheduler cycle loaded user settings count=${allUserSettings.length}`);
if (allUserSettings.length === 0) {
logger.debug(`[IntakeReminder] No users with settings found`);
return; // No users with settings
}
logger.debug(`[IntakeReminder] Evaluating ${allUserSettings.length} intake profile(s) for auto-marking`);
for (const userSettings of allUserSettings) {
await checkAndSendIntakeRemindersForUser(userSettings, logger);
}
logger.debug(`[IntakeReminder] Scheduler cycle finished`);
}
export async function checkAndSendIntakeRemindersForUser(
settings: UserSettings & { userId: number },
logger: ServiceLogger
): Promise<void> {
const username = await getUsernameForLog(settings.userId);
logger.info(
`[IntakeReminder] Evaluating intake reminders for user=${username} (userId=${settings.userId}, emailEnabled=${settings.emailEnabled}, pushEnabled=${settings.shoutrrrEnabled}, skipTaken=${settings.skipRemindersForTakenDoses}, repeat=${settings.repeatRemindersEnabled}, mode=${settings.stockCalculationMode})`
);
const language = settings.language;
const tr = getTranslations(language);
const schedulerUserName = await resolveSchedulerUserDisplayName(settings.userId);
logger.debug(`[IntakeReminder] Evaluating intake reminder profile for user '${schedulerUserName}'`);
const rows = await db
.select()
.from(medications)
.where(eq(medications.userId, settings.userId))
.orderBy(medications.id);
.where(and(eq(medications.userId, settings.userId), eq(medications.isObsolete, false)));
const activeRows = rows.filter((med) => med.isObsolete !== true).sort((left, right) => left.id - right.id);
const locale = getDateLocale(language);
const tz = getTimezone();
await autoMarkDueIntakesAsTaken(settings, rows, locale, tz, logger);
const autoMarkedCount = await autoMarkDueIntakesAsTaken(settings, activeRows, locale, tz, logger);
if (autoMarkedCount > 0) {
logger.info(
`[IntakeReminder] Auto-mark summary for user=${username} (userId=${settings.userId}): autoMarkedCount=${autoMarkedCount}`
);
}
if (settings.stockCalculationMode === "automatic" && settings.skipRemindersForTakenDoses) {
logger.info(
`[IntakeReminder] Reminder sending skipped for user=${username} (userId=${settings.userId}) because stockCalculationMode=automatic and skipRemindersForTakenDoses=true`
);
return;
}
// Check if any intake reminder notifications are enabled (granular check)
const emailEnabled = settings.emailEnabled && settings.notificationEmail && settings.emailIntakeReminders;
const shoutrrrEnabled = settings.shoutrrrEnabled && settings.shoutrrrUrl && settings.shoutrrrIntakeReminders;
if (!emailEnabled && !shoutrrrEnabled) {
logger.debug(
`[IntakeReminder] Notification sending disabled for user=${username} (userId=${settings.userId}): both email and push intake reminders are off`
);
return; // No intake reminder notifications enabled for this user
}
logger.debug(
`[IntakeReminder] Notifications enabled for current scheduler context (email:${emailEnabled}, shoutrrr:${shoutrrrEnabled})`
);
// Build medication entries that have at least one reminder-enabled intake.
// Intake-level reminders are the single source of truth.
const reminderEntries = rows
const reminderEntries = activeRows
.map((med) => {
const intakes = parseIntakesJson(
med.intakesJson,
@@ -478,42 +517,32 @@ export async function checkAndSendIntakeRemindersForUser(
.filter((entry) => entry.intakesWithReminders.length > 0);
if (reminderEntries.length === 0) {
logger.debug("[IntakeReminder] No medications have reminders enabled for current scheduler context");
logger.debug(
`[IntakeReminder] No reminder-enabled intake definitions for user=${username} (userId=${settings.userId})`
);
return; // No medications have reminders enabled for this user
}
logger.debug(`[IntakeReminder] Found ${reminderEntries.length} medications with reminders`);
const state = loadIntakeReminderState();
const allUpcoming: (UpcomingIntake & { medicationId: number; blisterIndex: number })[] = [];
let scheduledIntakesTodayCount = 0;
// Get start and end of today in user's timezone (for filtering today's doses only)
const now = new Date();
const checkMinuteStart = new Date(Math.floor(now.getTime() / 60000) * 60000);
const checkMinuteEnd = new Date(checkMinuteStart.getTime() + 60000);
const todayStart = new Date(now.toLocaleString("en-US", { timeZone: tz }));
todayStart.setHours(0, 0, 0, 0);
const todayEnd = new Date(now.toLocaleString("en-US", { timeZone: tz }));
todayEnd.setHours(23, 59, 59, 999);
logger.debug(`[IntakeReminder] Today range: ${todayStart.toISOString()} to ${todayEnd.toISOString()}`);
// Find intakes: upcoming ones in reminder window + past ones for repeat reminders
for (const { med, intakes, intakesWithReminders } of reminderEntries) {
// Medication-level takenBy (for fallback/display purposes)
const medicationTakenBy = parseTakenByJson(med.takenByJson);
const medDisplayName = med.name || med.genericName || "";
logger.debug(
`[IntakeReminder] Processing medication '${medDisplayName}' (id=${med.id}) with ${intakes.length} intake definition(s)`
);
// Process each intake separately to track blisterIndex
intakesWithReminders.forEach((intake, _blisterIndex) => {
const actualIndex = intakes.indexOf(intake); // Get the actual index in original array
const intakeDescriptor = formatIntakeDescriptor(actualIndex, medDisplayName, med.id, intake);
logger.debug(`[IntakeReminder] ${intakeDescriptor}`);
const todaysIntakesForThisDefinition = getTodaysIntakes(
medDisplayName,
@@ -540,12 +569,6 @@ export async function checkAndSendIntakeRemindersForUser(
med.id,
med.doseUnit ?? "mg"
);
logger.debug(
`[IntakeReminder] ${intakeDescriptor} -> ${upcomingIntakes.length} intake(s) currently due for advance reminder (default ${REMINDER_MINUTES_BEFORE} min before intake, with catch-up while intake is still in the future)`
);
logger.debug(
`[IntakeReminder] ${intakeDescriptor} -> ${todaysIntakesForThisDefinition.length} scheduled intake(s) today (independent of reminder window)`
);
// Add upcoming intakes for first reminders
allUpcoming.push(
@@ -558,15 +581,9 @@ export async function checkAndSendIntakeRemindersForUser(
// If repeat reminders enabled, also check for missed intakes (past the intake time)
if (settings.repeatRemindersEnabled) {
logger.debug(
`[IntakeReminder] ${intakeDescriptor} -> ${todaysIntakesForThisDefinition.length} candidate intake(s) for repeat reminders`
);
const missedIntakes = todaysIntakesForThisDefinition.filter(
(todayIntake) => todayIntake.intakeTime.getTime() < now.getTime()
);
logger.debug(
`[IntakeReminder] ${intakeDescriptor} -> ${missedIntakes.length} missed intake(s) (past intake time)`
);
// Add missed intakes for repeat reminders (only if not already in upcoming list)
const upcomingTimes = new Set(upcomingIntakes.map((i) => i.intakeTime.getTime()));
@@ -583,16 +600,17 @@ export async function checkAndSendIntakeRemindersForUser(
});
}
logger.debug(`[IntakeReminder] Total scheduled intakes for today: ${scheduledIntakesTodayCount}`);
logger.debug(`[IntakeReminder] Total reminder candidates in current check: ${allUpcoming.length}`);
if (allUpcoming.length === 0) {
logger.debug(
`[IntakeReminder] No reminder due in this check window (minute=${checkMinuteStart.toISOString()}..${checkMinuteEnd.toISOString()}, advanceLead=${REMINDER_MINUTES_BEFORE}m, plus catch-up while intake is still future)`
`[IntakeReminder] No upcoming intakes in reminder window for user=${username} (userId=${settings.userId}, scheduledToday=${scheduledIntakesTodayCount})`
);
return; // No upcoming intakes for today
}
logger.info(
`[IntakeReminder] Candidate intakes for user=${username} (userId=${settings.userId}): scheduledToday=${scheduledIntakesTodayCount}, candidates=${allUpcoming.length}`
);
// Determine which doses need reminders (new or repeated)
const nowMs = Date.now();
const maxReminders = settings.maxNaggingReminders ?? 5;
@@ -620,9 +638,6 @@ export async function checkAndSendIntakeRemindersForUser(
// Recently missed — scheduler likely recovered from sleep/restart.
// Send a catch-up reminder (counts as first nagging reminder).
remindersToSend.push({ ...intake, currentSendCount: 1, maxReminders, isAdvanceReminder: false });
logger.info(
`[IntakeReminder] Catch-up reminder for recently missed intake (${Math.round(minutesSinceIntake)} min ago)`
);
} else {
// Long ago — seed state without notification (user likely already noticed)
state.reminders[key] = {
@@ -631,14 +646,10 @@ export async function checkAndSendIntakeRemindersForUser(
sendCount: 0,
advanceSent: false,
};
logger.debug(
`[IntakeReminder] Seeding state for old past intake (no notification — ${Math.round(minutesSinceIntake)} min ago)`
);
}
} else {
// Upcoming - this is advance reminder (no counter)
remindersToSend.push({ ...intake, currentSendCount: 0, maxReminders, isAdvanceReminder: true });
logger.debug("[IntakeReminder] Advance reminder candidate added");
}
} else if (settings.repeatRemindersEnabled && isIntakePast) {
// Intake time passed - check if we need to send nagging reminder
@@ -650,23 +661,41 @@ export async function checkAndSendIntakeRemindersForUser(
const currentNaggingCount = existingEntry.sendCount;
if (currentNaggingCount >= maxReminders) {
// Max nagging reminders reached - stop
logger.debug(`[IntakeReminder] Max nagging (${maxReminders}) reached for intake reminder key`);
} else if (timeSinceLastReminder >= intervalMs) {
const nextSendCount = currentNaggingCount + 1;
remindersToSend.push({ ...intake, currentSendCount: nextSendCount, maxReminders, isAdvanceReminder: false });
logger.debug(`[IntakeReminder] Nagging reminder candidate added (${nextSendCount}/${maxReminders})`);
}
}
// Else: Already sent and either repeats disabled or intake not yet past - skip
}
if (remindersToSend.length === 0) {
logger.debug(
`[IntakeReminder] No reminders to send for user=${username} (userId=${settings.userId}) after state/repeat evaluation`
);
return; // All reminders already sent and no repeats needed
}
logger.info(
`[IntakeReminder] Reminders selected for user=${username} (userId=${settings.userId}): count=${remindersToSend.length} :: ${remindersToSend
.map((intake) =>
formatIntakeLog({
medName: intake.medName,
medicationId: intake.medicationId,
blisterIndex: intake.blisterIndex,
intakeTime: intake.intakeTime,
intakeTimeStr: intake.intakeTimeStr,
usage: intake.usage,
doseUnit: intake.doseUnit,
takenBy: intake.takenBy,
})
)
.join(" | ")}`
);
// If skipRemindersForTakenDoses is enabled, filter out doses that were already taken today
if (settings.skipRemindersForTakenDoses) {
const beforeFilterCount = remindersToSend.length;
// Query doses marked as taken today (takenAt is timestamp, stored as seconds since epoch)
const takenToday = await db
.select()
@@ -692,29 +721,30 @@ export async function checkAndSendIntakeRemindersForUser(
// For person-specific intake, check if that person has taken it
const doseId = `${intake.medicationId}-${intake.blisterIndex}-${dateOnlyMs}-${intake.takenBy}`;
const isTaken = takenDoseIds.has(doseId);
if (isTaken) {
logger.debug("[IntakeReminder] Skipping reminder candidate - dose already taken");
}
return !isTaken;
} else {
// For non-person-specific intakes
const doseId = `${intake.medicationId}-${intake.blisterIndex}-${dateOnlyMs}`;
const isTaken = takenDoseIds.has(doseId);
if (isTaken) {
logger.debug("[IntakeReminder] Skipping reminder candidate - dose already taken");
}
return !isTaken;
}
});
const filteredOutCount = beforeFilterCount - remindersToSend.length;
if (filteredOutCount > 0) {
logger.info(
`[IntakeReminder] Removed reminders for already taken doses for user=${username} (userId=${settings.userId}): removed=${filteredOutCount}, remaining=${remindersToSend.length}`
);
}
if (remindersToSend.length === 0) {
logger.debug("[IntakeReminder] All doses taken, skipping reminders");
logger.info(
`[IntakeReminder] All candidate reminders already taken for user=${username} (userId=${settings.userId}); nothing to send`
);
return;
}
}
logger.info(`[IntakeReminder] Sending reminder for ${remindersToSend.length} intakes...`);
// Determine if this is a repeat reminder:
// - Any intake already has a state entry AND is past (repeat after first reminder)
// - OR intake is past even without state entry (missed the 15-min window)
@@ -744,10 +774,14 @@ export async function checkAndSendIntakeRemindersForUser(
hasNaggingReminder ? maxReminderCount : undefined
);
emailSuccess = result.success;
if (result.success) {
logger.info("[IntakeReminder] Email sent successfully");
if (!result.success) {
logger.error(
`[IntakeReminder] Email delivery failed for user=${username} (userId=${settings.userId}): ${result.error}`
);
} else {
logger.error(`[IntakeReminder] Failed to send email: ${result.error}`);
logger.info(
`[IntakeReminder] Email delivered for user=${username} (userId=${settings.userId}, recipient=${settings.notificationEmail}, reminders=${remindersToSend.length}, messageId=${result.messageId ?? "n/a"})`
);
}
}
@@ -810,10 +844,14 @@ export async function checkAndSendIntakeRemindersForUser(
const result = await sendShoutrrrNotification(settings.shoutrrrUrl!, title, message);
shoutrrrSuccess = result.success;
if (result.success) {
logger.info("[IntakeReminder] Push notification sent successfully");
if (!result.success) {
logger.error(
`[IntakeReminder] Push delivery failed for user=${username} (userId=${settings.userId}): ${result.error}`
);
} else {
logger.error(`[IntakeReminder] Failed to send push: ${result.error}`);
logger.info(
`[IntakeReminder] Push delivered for user=${username} (userId=${settings.userId}, reminders=${remindersToSend.length})`
);
}
}
@@ -880,6 +918,13 @@ export async function checkAndSendIntakeRemindersForUser(
const medName = firstReminder?.medName;
const takenBy = firstReminder?.takenBy || undefined;
await updateUserReminderSentTime(settings.userId, "intake", channel, medName, takenBy);
logger.info(
`[IntakeReminder] Reminder state persisted for user=${username} (userId=${settings.userId}, channel=${channel}, reminders=${remindersToSend.length}, firstMed=${medName ?? "n/a"}, firstTakenBy=${takenBy ?? "none"})`
);
} else {
logger.info(
`[IntakeReminder] No reminder channel succeeded for user=${username} (userId=${settings.userId}, remindersAttempted=${remindersToSend.length})`
);
}
}
+20 -9
View File
@@ -539,6 +539,14 @@ describe("E2E Tests with Real Routes", () => {
it("should return shared medication overview for a valid token", async () => {
await createMedication(testClient, userId, "Aspirin", ["Daniel"]);
await testClient.execute({
sql: `INSERT INTO medications (
user_id, name, taken_by_json, package_type, pack_count, blisters_per_pack, pills_per_blister,
package_amount_value, package_amount_unit, total_pills, loose_tablets, medication_form,
usage_json, every_json, start_json
) VALUES (?, ?, ?, 'tube', 2, 1, 1, 40, 'g', 80, 80, 'topical', '[1]', '[1]', '["2025-01-01T08:00:00.000Z"]')`,
args: [userId, "Hydrogel", JSON.stringify(["Daniel"])],
});
const token = "abcdef0123456789";
await createShareToken(testClient, userId, "Daniel", token);
@@ -554,9 +562,17 @@ describe("E2E Tests with Real Routes", () => {
expect(data.takenBy).toBe("Daniel");
expect(data.sharedBy).toBe("__anonymous__");
expect(Array.isArray(data.medications)).toBe(true);
expect(data.medications).toHaveLength(1);
expect(data.medications).toHaveLength(2);
expect(data.medications[0].name).toBe("Aspirin");
expect(data.medications[0].currentStock).toBeTypeOf("number");
const hydrogel = data.medications.find((med: { name: string }) => med.name === "Hydrogel");
expect(hydrogel).toMatchObject({
packageType: "tube",
packCount: 2,
packageAmountValue: 40,
packageAmountUnit: "g",
totalPills: 80,
});
});
it("should return 404 for unknown overview token", async () => {
@@ -587,7 +603,7 @@ describe("E2E Tests with Real Routes", () => {
expect(data.expiredAt).toBeTypeOf("string");
});
it("should hide stock fields in overview when share_stock_status is disabled", async () => {
it("should always show stock fields in overview regardless of share_stock_status setting", async () => {
await createMedication(testClient, userId, "Ibuprofen", ["Daniel"]);
const token = "0123456789abcdef";
await createShareToken(testClient, userId, "Daniel", token);
@@ -604,11 +620,8 @@ describe("E2E Tests with Real Routes", () => {
expect(response.statusCode).toBe(200);
const [medication] = response.json().medications;
expect(medication.currentStock).toBeNull();
expect(medication.capacity).toBeNull();
expect(medication.daysLeft).toBeNull();
expect(medication.depletionDate).toBeNull();
expect(medication.priority).toBeNull();
expect(medication.currentStock).toBeTypeOf("number");
expect(medication.capacity).toBeTypeOf("number");
});
});
@@ -2469,7 +2482,6 @@ describe("E2E Tests with Real Routes", () => {
maxNaggingReminders: 5,
language: "en",
stockCalculationMode: "automatic",
shareStockStatus: true,
upcomingTodayOnly: false,
shareScheduleTodayOnly: false,
swapDashboardMainSections: false,
@@ -2513,7 +2525,6 @@ describe("E2E Tests with Real Routes", () => {
maxNaggingReminders: 5,
language: "en",
stockCalculationMode: "automatic",
shareStockStatus: true,
upcomingTodayOnly: false,
shareScheduleTodayOnly: false,
swapDashboardMainSections: false,
+158 -133
View File
@@ -18,6 +18,14 @@ function createLogger() {
};
}
function mockSelectWhere<T>(result: T) {
return {
from: () => ({
where: async () => result,
}),
} as never;
}
describe("checkAndSendIntakeRemindersForUser", () => {
const mockedDb = vi.mocked(db);
let originalTz: string | undefined;
@@ -45,73 +53,43 @@ describe("checkAndSendIntakeRemindersForUser", () => {
const insertMock = vi.mocked(mockedDb.insert);
selectMock
.mockImplementationOnce(
() =>
({
from: () => ({
where: () => ({
limit: async () => [{ username: "auto-user" }],
}),
}),
}) as never
.mockImplementationOnce(() => mockSelectWhere([{ username: "test-user" }]))
.mockImplementationOnce(() =>
mockSelectWhere([
{
id: 7,
userId: 11,
name: "Vitamin D",
genericName: null,
takenByJson: null,
packageType: "blister",
medicationForm: "tablet",
packCount: 1,
blistersPerPack: 1,
pillsPerBlister: 10,
looseTablets: 0,
stockAdjustment: 0,
pillWeightMg: null,
doseUnit: "mg",
isObsolete: false,
intakeRemindersEnabled: false,
intakesJson: JSON.stringify([
{
usage: 1,
every: 1,
start: "2026-01-05T08:00:00.000Z",
takenBy: null,
intakeRemindersEnabled: false,
},
]),
usageJson: "[]",
everyJson: "[]",
startJson: "[]",
},
])
)
.mockImplementationOnce(
() =>
({
from: () => ({
where: () => ({
orderBy: async () => [
{
id: 7,
userId: 11,
name: "Vitamin D",
genericName: null,
takenByJson: null,
packageType: "blister",
medicationForm: "tablet",
packCount: 1,
blistersPerPack: 1,
pillsPerBlister: 10,
looseTablets: 0,
stockAdjustment: 0,
pillWeightMg: null,
doseUnit: "mg",
isObsolete: false,
intakeRemindersEnabled: false,
intakesJson: JSON.stringify([
{
usage: 1,
every: 1,
start: "2026-01-05T08:00:00.000Z",
takenBy: null,
intakeRemindersEnabled: false,
},
]),
usageJson: "[]",
everyJson: "[]",
startJson: "[]",
},
],
}),
}),
}) as never
)
.mockImplementationOnce(
() =>
({
from: () => ({
where: async () => [],
}),
}) as never
)
.mockImplementationOnce(
() =>
({
from: () => ({
where: async () => [],
}),
}) as never
);
.mockImplementationOnce(() => mockSelectWhere([]))
.mockImplementationOnce(() => mockSelectWhere([]));
insertMock.mockImplementation(
() =>
@@ -148,7 +126,7 @@ describe("checkAndSendIntakeRemindersForUser", () => {
takenSource: "automatic",
dismissed: false,
});
expect(logger.info).toHaveBeenCalledWith("[IntakeReminder] Auto-marked 1 due intake dose(s) as taken");
expect(logger.info).toHaveBeenCalledWith("[IntakeReminder] Auto-mark completed for userId=11: inserted=1");
});
it("does not auto-mark due intakes when current stock is empty", async () => {
@@ -157,73 +135,43 @@ describe("checkAndSendIntakeRemindersForUser", () => {
const insertMock = vi.mocked(mockedDb.insert);
selectMock
.mockImplementationOnce(
() =>
({
from: () => ({
where: () => ({
limit: async () => [{ username: "auto-user" }],
}),
}),
}) as never
.mockImplementationOnce(() => mockSelectWhere([{ username: "test-user" }]))
.mockImplementationOnce(() =>
mockSelectWhere([
{
id: 7,
userId: 11,
name: "Vitamin D",
genericName: null,
takenByJson: null,
packageType: "blister",
medicationForm: "tablet",
packCount: 0,
blistersPerPack: 1,
pillsPerBlister: 10,
looseTablets: 0,
stockAdjustment: 0,
pillWeightMg: null,
doseUnit: "mg",
isObsolete: false,
intakeRemindersEnabled: false,
intakesJson: JSON.stringify([
{
usage: 1,
every: 1,
start: "2026-01-05T08:00:00.000Z",
takenBy: null,
intakeRemindersEnabled: false,
},
]),
usageJson: "[]",
everyJson: "[]",
startJson: "[]",
},
])
)
.mockImplementationOnce(
() =>
({
from: () => ({
where: () => ({
orderBy: async () => [
{
id: 7,
userId: 11,
name: "Vitamin D",
genericName: null,
takenByJson: null,
packageType: "blister",
medicationForm: "tablet",
packCount: 0,
blistersPerPack: 1,
pillsPerBlister: 10,
looseTablets: 0,
stockAdjustment: 0,
pillWeightMg: null,
doseUnit: "mg",
isObsolete: false,
intakeRemindersEnabled: false,
intakesJson: JSON.stringify([
{
usage: 1,
every: 1,
start: "2026-01-05T08:00:00.000Z",
takenBy: null,
intakeRemindersEnabled: false,
},
]),
usageJson: "[]",
everyJson: "[]",
startJson: "[]",
},
],
}),
}),
}) as never
)
.mockImplementationOnce(
() =>
({
from: () => ({
where: async () => [],
}),
}) as never
)
.mockImplementationOnce(
() =>
({
from: () => ({
where: async () => [],
}),
}) as never
);
.mockImplementationOnce(() => mockSelectWhere([]))
.mockImplementationOnce(() => mockSelectWhere([]));
insertMock.mockImplementation(
() =>
@@ -255,4 +203,81 @@ describe("checkAndSendIntakeRemindersForUser", () => {
expect(insertedRows).toHaveLength(0);
expect(logger.info).not.toHaveBeenCalledWith("[IntakeReminder] Auto-marked 1 due intake dose(s) as taken");
});
it("suppresses intake notifications entirely when automatic mode and skip-taken reminders are both enabled", async () => {
const insertedRows: Array<Record<string, unknown>> = [];
const selectMock = vi.mocked(mockedDb.select);
const insertMock = vi.mocked(mockedDb.insert);
selectMock
.mockImplementationOnce(() => mockSelectWhere([{ username: "test-user" }]))
.mockImplementationOnce(() =>
mockSelectWhere([
{
id: 7,
userId: 11,
name: "Vitamin D",
genericName: null,
takenByJson: null,
packageType: "blister",
medicationForm: "tablet",
packCount: 1,
blistersPerPack: 1,
pillsPerBlister: 10,
looseTablets: 0,
stockAdjustment: 0,
pillWeightMg: null,
doseUnit: "mg",
isObsolete: false,
intakeRemindersEnabled: true,
intakesJson: JSON.stringify([
{
usage: 1,
every: 1,
start: "2026-01-05T08:00:00.000Z",
takenBy: null,
intakeRemindersEnabled: true,
},
]),
usageJson: "[]",
everyJson: "[]",
startJson: "[]",
},
])
)
.mockImplementationOnce(() => mockSelectWhere([]))
.mockImplementationOnce(() => mockSelectWhere([]));
insertMock.mockImplementation(
() =>
({
values: async (row: Record<string, unknown>) => {
insertedRows.push(row);
},
}) as never
);
const logger = createLogger();
await checkAndSendIntakeRemindersForUser(
{
userId: 11,
language: "en",
stockCalculationMode: "automatic",
skipRemindersForTakenDoses: true,
emailEnabled: true,
notificationEmail: "user@example.com",
emailIntakeReminders: true,
shoutrrrEnabled: false,
shoutrrrUrl: null,
shoutrrrIntakeReminders: false,
repeatRemindersEnabled: false,
} as never,
logger as never
);
expect(insertedRows).toHaveLength(1);
expect(logger.info).not.toHaveBeenCalledWith("[IntakeReminder] Sending reminder for 1 intakes...");
expect(logger.error).not.toHaveBeenCalled();
});
});
-6
View File
@@ -140,7 +140,6 @@ describe("Real route coverage: settings/export/report", () => {
expect(response.statusCode).toBe(200);
const body = response.json();
expect(body.language).toBe("en");
expect(body.shareStockStatus).toBe(true);
expect(body.upcomingTodayOnly).toBe(false);
expect(body.shareScheduleTodayOnly).toBe(false);
});
@@ -177,7 +176,6 @@ describe("Real route coverage: settings/export/report", () => {
maxNaggingReminders: 4,
language: "en",
stockCalculationMode: "manual",
shareStockStatus: true,
upcomingTodayOnly: true,
shareScheduleTodayOnly: true,
swapDashboardMainSections: true,
@@ -238,7 +236,6 @@ describe("Real route coverage: settings/export/report", () => {
maxNaggingReminders: 5,
language: "en",
stockCalculationMode: "automatic",
shareStockStatus: true,
upcomingTodayOnly: false,
shareScheduleTodayOnly: false,
swapDashboardMainSections: false,
@@ -453,7 +450,6 @@ describe("Real route coverage: settings/export/report", () => {
emailPrescriptionReminders: true,
shoutrrrPrescriptionReminders: true,
stockCalculationMode: "automatic",
shareStockStatus: true,
})
);
});
@@ -509,7 +505,6 @@ describe("Real route coverage: settings/export/report", () => {
reminderRepeatIntervalMinutes: 30,
maxNaggingReminders: 5,
stockCalculationMode: "manual",
shareStockStatus: true,
upcomingTodayOnly: false,
shareScheduleTodayOnly: false,
swapDashboardMainSections: false,
@@ -556,7 +551,6 @@ describe("Real route coverage: settings/export/report", () => {
emailPrescriptionReminders: true,
shoutrrrPrescriptionReminders: true,
stockCalculationMode: "automatic",
shareStockStatus: true,
}),
])
);
@@ -224,7 +224,6 @@ describe("Settings and API key security contracts", () => {
maxNaggingReminders: 5,
language: "en",
stockCalculationMode: "automatic",
shareStockStatus: true,
upcomingTodayOnly: false,
shareScheduleTodayOnly: false,
swapDashboardMainSections: false,
+2 -61
View File
@@ -51,7 +51,6 @@ async function registerSettingsRoutes(ctx: TestContext) {
expiryWarningDays: 90,
language: "en",
stockCalculationMode: "automatic",
shareStockStatus: true,
};
}
@@ -77,7 +76,6 @@ async function registerSettingsRoutes(ctx: TestContext) {
expiryWarningDays: s.expiry_warning_days,
language: s.language,
stockCalculationMode: s.stock_calculation_mode,
shareStockStatus: Boolean(s.share_stock_status ?? 1),
};
});
@@ -104,7 +102,6 @@ async function registerSettingsRoutes(ctx: TestContext) {
expiryWarningDays?: number;
language?: string;
stockCalculationMode?: "automatic" | "manual";
shareStockStatus?: boolean;
};
}>("/settings", async (request, reply) => {
const userId = 1;
@@ -177,7 +174,7 @@ async function registerSettingsRoutes(ctx: TestContext) {
body.expiryWarningDays ?? 90,
body.language || "en",
body.stockCalculationMode || "automatic",
body.shareStockStatus !== false ? 1 : 0,
1,
],
});
} else {
@@ -228,7 +225,7 @@ async function registerSettingsRoutes(ctx: TestContext) {
body.expiryWarningDays ?? 90,
body.language || "en",
body.stockCalculationMode || "automatic",
body.shareStockStatus !== false ? 1 : 0,
1,
userId,
],
});
@@ -550,62 +547,6 @@ describe("Settings API", () => {
// ---------------------------------------------------------------------------
// Share Stock Status
// ---------------------------------------------------------------------------
describe("Share Stock Status", () => {
it("should default to true (show stock on shared links)", async () => {
const response = await ctx.app.inject({
method: "GET",
url: "/settings",
});
expect(response.statusCode).toBe(200);
expect(response.json().shareStockStatus).toBe(true);
});
it("should disable share stock status", async () => {
const response = await ctx.app.inject({
method: "PUT",
url: "/settings",
payload: { shareStockStatus: false },
});
expect(response.statusCode).toBe(200);
const getResponse = await ctx.app.inject({
method: "GET",
url: "/settings",
});
expect(getResponse.json().shareStockStatus).toBe(false);
});
it("should re-enable share stock status", async () => {
// Disable first
await ctx.app.inject({
method: "PUT",
url: "/settings",
payload: { shareStockStatus: false },
});
// Re-enable
const response = await ctx.app.inject({
method: "PUT",
url: "/settings",
payload: { shareStockStatus: true },
});
expect(response.statusCode).toBe(200);
const getResponse = await ctx.app.inject({
method: "GET",
url: "/settings",
});
expect(getResponse.json().shareStockStatus).toBe(true);
});
});
// ---------------------------------------------------------------------------
// Repeat Reminders & Skip Reminders Settings
// ---------------------------------------------------------------------------
-44
View File
@@ -142,14 +142,6 @@ async function registerShareRoutes(ctx: TestContext) {
const lowStockDays = settingsResult.rows.length > 0 ? (settingsResult.rows[0].low_stock_days as number) : 30;
// Get shareStockStatus setting
const shareStockResult = await client.execute({
sql: `SELECT share_stock_status FROM user_settings WHERE user_id = ?`,
args: [share.user_id],
});
const shareStockStatus =
shareStockResult.rows.length > 0 ? Boolean(shareStockResult.rows[0].share_stock_status ?? 1) : true;
return {
takenBy: share.taken_by,
sharedBy: share.owner_username,
@@ -158,7 +150,6 @@ async function registerShareRoutes(ctx: TestContext) {
stockThresholds: {
lowStockDays,
},
shareStockStatus,
};
});
@@ -431,41 +422,6 @@ describe("Share Link API", () => {
expect(med.blisters).toHaveLength(1);
expect(med.blisters[0].usage).toBe(1);
expect(med.blisters[0].every).toBe(1);
// shareStockStatus should default to true
expect(data.shareStockStatus).toBe(true);
});
it("should respect shareStockStatus setting when disabled", async () => {
// Create medication
await createTestMedication(ctx.client, {
userId,
name: "TestMed",
takenBy: ["Daniel"],
packCount: 1,
blistersPerPack: 1,
pillsPerBlister: 10,
looseTablets: 0,
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
});
// Set shareStockStatus to false
await setUserSettings(ctx.client, { userId, shareStockStatus: false });
// Create share token
const token = await createTestShareToken(ctx.client, {
userId,
takenBy: "Daniel",
scheduleDays: 30,
});
const response = await ctx.app.inject({
method: "GET",
url: `/share/${token}`,
});
expect(response.statusCode).toBe(200);
expect(response.json().shareStockStatus).toBe(false);
});
it("should return 404 for invalid token", async () => {
+784 -1572
View File
File diff suppressed because it is too large Load Diff
+6 -6
View File
@@ -1,7 +1,7 @@
{
"name": "medassist-ng-frontend",
"private": true,
"version": "1.20.0",
"version": "1.20.2",
"type": "module",
"scripts": {
"dev": "vite",
@@ -46,11 +46,11 @@
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/react-router-dom": "^5.3.3",
"@vitejs/plugin-react": "^5.1.4",
"@vitest/coverage-v8": "^4.0.18",
"jsdom": "^28.1.0",
"@vitejs/plugin-react": "^6.0.1",
"@vitest/coverage-v8": "^4.1.0",
"jsdom": "^29.0.0",
"typescript": "^5.5.4",
"vite": "^7.3.1",
"vitest": "^4.0.17"
"vite": "^8.0.0",
"vitest": "^4.1.0"
}
}
+3 -5
View File
@@ -980,17 +980,15 @@ export function MedDetailModal({
)}
</h3>
<div className="med-detail-schedules">
{scheduleIntakes.map((intake, idx) => {
{scheduleIntakes.map((intake) => {
const hasPerIntakeTakenBy = !!intake.takenBy;
const personCount = Math.max(1, selectedMed.takenBy?.length ?? 0);
const totalUsage = hasPerIntakeTakenBy ? intake.usage : intake.usage * personCount;
const showIntakeBell = intake.intakeRemindersEnabled === true;
const intakeKey = `${intake.start}-${intake.usage}-${intake.every}-${intake.takenBy ?? ""}-${intake.intakeRemindersEnabled ? "reminder" : "silent"}`;
return (
<div
key={`${intake.start}-${intake.usage}-${intake.every}-${idx}`}
className="med-schedule-row blister-row-simple"
>
<div key={intakeKey} className="med-schedule-row blister-row-simple">
<span className="med-schedule-usage">
{getScheduleUsageLabel(totalUsage, intake.intakeUnit)}
{showPillWeightDetails && ` (${totalUsage * pillWeightMg} ${selectedMed.doseUnit ?? "mg"})`}
+97 -97
View File
@@ -814,106 +814,106 @@ export function MobileEditModal({
</button>
)}
</div>
{form.intakes.map((intake, idx) => (
<div
key={`${intake.startDate}-${intake.startTime}-${intake.usage}-${intake.every}-${intake.takenBy ?? ""}-${idx}`}
className="blister-row"
>
<label className="compact">
<span>{getUsageLabel(intake)}</span>
<FormNumberStepper
value={intake.usage}
onChange={(nextValue) => onSetIntakeValue(idx, "usage", nextValue)}
min={allowFractionalIntake ? 0.5 : 1}
step={allowFractionalIntake ? 0.5 : 1}
allowDecimal={allowFractionalIntake}
decrementLabel={decrementValueLabel}
incrementLabel={incrementValueLabel}
/>
</label>
<label className="compact">
<span>{t("form.blisters.everyDays")}</span>
<FormNumberStepper
value={intake.every}
onChange={(nextValue) => onSetIntakeValue(idx, "every", nextValue)}
min={1}
decrementLabel={decrementValueLabel}
incrementLabel={incrementValueLabel}
/>
</label>
<label className="compact full-row">
<span>{t("form.blisters.startDate")}</span>
<DateInput
value={intake.startDate}
onChange={(e) => onSetIntakeValue(idx, "startDate", e.target.value)}
/>
</label>
<label className="compact time-label">
<span>{t("form.blisters.startTime")}</span>
<input
type="time"
value={intake.startTime}
onChange={(e) => onSetIntakeValue(idx, "startTime", e.target.value)}
/>
</label>
{isLiquidContainerPackageType(form.packageType) && (
<label className="compact full-row">
<span>{t("form.blisters.intakeUnit")}</span>
<select
className="select-field"
value={intake.intakeUnit}
onChange={(e) =>
onSetIntakeValue(idx, "intakeUnit", e.target.value as "ml" | "tsp" | "tbsp")
}
>
<option value="ml">{t("form.blisters.intakeUnitMl")}</option>
<option value="tsp">{t("form.blisters.intakeUnitTsp")}</option>
<option value="tbsp">{t("form.blisters.intakeUnitTbsp")}</option>
</select>
</label>
)}
{form.takenBy.length === 0 ? null : (
<label className="compact full-row taken-by-field">
<span>{t("form.blisters.takenByIntake")}</span>
<select
className="select-field"
value={intake.takenBy}
onChange={(e) => onSetIntakeValue(idx, "takenBy", e.target.value)}
>
{form.takenBy.map((person) => (
<option key={person} value={person}>
{person}
</option>
))}
</select>
</label>
)}
<div className="remind-toggle-row" title={t("form.blisters.remindTooltip")}>
<span className="legend-hint">
<Bell size={14} aria-hidden="true" />
</span>
<label className="toggle-switch small">
<input
type="checkbox"
checked={intake.intakeRemindersEnabled}
onChange={(e) => onSetIntakeValue(idx, "intakeRemindersEnabled", e.target.checked)}
{form.intakes.map((intake, idx) => {
const intakeKey = `${intake.startDate}-${intake.startTime}-${intake.usage}-${intake.every}-${intake.takenBy ?? ""}-${intake.intakeUnit ?? "unit"}-${intake.intakeRemindersEnabled ? "reminder" : "silent"}`;
return (
<div key={intakeKey} className="blister-row">
<label className="compact">
<span>{getUsageLabel(intake)}</span>
<FormNumberStepper
value={intake.usage}
onChange={(nextValue) => onSetIntakeValue(idx, "usage", nextValue)}
min={allowFractionalIntake ? 0.5 : 1}
step={allowFractionalIntake ? 0.5 : 1}
allowDecimal={allowFractionalIntake}
decrementLabel={decrementValueLabel}
incrementLabel={incrementValueLabel}
/>
<span className="toggle-slider"></span>
</label>
<label className="compact">
<span>{t("form.blisters.everyDays")}</span>
<FormNumberStepper
value={intake.every}
onChange={(nextValue) => onSetIntakeValue(idx, "every", nextValue)}
min={1}
decrementLabel={decrementValueLabel}
incrementLabel={incrementValueLabel}
/>
</label>
<label className="compact full-row">
<span>{t("form.blisters.startDate")}</span>
<DateInput
value={intake.startDate}
onChange={(e) => onSetIntakeValue(idx, "startDate", e.target.value)}
/>
</label>
<label className="compact time-label">
<span>{t("form.blisters.startTime")}</span>
<input
type="time"
value={intake.startTime}
onChange={(e) => onSetIntakeValue(idx, "startTime", e.target.value)}
/>
</label>
{isLiquidContainerPackageType(form.packageType) && (
<label className="compact full-row">
<span>{t("form.blisters.intakeUnit")}</span>
<select
className="select-field"
value={intake.intakeUnit}
onChange={(e) =>
onSetIntakeValue(idx, "intakeUnit", e.target.value as "ml" | "tsp" | "tbsp")
}
>
<option value="ml">{t("form.blisters.intakeUnitMl")}</option>
<option value="tsp">{t("form.blisters.intakeUnitTsp")}</option>
<option value="tbsp">{t("form.blisters.intakeUnitTbsp")}</option>
</select>
</label>
)}
{form.takenBy.length === 0 ? null : (
<label className="compact full-row taken-by-field">
<span>{t("form.blisters.takenByIntake")}</span>
<select
className="select-field"
value={intake.takenBy}
onChange={(e) => onSetIntakeValue(idx, "takenBy", e.target.value)}
>
{form.takenBy.map((person) => (
<option key={person} value={person}>
{person}
</option>
))}
</select>
</label>
)}
<div className="remind-toggle-row" title={t("form.blisters.remindTooltip")}>
<span className="legend-hint">
<Bell size={14} aria-hidden="true" />
</span>
<label className="toggle-switch small">
<input
type="checkbox"
checked={intake.intakeRemindersEnabled}
onChange={(e) => onSetIntakeValue(idx, "intakeRemindersEnabled", e.target.checked)}
/>
<span className="toggle-slider"></span>
</label>
</div>
{!readOnlyMode && form.intakes.length > 1 && (
<button
type="button"
className="danger remove-blister-btn icon-only tooltip-trigger"
onClick={() => onRemoveIntake(idx)}
aria-label={t("common.remove")}
data-tooltip={t("common.remove")}
>
<Minus size={18} aria-hidden="true" />
</button>
)}
</div>
{!readOnlyMode && form.intakes.length > 1 && (
<button
type="button"
className="danger remove-blister-btn icon-only tooltip-trigger"
onClick={() => onRemoveIntake(idx)}
aria-label={t("common.remove")}
data-tooltip={t("common.remove")}
>
<Minus size={18} aria-hidden="true" />
</button>
)}
</div>
))}
);
})}
</div>
</div>
<div className={`form-tab-panel${activeTab === "prescription" ? " active" : ""}`}>
@@ -1,18 +1,50 @@
import { useTranslation } from "react-i18next";
import type { SharedMedicationOverviewItem } from "../types";
import {
getPackageSize,
isLiquidContainerPackageType,
isTubePackageType,
type SharedMedicationOverviewItem,
} from "../types";
import { formatDate } from "../utils/formatters";
import { MedicationAvatar } from "./MedicationAvatar";
function formatPackageInfo(medication: SharedMedicationOverviewItem): string {
function formatPackageAmountUnit(medication: SharedMedicationOverviewItem, t: (key: string) => string): string | null {
if (isTubePackageType(medication.packageType)) {
return t("form.packageAmountUnitG");
}
if (isLiquidContainerPackageType(medication.packageType)) {
return t("form.packageAmountUnitMl");
}
if (medication.packageAmountUnit === "g") {
return t("form.packageAmountUnitG");
}
if (medication.packageAmountUnit === "ml") {
return t("form.packageAmountUnitMl");
}
return null;
}
function formatPackageInfo(medication: SharedMedicationOverviewItem, t: (key: string) => string): string {
if (medication.packageType === "blister") {
return `${medication.packCount} x ${medication.blistersPerPack} x ${medication.pillsPerBlister}`;
}
if (medication.totalPills !== null) {
return `${medication.packCount} x ${medication.totalPills}`;
const unitLabel = formatPackageAmountUnit(medication, t);
if (unitLabel && medication.packageAmountValue && medication.packageAmountValue > 0) {
const sizeLabel = `${medication.packageAmountValue} ${unitLabel}`;
return medication.packCount > 1 ? `${medication.packCount} x ${sizeLabel}` : sizeLabel;
}
return `${medication.packCount}`;
const packageSize = getPackageSize(medication);
if (packageSize > 0) {
return medication.packCount > 1 ? `${medication.packCount} x ${packageSize}` : `${packageSize}`;
}
return `${Math.max(medication.packCount, 1)}`;
}
function getOverviewStatus(
@@ -105,7 +137,7 @@ export function SharedMedicationOverviewSection({
</div>
</div>
</td>
<td>{formatPackageInfo(medication)}</td>
<td>{formatPackageInfo(medication, t)}</td>
<td>
<span className="shared-overview-stock-value">
{medication.currentStock === null || medication.capacity === null
@@ -158,7 +190,7 @@ export function SharedMedicationOverviewSection({
</div>
<div className="shared-overview-card-grid">
<span>{t("sharedOverview.columns.package")}</span>
<strong>{formatPackageInfo(medication)}</strong>
<strong>{formatPackageInfo(medication, t)}</strong>
<span>{t("sharedOverview.columns.stock")}</span>
<strong>
<span className="shared-overview-stock-value">
+142 -33
View File
@@ -15,9 +15,10 @@ import {
getMedTotal,
isLiquidContainerPackageType,
isTubePackageType,
type StockThresholds,
} from "../types";
import { getSystemLocale } from "../utils/formatters";
import { isDoseDismissed, parseLocalDateTime } from "../utils/schedule";
import { getStockStatus, isDoseDismissed, parseLocalDateTime } from "../utils/schedule";
import { loadCollapsedDaysFromStorage } from "../utils/storage";
import { MedicationAvatar } from "./MedicationAvatar";
import { SharedMedicationOverviewSection } from "./SharedMedicationOverviewSection";
@@ -401,8 +402,7 @@ export function SharedSchedule() {
fetchData();
}, [token, t]);
// Build schedule from medications - matches buildSchedulePreview logic exactly
const schedule = useMemo(() => {
function buildGroupedSchedule() {
if (!data) return [];
// Use same logic as buildSchedulePreview in main app
@@ -514,6 +514,11 @@ export function SharedSchedule() {
isPast: d.isPast,
meds: Array.from(d.meds.values()),
}));
}
// Visible schedule respects share-person filtering.
const schedule = useMemo(() => {
return buildGroupedSchedule();
}, [data, i18n.language]);
// Split into past, today, and future - matches main app logic
@@ -676,26 +681,42 @@ export function SharedSchedule() {
return coverage;
}, [data, takenDoses]);
const outOfStockMedicationIds = useMemo(
() =>
new Set(
(data?.medications ?? [])
.filter((med) => (coverageByMed[getMedDisplayName(med)]?.medsLeft ?? 1) <= 0)
.map((med) => med.id)
),
[data, coverageByMed]
);
const sharedStockThresholds = useMemo<StockThresholds | null>(() => {
if (!data?.stockThresholds) return null;
return {
lowStockDays: data.stockThresholds.lowStockDays,
normalStockDays: data.stockThresholds.normalStockDays ?? data.stockThresholds.lowStockDays,
highStockDays:
data.stockThresholds.highStockDays ??
Math.max(
(data.stockThresholds.normalStockDays ?? data.stockThresholds.lowStockDays) + 1,
data.stockThresholds.lowStockDays + 1
),
criticalStockDays:
data.stockThresholds.reminderDaysBefore ?? Math.max(1, Math.ceil(data.stockThresholds.lowStockDays / 2)),
expiryWarningDays: data.stockThresholds.expiryWarningDays ?? 30,
};
}, [data?.stockThresholds]);
const isDoseTakenForDisplay = useCallback(
(doseId: string) => {
const medId = Number.parseInt(doseId.split("-")[0] ?? "", 10);
if (!Number.isNaN(medId) && outOfStockMedicationIds.has(medId)) {
return false;
const medicationOverviewByName = useMemo(() => {
const overview = new Map<string, NonNullable<SharedScheduleData["medicationOverview"]>[number]>();
for (const item of data?.medicationOverview ?? []) {
overview.set(item.name, item);
}
return overview;
}, [data?.medicationOverview]);
const emptyByOverviewName = useMemo(() => {
const emptyNames = new Set<string>();
for (const item of data?.medicationOverview ?? []) {
if ((item.currentStock ?? 0) <= 0) {
emptyNames.add(item.name);
}
return takenDoses.has(doseId);
},
[outOfStockMedicationIds, takenDoses]
);
}
return emptyNames;
}, [data?.medicationOverview]);
const isDoseTakenForDisplay = useCallback((doseId: string) => takenDoses.has(doseId), [takenDoses]);
const showMedicationOverview = data?.shareMedicationOverview === true && data?.medicationOverview !== null;
const showOnlyToday = data?.shareScheduleTodayOnly === true;
@@ -942,10 +963,35 @@ export function SharedSchedule() {
day.meds.map((item) => {
const med = data.medications.find((m) => getMedDisplayName(m) === item.medName);
const medCoverage = coverageByMed[item.medName];
const isEmpty = medCoverage ? medCoverage.medsLeft <= 0 : false;
const isEmpty =
emptyByOverviewName.has(item.medName) ||
(medCoverage ? medCoverage.medsLeft <= 0 : false);
const medOverview = medicationOverviewByName.get(item.medName);
let stockStatus = null;
if (!isEmpty && sharedStockThresholds) {
if (medOverview && medOverview.currentStock !== null) {
stockStatus = getStockStatus(
medOverview.daysLeft,
medOverview.currentStock,
sharedStockThresholds,
med?.packageType
);
} else if (medCoverage) {
stockStatus = getStockStatus(
medCoverage.daysLeft,
medCoverage.medsLeft,
sharedStockThresholds,
med?.packageType
);
}
}
const isLowStock = stockStatus?.className === "warning";
const rowClasses = ["time-row"];
if (isEmpty) rowClasses.push("med-empty");
else if (isLowStock) rowClasses.push("med-low");
return (
<div key={`${day.dateStr}-${item.medName}`} className="time-row">
<div key={`${day.dateStr}-${item.medName}`} className={rowClasses.join(" ")}>
<div className="time-main">
<div className="med-name">
<div
@@ -972,6 +1018,7 @@ export function SharedSchedule() {
<span className="tag subtle">
{formatTotalUsageLabel(med, item.total, item.doses)}
</span>
{isLowStock && <span className="tag warning">{t("status.lowStock")}</span>}
</div>
</div>
<div className="doses-col">
@@ -979,8 +1026,12 @@ export function SharedSchedule() {
const isTaken = isDoseTakenForDisplay(dose.id);
const isAutomaticallyTaken =
isTaken && isDoseTakenAutomatically(dose.id) && dose.when <= Date.now();
const doseClasses = ["dose-item", "past"];
if (isTaken) doseClasses.push("all-taken");
if (isEmpty) doseClasses.push("med-empty");
else if (isLowStock) doseClasses.push("med-low");
return (
<div key={dose.id} className={`dose-item past ${isTaken ? "all-taken" : ""}`}>
<div key={dose.id} className={doseClasses.join(" ")}>
<span className="dose-time">{dose.timeStr}</span>
<span className="dose-usage">
<span className="dose-usage-main">{renderDoseUsage(med, dose)}</span>
@@ -1128,9 +1179,34 @@ export function SharedSchedule() {
day.meds.map((item) => {
const med = data.medications.find((m) => getMedDisplayName(m) === item.medName);
const medCoverage = coverageByMed[item.medName];
const isEmpty = medCoverage ? medCoverage.medsLeft <= 0 : false;
const isEmpty =
emptyByOverviewName.has(item.medName) ||
(medCoverage ? medCoverage.medsLeft <= 0 : false);
const medOverview = medicationOverviewByName.get(item.medName);
let stockStatus = null;
if (!isEmpty && sharedStockThresholds) {
if (medOverview && medOverview.currentStock !== null) {
stockStatus = getStockStatus(
medOverview.daysLeft,
medOverview.currentStock,
sharedStockThresholds,
med?.packageType
);
} else if (medCoverage) {
stockStatus = getStockStatus(
medCoverage.daysLeft,
medCoverage.medsLeft,
sharedStockThresholds,
med?.packageType
);
}
}
const isLowStock = stockStatus?.className === "warning";
const rowClasses = ["time-row"];
if (isEmpty) rowClasses.push("med-empty");
else if (isLowStock) rowClasses.push("med-low");
return (
<div key={`${day.dateStr}-${item.medName}`} className="time-row">
<div key={`${day.dateStr}-${item.medName}`} className={rowClasses.join(" ")}>
<div className="time-main">
<div className="med-name">
<div
@@ -1157,6 +1233,7 @@ export function SharedSchedule() {
<span className="tag subtle">
{formatTotalUsageLabel(med, item.total, item.doses)}
</span>
{isLowStock && <span className="tag warning">{t("status.lowStock")}</span>}
</div>
</div>
<div className="doses-col">
@@ -1165,11 +1242,13 @@ export function SharedSchedule() {
const isAutomaticallyTaken =
isTaken && isDoseTakenAutomatically(dose.id) && dose.when <= Date.now();
const isOverdue = dose.when < Date.now() && !isTaken;
const doseClasses = ["dose-item"];
if (isOverdue) doseClasses.push("overdue");
if (isTaken) doseClasses.push("all-taken");
if (isEmpty) doseClasses.push("med-empty");
else if (isLowStock) doseClasses.push("med-low");
return (
<div
key={dose.id}
className={`dose-item ${isOverdue ? "overdue" : ""} ${isTaken ? "all-taken" : ""}`}
>
<div key={dose.id} className={doseClasses.join(" ")}>
<span className="dose-time">{dose.timeStr}</span>
<span className="dose-usage">
<span className="dose-usage-main">{renderDoseUsage(med, dose)}</span>
@@ -1302,9 +1381,34 @@ export function SharedSchedule() {
day.meds.map((item) => {
const med = data.medications.find((m) => getMedDisplayName(m) === item.medName);
const medCoverage = coverageByMed[item.medName];
const isEmpty = medCoverage ? medCoverage.medsLeft <= 0 : false;
const isEmpty =
emptyByOverviewName.has(item.medName) ||
(medCoverage ? medCoverage.medsLeft <= 0 : false);
const medOverview = medicationOverviewByName.get(item.medName);
let stockStatus = null;
if (!isEmpty && sharedStockThresholds) {
if (medOverview && medOverview.currentStock !== null) {
stockStatus = getStockStatus(
medOverview.daysLeft,
medOverview.currentStock,
sharedStockThresholds,
med?.packageType
);
} else if (medCoverage) {
stockStatus = getStockStatus(
medCoverage.daysLeft,
medCoverage.medsLeft,
sharedStockThresholds,
med?.packageType
);
}
}
const isLowStock = stockStatus?.className === "warning";
const rowClasses = ["time-row"];
if (isEmpty) rowClasses.push("med-empty");
else if (isLowStock) rowClasses.push("med-low");
return (
<div key={`${day.dateStr}-${item.medName}`} className="time-row">
<div key={`${day.dateStr}-${item.medName}`} className={rowClasses.join(" ")}>
<div className="time-main">
<div className="med-name">
<div
@@ -1331,6 +1435,7 @@ export function SharedSchedule() {
<span className="tag subtle">
{formatTotalUsageLabel(med, item.total, item.doses)}
</span>
{isLowStock && <span className="tag warning">{t("status.lowStock")}</span>}
</div>
</div>
<div className="doses-col">
@@ -1338,8 +1443,12 @@ export function SharedSchedule() {
const isTaken = isDoseTakenForDisplay(dose.id);
const isAutomaticallyTaken =
isTaken && isDoseTakenAutomatically(dose.id) && dose.when <= Date.now();
const doseClasses = ["dose-item", "future"];
if (isTaken) doseClasses.push("all-taken");
if (isEmpty) doseClasses.push("med-empty");
else if (isLowStock) doseClasses.push("med-low");
return (
<div key={dose.id} className={`dose-item future ${isTaken ? "all-taken" : ""}`}>
<div key={dose.id} className={doseClasses.join(" ")}>
<span className="dose-time">{dose.timeStr}</span>
<span className="dose-usage">
<span className="dose-usage-main">{renderDoseUsage(med, dose)}</span>
-1
View File
@@ -792,7 +792,6 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
settings.reminderRepeatIntervalMinutes !== savedSettings.reminderRepeatIntervalMinutes ||
settings.maxNaggingReminders !== savedSettings.maxNaggingReminders ||
settings.stockCalculationMode !== savedSettings.stockCalculationMode ||
settings.shareStockStatus !== savedSettings.shareStockStatus ||
settings.shareMedicationOverview !== savedSettings.shareMedicationOverview ||
settings.upcomingTodayOnly !== savedSettings.upcomingTodayOnly ||
settings.shareScheduleTodayOnly !== savedSettings.shareScheduleTodayOnly ||
-3
View File
@@ -46,7 +46,6 @@ export interface Settings {
shoutrrrIntakeReminders: boolean;
shoutrrrPrescriptionReminders: boolean;
stockCalculationMode: "automatic" | "manual";
shareStockStatus: boolean;
shareMedicationOverview: boolean;
upcomingTodayOnly: boolean;
shareScheduleTodayOnly: boolean;
@@ -98,7 +97,6 @@ const defaultSettings: Settings = {
shoutrrrIntakeReminders: true,
shoutrrrPrescriptionReminders: true,
stockCalculationMode: "automatic",
shareStockStatus: true,
shareMedicationOverview: false,
upcomingTodayOnly: false,
shareScheduleTodayOnly: false,
@@ -258,7 +256,6 @@ export function useSettings(): UseSettingsReturn {
shoutrrrIntakeReminders: settingsToSave.shoutrrrIntakeReminders,
shoutrrrPrescriptionReminders: settingsToSave.shoutrrrPrescriptionReminders,
stockCalculationMode: settingsToSave.stockCalculationMode,
shareStockStatus: settingsToSave.shareStockStatus,
shareMedicationOverview: settingsToSave.shareMedicationOverview,
upcomingTodayOnly: settingsToSave.upcomingTodayOnly,
shareScheduleTodayOnly: settingsToSave.shareScheduleTodayOnly,
+1 -1
View File
@@ -314,7 +314,7 @@
"prescriptionReminders": "Rezept-Erinnerungen",
"enableHint": "Aktiviere mindestens einen Kanal, um Benachrichtigungen zu erhalten.",
"skipTakenDoses": "Keine Erinnerungen für genommene Dosen",
"skipTakenDosesTooltip": "Sende keine Einnahme-Erinnerungen für Dosen, die heute bereits als genommen markiert wurden",
"skipTakenDosesTooltip": "Sende keine Einnahme-Erinnerungen für Dosen, die heute bereits als genommen markiert wurden. Wenn diese Option und die automatische Einnahme gleichzeitig aktiviert sind, werden gar keine Einnahme-Erinnerungen mehr gesendet.",
"repeatReminders": "Wiederholte Erinnerungen für verpasste Dosen",
"repeatRemindersTooltip": "Sende automatisch wiederholte Erinnerungen für Dosen, die noch nicht als genommen markiert wurden",
"reminderInterval": "Erinnerungsintervall (Minuten)",
+1 -1
View File
@@ -314,7 +314,7 @@
"prescriptionReminders": "Prescription Reminders",
"enableHint": "Enable at least one channel below to receive notifications.",
"skipTakenDoses": "Skip reminders for taken doses",
"skipTakenDosesTooltip": "Don't send intake reminders for doses that have already been marked as taken today",
"skipTakenDosesTooltip": "Don't send intake reminders for doses that have already been marked as taken today. If this option and automatic intake are both enabled, no intake reminders will be sent.",
"repeatReminders": "Repeat reminders for missed doses",
"repeatRemindersTooltip": "Automatically send repeated reminders for doses that haven't been marked as taken",
"reminderInterval": "Reminder interval (minutes)",
+101 -22
View File
@@ -1,5 +1,5 @@
/* biome-ignore-all lint/style/noNestedTernary: timeline rendering uses explicit UI-state branching */
import { Bell, ClipboardList, NotebookPen, Share2 } from "lucide-react";
import { Archive, Bell, ClipboardList, NotebookPen, Share2 } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { ConfirmModal, MedicationAvatar } from "../components";
@@ -85,18 +85,10 @@ export function DashboardPage() {
} = useAppContext();
const [showClearMissedConfirm, setShowClearMissedConfirm] = useState(false);
const [clearingMissed, setClearingMissed] = useState(false);
const [showObsoleteConfirm, setShowObsoleteConfirm] = useState(false);
const [obsoleteCandidate, setObsoleteCandidate] = useState<{ id: number; name: string } | null>(null);
const outOfStockMedicationIds = new Set(
meds.filter((med) => (coverageByMed[getMedDisplayName(med)]?.medsLeft ?? 1) <= 0).map((med) => med.id)
);
const isDoseTakenForDisplay = (doseId: string) => {
const medId = Number.parseInt(doseId.split("-")[0] ?? "", 10);
if (!Number.isNaN(medId) && outOfStockMedicationIds.has(medId)) {
return false;
}
return takenDoses.has(doseId);
};
const isDoseTakenForDisplay = (doseId: string) => takenDoses.has(doseId);
// Get structured reminder data
const reminderData = getReminderStatusData(
@@ -210,6 +202,32 @@ export function DashboardPage() {
}
};
const requestMarkObsolete = (med: { id: number; name: string }) => {
setObsoleteCandidate(med);
setShowObsoleteConfirm(true);
};
const handleConfirmMarkObsolete = async () => {
if (!obsoleteCandidate) return;
try {
const res = await fetch(`/api/medications/${obsoleteCandidate.id}/obsolete`, {
method: "POST",
credentials: "include",
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
await loadMeds();
setShowObsoleteConfirm(false);
setObsoleteCandidate(null);
} catch {
alert(t("common.saveFailed"));
}
};
const handleCancelMarkObsolete = () => {
setShowObsoleteConfirm(false);
setObsoleteCandidate(null);
};
const getTubeUnitLabel = (med: (typeof meds)[number] | undefined, value: number) =>
isLiquidContainerPackageType(med?.packageType) || med?.medicationForm === "liquid"
? t("form.packageAmountUnitMl")
@@ -1034,8 +1052,12 @@ export function DashboardPage() {
? getStockStatus(medCov.daysLeft, medCov.medsLeft, stockThresholds, med?.packageType)
: null;
const status = getVisibleStockStatus(med, rawStatus);
const isLowStock = !isEmpty && status?.className === "warning";
const rowClasses = ["time-row"];
if (isEmpty) rowClasses.push("med-empty");
else if (isLowStock) rowClasses.push("med-low");
return (
<div key={`${day.dateStr}-${item.medName}`} className="time-row">
<div key={`${day.dateStr}-${item.medName}`} className={rowClasses.join(" ")}>
<div className="time-main">
<div className="med-name">
<div
@@ -1082,8 +1104,12 @@ export function DashboardPage() {
const allTaken = people.every((person) =>
isDoseTakenForDisplay(getDoseId(dose.id, person))
);
const doseClasses = ["dose-item", "past"];
if (allTaken) doseClasses.push("all-taken");
if (isEmpty) doseClasses.push("med-empty");
else if (isLowStock) doseClasses.push("med-low");
return (
<div key={dose.id} className={`dose-item past ${allTaken ? "all-taken" : ""}`}>
<div key={dose.id} className={doseClasses.join(" ")}>
<span className="dose-time">{dose.timeStr}</span>
<span className="dose-usage">
<span className="dose-usage-main">
@@ -1251,6 +1277,17 @@ export function DashboardPage() {
confirmVariant="warning"
/>
)}
{showObsoleteConfirm && obsoleteCandidate && (
<ConfirmModal
title={t("medications.obsoleteModal.title")}
message={t("medications.obsoleteModal.message", { name: obsoleteCandidate.name })}
confirmLabel={t("medications.list.markObsolete")}
cancelLabel={t("common.cancel")}
onConfirm={() => void handleConfirmMarkObsolete()}
onCancel={handleCancelMarkObsolete}
confirmVariant="warning"
/>
)}
{/* Today - always visible */}
{todayDay &&
(() => {
@@ -1329,8 +1366,12 @@ export function DashboardPage() {
)
: null;
const visibleStatus = getVisibleStockStatus(med, status);
const isLowStock = !isEmpty && visibleStatus?.className === "warning";
const rowClasses = ["time-row"];
if (isEmpty) rowClasses.push("med-empty");
else if (isLowStock) rowClasses.push("med-low");
return (
<div key={`${day.dateStr}-${item.medName}`} className="time-row">
<div key={`${day.dateStr}-${item.medName}`} className={rowClasses.join(" ")}>
<div className="time-main">
<div className="med-name">
<div
@@ -1371,6 +1412,20 @@ export function DashboardPage() {
</span>
)}
</div>
{isEmpty && med && !med.isObsolete && (
<div className="timeline-obsolete-row">
<button
type="button"
className="timeline-obsolete-btn btn-obsolete"
onClick={() =>
requestMarkObsolete({ id: med.id, name: getMedDisplayName(med) })
}
>
<Archive size={16} aria-hidden="true" />
<span>{t("medications.list.markObsolete")}</span>
</button>
</div>
)}
</div>
<div className="doses-col">
{item.doses.map((dose) => {
@@ -1379,11 +1434,13 @@ export function DashboardPage() {
const allTaken = people.every((person) =>
isDoseTakenForDisplay(getDoseId(dose.id, person))
);
const doseClasses = ["dose-item"];
if (isOverdue) doseClasses.push("overdue");
if (allTaken) doseClasses.push("all-taken");
if (isEmpty) doseClasses.push("med-empty");
else if (isLowStock) doseClasses.push("med-low");
return (
<div
key={dose.id}
className={`dose-item ${isOverdue ? "overdue" : ""} ${allTaken ? "all-taken" : ""}`}
>
<div key={dose.id} className={doseClasses.join(" ")}>
<span className="dose-time">{dose.timeStr}</span>
<span className="dose-usage">
<span className="dose-usage-main">
@@ -1569,7 +1626,7 @@ export function DashboardPage() {
const medCoverage = coverageByMed[item.medName];
const med = meds.find((m) => getMedDisplayName(m) === item.medName);
const depletionTime = depletionByMed[item.medName];
const _isEmpty = medCoverage ? medCoverage.medsLeft <= 0 : false;
const isEmpty = medCoverage ? medCoverage.medsLeft <= 0 : false;
const willBeOutOfStock = typeof depletionTime === "number" && item.lastWhen > depletionTime;
const status = willBeOutOfStock
? { className: "danger", label: "status.outOfStock" }
@@ -1582,8 +1639,12 @@ export function DashboardPage() {
)
: null;
const visibleStatus = getVisibleStockStatus(med, status);
const isLowStock = !isEmpty && visibleStatus?.className === "warning";
const rowClasses = ["time-row"];
if (isEmpty) rowClasses.push("med-empty");
else if (isLowStock) rowClasses.push("med-low");
return (
<div key={`${day.dateStr}-${item.medName}`} className="time-row">
<div key={`${day.dateStr}-${item.medName}`} className={rowClasses.join(" ")}>
<div className="time-main">
<div className="med-name">
<div
@@ -1624,6 +1685,20 @@ export function DashboardPage() {
</span>
)}
</div>
{isEmpty && med && !med.isObsolete && (
<div className="timeline-obsolete-row">
<button
type="button"
className="timeline-obsolete-btn btn-obsolete"
onClick={() =>
requestMarkObsolete({ id: med.id, name: getMedDisplayName(med) })
}
>
<Archive size={16} aria-hidden="true" />
<span>{t("medications.list.markObsolete")}</span>
</button>
</div>
)}
</div>
<div className="doses-col">
{item.doses.map((dose) => {
@@ -1631,8 +1706,12 @@ export function DashboardPage() {
const allTaken = people.every((person) =>
isDoseTakenForDisplay(getDoseId(dose.id, person))
);
const doseClasses = ["dose-item", "future"];
if (allTaken) doseClasses.push("all-taken");
if (isEmpty) doseClasses.push("med-empty");
else if (isLowStock) doseClasses.push("med-low");
return (
<div key={dose.id} className={`dose-item future ${allTaken ? "all-taken" : ""}`}>
<div key={dose.id} className={doseClasses.join(" ")}>
<span className="dose-time">{dose.timeStr}</span>
<span className="dose-usage">
<span className="dose-usage-main">
+11 -4
View File
@@ -1,7 +1,7 @@
/* biome-ignore-all lint/a11y/noLabelWithoutControl: form uses custom inputs and display fields wrapped in label-like layout */
/* biome-ignore-all lint/correctness/useExhaustiveDependencies: modal-history callbacks are intentionally managed outside hook deps */
/* biome-ignore-all lint/suspicious/noArrayIndexKey: local draft intake rows do not have stable ids before persistence */
import { Bell, Eye, Minus, Pencil, Plus, Trash2 } from "lucide-react";
import { Archive, Bell, Eye, Minus, Pencil, Plus, Trash2 } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useSearchParams } from "react-router-dom";
@@ -979,6 +979,16 @@ export function MedicationsPage() {
</button>
)}
<button
type="button"
className="btn-obsolete"
onClick={() => requestMarkObsolete(med)}
aria-label={t("medications.list.markObsolete")}
>
<Archive size={16} aria-hidden="true" />
<span>{t("medications.list.markObsolete")}</span>
</button>
<button
type="button"
className="danger icon-only tooltip-trigger"
onClick={() => requestDeleteMed(med)}
aria-label={t("common.delete")}
@@ -986,9 +996,6 @@ export function MedicationsPage() {
>
<Trash2 size={18} aria-hidden="true" />
</button>
<button className="btn-obsolete" onClick={() => requestMarkObsolete(med)}>
{t("medications.list.markObsolete")}
</button>
</div>
<div className="med-details">
<span>
+77 -16
View File
@@ -1,5 +1,5 @@
/* biome-ignore-all lint/style/noNestedTernary: schedule timeline branches are intentionally explicit */
import { Bell } from "lucide-react";
import { Archive, Bell } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { ConfirmModal, MedicationAvatar } from "../components";
@@ -95,18 +95,10 @@ export function SchedulePage() {
} = useAppContext();
const [showClearMissedConfirm, setShowClearMissedConfirm] = useState(false);
const [clearingMissed, setClearingMissed] = useState(false);
const [showObsoleteConfirm, setShowObsoleteConfirm] = useState(false);
const [obsoleteCandidate, setObsoleteCandidate] = useState<{ id: number; name: string } | null>(null);
const outOfStockMedicationIds = new Set(
meds.filter((med) => (coverageByMed[getMedDisplayName(med)]?.medsLeft ?? 1) <= 0).map((med) => med.id)
);
const isDoseTakenForDisplay = (doseId: string) => {
const medId = Number.parseInt(doseId.split("-")[0] ?? "", 10);
if (!Number.isNaN(medId) && outOfStockMedicationIds.has(medId)) {
return false;
}
return takenDoses.has(doseId);
};
const isDoseTakenForDisplay = (doseId: string) => takenDoses.has(doseId);
const shouldHideNoScheduleStatusForTube = (
med: (typeof meds)[number] | undefined,
@@ -174,6 +166,32 @@ export function SchedulePage() {
}
};
const requestMarkObsolete = (med: { id: number; name: string }) => {
setObsoleteCandidate(med);
setShowObsoleteConfirm(true);
};
const handleConfirmMarkObsolete = async () => {
if (!obsoleteCandidate) return;
try {
const res = await fetch(`/api/medications/${obsoleteCandidate.id}/obsolete`, {
method: "POST",
credentials: "include",
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
await loadMeds();
setShowObsoleteConfirm(false);
setObsoleteCandidate(null);
} catch {
alert(t("common.saveFailed"));
}
};
const handleCancelMarkObsolete = () => {
setShowObsoleteConfirm(false);
setObsoleteCandidate(null);
};
const getTubeUnitLabel = (med: (typeof meds)[number] | undefined, value: number) =>
isLiquidContainerPackageType(med?.packageType) || med?.medicationForm === "liquid"
? t("form.packageAmountUnitMl")
@@ -345,8 +363,16 @@ export function SchedulePage() {
const med = meds.find((m) => getMedDisplayName(m) === item.medName);
const medCov = coverageByMed[item.medName];
const isEmpty = medCov ? medCov.medsLeft <= 0 : false;
const rawStatus = medCov
? getStockStatus(medCov.daysLeft, medCov.medsLeft, settings, med?.packageType)
: null;
const visibleStatus = shouldHideNoScheduleStatusForTube(med, rawStatus) ? null : rawStatus;
const isLowStock = !isEmpty && visibleStatus?.className === "warning";
const rowClasses = ["time-row"];
if (isEmpty) rowClasses.push("med-empty");
else if (isLowStock) rowClasses.push("med-low");
return (
<div key={`${day.dateStr}-${item.medName}`} className="time-row">
<div key={`${day.dateStr}-${item.medName}`} className={rowClasses.join(" ")}>
<div className="time-main">
<div className="med-name">
<MedicationAvatar name={item.medName} imageUrl={med?.imageUrl} size="sm" />
@@ -363,8 +389,12 @@ export function SchedulePage() {
const allTaken = people.every((person) =>
isDoseTakenForDisplay(getDoseId(dose.id, person))
);
const doseClasses = ["dose-item", "past"];
if (allTaken) doseClasses.push("all-taken");
if (isEmpty) doseClasses.push("med-empty");
else if (isLowStock) doseClasses.push("med-low");
return (
<div key={dose.id} className={`dose-item past ${allTaken ? "all-taken" : ""}`}>
<div key={dose.id} className={doseClasses.join(" ")}>
<span className="dose-time">{dose.timeStr}</span>
<span className="dose-usage">
<span className="dose-usage-main">
@@ -517,6 +547,17 @@ export function SchedulePage() {
confirmVariant="warning"
/>
)}
{showObsoleteConfirm && obsoleteCandidate && (
<ConfirmModal
title={t("medications.obsoleteModal.title")}
message={t("medications.obsoleteModal.message", { name: obsoleteCandidate.name })}
confirmLabel={t("medications.list.markObsolete")}
cancelLabel={t("common.cancel")}
onConfirm={() => void handleConfirmMarkObsolete()}
onCancel={handleCancelMarkObsolete}
confirmVariant="warning"
/>
)}
{/* Current and future days */}
{futureDays.map((day) => {
const today = new Date();
@@ -540,8 +581,12 @@ export function SchedulePage() {
? getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, settings, med?.packageType)
: null;
const visibleStatus = shouldHideNoScheduleStatusForTube(med, status) ? null : status;
const isLowStock = !isEmpty && visibleStatus?.className === "warning";
const rowClasses = ["time-row"];
if (isEmpty) rowClasses.push("med-empty");
else if (isLowStock) rowClasses.push("med-low");
return (
<div key={`${day.dateStr}-${item.medName}`} className="time-row">
<div key={`${day.dateStr}-${item.medName}`} className={rowClasses.join(" ")}>
<div className="time-main">
<div className="med-name">
<MedicationAvatar name={item.medName} imageUrl={med?.imageUrl} size="sm" />
@@ -553,6 +598,18 @@ export function SchedulePage() {
<span className={`tag ${visibleStatus.className}`}>{t(visibleStatus.label)}</span>
)}
</div>
{isEmpty && med && !med.isObsolete && (
<div className="timeline-obsolete-row">
<button
type="button"
className="timeline-obsolete-btn btn-obsolete"
onClick={() => requestMarkObsolete({ id: med.id, name: getMedDisplayName(med) })}
>
<Archive size={16} aria-hidden="true" />
<span>{t("medications.list.markObsolete")}</span>
</button>
</div>
)}
</div>
<div className="doses-col">
{item.doses.map((dose) => {
@@ -561,8 +618,12 @@ export function SchedulePage() {
const dayStart = new Date(day.date).setHours(0, 0, 0, 0);
const isPastDay = dayStart < new Date().setHours(0, 0, 0, 0);
const allTaken = people.every((person) => isDoseTakenForDisplay(getDoseId(dose.id, person)));
const doseClasses = ["dose-item"];
if (allTaken) doseClasses.push("all-taken");
if (isEmpty) doseClasses.push("med-empty");
else if (isLowStock) doseClasses.push("med-low");
return (
<div key={dose.id} className={`dose-item ${allTaken ? "all-taken" : ""}`}>
<div key={dose.id} className={doseClasses.join(" ")}>
<span className="dose-time">{dose.timeStr}</span>
<span className="dose-usage">
<span className="dose-usage-main">
+319 -166
View File
@@ -1,3 +1,4 @@
/* biome-ignore-all lint/style/noDescendingSpecificity: legacy shared stylesheet relies on intentional cascade ordering across base and contextual selectors */
@import url("https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&display=swap");
:root {
@@ -577,25 +578,6 @@ body.modal-open {
display: flex;
gap: 0.5rem;
}
.tabs .pill {
cursor: pointer;
transition:
background 150ms ease,
border-color 150ms ease;
background: transparent;
border: 1px solid var(--border-secondary);
color: var(--text-muted);
box-shadow: none;
}
.tabs .pill:hover {
background: var(--btn-ghost-hover);
border-color: var(--accent);
}
.tabs .pill.primary {
background: var(--accent-bg);
border-color: var(--accent);
color: var(--text-primary);
}
.stats {
display: grid;
@@ -732,6 +714,26 @@ body.modal-open {
background: rgba(0, 0, 0, 0.04);
}
.tabs .pill {
cursor: pointer;
transition:
background 150ms ease,
border-color 150ms ease;
background: transparent;
border: 1px solid var(--border-secondary);
color: var(--text-muted);
box-shadow: none;
}
.tabs .pill:hover {
background: var(--btn-ghost-hover);
border-color: var(--accent);
}
.tabs .pill.primary {
background: var(--accent-bg);
border-color: var(--accent);
color: var(--text-primary);
}
.badges {
display: flex;
gap: 0.5rem;
@@ -804,10 +806,6 @@ body.modal-open {
background: var(--bg-tertiary);
}
.med-group-head-toggle:hover .med-group-title {
color: var(--text-primary);
}
.med-group-title {
margin: 0;
font-size: 0.92rem;
@@ -817,6 +815,10 @@ body.modal-open {
color: var(--text-muted);
}
.med-group-head-toggle:hover .med-group-title {
color: var(--text-primary);
}
.med-group-obsolete {
border-color: var(--border-primary);
background: var(--bg-secondary);
@@ -832,17 +834,6 @@ body.modal-open {
border-color: var(--border-primary);
}
.obsolete-row .med-actions button {
opacity: 0.72;
filter: saturate(0.72);
box-shadow: none;
}
.obsolete-row .med-actions button:hover {
opacity: 0.9;
filter: saturate(0.85);
}
@media (max-width: 768px) {
.med-grid {
grid-template-columns: 1fr;
@@ -1024,6 +1015,51 @@ body.modal-open {
flex-wrap: wrap;
margin-top: 0.25rem;
}
.timeline-obsolete-row {
margin-top: 0.15rem;
display: flex;
justify-content: flex-start;
}
.timeline-obsolete-btn {
display: inline-flex;
align-items: center;
gap: 0.45rem;
padding: 0.55rem 0.9rem;
min-height: 2.5rem;
font-size: 0.85rem;
line-height: 1;
background: transparent;
border: 1px solid #f8e38a;
color: #ffd54a;
box-shadow: none;
}
.timeline-obsolete-btn:hover {
background: color-mix(in srgb, #facc15 12%, transparent);
border-color: #ffe27c;
color: #ffe27c;
box-shadow: none;
}
.timeline-obsolete-btn svg {
width: 1rem;
height: 1rem;
flex-shrink: 0;
}
[data-theme="light"] .timeline-obsolete-btn {
background: transparent;
border-color: #d97706;
color: #b45309;
}
[data-theme="light"] .timeline-obsolete-btn:hover {
background: color-mix(in srgb, #f59e0b 10%, transparent);
border-color: #b45309;
color: #92400e;
}
.danger-text {
color: var(--danger);
font-weight: 700;
@@ -1047,6 +1083,153 @@ body.modal-open {
font-weight: 400;
}
button {
padding: 0.7rem 1.25rem;
border-radius: var(--btn-radius);
border: none;
background: var(--btn-primary-bg);
color: white;
cursor: pointer;
font-weight: 600;
font-size: 0.9rem;
box-shadow: var(--btn-shadow);
transition:
background 150ms ease,
box-shadow 150ms ease,
opacity 150ms ease;
}
button:hover {
background: var(--btn-primary-hover);
}
button:active {
box-shadow: var(--btn-shadow);
}
button:focus-visible {
outline: 2px solid var(--accent-light);
outline-offset: 2px;
}
button.icon-only {
min-width: 2.75rem;
min-height: 2.75rem;
padding: 0.5rem;
display: inline-flex;
align-items: center;
justify-content: center;
line-height: 1;
}
button.icon-only svg,
.modal-close svg,
.btn-copy svg,
.share-btn svg {
width: 1.1rem;
height: 1.1rem;
stroke-width: 2;
flex-shrink: 0;
}
/* Secondary button (Edit, etc.) */
button.secondary {
background: var(--bg-tertiary);
color: var(--text-primary);
border: 1px solid var(--border-secondary);
}
button.secondary:hover {
background: var(--bg-secondary);
border-color: var(--accent);
}
[data-theme="light"] button.secondary {
background: var(--bg-tertiary);
}
[data-theme="light"] button.secondary:hover {
background: var(--bg-secondary);
}
/* Success button (Refill, etc.) */
button.success {
background: var(--success);
color: var(--btn-success-text);
border: none;
}
button.success:hover {
filter: brightness(1.1);
}
button.success:disabled {
background: var(--bg-tertiary);
color: var(--text-tertiary);
cursor: not-allowed;
}
/* Primary/Accent button (New entry, Add intake, etc.) */
button.primary {
background: var(--accent);
color: white;
border: none;
}
button.primary:hover {
background: var(--accent-light);
}
button.primary:disabled {
background: var(--bg-tertiary);
color: var(--text-tertiary);
cursor: not-allowed;
}
/* Info button (Edit, secondary actions) */
button.info {
background: #3b82f6;
color: white;
border: none;
}
button.info:hover {
background: #60a5fa;
}
/* Ghost button (Cancel, etc.) */
button.ghost {
background: transparent;
border: 1px solid var(--border-secondary);
color: var(--text-muted);
box-shadow: none;
}
button.ghost:hover {
background: var(--btn-ghost-hover);
}
[data-theme="light"] button.ghost:hover {
background: var(--btn-ghost-hover);
}
/* Navigation button (Back): neutral and low visual urgency */
button.btn-nav {
background: var(--bg-secondary);
border: 1px solid var(--border-secondary);
color: var(--text-primary);
box-shadow: none;
}
button.btn-nav:hover {
background: var(--btn-ghost-hover);
border-color: var(--accent);
}
/* Reversible status-change button (Mark obsolete): warning, not destructive */
button.btn-obsolete {
background: var(--btn-obsolete-bg);
border: 1px solid var(--btn-obsolete-border);
color: var(--btn-obsolete-text);
box-shadow: none;
font-weight: 700;
}
button.btn-obsolete:hover {
background: var(--btn-obsolete-hover);
transform: none;
box-shadow: none;
}
button.btn-obsolete:active {
transform: none;
}
.med-actions {
display: flex;
align-items: center;
@@ -1102,6 +1285,8 @@ body.modal-open {
color: #ffd54a;
background: var(--bg-input);
border: 1px solid var(--border-secondary);
gap: 0.45rem;
white-space: nowrap;
}
.med-actions button.btn-obsolete:hover {
@@ -1353,152 +1538,43 @@ body.modal-open {
margin: 0;
}
button {
padding: 0.7rem 1.25rem;
border-radius: var(--btn-radius);
border: none;
background: var(--btn-primary-bg);
color: white;
cursor: pointer;
font-weight: 600;
font-size: 0.9rem;
box-shadow: var(--btn-shadow);
transition:
background 150ms ease,
box-shadow 150ms ease,
opacity 150ms ease;
}
button:hover {
background: var(--btn-primary-hover);
}
button:active {
box-shadow: var(--btn-shadow);
.obsolete-row .med-actions button {
opacity: 0.72;
filter: saturate(0.72);
box-shadow: none;
}
button:focus-visible {
outline: 2px solid var(--accent-light);
outline-offset: 2px;
.obsolete-row .med-actions button:hover {
opacity: 0.9;
filter: saturate(0.85);
}
button.icon-only {
min-width: 2.75rem;
min-height: 2.75rem;
padding: 0.5rem;
display: inline-flex;
align-items: center;
justify-content: center;
line-height: 1;
}
button.icon-only svg,
.modal-close svg,
.btn-copy svg,
.share-btn svg {
width: 1.1rem;
height: 1.1rem;
stroke-width: 2;
flex-shrink: 0;
}
/* Secondary button (Edit, etc.) */
button.secondary {
background: var(--bg-tertiary);
color: var(--text-primary);
border: 1px solid var(--border-secondary);
}
button.secondary:hover {
background: var(--bg-secondary);
border-color: var(--accent);
}
[data-theme="light"] button.secondary {
background: var(--bg-tertiary);
}
[data-theme="light"] button.secondary:hover {
background: var(--bg-secondary);
}
/* Success button (Refill, etc.) */
button.success {
background: var(--success);
color: var(--btn-success-text);
border: none;
}
button.success:hover {
filter: brightness(1.1);
}
button.success:disabled {
background: var(--bg-tertiary);
color: var(--text-tertiary);
cursor: not-allowed;
}
/* Primary/Accent button (New entry, Add intake, etc.) */
button.primary {
background: var(--accent);
color: white;
border: none;
}
button.primary:hover {
background: var(--accent-light);
}
button.primary:disabled {
background: var(--bg-tertiary);
color: var(--text-tertiary);
cursor: not-allowed;
}
/* Info button (Edit, secondary actions) */
button.info {
background: #3b82f6;
color: white;
border: none;
}
button.info:hover {
background: #60a5fa;
}
/* Ghost button (Cancel, etc.) */
button.ghost {
button.timeline-obsolete-btn.btn-obsolete {
background: transparent;
border: 1px solid var(--border-secondary);
color: var(--text-muted);
border: 1px solid #f8e38a;
color: #ffd54a;
box-shadow: none;
}
button.ghost:hover {
background: var(--btn-ghost-hover);
}
[data-theme="light"] button.ghost:hover {
background: var(--btn-ghost-hover);
}
/* Navigation button (Back): neutral and low visual urgency */
button.btn-nav {
background: var(--bg-secondary);
border: 1px solid var(--border-secondary);
color: var(--text-primary);
button.timeline-obsolete-btn.btn-obsolete:hover {
background: color-mix(in srgb, #facc15 12%, transparent);
border-color: #ffe27c;
color: #ffe27c;
box-shadow: none;
}
button.btn-nav:hover {
background: var(--btn-ghost-hover);
border-color: var(--accent);
}
/* Reversible status-change button (Mark obsolete): warning, not destructive */
button.btn-obsolete {
background: var(--btn-obsolete-bg);
border: 1px solid var(--btn-obsolete-border);
color: var(--btn-obsolete-text);
box-shadow: none;
font-weight: 700;
[data-theme="light"] button.timeline-obsolete-btn.btn-obsolete {
background: transparent;
border-color: #d97706;
color: #b45309;
}
button.btn-obsolete:hover {
background: var(--btn-obsolete-hover);
transform: none;
[data-theme="light"] button.timeline-obsolete-btn.btn-obsolete:hover {
background: color-mix(in srgb, #f59e0b 10%, transparent);
border-color: #b45309;
color: #92400e;
box-shadow: none;
}
button.btn-obsolete:active {
transform: none;
}
/* Danger button (Delete, etc.) */
button.danger {
@@ -2392,6 +2468,42 @@ button.has-validation-error {
border-bottom: none;
padding-bottom: 0;
}
.time-row.med-empty {
border-left: 3px solid var(--danger);
padding-left: 0.6rem;
background: color-mix(in srgb, var(--danger) 8%, transparent);
border-radius: 8px;
}
.time-row.med-low {
border-left: 3px solid var(--warning);
padding-left: 0.6rem;
background: color-mix(in srgb, var(--warning) 10%, transparent);
border-radius: 8px;
}
.time-row.med-low .med-name-text {
color: color-mix(in srgb, var(--warning) 88%, white 12%);
}
.time-row.med-low .tag.subtle {
background: color-mix(in srgb, var(--warning) 16%, transparent);
border-color: color-mix(in srgb, var(--warning) 42%, transparent);
color: color-mix(in srgb, var(--warning) 82%, white 18%);
}
.time-row.med-empty .med-name-text {
color: var(--danger);
text-decoration: line-through;
text-decoration-thickness: 2px;
}
.time-row.med-empty .tag.subtle {
background: color-mix(in srgb, var(--danger) 16%, transparent);
border-color: color-mix(in srgb, var(--danger) 42%, transparent);
color: color-mix(in srgb, var(--danger) 82%, white 18%);
}
.time-main {
display: flex;
flex-direction: column;
@@ -2521,6 +2633,47 @@ button.has-validation-error {
color: var(--text-secondary);
}
.dose-item.med-low,
.dose-item.med-low.all-taken,
.dose-item.med-low.taken,
.dose-item.med-low.future,
.dose-item.med-low.overdue,
.dose-item.med-low.overdue.taken {
border-color: rgba(252, 211, 77, 0.5);
box-shadow: inset 3px 0 0 color-mix(in srgb, var(--warning) 88%, black 12%);
}
.dose-item.med-low:not(.all-taken):not(.taken):not(.overdue):not(.med-empty) {
background: color-mix(in srgb, var(--warning) 9%, var(--accent-bg));
}
.dose-item.med-empty,
.dose-item.med-empty.all-taken,
.dose-item.med-empty.taken,
.dose-item.med-empty.future,
.dose-item.med-empty.overdue,
.dose-item.med-empty.overdue.taken {
background: color-mix(in srgb, var(--danger) 13%, transparent);
border-color: color-mix(in srgb, var(--danger) 46%, transparent);
opacity: 1;
}
.dose-item.med-empty .dose-time,
.dose-item.med-empty .dose-usage {
color: color-mix(in srgb, var(--danger) 82%, white 18%);
text-decoration: line-through;
text-decoration-thickness: 2px;
}
.dose-item.med-empty .dose-person {
background: color-mix(in srgb, var(--danger) 18%, transparent);
}
.dose-item.med-empty .dose-person .person-name,
.dose-item.med-empty .dose-person.taken .person-name {
color: color-mix(in srgb, var(--danger) 80%, white 20%);
}
.dose-time {
font-weight: 600;
color: var(--accent-light);
+31 -31
View File
@@ -45,10 +45,41 @@
gap: 1rem;
}
.refill-preview {
display: inline-flex;
align-items: center;
justify-content: center;
height: 42px;
padding: 0 0.75rem;
background: transparent;
border: 1px dashed var(--success);
border-radius: 6px;
color: var(--success);
font-size: 0.85rem;
font-weight: 600;
flex-shrink: 0;
align-self: flex-end;
box-sizing: border-box;
}
.refill-footer-right .refill-preview {
height: 42px;
}
/* Refill: submit row (button + pill preview) */
.refill-submit-row {
display: flex;
align-items: center;
gap: 1rem;
}
.refill-submit-row button {
height: 42px;
padding: 0 2rem;
min-width: 120px;
flex-shrink: 0;
}
/* Refill modal footer mobile */
@media (max-width: 640px) {
.refill-modal .modal-footer {
@@ -70,20 +101,6 @@
}
}
/* Refill: submit row (button + pill preview) */
.refill-submit-row {
display: flex;
align-items: center;
gap: 1rem;
}
.refill-submit-row button {
height: 42px;
padding: 0 2rem;
min-width: 120px;
flex-shrink: 0;
}
/* Refill: prescription toggle row */
.refill-prescription-row {
display: flex;
@@ -134,23 +151,6 @@
margin-left: auto;
}
.refill-preview {
display: inline-flex;
align-items: center;
justify-content: center;
height: 42px;
padding: 0 0.75rem;
background: transparent;
border: 1px dashed var(--success);
border-radius: 6px;
color: var(--success);
font-size: 0.85rem;
font-weight: 600;
flex-shrink: 0;
align-self: flex-end;
box-sizing: border-box;
}
.refill-section {
margin-top: 0;
}
@@ -18,7 +18,6 @@ function createSharedData() {
sharedBy: "Owner",
takenBy: "Max",
scheduleDays: 30,
shareStockStatus: true,
medications: [],
};
}
@@ -35,6 +34,8 @@ function createSharedDataWithEmbeddedOverview() {
imageUrl: null,
packageType: "blister",
packCount: 1,
packageAmountValue: null,
packageAmountUnit: null,
blistersPerPack: 2,
pillsPerBlister: 10,
totalPills: null,
@@ -50,21 +51,97 @@ function createSharedDataWithEmbeddedOverview() {
prescriptionEnabled: false,
prescriptionRemainingRefills: null,
},
{
name: "Vitamin D",
genericName: null,
imageUrl: null,
packageType: "bottle",
packCount: 0,
packageAmountValue: null,
packageAmountUnit: null,
blistersPerPack: 1,
pillsPerBlister: 1,
totalPills: 100,
looseTablets: 100,
currentStock: 40,
capacity: 100,
daysLeft: 40,
nextIntakeDate: null,
depletionDate: "2026-02-21",
priority: "normal",
expiryDate: null,
medicationStartDate: null,
prescriptionEnabled: false,
prescriptionRemainingRefills: null,
},
{
name: "Hydrogel",
genericName: null,
imageUrl: null,
packageType: "tube",
packCount: 2,
packageAmountValue: 40,
packageAmountUnit: "g",
blistersPerPack: 1,
pillsPerBlister: 1,
totalPills: 80,
looseTablets: 80,
currentStock: 80,
capacity: 80,
daysLeft: null,
nextIntakeDate: null,
depletionDate: null,
priority: "normal",
expiryDate: null,
medicationStartDate: null,
prescriptionEnabled: false,
prescriptionRemainingRefills: null,
},
{
name: "Cough Syrup",
genericName: null,
imageUrl: null,
packageType: "liquid_container",
packCount: 3,
packageAmountValue: 150,
packageAmountUnit: "ml",
blistersPerPack: 1,
pillsPerBlister: 1,
totalPills: 450,
looseTablets: 450,
currentStock: 450,
capacity: 450,
daysLeft: null,
nextIntakeDate: null,
depletionDate: null,
priority: "normal",
expiryDate: null,
medicationStartDate: null,
prescriptionEnabled: false,
prescriptionRemainingRefills: null,
},
],
};
}
function createSharedDataWithTodayDose() {
const now = new Date();
now.setHours(10, 0, 0, 0);
const dateOnlyMs = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
function createSharedDataWithTodayDose(referenceNow: Date) {
const currentDay = new Date(referenceNow);
currentDay.setHours(12, 0, 0, 0);
const scheduledAt = new Date(currentDay);
scheduledAt.setHours(9, 0, 0, 0);
const dateOnlyMs = new Date(scheduledAt.getFullYear(), scheduledAt.getMonth(), scheduledAt.getDate()).getTime();
const start = `${scheduledAt.getFullYear()}-${String(scheduledAt.getMonth() + 1).padStart(2, "0")}-${String(
scheduledAt.getDate()
).padStart(
2,
"0"
)}T${String(scheduledAt.getHours()).padStart(2, "0")}:${String(scheduledAt.getMinutes()).padStart(2, "0")}:00`;
return {
sharedBy: "Owner",
takenBy: "Max",
scheduleDays: 30,
automaticDoseId: `1-0-${dateOnlyMs}`,
shareStockStatus: true,
medications: [
{
id: 1,
@@ -81,8 +158,8 @@ function createSharedDataWithTodayDose() {
expiryDate: null,
notes: null,
intakeRemindersEnabled: false,
blisters: [{ usage: 1, every: 1, start: now.toISOString() }],
intakes: [{ usage: 1, every: 1, start: now.toISOString(), takenBy: null, intakeRemindersEnabled: false }],
blisters: [{ usage: 1, every: 1, start }],
intakes: [{ usage: 1, every: 1, start, takenBy: null, intakeRemindersEnabled: false }],
updatedAt: null,
dismissedUntil: null,
lastStockCorrectionAt: null,
@@ -187,7 +264,10 @@ describe("SharedSchedule", () => {
});
it("shows the robot marker for automatically taken shared doses", async () => {
const sharedData = createSharedDataWithTodayDose();
const referenceNow = new Date();
referenceNow.setHours(12, 0, 0, 0);
vi.spyOn(Date, "now").mockReturnValue(referenceNow.getTime());
const sharedData = createSharedDataWithTodayDose(referenceNow);
(globalThis.fetch as ReturnType<typeof vi.fn>).mockImplementation((url: string, init?: RequestInit) => {
if (url === "/api/share/token-123/doses" && (!init || !init.method || init.method === "GET")) {
@@ -233,6 +313,9 @@ describe("SharedSchedule", () => {
});
expect(screen.getByText("sharedOverview.columns.priority")).toBeInTheDocument();
expect(screen.getAllByText("100").length).toBeGreaterThan(0);
expect(screen.getAllByText("2 x 40 form.packageAmountUnitG").length).toBeGreaterThan(0);
expect(screen.getAllByText("3 x 150 form.packageAmountUnitMl").length).toBeGreaterThan(0);
expect(screen.getByText("share.noSchedule")).toBeInTheDocument();
});
});
@@ -22,7 +22,6 @@ function createSharedData(overrides: Record<string, unknown> = {}) {
sharedBy: "Owner",
takenBy: "Max",
scheduleDays: 30,
shareStockStatus: true,
upcomingTodayOnly: false,
shareScheduleTodayOnly: true,
stockCalculationMode: "automatic",
@@ -131,7 +131,6 @@ describe("useAppContext", () => {
shoutrrrStockReminders: true,
shoutrrrIntakeReminders: true,
stockCalculationMode: "automatic",
shareStockStatus: true,
shareMedicationOverview: false,
expiryWarningDays: 30,
},
@@ -171,7 +170,6 @@ describe("useAppContext", () => {
shoutrrrStockReminders: true,
shoutrrrIntakeReminders: true,
stockCalculationMode: "automatic",
shareStockStatus: true,
shareMedicationOverview: false,
expiryWarningDays: 30,
},
@@ -43,7 +43,6 @@ const createMockContext = (overrides = {}) => ({
maxNaggingReminders: 5,
language: "en",
stockCalculationMode: "automatic",
shareStockStatus: true,
smtpHost: "",
smtpPort: 587,
smtpUser: "",
+2 -1
View File
@@ -253,7 +253,6 @@ export type SharedScheduleData = {
expiryWarningDays?: number;
};
stockCalculationMode?: "automatic" | "manual";
shareStockStatus?: boolean;
shareMedicationOverview?: boolean;
medicationOverview?: SharedMedicationOverviewItem[] | null;
upcomingTodayOnly?: boolean;
@@ -272,6 +271,8 @@ export type SharedMedicationOverviewItem = {
imageUrl: string | null;
packageType: PackageType;
packCount: number;
packageAmountValue: number | null;
packageAmountUnit: PackageAmountUnit | null;
blistersPerPack: number;
pillsPerBlister: number;
totalPills: number | null;
+58 -121
View File
@@ -6,9 +6,9 @@
"": {
"name": "medassist-ng",
"devDependencies": {
"@biomejs/biome": "^2.4.6",
"@biomejs/biome": "^2.4.7",
"husky": "^9.1.0",
"lint-staged": "^16.3.2"
"lint-staged": "^16.4.0"
}
},
"backend": {
@@ -76,9 +76,9 @@
}
},
"node_modules/@biomejs/biome": {
"version": "2.4.6",
"resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.4.6.tgz",
"integrity": "sha512-QnHe81PMslpy3mnpL8DnO2M4S4ZnYPkjlGCLWBZT/3R9M6b5daArWMMtEfP52/n174RKnwRIf3oT8+wc9ihSfQ==",
"version": "2.4.7",
"resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.4.7.tgz",
"integrity": "sha512-vXrgcmNGZ4lpdwZSpMf1hWw1aWS6B+SyeSYKTLrNsiUsAdSRN0J4d/7mF3ogJFbIwFFSOL3wT92Zzxia/d5/ng==",
"dev": true,
"license": "MIT OR Apache-2.0",
"bin": {
@@ -92,20 +92,20 @@
"url": "https://opencollective.com/biome"
},
"optionalDependencies": {
"@biomejs/cli-darwin-arm64": "2.4.6",
"@biomejs/cli-darwin-x64": "2.4.6",
"@biomejs/cli-linux-arm64": "2.4.6",
"@biomejs/cli-linux-arm64-musl": "2.4.6",
"@biomejs/cli-linux-x64": "2.4.6",
"@biomejs/cli-linux-x64-musl": "2.4.6",
"@biomejs/cli-win32-arm64": "2.4.6",
"@biomejs/cli-win32-x64": "2.4.6"
"@biomejs/cli-darwin-arm64": "2.4.7",
"@biomejs/cli-darwin-x64": "2.4.7",
"@biomejs/cli-linux-arm64": "2.4.7",
"@biomejs/cli-linux-arm64-musl": "2.4.7",
"@biomejs/cli-linux-x64": "2.4.7",
"@biomejs/cli-linux-x64-musl": "2.4.7",
"@biomejs/cli-win32-arm64": "2.4.7",
"@biomejs/cli-win32-x64": "2.4.7"
}
},
"node_modules/@biomejs/cli-darwin-arm64": {
"version": "2.4.6",
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.4.6.tgz",
"integrity": "sha512-NW18GSyxr+8sJIqgoGwVp5Zqm4SALH4b4gftIA0n62PTuBs6G2tHlwNAOj0Vq0KKSs7Sf88VjjmHh0O36EnzrQ==",
"version": "2.4.7",
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.4.7.tgz",
"integrity": "sha512-Oo0cF5mHzmvDmTXw8XSjhCia8K6YrZnk7aCS54+/HxyMdZMruMO3nfpDsrlar/EQWe41r1qrwKiCa2QDYHDzWA==",
"cpu": [
"arm64"
],
@@ -120,9 +120,9 @@
}
},
"node_modules/@biomejs/cli-darwin-x64": {
"version": "2.4.6",
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.4.6.tgz",
"integrity": "sha512-4uiE/9tuI7cnjtY9b07RgS7gGyYOAfIAGeVJWEfeCnAarOAS7qVmuRyX6d7JTKw28/mt+rUzMasYeZ+0R/U1Mw==",
"version": "2.4.7",
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.4.7.tgz",
"integrity": "sha512-I+cOG3sd/7HdFtvDSnF9QQPrWguUH7zrkIMMykM3PtfWU9soTcS2yRb9Myq6MHmzbeCT08D1UmY+BaiMl5CcoQ==",
"cpu": [
"x64"
],
@@ -137,9 +137,9 @@
}
},
"node_modules/@biomejs/cli-linux-arm64": {
"version": "2.4.6",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.4.6.tgz",
"integrity": "sha512-kMLaI7OF5GN1Q8Doymjro1P8rVEoy7BKQALNz6fiR8IC1WKduoNyteBtJlHT7ASIL0Cx2jR6VUOBIbcB1B8pew==",
"version": "2.4.7",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.4.7.tgz",
"integrity": "sha512-om6FugwmibzfP/6ALj5WRDVSND4H2G9X0nkI1HZpp2ySf9lW2j0X68oQSaHEnls6666oy4KDsc5RFjT4m0kV0w==",
"cpu": [
"arm64"
],
@@ -154,9 +154,9 @@
}
},
"node_modules/@biomejs/cli-linux-arm64-musl": {
"version": "2.4.6",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.4.6.tgz",
"integrity": "sha512-F/JdB7eN22txiTqHM5KhIVt0jVkzZwVYrdTR1O3Y4auBOQcXxHK4dxULf4z43QyZI5tsnQJrRBHZy7wwtL+B3A==",
"version": "2.4.7",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.4.7.tgz",
"integrity": "sha512-I2NvM9KPb09jWml93O2/5WMfNR7Lee5Latag1JThDRMURVhPX74p9UDnyTw3Ae6cE1DgXfw7sqQgX7rkvpc0vw==",
"cpu": [
"arm64"
],
@@ -171,9 +171,9 @@
}
},
"node_modules/@biomejs/cli-linux-x64": {
"version": "2.4.6",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.4.6.tgz",
"integrity": "sha512-oHXmUFEoH8Lql1xfc3QkFLiC1hGR7qedv5eKNlC185or+o4/4HiaU7vYODAH3peRCfsuLr1g6v2fK9dFFOYdyw==",
"version": "2.4.7",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.4.7.tgz",
"integrity": "sha512-bV8/uo2Tj+gumnk4sUdkerWyCPRabaZdv88IpbmDWARQQoA/Q0YaqPz1a+LSEDIL7OfrnPi9Hq1Llz4ZIGyIQQ==",
"cpu": [
"x64"
],
@@ -188,9 +188,9 @@
}
},
"node_modules/@biomejs/cli-linux-x64-musl": {
"version": "2.4.6",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.4.6.tgz",
"integrity": "sha512-C9s98IPDu7DYarjlZNuzJKTjVHN03RUnmHV5htvqsx6vEUXCDSJ59DNwjKVD5XYoSS4N+BYhq3RTBAL8X6svEg==",
"version": "2.4.7",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.4.7.tgz",
"integrity": "sha512-00kx4YrBMU8374zd2wHuRV5wseh0rom5HqRND+vDldJPrWwQw+mzd/d8byI9hPx926CG+vWzq6AeiT7Yi5y59g==",
"cpu": [
"x64"
],
@@ -205,9 +205,9 @@
}
},
"node_modules/@biomejs/cli-win32-arm64": {
"version": "2.4.6",
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.4.6.tgz",
"integrity": "sha512-xzThn87Pf3YrOGTEODFGONmqXpTwUNxovQb72iaUOdcw8sBSY3+3WD8Hm9IhMYLnPi0n32s3L3NWU6+eSjfqFg==",
"version": "2.4.7",
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.4.7.tgz",
"integrity": "sha512-hOUHBMlFCvDhu3WCq6vaBoG0dp0LkWxSEnEEsxxXvOa9TfT6ZBnbh72A/xBM7CBYB7WgwqboetzFEVDnMxelyw==",
"cpu": [
"arm64"
],
@@ -222,9 +222,9 @@
}
},
"node_modules/@biomejs/cli-win32-x64": {
"version": "2.4.6",
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.4.6.tgz",
"integrity": "sha512-7++XhnsPlr1HDbor5amovPjOH6vsrFOCdp93iKXhFn6bcMUI6soodj3WWKfgEO6JosKU1W5n3uky3WW9RlRjTg==",
"version": "2.4.7",
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.4.7.tgz",
"integrity": "sha512-qEpGjSkPC3qX4ycbMUthXvi9CkRq7kZpkqMY1OyhmYlYLnANnooDQ7hDerM8+0NJ+DZKVnsIc07h30XOpt7LtQ==",
"cpu": [
"x64"
],
@@ -280,19 +280,6 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/braces": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
"dev": true,
"license": "MIT",
"dependencies": {
"fill-range": "^7.1.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/cli-cursor": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz",
@@ -370,19 +357,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/fill-range": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
"dev": true,
"license": "MIT",
"dependencies": {
"to-regex-range": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/get-east-asian-width": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.4.0.tgz",
@@ -428,28 +402,18 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-number": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.12.0"
}
},
"node_modules/lint-staged": {
"version": "16.3.2",
"resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-16.3.2.tgz",
"integrity": "sha512-xKqhC2AeXLwiAHXguxBjuChoTTWFC6Pees0SHPwOpwlvI3BH7ZADFPddAdN3pgo3aiKgPUx/bxE78JfUnxQnlg==",
"version": "16.4.0",
"resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-16.4.0.tgz",
"integrity": "sha512-lBWt8hujh/Cjysw5GYVmZpFHXDCgZzhrOm8vbcUdobADZNOK/bRshr2kM3DfgrrtR1DQhfupW9gnIXOfiFi+bw==",
"dev": true,
"license": "MIT",
"dependencies": {
"commander": "^14.0.3",
"listr2": "^9.0.5",
"micromatch": "^4.0.8",
"picomatch": "^4.0.3",
"string-argv": "^0.3.2",
"tinyexec": "^1.0.2",
"tinyexec": "^1.0.4",
"yaml": "^2.8.2"
},
"bin": {
@@ -500,33 +464,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/micromatch": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
"dev": true,
"license": "MIT",
"dependencies": {
"braces": "^3.0.3",
"picomatch": "^2.3.1"
},
"engines": {
"node": ">=8.6"
}
},
"node_modules/micromatch/node_modules/picomatch": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8.6"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/mimic-function": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz",
@@ -556,6 +493,19 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/restore-cursor": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz",
@@ -654,28 +604,15 @@
}
},
"node_modules/tinyexec": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz",
"integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==",
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.4.tgz",
"integrity": "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-number": "^7.0.0"
},
"engines": {
"node": ">=8.0"
}
},
"node_modules/wrap-ansi": {
"version": "9.0.2",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz",
+2 -2
View File
@@ -7,9 +7,9 @@
"lint:fix": "cd backend && npm run lint:fix && cd ../frontend && npm run lint:fix"
},
"devDependencies": {
"@biomejs/biome": "^2.4.6",
"@biomejs/biome": "^2.4.7",
"husky": "^9.1.0",
"lint-staged": "^16.3.2"
"lint-staged": "^16.4.0"
},
"lint-staged": {
"backend/src/**/*.ts": [