Compare commits

..

34 Commits

Author SHA1 Message Date
dependabot[bot] 67ad693b31 build(deps): bump fastify from 5.8.4 to 5.8.5 in /backend
Bumps [fastify](https://github.com/fastify/fastify) from 5.8.4 to 5.8.5.
- [Release notes](https://github.com/fastify/fastify/releases)
- [Commits](https://github.com/fastify/fastify/compare/v5.8.4...v5.8.5)

---
updated-dependencies:
- dependency-name: fastify
  dependency-version: 5.8.5
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-16 07:00:31 +02:00
Daniel Volz ab3facc47a Merge github/dependabot/github_actions/softprops/action-gh-release-3
* github/dependabot/github_actions/softprops/action-gh-release-3:
  build(deps): bump softprops/action-gh-release from 2 to 3
2026-04-14 17:19:14 +02:00
Daniel Volz ce02b4211a Merge github/dependabot/github_actions/dependabot/fetch-metadata-3
* github/dependabot/github_actions/dependabot/fetch-metadata-3:
  build(deps): bump dependabot/fetch-metadata from 2 to 3
2026-04-14 17:19:14 +02:00
Daniel Volz 40bd7ba3b7 Merge github/dependabot/github_actions/actions/github-script-9
* github/dependabot/github_actions/actions/github-script-9:
  build(deps): bump actions/github-script from 8 to 9
2026-04-14 17:19:14 +02:00
Daniel Volz 826d85937c Merge github/dependabot/npm_and_yarn/backend/fastify/multipart-10.0.0
* github/dependabot/npm_and_yarn/backend/fastify/multipart-10.0.0:
  build(deps): bump @fastify/multipart from 9.4.0 to 10.0.0 in /backend
2026-04-14 17:19:14 +02:00
Daniel Volz 6d98a049bc Merge github/dependabot/npm_and_yarn/backend/minor-and-patch-38fe55292f
* github/dependabot/npm_and_yarn/backend/minor-and-patch-38fe55292f:
  build(deps): bump the minor-and-patch group in /backend with 6 updates
2026-04-14 17:19:14 +02:00
Daniel Volz 435ca5f1d6 Merge github/dependabot/npm_and_yarn/minor-and-patch-0d57c39e52
* github/dependabot/npm_and_yarn/minor-and-patch-0d57c39e52:
  build(deps-dev): bump @biomejs/biome in the minor-and-patch group
2026-04-14 17:19:13 +02:00
Daniel Volz ecf9cfb539 Merge branch 'main' into dependabot/github_actions/softprops/action-gh-release-3 2026-04-13 09:28:51 +02:00
dependabot[bot] dafa5abab4 build(deps): bump the minor-and-patch group in /frontend with 10 updates (#538)
Bumps the minor-and-patch group in /frontend with 10 updates:

| Package | From | To |
| --- | --- | --- |
| [i18next](https://github.com/i18next/i18next) | `26.0.3` | `26.0.4` |
| [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react) | `1.7.0` | `1.8.0` |
| [react](https://github.com/facebook/react/tree/HEAD/packages/react) | `19.2.4` | `19.2.5` |
| [react-dom](https://github.com/facebook/react/tree/HEAD/packages/react-dom) | `19.2.4` | `19.2.5` |
| [@biomejs/biome](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) | `2.4.10` | `2.4.11` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `25.5.2` | `25.6.0` |
| [@vitest/coverage-v8](https://github.com/vitest-dev/vitest/tree/HEAD/packages/coverage-v8) | `4.1.2` | `4.1.4` |
| [jsdom](https://github.com/jsdom/jsdom) | `29.0.1` | `29.0.2` |
| [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) | `8.0.5` | `8.0.8` |
| [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) | `4.1.2` | `4.1.4` |


Updates `i18next` from 26.0.3 to 26.0.4
- [Release notes](https://github.com/i18next/i18next/releases)
- [Changelog](https://github.com/i18next/i18next/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/i18next/compare/v26.0.3...v26.0.4)

Updates `lucide-react` from 1.7.0 to 1.8.0
- [Release notes](https://github.com/lucide-icons/lucide/releases)
- [Commits](https://github.com/lucide-icons/lucide/commits/1.8.0/packages/lucide-react)

Updates `react` from 19.2.4 to 19.2.5
- [Release notes](https://github.com/facebook/react/releases)
- [Changelog](https://github.com/facebook/react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/facebook/react/commits/v19.2.5/packages/react)

Updates `react-dom` from 19.2.4 to 19.2.5
- [Release notes](https://github.com/facebook/react/releases)
- [Changelog](https://github.com/facebook/react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/facebook/react/commits/v19.2.5/packages/react-dom)

Updates `@biomejs/biome` from 2.4.10 to 2.4.11
- [Release notes](https://github.com/biomejs/biome/releases)
- [Changelog](https://github.com/biomejs/biome/blob/main/packages/@biomejs/biome/CHANGELOG.md)
- [Commits](https://github.com/biomejs/biome/commits/@biomejs/biome@2.4.11/packages/@biomejs/biome)

Updates `@types/node` from 25.5.2 to 25.6.0
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `@vitest/coverage-v8` from 4.1.2 to 4.1.4
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.4/packages/coverage-v8)

Updates `jsdom` from 29.0.1 to 29.0.2
- [Release notes](https://github.com/jsdom/jsdom/releases)
- [Commits](https://github.com/jsdom/jsdom/compare/v29.0.1...v29.0.2)

Updates `vite` from 8.0.5 to 8.0.8
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v8.0.8/packages/vite)

Updates `vitest` from 4.1.2 to 4.1.4
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.4/packages/vitest)

---
updated-dependencies:
- dependency-name: i18next
  dependency-version: 26.0.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: lucide-react
  dependency-version: 1.8.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: react
  dependency-version: 19.2.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: react-dom
  dependency-version: 19.2.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: "@biomejs/biome"
  dependency-version: 2.4.11
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: "@types/node"
  dependency-version: 25.6.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: "@vitest/coverage-v8"
  dependency-version: 4.1.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: jsdom
  dependency-version: 29.0.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: vite
  dependency-version: 8.0.8
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: vitest
  dependency-version: 4.1.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-13 09:28:08 +02:00
dependabot[bot] cc5141c997 build(deps): bump softprops/action-gh-release from 2 to 3
Bumps [softprops/action-gh-release](https://github.com/softprops/action-gh-release) from 2 to 3.
- [Release notes](https://github.com/softprops/action-gh-release/releases)
- [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md)
- [Commits](https://github.com/softprops/action-gh-release/compare/v2...v3)

---
updated-dependencies:
- dependency-name: softprops/action-gh-release
  dependency-version: '3'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-13 06:45:25 +00:00
dependabot[bot] 22725fa566 build(deps): bump dependabot/fetch-metadata from 2 to 3
Bumps [dependabot/fetch-metadata](https://github.com/dependabot/fetch-metadata) from 2 to 3.
- [Release notes](https://github.com/dependabot/fetch-metadata/releases)
- [Commits](https://github.com/dependabot/fetch-metadata/compare/v2...v3)

---
updated-dependencies:
- dependency-name: dependabot/fetch-metadata
  dependency-version: '3'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-13 06:45:22 +00:00
dependabot[bot] a5fe76545e build(deps): bump actions/github-script from 8 to 9
Bumps [actions/github-script](https://github.com/actions/github-script) from 8 to 9.
- [Release notes](https://github.com/actions/github-script/releases)
- [Commits](https://github.com/actions/github-script/compare/v8...v9)

---
updated-dependencies:
- dependency-name: actions/github-script
  dependency-version: '9'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-13 06:45:19 +00:00
dependabot[bot] 527f4251e5 build(deps): bump @fastify/multipart from 9.4.0 to 10.0.0 in /backend
Bumps [@fastify/multipart](https://github.com/fastify/fastify-multipart) from 9.4.0 to 10.0.0.
- [Release notes](https://github.com/fastify/fastify-multipart/releases)
- [Commits](https://github.com/fastify/fastify-multipart/compare/v9.4.0...v10.0.0)

---
updated-dependencies:
- dependency-name: "@fastify/multipart"
  dependency-version: 10.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-13 06:38:56 +00:00
dependabot[bot] 5064de3bff build(deps): bump the minor-and-patch group in /backend with 6 updates
Bumps the minor-and-patch group in /backend with 6 updates:

| Package | From | To |
| --- | --- | --- |
| [@fastify/static](https://github.com/fastify/fastify-static) | `9.0.0` | `9.1.0` |
| [dotenv](https://github.com/motdotla/dotenv) | `17.4.1` | `17.4.2` |
| [@biomejs/biome](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) | `2.4.10` | `2.4.11` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `25.5.2` | `25.6.0` |
| [@vitest/coverage-v8](https://github.com/vitest-dev/vitest/tree/HEAD/packages/coverage-v8) | `4.1.2` | `4.1.4` |
| [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) | `4.1.2` | `4.1.4` |


Updates `@fastify/static` from 9.0.0 to 9.1.0
- [Release notes](https://github.com/fastify/fastify-static/releases)
- [Commits](https://github.com/fastify/fastify-static/compare/v9.0.0...v9.1.0)

Updates `dotenv` from 17.4.1 to 17.4.2
- [Changelog](https://github.com/motdotla/dotenv/blob/master/CHANGELOG.md)
- [Commits](https://github.com/motdotla/dotenv/compare/v17.4.1...v17.4.2)

Updates `@biomejs/biome` from 2.4.10 to 2.4.11
- [Release notes](https://github.com/biomejs/biome/releases)
- [Changelog](https://github.com/biomejs/biome/blob/main/packages/@biomejs/biome/CHANGELOG.md)
- [Commits](https://github.com/biomejs/biome/commits/@biomejs/biome@2.4.11/packages/@biomejs/biome)

Updates `@types/node` from 25.5.2 to 25.6.0
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `@vitest/coverage-v8` from 4.1.2 to 4.1.4
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.4/packages/coverage-v8)

Updates `vitest` from 4.1.2 to 4.1.4
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.4/packages/vitest)

---
updated-dependencies:
- dependency-name: "@fastify/static"
  dependency-version: 9.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: dotenv
  dependency-version: 17.4.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: "@biomejs/biome"
  dependency-version: 2.4.11
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: "@types/node"
  dependency-version: 25.6.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: "@vitest/coverage-v8"
  dependency-version: 4.1.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: vitest
  dependency-version: 4.1.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-13 06:38:43 +00:00
dependabot[bot] 40d6f33676 build(deps-dev): bump @biomejs/biome in the minor-and-patch group
Bumps the minor-and-patch group with 1 update: [@biomejs/biome](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome).


Updates `@biomejs/biome` from 2.4.10 to 2.4.11
- [Release notes](https://github.com/biomejs/biome/releases)
- [Changelog](https://github.com/biomejs/biome/blob/main/packages/@biomejs/biome/CHANGELOG.md)
- [Commits](https://github.com/biomejs/biome/commits/@biomejs/biome@2.4.11/packages/@biomejs/biome)

---
updated-dependencies:
- dependency-name: "@biomejs/biome"
  dependency-version: 2.4.11
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-13 06:17:31 +00:00
Daniel Volz 0dab318b66 chore: release v1.23.0 (#536) 2026-04-10 22:40:01 +02:00
github-actions[bot] 932524125e chore: update test count badges [skip ci] 2026-04-10 20:38:49 +00:00
Daniel Volz c291c88f2b fix(notifications): fallback to generic medication names (#532)
* fix(notifications): fallback to generic medication names

* test(backend): add timezone column to in-memory user_settings schemas
2026-04-10 22:34:06 +02:00
Daniel Volz e42e4f5639 fix(stock): ignore doses from other medications (#533)
* fix(stock): ignore doses from other medications

* test(backend): add timezone column to in-memory user_settings schemas
2026-04-10 22:33:58 +02:00
Daniel Volz b70fc88921 chore(gitignore): ignore local agent workspace artifacts (#534) 2026-04-10 22:31:54 +02:00
Daniel Volz 95aec8350a fix(settings): stabilize timezone edit UX and tooltip visibility (#535) 2026-04-10 22:31:22 +02:00
Daniel Volz 401228699f Add searchable timezone settings override for reminder scheduling 2026-04-10 21:08:16 +02:00
Daniel Volz 0d2b21199e chore(release): bump app version to 1.22.3
chore(release): bump app version to 1.22.3
2026-04-10 19:01:40 +02:00
Copilot d5b3c5c21f fix: remove upgrade-insecure-requests from CSP — blank homepage on HTTP deployments (#525)
* Initial plan

* fix: remove upgrade-insecure-requests from CSP to fix blank homepage over HTTP

The upgrade-insecure-requests CSP directive instructs browsers to
upgrade same-host HTTP requests to HTTPS (preserving port). In the
default plain-HTTP Docker deployment (port 4174), the browser upgrades
every asset URL to https://host:4174/... and sends a TLS Client Hello
to the HTTP nginx port. nginx cannot parse TLS bytes as HTTP and returns
400 with no method/URI (the observed "400 - -" log pattern). All
JS/CSS bundles fail to load, React never mounts, page stays blank.

Fix: remove "; upgrade-insecure-requests" from the CSP string.
This directive is intended for HTTPS-only sites and is harmful on
plain-HTTP servers. Removing it does not weaken security for HTTP
deployments.

Agent-Logs-Url: https://github.com/DanielVolz/medassist-ng/sessions/9c4db7bd-1272-49ca-abf3-73c2ad5a5354

Co-authored-by: DanielVolz <3275994+DanielVolz@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: DanielVolz <3275994+DanielVolz@users.noreply.github.com>
2026-04-10 12:28:38 +02:00
Daniel Volz 002f16c505 fix(security): centralize SMTP transport creation
Centralize SMTP transport creation to reduce the duplicated CodeQL SMTP request-forgery path.
2026-04-08 20:00:57 +02:00
dependabot[bot] aa050f7dc5 build(deps): bump nodemailer from 8.0.4 to 8.0.5 in /backend
Bump nodemailer from 8.0.4 to 8.0.5 in /backend
2026-04-08 19:39:51 +02:00
Daniel Volz 0795bfe589 chore(release): 1.22.2
Release v1.22.2
2026-04-08 19:34:24 +02:00
Daniel Volz 25483c12f0 fix(security): mitigate backend drizzle-kit audit chain 2026-04-08 19:21:05 +02:00
dependabot[bot] 2a340855fb build(deps): bump vite from 8.0.3 to 8.0.5 in /backend
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 8.0.3 to 8.0.5.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v8.0.5/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 8.0.5
  dependency-type: indirect
...

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-04-07 07:01:11 +02:00
dependabot[bot] 52fec1a4e5 build(deps-dev): bump vite from 8.0.3 to 8.0.5 in /frontend
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 8.0.3 to 8.0.5.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v8.0.5/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 8.0.5
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-07 06:56:40 +02:00
dependabot[bot] 1cb4a44cef build(deps-dev): bump @types/nodemailer from 7.0.11 to 8.0.0 in /backend
Bumps [@types/nodemailer](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/nodemailer) from 7.0.11 to 8.0.0.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/nodemailer)

---
updated-dependencies:
- dependency-name: "@types/nodemailer"
  dependency-version: 8.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-04-06 11:00:55 +02:00
dependabot[bot] 51b09dc563 build(deps): bump the minor-and-patch group in /backend with 3 updates
Bumps the minor-and-patch group in /backend with 3 updates: [dotenv](https://github.com/motdotla/dotenv), [@biomejs/biome](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) and [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node).


Updates `dotenv` from 17.3.1 to 17.4.1
- [Changelog](https://github.com/motdotla/dotenv/blob/master/CHANGELOG.md)
- [Commits](https://github.com/motdotla/dotenv/compare/v17.3.1...v17.4.1)

Updates `@biomejs/biome` from 2.4.9 to 2.4.10
- [Release notes](https://github.com/biomejs/biome/releases)
- [Changelog](https://github.com/biomejs/biome/blob/main/packages/@biomejs/biome/CHANGELOG.md)
- [Commits](https://github.com/biomejs/biome/commits/@biomejs/biome@2.4.10/packages/@biomejs/biome)

Updates `@types/node` from 25.5.0 to 25.5.2
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: dotenv
  dependency-version: 17.4.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: "@biomejs/biome"
  dependency-version: 2.4.10
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: "@types/node"
  dependency-version: 25.5.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
...

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-04-06 10:54:02 +02:00
dependabot[bot] dbbd9d5ed8 build(deps-dev): bump @biomejs/biome from 2.4.9 to 2.4.10 in the minor-and-patch group
Bumps the minor-and-patch group with 1 update: [@biomejs/biome](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome).


Updates `@biomejs/biome` from 2.4.9 to 2.4.10
- [Release notes](https://github.com/biomejs/biome/releases)
- [Changelog](https://github.com/biomejs/biome/blob/main/packages/@biomejs/biome/CHANGELOG.md)
- [Commits](https://github.com/biomejs/biome/commits/@biomejs/biome@2.4.10/packages/@biomejs/biome)

---
updated-dependencies:
- dependency-name: "@biomejs/biome"
  dependency-version: 2.4.10
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
...

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-04-06 10:50:19 +02:00
dependabot[bot] 15f1e33aa4 build(deps): bump the minor-and-patch group in /frontend with 6 updates
Bumps the minor-and-patch group in /frontend with 6 updates:

| Package | From | To |
| --- | --- | --- |
| [i18next](https://github.com/i18next/i18next) | `26.0.1` | `26.0.3` |
| [react-i18next](https://github.com/i18next/react-i18next) | `17.0.1` | `17.0.2` |
| [react-router-dom](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router-dom) | `7.13.2` | `7.14.0` |
| [@biomejs/biome](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) | `2.4.9` | `2.4.10` |
| [@playwright/test](https://github.com/microsoft/playwright) | `1.58.2` | `1.59.1` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `25.5.0` | `25.5.2` |


Updates `i18next` from 26.0.1 to 26.0.3
- [Release notes](https://github.com/i18next/i18next/releases)
- [Changelog](https://github.com/i18next/i18next/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/i18next/compare/v26.0.1...v26.0.3)

Updates `react-i18next` from 17.0.1 to 17.0.2
- [Changelog](https://github.com/i18next/react-i18next/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/react-i18next/compare/v17.0.1...v17.0.2)

Updates `react-router-dom` from 7.13.2 to 7.14.0
- [Release notes](https://github.com/remix-run/react-router/releases)
- [Changelog](https://github.com/remix-run/react-router/blob/main/packages/react-router-dom/CHANGELOG.md)
- [Commits](https://github.com/remix-run/react-router/commits/react-router-dom@7.14.0/packages/react-router-dom)

Updates `@biomejs/biome` from 2.4.9 to 2.4.10
- [Release notes](https://github.com/biomejs/biome/releases)
- [Changelog](https://github.com/biomejs/biome/blob/main/packages/@biomejs/biome/CHANGELOG.md)
- [Commits](https://github.com/biomejs/biome/commits/@biomejs/biome@2.4.10/packages/@biomejs/biome)

Updates `@playwright/test` from 1.58.2 to 1.59.1
- [Release notes](https://github.com/microsoft/playwright/releases)
- [Commits](https://github.com/microsoft/playwright/compare/v1.58.2...v1.59.1)

Updates `@types/node` from 25.5.0 to 25.5.2
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: i18next
  dependency-version: 26.0.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: react-i18next
  dependency-version: 17.0.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: react-router-dom
  dependency-version: 7.14.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: "@biomejs/biome"
  dependency-version: 2.4.10
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: "@playwright/test"
  dependency-version: 1.59.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: "@types/node"
  dependency-version: 25.5.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-06 10:49:32 +02:00
40 changed files with 1053 additions and 1643 deletions
+2 -1
View File
@@ -37,7 +37,8 @@ LOG_LEVEL=warn
# production: leave unset, or set OPENAPI_DOCS_ENABLED=false
# OPENAPI_DOCS_ENABLED=true
# Timezone for scheduled reminders (e.g., Europe/Berlin, America/New_York)
# Server default timezone for scheduled reminders (e.g., Europe/Berlin, America/New_York).
# Users can override this per account in Settings -> Timezone.
TZ=Europe/Berlin
# =============================================================================
+1 -1
View File
@@ -20,7 +20,7 @@ jobs:
steps:
- name: Read Dependabot metadata
id: metadata
uses: dependabot/fetch-metadata@v2
uses: dependabot/fetch-metadata@v3
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
+1 -1
View File
@@ -196,7 +196,7 @@ jobs:
- name: Create GitHub Release
if: steps.check_release.outputs.exists == 'false'
uses: softprops/action-gh-release@v2
uses: softprops/action-gh-release@v3
with:
tag_name: ${{ steps.current_tag.outputs.value }}
target_commitish: ${{ github.sha }}
+1 -1
View File
@@ -17,7 +17,7 @@ jobs:
(github.event_name == 'pull_request' && github.event.pull_request.merged == true)
steps:
- name: Move project item to Done
uses: actions/github-script@v8
uses: actions/github-script@v9
with:
github-token: ${{ secrets.ADD_TO_PROJECT_PAT }}
script: |
+1 -1
View File
@@ -12,7 +12,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Sync fields
uses: actions/github-script@v8
uses: actions/github-script@v9
with:
github-token: ${{ secrets.ADD_TO_PROJECT_PAT }}
script: |
+2 -2
View File
@@ -15,7 +15,7 @@ jobs:
steps:
- name: Build weekly summary
id: summary
uses: actions/github-script@v8
uses: actions/github-script@v9
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
@@ -59,7 +59,7 @@ jobs:
core.setOutput('body', body);
- name: Publish report issue
uses: actions/github-script@v8
uses: actions/github-script@v9
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
+12 -2
View File
@@ -83,18 +83,28 @@ Thumbs.db
AGENTS.md
docs/TECH_STACK.md
doku/
# Local agent work logs stay on disk but must never go upstream.
doku/memory_notes.md
doku/report.md
plan/
.copilot-tracking/
.playwright-cli/
.agents/
skills-lock.json
# ===================
# Local Spec Kit artifacts
# Local Spec Kit workspace state
# ===================
.specify/
specs/
docs/SPEC_KIT.md
.github/agents/medassist-feature-orchestrator.agent.md
.github/agents/speckit.*.agent.md
.github/prompts/speckit.*.prompt.md
.github/prompts/speckit.*.prompt.md
.github/skills/accessibility/
.github/skills/frontend-design/
.github/skills/nodejs-backend-patterns/
.github/skills/nodejs-best-practices/
.github/skills/seo/
.playwright-mcp
+4 -2
View File
@@ -18,7 +18,7 @@
</p>
<p align="center">
<img src="https://img.shields.io/badge/Backend_Tests-639%2F639-brightgreen?logo=vitest" alt="Backend Tests 454/454" />
<img src="https://img.shields.io/badge/Backend_Tests-640%2F640-brightgreen?logo=vitest" alt="Backend Tests 454/454" />
<img src="https://img.shields.io/badge/Frontend_Tests-884%2F884-brightgreen?logo=vitest" alt="Frontend Tests 611/611" />
</p>
@@ -203,7 +203,7 @@ All configuration is done via environment variables in `.env`. Copy `.env.exampl
| `CORS_ORIGINS` | `http://localhost:4174` | Allowed origins for CORS |
| `LOG_LEVEL` | `info` | Log verbosity (`debug`, `info`, `warn`, `error`, `silent`). At `info` (default), high-frequency polling endpoints are suppressed. Set `debug` to see all requests. |
| `OPENAPI_DOCS_ENABLED` | `auto` | Enables API docs in non-production by default. Set explicitly to `true`/`false` to override. |
| `TZ` | `Europe/Berlin` | Timezone for scheduled reminders |
| `TZ` | `Europe/Berlin` | Server default timezone for scheduled reminders (can be overridden per user in Settings) |
Recommended values for API docs by environment:
@@ -305,6 +305,8 @@ API reference:
| `REMINDER_MINUTES_BEFORE` | `15` | Minutes before intake to send reminder |
| `EXPIRY_WARNING_DAYS` | `30` | Days before expiry to show warning |
Intake reminder timing uses IANA timezones. The server uses `TZ` as default, and each user can set an override in Settings. If no user timezone is set, reminders continue using the server default.
### Push Notifications (Shoutrrr)
MedAssist uses [Shoutrrr](https://containrrr.dev/shoutrrr/) for push notifications, supporting many services with a single URL format.
@@ -0,0 +1 @@
ALTER TABLE `user_settings` ADD `timezone` text DEFAULT '' NOT NULL;
+7
View File
@@ -99,6 +99,13 @@
"when": 1773348659979,
"tag": "0013_add_share_medication_overview",
"breakpoints": true
},
{
"idx": 14,
"version": "6",
"when": 1775849300000,
"tag": "0014_add_user_settings_timezone",
"breakpoints": true
}
]
}
+329 -1235
View File
File diff suppressed because it is too large Load Diff
+15 -10
View File
@@ -1,6 +1,6 @@
{
"name": "medassist-ng-backend",
"version": "1.22.1",
"version": "1.23.0",
"private": true,
"type": "module",
"scripts": {
@@ -20,35 +20,40 @@
"@fastify/cookie": "^11.0.2",
"@fastify/cors": "^11.2.0",
"@fastify/helmet": "^13.0.2",
"@fastify/multipart": "^9.4.0",
"@fastify/multipart": "^10.0.0",
"@fastify/rate-limit": "^10.3.0",
"@fastify/sensible": "^6.0.4",
"@fastify/static": "^9.0.0",
"@fastify/static": "^9.1.0",
"@fastify/swagger": "^9.7.0",
"@fastify/swagger-ui": "^5.2.5",
"@libsql/client": "^0.17.2",
"argon2": "^0.44.0",
"dotenv": "^17.3.1",
"dotenv": "^17.4.2",
"drizzle-orm": "^0.45.2",
"fastify": "^5.8.4",
"fastify": "^5.8.5",
"fastify-plugin": "^5.0.1",
"jose": "^6.2.2",
"nodemailer": "^8.0.4",
"nodemailer": "^8.0.5",
"openid-client": "^6.8.2",
"sharp": "^0.34.5",
"zod": "^3.23.8"
},
"devDependencies": {
"@biomejs/biome": "^2.4.9",
"@types/node": "^25.5.0",
"@types/nodemailer": "^7.0.11",
"@biomejs/biome": "^2.4.11",
"@types/node": "^25.6.0",
"@types/nodemailer": "^8.0.0",
"@types/supertest": "^7.2.0",
"@vitest/coverage-v8": "^4.1.2",
"@vitest/coverage-v8": "^4.1.4",
"drizzle-kit": "^0.31.10",
"pino-pretty": "^13.1.3",
"supertest": "^7.2.2",
"tsx": "^4.19.0",
"typescript": "^6.0.2",
"vitest": "^4.0.16"
},
"overrides": {
"@esbuild-kit/esm-loader": "2.6.5",
"@esbuild-kit/core-utils": "3.3.2",
"esbuild": "0.25.4"
}
}
+1
View File
@@ -33,6 +33,7 @@ export async function runAlterMigrations(client: Client): Promise<{ success: boo
`ALTER TABLE user_settings ADD COLUMN repeat_reminders_enabled integer NOT NULL DEFAULT 0`,
`ALTER TABLE user_settings ADD COLUMN reminder_repeat_interval_minutes integer NOT NULL DEFAULT 30`,
`ALTER TABLE user_settings ADD COLUMN max_nagging_reminders integer NOT NULL DEFAULT 5`,
`ALTER TABLE user_settings ADD COLUMN timezone text NOT NULL DEFAULT ''`,
`ALTER TABLE dose_tracking ADD COLUMN dismissed integer NOT NULL DEFAULT 0`,
`ALTER TABLE dose_tracking ADD COLUMN taken_source text NOT NULL DEFAULT 'manual'`,
`ALTER TABLE user_settings ADD COLUMN stock_calculation_mode text NOT NULL DEFAULT 'automatic'`,
+1
View File
@@ -64,6 +64,7 @@ export function getTableCreationSQL(): string[] {
high_stock_days integer NOT NULL DEFAULT 180,
expiry_warning_days integer NOT NULL DEFAULT 90,
language text NOT NULL DEFAULT 'en',
timezone text NOT NULL DEFAULT '',
stock_calculation_mode text NOT NULL DEFAULT 'automatic',
share_stock_status integer NOT NULL DEFAULT 1,
upcoming_today_only integer NOT NULL DEFAULT 0,
+1
View File
@@ -105,6 +105,7 @@ export const userSettings = sqliteTable("user_settings", {
expiryWarningDays: integer("expiry_warning_days").notNull().default(90),
// UI preferences
language: text("language", { length: 10 }).notNull().default("en"),
timezone: text("timezone", { length: 64 }).notNull().default(""),
// Stock calculation mode: "automatic" (schedule-based) or "manual" (only marked doses)
stockCalculationMode: text("stock_calculation_mode", { length: 20 }).notNull().default("automatic"),
// Whether shared schedule links show stock status (Critical/Low/Normal) to intake users
+4 -16
View File
@@ -1,6 +1,5 @@
import { and, eq } from "drizzle-orm";
import type { FastifyInstance, FastifyRequest } from "fastify";
import nodemailer from "nodemailer";
import { db } from "../db/client.js";
import { medications } from "../db/schema.js";
import {
@@ -20,7 +19,7 @@ import {
type StockReminderItem as SharedStockReminderItem,
} from "../services/notifications/builders.js";
import { getSmtpConfig, sendEmailNotification, sendPushNotification } from "../services/notifications/delivery.js";
import { escapeHtml, getDeliveryError, getPlannerUnit, isContainerPackage } from "../services/planner-service.js";
import { escapeHtml, getPlannerUnit, isContainerPackage } from "../services/planner-service.js";
import { updateReminderSentTime, updateUserReminderSentTime } from "../services/reminder-scheduler.js";
import type { AuthUser } from "../types/fastify.js";
import {
@@ -428,19 +427,9 @@ ${getFooterPlain(language)}`;
`;
try {
const transporter = nodemailer.createTransport({
host: smtpHost,
port: smtpPort,
secure: smtpSecure,
auth: {
user: smtpUser,
pass: smtpPass ?? "",
},
});
request.log.info({ userId, recipientEmail: email }, "[Planner] Sending demand email");
const mailResult = await transporter.sendMail({
const mailResult = await sendEmailNotification({
from: smtpFrom,
to: email,
subject: t(dc.subject, { from: fromDate, until: untilDate }),
@@ -448,9 +437,8 @@ ${getFooterPlain(language)}`;
html,
});
const deliveryError = getDeliveryError(mailResult);
if (deliveryError) {
throw new Error(deliveryError);
if (!mailResult.success) {
throw new Error(mailResult.error ?? "Failed to send demand email");
}
request.log.info(
+23 -30
View File
@@ -1,16 +1,18 @@
import { eq } from "drizzle-orm";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import nodemailer from "nodemailer";
import { db } from "../db/client.js";
import { userSettings } from "../db/schema.js";
import { getAnonymousUserId, requireAuth } from "../plugins/auth.js";
import { env } from "../plugins/env.js";
import { getSmtpConfig, sendEmailNotification } from "../services/notifications/delivery.js";
import {
classifyTestEmailFailure,
getAllUserSettingsFromDb,
getAvailableTimezones,
getDefaultSettings,
getNotificationProvider,
loadUserSettingsFromDb,
normalizeSettingsTimezone,
sanitizeNotificationUrl,
type UserSettings,
validateNotificationHostname,
@@ -20,6 +22,7 @@ import type { AuthUser } from "../types/fastify.js";
export type { UserSettings } from "../services/settings-service.js";
type SettingsBody = {
timezone: string;
emailEnabled: boolean;
notificationEmail: string;
reminderDaysBefore: number;
@@ -174,6 +177,9 @@ export async function settingsRoutes(app: FastifyInstance) {
const reminderMinutesBefore = envInt("REMINDER_MINUTES_BEFORE", 15);
return reply.send({
timezone: settings.timezone ?? "",
availableTimezones: getAvailableTimezones(),
serverTimezone: process.env.TZ || "UTC",
// User notification settings (from DB)
emailEnabled: settings.emailEnabled,
notificationEmail: settings.notificationEmail ?? "",
@@ -241,6 +247,7 @@ export async function settingsRoutes(app: FastifyInstance) {
type: "object",
required: ["emailEnabled", "notificationEmail", "reminderDaysBefore", "language"],
properties: {
timezone: { type: "string" },
emailEnabled: { type: "boolean" },
notificationEmail: { type: "string" },
reminderDaysBefore: { type: "number" },
@@ -293,6 +300,7 @@ export async function settingsRoutes(app: FastifyInstance) {
upcomingTodayOnly: false,
shareScheduleTodayOnly: false,
swapDashboardMainSections: false,
timezone: "",
},
},
response: {
@@ -318,6 +326,7 @@ export async function settingsRoutes(app: FastifyInstance) {
const existingSettings = await db.select().from(userSettings).where(eq(userSettings.userId, userId));
const settingsData = {
timezone: normalizeSettingsTimezone(body.timezone),
emailEnabled: body.emailEnabled,
notificationEmail: body.notificationEmail || null,
emailStockReminders: body.emailStockReminders ?? true,
@@ -445,49 +454,34 @@ export async function settingsRoutes(app: FastifyInstance) {
async (request, reply) => {
const { email } = request.body;
const smtpHost = process.env.SMTP_HOST;
const smtpUser = process.env.SMTP_USER;
const smtpPass = process.env.SMTP_TOKEN || process.env.SMTP_PASS;
const smtpPort = parseInt(process.env.SMTP_PORT ?? "587", 10);
const smtpSecure = process.env.SMTP_SECURE === "true";
const smtpFrom = process.env.SMTP_FROM ?? smtpUser;
const smtp = getSmtpConfig();
request.log.info(
{
to: email,
hasSmtpHost: Boolean(smtpHost),
hasSmtpUser: Boolean(smtpUser),
hasSmtpPass: Boolean(smtpPass),
hasSmtpFrom: Boolean(smtpFrom),
smtpPort,
smtpSecure,
hasSmtpHost: Boolean(smtp.host),
hasSmtpUser: Boolean(smtp.user),
hasSmtpPass: Boolean(smtp.pass),
hasSmtpFrom: Boolean(smtp.from),
smtpPort: smtp.port,
smtpSecure: smtp.secure,
},
"[Settings] Test email request received"
);
if (!smtpHost || !smtpUser) {
if (!smtp.host || !smtp.user) {
request.log.warn(
{ to: email, hasSmtpHost: Boolean(smtpHost), hasSmtpUser: Boolean(smtpUser) },
{ to: email, hasSmtpHost: Boolean(smtp.host), hasSmtpUser: Boolean(smtp.user) },
"[Settings] Test email skipped: SMTP not configured"
);
return reply.status(400).send({ error: "SMTP not configured" });
}
try {
const transporter = nodemailer.createTransport({
host: smtpHost,
port: smtpPort,
secure: smtpSecure,
auth: {
user: smtpUser,
pass: smtpPass ?? "",
},
});
request.log.info({ to: email }, "[Settings] Sending test email");
const mailResult = await transporter.sendMail({
from: smtpFrom,
const mailResult = await sendEmailNotification({
from: smtp.from,
to: email,
subject: "MedAssist-ng - Test Email",
text: "This is a test email from MedAssist-ng. If you received this, your email configuration is working correctly!",
@@ -502,9 +496,8 @@ export async function settingsRoutes(app: FastifyInstance) {
`,
});
const deliveryError = getDeliveryError(mailResult);
if (deliveryError) {
throw new Error(deliveryError);
if (!mailResult.success) {
throw new Error(mailResult.error ?? "Failed to send test email");
}
request.log.info({ to: email, messageId: mailResult.messageId }, "[Settings] Test email sent");
+16 -2
View File
@@ -99,9 +99,16 @@ export function computeMedicationCurrentStock(options: {
const match = doseIdPattern.exec(dose.doseId);
if (!match) continue;
const parsedMedicationId = Number.parseInt(match[1], 10);
const parsedIntakeIndex = Number.parseInt(match[2], 10);
const doseDateOnlyMs = Number.parseInt(match[3], 10);
if (Number.isNaN(parsedIntakeIndex) || Number.isNaN(doseDateOnlyMs) || parsedIntakeIndex !== intakeIndex) {
if (
Number.isNaN(parsedMedicationId) ||
Number.isNaN(parsedIntakeIndex) ||
Number.isNaN(doseDateOnlyMs) ||
parsedMedicationId !== medication.id ||
parsedIntakeIndex !== intakeIndex
) {
continue;
}
@@ -125,9 +132,16 @@ export function computeMedicationCurrentStock(options: {
const match = doseIdPattern.exec(dose.doseId);
if (!match) continue;
const parsedMedicationId = Number.parseInt(match[1], 10);
const parsedIntakeIndex = Number.parseInt(match[2], 10);
const doseDateOnlyMs = Number.parseInt(match[3], 10);
if (Number.isNaN(parsedIntakeIndex) || Number.isNaN(doseDateOnlyMs) || parsedIntakeIndex !== intakeIndex) {
if (
Number.isNaN(parsedMedicationId) ||
Number.isNaN(parsedIntakeIndex) ||
Number.isNaN(doseDateOnlyMs) ||
parsedMedicationId !== medication.id ||
parsedIntakeIndex !== intakeIndex
) {
continue;
}
@@ -18,7 +18,7 @@ import type { ServiceLogger } from "../utils/logger.js";
import {
cleanOldIntakeReminders,
createDefaultIntakeReminderState,
getTimezone,
getEffectiveTimezone,
getTodaysIntakes,
getUpcomingIntakes,
type IntakeReminderState,
@@ -83,6 +83,16 @@ function formatIntakeLog(intake: {
return `${intake.medName} (medId=${intake.medicationId}, intakeIndex=${intake.blisterIndex}, time=${intake.intakeTime.toISOString()}, localTime=${intake.intakeTimeStr}, usage=${intake.usage} ${doseUnit}, takenBy=${takenBy})`;
}
function getMedicationDisplayName(med: { id: number; name: string | null; genericName: string | null }): string {
const commercialName = med.name?.trim() ?? "";
if (commercialName) return commercialName;
const genericName = med.genericName?.trim() ?? "";
if (genericName) return genericName;
return `Medication #${med.id}`;
}
async function autoMarkDueIntakesAsTaken(
settings: UserSettings & { userId: number },
rows: (typeof medications.$inferSelect)[],
@@ -137,7 +147,7 @@ async function autoMarkDueIntakesAsTaken(
}
const medicationTakenBy = parseTakenByJson(med.takenByJson);
const medDisplayName = med.name || med.genericName || "";
const medDisplayName = getMedicationDisplayName({ id: med.id, name: med.name, genericName: med.genericName });
let remainingStock = computeMedicationCurrentStock({
medication: med,
doses: trackedDoses,
@@ -425,7 +435,7 @@ export async function checkAndSendIntakeRemindersForUser(
const activeRows = rows.filter((med) => med.isObsolete !== true).sort((left, right) => left.id - right.id);
const locale = getDateLocale(language);
const tz = getTimezone();
const tz = getEffectiveTimezone(settings.timezone ?? null);
const autoMarkedCount = await autoMarkDueIntakesAsTaken(settings, activeRows, locale, tz, logger);
if (autoMarkedCount > 0) {
@@ -488,7 +498,7 @@ export async function checkAndSendIntakeRemindersForUser(
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 || "";
const medDisplayName = getMedicationDisplayName({ id: med.id, name: med.name, genericName: med.genericName });
// Process each intake separately to track blisterIndex
intakesWithReminders.forEach((intake, _blisterIndex) => {
+23 -9
View File
@@ -64,6 +64,25 @@ export function getSmtpConfig(): {
return { host, user, pass, port, secure, from };
}
export function createSmtpTransport(smtp = getSmtpConfig()) {
if (!smtp.host || !smtp.user) {
return null;
}
// The SMTP endpoint is configured by the server operator via environment variables,
// not derived from request-controlled input.
// lgtm [js/request-forgery]
return nodemailer.createTransport({
host: smtp.host,
port: smtp.port,
secure: smtp.secure,
auth: {
user: smtp.user,
pass: smtp.pass ?? "",
},
});
}
export async function sendEmailNotification(input: EmailDeliveryRequest): Promise<EmailDeliveryResult> {
const smtp = getSmtpConfig();
if (!smtp.host || !smtp.user) {
@@ -71,15 +90,10 @@ export async function sendEmailNotification(input: EmailDeliveryRequest): Promis
}
try {
const transporter = nodemailer.createTransport({
host: smtp.host,
port: smtp.port,
secure: smtp.secure,
auth: {
user: smtp.user,
pass: smtp.pass ?? "",
},
});
const transporter = createSmtpTransport(smtp);
if (!transporter) {
return { success: false, error: "SMTP not configured" };
}
const mailResult = await transporter.sendMail({
from: input.from ?? smtp.from,
+15 -3
View File
@@ -21,6 +21,7 @@ import {
formatInTimezone,
getCurrentHourInTimezone,
getDateOnlyTimestamp,
getEffectiveTimezone,
getMsUntilNextCheck,
getNextScheduledOccurrenceTime,
getNextScheduledTime,
@@ -125,6 +126,16 @@ type PrescriptionReminderItem = {
expiryDate: string | null;
};
function getMedicationDisplayName(row: { id: number; name: string | null; genericName: string | null }): string {
const commercialName = row.name?.trim() ?? "";
if (commercialName) return commercialName;
const genericName = row.genericName?.trim() ?? "";
if (genericName) return genericName;
return `Medication #${row.id}`;
}
async function getMedicationsNeedingReminder(
userId: number,
reminderDaysBefore: number,
@@ -296,7 +307,7 @@ async function getMedicationsNeedingReminder(
if (isCritical || isLow) {
lowStock.push({
name: row.name,
name: getMedicationDisplayName({ id: row.id, name: row.name, genericName: row.genericName }),
medsLeft: currentPills,
daysLeft,
depletionDate,
@@ -322,7 +333,7 @@ async function getMedicationsNeedingPrescriptionReminder(userId: number): Promis
(row.prescriptionRemainingRefills ?? 0) <= (row.prescriptionLowRefillThreshold ?? 1)
)
.map((row) => ({
name: row.name,
name: getMedicationDisplayName({ id: row.id, name: row.name, genericName: row.genericName }),
remainingRefills: row.prescriptionRemainingRefills ?? 0,
lowThreshold: row.prescriptionLowRefillThreshold ?? 1,
expiryDate: row.prescriptionExpiryDate ?? null,
@@ -534,7 +545,8 @@ async function checkAndSendReminderForUser(
}
const state = loadReminderState();
const today = getTodayInTimezone(); // YYYY-MM-DD in configured timezone
const userTimezone = getEffectiveTimezone(settings.timezone ?? null);
const today = getTodayInTimezone(userTimezone); // YYYY-MM-DD in effective user timezone
const userStateKey = `user_${settings.userId}`;
const userStockNotifiedKey = `${userStateKey}_${today}_stock`;
const userPrescriptionNotifiedKey = `${userStateKey}_${today}_prescription`;
+31
View File
@@ -5,6 +5,7 @@ import type { Language } from "../i18n/translations.js";
export type UserSettings = {
userId: number;
timezone?: string | null;
emailEnabled: boolean;
notificationEmail: string | null;
emailStockReminders: boolean;
@@ -105,6 +106,7 @@ function envInt(key: string, defaultVal: number): number {
export function getDefaultSettings() {
return {
timezone: "",
emailEnabled: envBool("DEFAULT_EMAIL_ENABLED", false),
notificationEmail: process.env.DEFAULT_NOTIFICATION_EMAIL || null,
emailStockReminders: envBool("DEFAULT_EMAIL_STOCK_REMINDERS", true),
@@ -144,6 +146,33 @@ export function getDefaultSettings() {
};
}
type IntlWithSupportedValuesOf = typeof Intl & {
supportedValuesOf?: (key: string) => string[];
};
let cachedTimezones: Set<string> | null = null;
function getTimezoneSet(): Set<string> {
if (cachedTimezones) return cachedTimezones;
const intlWithSupportedValues = Intl as IntlWithSupportedValuesOf;
if (typeof intlWithSupportedValues.supportedValuesOf === "function") {
cachedTimezones = new Set(intlWithSupportedValues.supportedValuesOf("timeZone"));
return cachedTimezones;
}
cachedTimezones = new Set([process.env.TZ || "UTC", "UTC"]);
return cachedTimezones;
}
export function getAvailableTimezones(): string[] {
return [...getTimezoneSet()].sort((left, right) => left.localeCompare(right));
}
export function normalizeSettingsTimezone(value: string | null | undefined): string {
const trimmed = value?.trim() ?? "";
if (!trimmed) return "";
return getTimezoneSet().has(trimmed) ? trimmed : "";
}
export function validateNotificationHostname(hostnameRaw: string): string | null {
const hostname = hostnameRaw.toLowerCase();
@@ -245,6 +274,7 @@ export async function loadUserSettingsFromDb(userId: number): Promise<UserSettin
const settings = await getOrCreateUserSettings(userId);
return {
userId: settings.userId,
timezone: settings.timezone?.trim() ? settings.timezone : null,
emailEnabled: settings.emailEnabled,
notificationEmail: settings.notificationEmail,
emailStockReminders: settings.emailStockReminders,
@@ -288,6 +318,7 @@ export async function getAllUserSettingsFromDb(): Promise<UserSettings[]> {
const allSettings = await db.select().from(userSettings);
return allSettings.map((settings) => ({
userId: settings.userId,
timezone: settings.timezone?.trim() ? settings.timezone : null,
emailEnabled: settings.emailEnabled,
notificationEmail: settings.notificationEmail,
emailStockReminders: settings.emailStockReminders,
+1
View File
@@ -123,6 +123,7 @@ async function createSchema(client: Client) {
`CREATE TABLE IF NOT EXISTS user_settings (
id integer PRIMARY KEY AUTOINCREMENT,
user_id integer NOT NULL UNIQUE,
timezone text NOT NULL DEFAULT '',
email_enabled integer NOT NULL DEFAULT 0,
notification_email text,
email_stock_reminders integer NOT NULL DEFAULT 1,
+1
View File
@@ -117,6 +117,7 @@ async function createSchema(client: Client) {
`CREATE TABLE IF NOT EXISTS user_settings (
id integer PRIMARY KEY AUTOINCREMENT,
user_id integer NOT NULL UNIQUE,
timezone text NOT NULL DEFAULT '',
email_enabled integer NOT NULL DEFAULT 0,
notification_email text,
email_stock_reminders integer NOT NULL DEFAULT 1,
+1
View File
@@ -134,6 +134,7 @@ async function createSchema(client: Client) {
`CREATE TABLE IF NOT EXISTS user_settings (
id integer PRIMARY KEY AUTOINCREMENT,
user_id integer NOT NULL UNIQUE,
timezone text NOT NULL DEFAULT '',
email_enabled integer NOT NULL DEFAULT 0,
notification_email text,
email_stock_reminders integer NOT NULL DEFAULT 1,
@@ -68,6 +68,7 @@ async function setStockMode(mode: "automatic" | "manual") {
async function createMedication(options: {
name: string;
genericName?: string | null;
packCount?: number;
blistersPerPack?: number;
pillsPerBlister?: number;
@@ -80,6 +81,7 @@ async function createMedication(options: {
}) {
const {
name,
genericName = null,
packCount = 1,
blistersPerPack = 1,
pillsPerBlister = 10,
@@ -106,16 +108,17 @@ async function createMedication(options: {
const result = await testClient.execute({
sql: `INSERT INTO medications (
user_id, name, taken_by_json, package_type,
user_id, name, generic_name, taken_by_json, package_type,
pack_count, blisters_per_pack, pills_per_blister, loose_tablets,
stock_adjustment, last_stock_correction_at,
usage_json, every_json, start_json, intakes_json,
is_obsolete, intake_reminders_enabled
) VALUES (?, ?, ?, 'blister', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0)
) VALUES (?, ?, ?, ?, 'blister', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0)
RETURNING id`,
args: [
1,
name,
genericName,
JSON.stringify(takenBy),
packCount,
blistersPerPack,
@@ -348,6 +351,21 @@ describe("Stock semantics parity (planner usage vs scheduler)", () => {
const lowStock = await getMedicationsNeedingReminderForTests(1, 7, 365, "en", "automatic");
expect(lowStock.some((r) => r.name === "Obsolete Med")).toBe(false);
});
it("uses generic name fallback in scheduler reminders when commercial name is empty", async () => {
await setStockMode("automatic");
await createMedication({
name: "",
genericName: "Acetylsalicylic acid",
packCount: 1,
blistersPerPack: 1,
pillsPerBlister: 10,
intakes: [{ usage: 1, every: 1, start: "2026-01-01T08:00:00" }],
});
const lowStock = await getMedicationsNeedingReminderForTests(1, 7, 365, "en", "automatic");
expect(lowStock.some((r) => r.name === "Acetylsalicylic acid")).toBe(true);
});
});
describe("getLiquidReminderThresholds", () => {
+57 -9
View File
@@ -64,6 +64,16 @@ function toDateOnly(date: Date): Date {
return new Date(date.getFullYear(), date.getMonth(), date.getDate(), 0, 0, 0, 0);
}
function getLocalDateOrdinal(date: Date): number {
return Math.floor(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()) / 86_400_000);
}
function addLocalCalendarDays(date: Date, days: number): Date {
const next = new Date(date);
next.setDate(next.getDate() + days);
return next;
}
export function getDateOnlyTimestamp(date: Date): number {
return toDateOnly(date).getTime();
}
@@ -175,13 +185,23 @@ export function getNextScheduledOccurrenceTime(
const lowerBound = inclusive ? fromMs : fromMs + 1;
if (schedule.scheduleMode !== "weekdays") {
const period = Math.max(1, schedule.every) * 86_400_000;
const intervalDays = Math.max(1, schedule.every);
if (startTime >= lowerBound) {
return startTime;
}
const intervals = Math.ceil((lowerBound - startTime) / period);
return startTime + intervals * period;
const lowerBoundDate = new Date(lowerBound);
const startOrdinal = getLocalDateOrdinal(startDate);
const lowerBoundOrdinal = getLocalDateOrdinal(lowerBoundDate);
const daysBetween = Math.max(0, lowerBoundOrdinal - startOrdinal);
const wholeIntervals = Math.floor(daysBetween / intervalDays);
let candidate = addLocalCalendarDays(startDate, wholeIntervals * intervalDays);
while (candidate.getTime() < lowerBound) {
candidate = addLocalCalendarDays(candidate, intervalDays);
}
return candidate.getTime();
}
const candidateStart = Math.max(lowerBound, startTime);
@@ -224,17 +244,28 @@ export function forEachScheduledOccurrenceInRange(
}
if (schedule.scheduleMode !== "weekdays") {
const period = Math.max(1, schedule.every) * 86_400_000;
let occurrenceMs = startTime;
if (occurrenceMs < rangeStartMs) {
const intervals = Math.ceil((rangeStartMs - occurrenceMs) / period);
occurrenceMs += intervals * period;
const intervalDays = Math.max(1, schedule.every);
let occurrence = new Date(startDate);
if (occurrence.getTime() < rangeStartMs) {
const rangeStartDate = new Date(rangeStartMs);
const startOrdinal = getLocalDateOrdinal(startDate);
const rangeStartOrdinal = getLocalDateOrdinal(rangeStartDate);
const daysBetween = Math.max(0, rangeStartOrdinal - startOrdinal);
const wholeIntervals = Math.floor(daysBetween / intervalDays);
occurrence = addLocalCalendarDays(startDate, wholeIntervals * intervalDays);
while (occurrence.getTime() < rangeStartMs) {
occurrence = addLocalCalendarDays(occurrence, intervalDays);
}
}
for (; occurrenceMs <= rangeEndMs; occurrenceMs += period) {
for (let occurrenceMs = occurrence.getTime(); occurrenceMs <= rangeEndMs; ) {
if (occurrenceMs >= rangeStartMs) {
callback(occurrenceMs);
}
occurrence = addLocalCalendarDays(occurrence, intervalDays);
occurrenceMs = occurrence.getTime();
}
return;
}
@@ -348,6 +379,23 @@ export function getTimezone(): string {
return process.env.TZ || "UTC";
}
export function isValidTimezone(value: string): boolean {
try {
new Intl.DateTimeFormat("en-US", { timeZone: value });
return true;
} catch {
return false;
}
}
export function getEffectiveTimezone(override?: string | null): string {
const normalized = override?.trim() ?? "";
if (normalized && isValidTimezone(normalized)) {
return normalized;
}
return getTimezone();
}
/** Format a date in the configured timezone */
export function formatInTimezone(date: Date, tz?: string): string {
return date.toLocaleString("de-DE", {
+8
View File
@@ -4,6 +4,14 @@ Purpose: persistent agent work memory to survive context loss.
## Entries
### 2026-04-10
- Task: Investigate and fix the production blank-homepage bug (user report: both containers running, blank page, many `400 - -` log lines in frontend container).
- Root cause: `upgrade-insecure-requests` directive was present in the `Content-Security-Policy` header in `frontend/nginx.conf`. This directive instructs browsers to upgrade all same-host HTTP requests to HTTPS (preserving the port). When users access the app over plain HTTP (e.g., `http://host:4174/`), the browser receives this CSP and upgrades subsequent asset requests (`/assets/index-*.js`, `/assets/index-*.css`, favicons, API calls) to `https://host:4174/...`. The nginx container only speaks plain HTTP on port 4174, so it receives TLS Client Hello bytes which it cannot parse as an HTTP request. nginx returns `400 Bad Request` with no parseable method or URI — producing the `400 - -` log pattern. All JS/CSS bundles fail to load, React never mounts, and the page stays blank.
- Fix: Removed `; upgrade-insecure-requests` from the CSP string in `frontend/nginx.conf` (line 20). No other changes needed.
- Validation notes: The directive is safe to remove — `upgrade-insecure-requests` is designed for HTTPS-only sites and is harmful when the server runs on plain HTTP. Removing it does not weaken security for self-hosted HTTP deployments (mixed content is not a concern when the origin itself is HTTP). If a reverse proxy with TLS termination is added in front, the directive can be re-introduced at the proxy level.
- Files touched: `frontend/nginx.conf`.
### 2026-03-25
- Task: Diagnose PR #475 GitHub CI failure for the frontend build job and fix testing/build-scope issues only.
+11
View File
@@ -2,6 +2,17 @@
## Entries
### 2026-04-10
- Scope: Investigate and fix the production blank-homepage bug.
- Root cause: The `Content-Security-Policy` header in `frontend/nginx.conf` included the `upgrade-insecure-requests` directive. This directive instructs browsers to upgrade all HTTP resource requests to HTTPS (same port). In a plain HTTP deployment (the default Docker setup on port 4174), this causes the browser to attempt TLS connections to the nginx HTTP port. nginx cannot parse the TLS bytes as HTTP and returns `400 Bad Request` with no method/URI — the `400 - -` log pattern the user observed. All JS/CSS bundles fail to load; React never mounts; the page stays blank.
- What changed:
- Removed `; upgrade-insecure-requests` from the CSP string in `frontend/nginx.conf`.
- Validation:
- `upgrade-insecure-requests` is designed for HTTPS-only sites. Removing it from a plain HTTP server is correct and does not reduce security.
- After this fix, browsers accessing the app over HTTP will load assets normally without being redirected to a non-existent HTTPS endpoint.
- If TLS termination is added via a reverse proxy in future, the directive can be applied at the proxy layer.
- Result: The blank-homepage bug is fixed. All asset and API requests now succeed over plain HTTP as expected.
### 2026-03-25
- Scope: Diagnose and fix the PR #475 frontend CI failure within testing/build ownership.
- What changed:
+1 -1
View File
@@ -17,7 +17,7 @@ server {
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Content-Security-Policy "default-src 'self'; base-uri 'self'; frame-ancestors 'self'; object-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com data:; img-src 'self' data: blob:; connect-src 'self' https://api.github.com; frame-src 'self'; form-action 'self'; upgrade-insecure-requests" always;
add_header Content-Security-Policy "default-src 'self'; base-uri 'self'; frame-ancestors 'self'; object-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com data:; img-src 'self' data: blob:; connect-src 'self' https://api.github.com; frame-src 'self'; form-action 'self'" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=(), usb=(), accelerometer=(), gyroscope=(), magnetometer=()" always;
# Allow larger file uploads (for medication images and data import/export)
+261 -256
View File
File diff suppressed because it is too large Load Diff
+13 -13
View File
@@ -1,7 +1,7 @@
{
"name": "medassist-ng-frontend",
"private": true,
"version": "1.22.1",
"version": "1.23.0",
"type": "module",
"scripts": {
"dev": "vite",
@@ -27,30 +27,30 @@
"test:e2e:report": "playwright show-report"
},
"dependencies": {
"i18next": "^26.0.1",
"i18next": "^26.0.4",
"i18next-browser-languagedetector": "^8.2.1",
"lucide-react": "^1.7.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-i18next": "^17.0.1",
"react-router-dom": "^7.13.2",
"lucide-react": "^1.8.0",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"react-i18next": "^17.0.2",
"react-router-dom": "^7.14.0",
"zod": "^4.3.6"
},
"devDependencies": {
"@biomejs/biome": "^2.4.9",
"@playwright/test": "^1.58.2",
"@biomejs/biome": "^2.4.11",
"@playwright/test": "^1.59.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^25.3.5",
"@types/node": "^25.6.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/react-router-dom": "^5.3.3",
"@vitejs/plugin-react": "^6.0.1",
"@vitest/coverage-v8": "^4.1.2",
"jsdom": "^29.0.1",
"@vitest/coverage-v8": "^4.1.4",
"jsdom": "^29.0.2",
"typescript": "^6.0.2",
"vite": "^8.0.3",
"vite": "^8.0.8",
"vitest": "^4.1.0"
}
}
+7
View File
@@ -7,6 +7,9 @@ import { useTranslation } from "react-i18next";
import { log } from "../utils/logger";
export interface Settings {
timezone: string;
availableTimezones: string[];
serverTimezone: string;
emailEnabled: boolean;
notificationEmail: string;
reminderDaysBefore: number;
@@ -58,6 +61,9 @@ export interface Settings {
export type SettingsLoadError = "auth" | "forbidden" | "request" | null;
const defaultSettings: Settings = {
timezone: "",
availableTimezones: [],
serverTimezone: "UTC",
emailEnabled: false,
notificationEmail: "",
reminderDaysBefore: 7,
@@ -243,6 +249,7 @@ export function useSettings(): UseSettingsReturn {
const repeatDailyReminders = hasAnyStockReminder ? settingsToSave.repeatDailyReminders : false;
return {
timezone: settingsToSave.timezone,
emailEnabled: effectiveEmailEnabled,
notificationEmail: settingsToSave.notificationEmail,
reminderDaysBefore: settingsToSave.reminderDaysBefore,
+8
View File
@@ -389,6 +389,14 @@
"title": "Sprache",
"select": "Sprache auswählen"
},
"timezone": {
"select": "Zeitzone",
"hint": "IANA-Zeitzone wählen. Wenn gesetzt, überschreibt sie die Server-TZ für deine Reminder-Zeitpunkte.",
"useServerDefault": "Server-Standard nutzen",
"currentServerTz": "Server-Standardzeitzone: {{timezone}}",
"saving": "Zeitzone wird gespeichert...",
"saved": "Zeitzone gespeichert"
},
"apiKey": {
"title": "API-Zugriff",
"generateTitle": "API-Key erzeugen",
+8
View File
@@ -389,6 +389,14 @@
"title": "Language",
"select": "Select language"
},
"timezone": {
"select": "Timezone",
"hint": "Select an IANA timezone. When set, this overrides server TZ for your reminder timing.",
"useServerDefault": "Use server default",
"currentServerTz": "Server default timezone: {{timezone}}",
"saving": "Saving timezone...",
"saved": "Timezone saved"
},
"apiKey": {
"title": "API Access",
"generateTitle": "Generate API key",
+96 -1
View File
@@ -1,5 +1,5 @@
/* biome-ignore-all lint/a11y/noLabelWithoutControl: settings rows use label-styled text with adjacent custom toggle controls */
import { useState } from "react";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { ConfirmModal, ExportModal } from "../components";
import { useAppContext } from "../context";
@@ -13,8 +13,11 @@ export function SettingsPage() {
const [apiKeyError, setApiKeyError] = useState<string | null>(null);
const {
settings,
savedSettings,
setSettings,
settingsLoading,
settingsSaving,
settingsSaved,
settingsLoadError,
// Email testing
testEmail,
@@ -39,6 +42,8 @@ export function SettingsPage() {
setImportResult,
meds,
} = useAppContext();
const [timezoneTouched, setTimezoneTouched] = useState(false);
const [timezoneDraft, setTimezoneDraft] = useState("");
const hasExistingData = meds.length > 0;
let emailUnavailableReason: string | null = null;
@@ -117,6 +122,49 @@ export function SettingsPage() {
const automaticStockCalculationId = "settings-stock-calculation-automatic";
const manualStockCalculationId = "settings-stock-calculation-manual";
useEffect(() => {
setTimezoneDraft(settings.timezone);
}, [settings.timezone]);
const commitTimezoneDraft = () => {
if (timezoneDraft === settings.timezone) {
return;
}
setTimezoneTouched(true);
setSettings((prev) => ({ ...prev, timezone: timezoneDraft }));
};
const savedTimezone = savedSettings?.timezone ?? settings.timezone;
const timezoneChanged = settings.timezone !== savedTimezone;
const showTimezoneSaving = timezoneTouched && timezoneChanged && settingsSaving;
const showTimezoneSaved = timezoneTouched && !timezoneChanged && settingsSaved;
let timezoneStatusText = "";
if (showTimezoneSaving) {
timezoneStatusText = t("settings.timezone.saving");
} else if (showTimezoneSaved) {
timezoneStatusText = t("settings.timezone.saved");
}
const timezoneStatusClassName = showTimezoneSaved ? "timezone-status timezone-status-saved" : "timezone-status";
const availableTimezones = Array.isArray(settings.availableTimezones) ? settings.availableTimezones : [];
const timezoneSuggestions =
availableTimezones.length > 0
? availableTimezones
: (() => {
try {
type IntlWithSupportedValuesOf = typeof Intl & {
supportedValuesOf?: (key: string) => string[];
};
const intlWithSupportedValues = Intl as IntlWithSupportedValuesOf;
if (typeof intlWithSupportedValues.supportedValuesOf === "function") {
return intlWithSupportedValues.supportedValuesOf("timeZone");
}
} catch {
// fall through
}
return [settings.serverTimezone || "UTC", "UTC"];
})();
return (
<section className="grid">
{settingsLoading ? (
@@ -160,6 +208,53 @@ export function SettingsPage() {
<option value="de">🇩🇪 Deutsch</option>
</select>
</label>
<div className="setting-row language-row" style={{ marginTop: "12px" }}>
<div className="setting-label">
<span>{t("settings.timezone.select")}</span>
<span className="info-tooltip small tooltip-align-left" data-tooltip={t("settings.timezone.hint")}>
</span>
</div>
<div className="setting-actions" style={{ margin: 0, flexWrap: "nowrap", gap: "8px", width: "auto" }}>
<input
type="text"
className="select-field language-select"
value={timezoneDraft}
onChange={(e) => {
setTimezoneDraft(e.target.value);
}}
onBlur={commitTimezoneDraft}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
(e.currentTarget as HTMLInputElement).blur();
}
}}
list="settings-timezone-suggestions"
placeholder={settings.serverTimezone || "UTC"}
/>
<datalist id="settings-timezone-suggestions">
{timezoneSuggestions.map((zone) => (
<option key={zone} value={zone} />
))}
</datalist>
<button
type="button"
className="ghost"
onClick={() => {
setTimezoneTouched(true);
setTimezoneDraft("");
setSettings((prev) => ({ ...prev, timezone: "" }));
}}
>
{t("settings.timezone.useServerDefault")}
</button>
</div>
</div>
<p className={timezoneStatusClassName}>{timezoneStatusText || " "}</p>
<p className="hint-text" style={{ marginTop: "8px" }}>
{t("settings.timezone.currentServerTz", { timezone: settings.serverTimezone || "UTC" })}
</p>
</article>
<article className="card" data-testid="settings-notification-card">
+1 -1
View File
@@ -613,7 +613,7 @@ body.modal-open {
grid-template-columns: repeat(auto-fit, minmax(min(320px, 100%), 1fr));
margin-bottom: 1rem;
max-width: 100%;
overflow: hidden;
overflow: visible;
}
.card {
+17 -3
View File
@@ -10,7 +10,7 @@
flex-direction: column;
gap: 1.5rem;
max-width: 100%;
overflow: hidden;
overflow: visible;
}
.setting-row {
@@ -311,7 +311,7 @@
transition:
opacity 0.15s,
visibility 0.15s;
z-index: 1100;
z-index: 12000;
pointer-events: none;
}
@@ -329,7 +329,7 @@
transition:
opacity 0.15s,
visibility 0.15s;
z-index: 1101;
z-index: 12001;
}
/* Tooltip aligned to left edge of icon (prevents clipping inside modals) */
@@ -507,6 +507,20 @@
border-radius: 6px;
}
.timezone-status {
min-height: 1.25rem;
margin-top: 8px;
margin-bottom: 0;
padding: 0;
font-size: 0.85rem;
color: transparent;
background: transparent;
}
.timezone-status-saved {
color: var(--success);
}
/* Notification Matrix Mobile */
@media (max-width: 480px) {
.notification-matrix {
+36 -36
View File
@@ -6,7 +6,7 @@
"": {
"name": "medassist-ng",
"devDependencies": {
"@biomejs/biome": "^2.4.9",
"@biomejs/biome": "^2.4.11",
"husky": "^9.1.0",
"lint-staged": "^16.4.0"
}
@@ -76,9 +76,9 @@
}
},
"node_modules/@biomejs/biome": {
"version": "2.4.9",
"resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.4.9.tgz",
"integrity": "sha512-wvZW92FrwitTcacvCBT8xdAbfbxWfDLwjYMmU3djjqQTh7Ni4ZdiWIT/x5VcZ+RQuxiKzIOzi5D+dcyJDFZMsA==",
"version": "2.4.11",
"resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.4.11.tgz",
"integrity": "sha512-nWxHX8tf3Opb/qRgZpBbsTOqOodkbrkJ7S+JxJAruxOReaDPPmPuLBAGQ8vigyUgo0QBB+oQltNEAvalLcjggA==",
"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.9",
"@biomejs/cli-darwin-x64": "2.4.9",
"@biomejs/cli-linux-arm64": "2.4.9",
"@biomejs/cli-linux-arm64-musl": "2.4.9",
"@biomejs/cli-linux-x64": "2.4.9",
"@biomejs/cli-linux-x64-musl": "2.4.9",
"@biomejs/cli-win32-arm64": "2.4.9",
"@biomejs/cli-win32-x64": "2.4.9"
"@biomejs/cli-darwin-arm64": "2.4.11",
"@biomejs/cli-darwin-x64": "2.4.11",
"@biomejs/cli-linux-arm64": "2.4.11",
"@biomejs/cli-linux-arm64-musl": "2.4.11",
"@biomejs/cli-linux-x64": "2.4.11",
"@biomejs/cli-linux-x64-musl": "2.4.11",
"@biomejs/cli-win32-arm64": "2.4.11",
"@biomejs/cli-win32-x64": "2.4.11"
}
},
"node_modules/@biomejs/cli-darwin-arm64": {
"version": "2.4.9",
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.4.9.tgz",
"integrity": "sha512-d5G8Gf2RpH5pYwiHLPA+UpG3G9TLQu4WM+VK6sfL7K68AmhcEQ9r+nkj/DvR/GYhYox6twsHUtmWWWIKfcfQQA==",
"version": "2.4.11",
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.4.11.tgz",
"integrity": "sha512-wOt+ed+L2dgZanWyL6i29qlXMc088N11optzpo10peayObBaAshbTcxKUchzEMp9QSY8rh5h6VfAFE3WTS1rqg==",
"cpu": [
"arm64"
],
@@ -120,9 +120,9 @@
}
},
"node_modules/@biomejs/cli-darwin-x64": {
"version": "2.4.9",
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.4.9.tgz",
"integrity": "sha512-LNCLNgqDMG7BLdc3a8aY/dwKPK7+R8/JXJoXjCvZh2gx8KseqBdFDKbhrr7HCWF8SzNhbTaALhTBoh/I6rf9lA==",
"version": "2.4.11",
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.4.11.tgz",
"integrity": "sha512-gZ6zR8XmZlExfi/Pz/PffmdpWOQ8Qhy7oBztgkR8/ylSRyLwfRPSadmiVCV8WQ8PoJ2MWUy2fgID9zmtgUUJmw==",
"cpu": [
"x64"
],
@@ -137,9 +137,9 @@
}
},
"node_modules/@biomejs/cli-linux-arm64": {
"version": "2.4.9",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.4.9.tgz",
"integrity": "sha512-4adnkAUi6K4C/emPRgYznMOcLlUqZdXWM6aIui4VP4LraE764g6Q4YguygnAUoxKjKIXIWPteKMgRbN0wsgwcg==",
"version": "2.4.11",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.4.11.tgz",
"integrity": "sha512-avdJaEElXrKceK0va9FkJ4P5ci3N01TGkc6ni3P8l3BElqbOz42Wg2IyX3gbh0ZLEd4HVKEIrmuVu/AMuSeFFA==",
"cpu": [
"arm64"
],
@@ -154,9 +154,9 @@
}
},
"node_modules/@biomejs/cli-linux-arm64-musl": {
"version": "2.4.9",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.4.9.tgz",
"integrity": "sha512-8RCww5xnPn2wpK4L/QDGDOW0dq80uVWfppPxHIUg6mOs9B6gRmqPp32h1Ls3T8GnW8Wo5A8u7vpTwz4fExN+sw==",
"version": "2.4.11",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.4.11.tgz",
"integrity": "sha512-+Sbo1OAmlegtdwqFE8iOxFIWLh1B3OEgsuZfBpyyN/kWuqZ8dx9ZEes6zVnDMo+zRHF2wLynRVhoQmV7ohxl2Q==",
"cpu": [
"arm64"
],
@@ -171,9 +171,9 @@
}
},
"node_modules/@biomejs/cli-linux-x64": {
"version": "2.4.9",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.4.9.tgz",
"integrity": "sha512-L10na7POF0Ks/cgLFNF1ZvIe+X4onLkTi5oP9hY+Rh60Q+7fWzKDDCeGyiHUFf1nGIa9dQOOUPGe2MyYg8nMSQ==",
"version": "2.4.11",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.4.11.tgz",
"integrity": "sha512-TagWV0iomp5LnEnxWFg4nQO+e52Fow349vaX0Q/PIcX6Zhk4GGBgp3qqZ8PVkpC+cuehRctMf3+6+FgQ8jCEFQ==",
"cpu": [
"x64"
],
@@ -188,9 +188,9 @@
}
},
"node_modules/@biomejs/cli-linux-x64-musl": {
"version": "2.4.9",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.4.9.tgz",
"integrity": "sha512-5TD+WS9v5vzXKzjetF0hgoaNFHMcpQeBUwKKVi3JbG1e9UCrFuUK3Gt185fyTzvRdwYkJJEMqglRPjmesmVv4A==",
"version": "2.4.11",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.4.11.tgz",
"integrity": "sha512-bexd2IklK7ZgPhrz6jXzpIL6dEAH9MlJU1xGTrypx+FICxrXUp4CqtwfiuoDKse+UlgAlWtzML3jrMqeEAHEhA==",
"cpu": [
"x64"
],
@@ -205,9 +205,9 @@
}
},
"node_modules/@biomejs/cli-win32-arm64": {
"version": "2.4.9",
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.4.9.tgz",
"integrity": "sha512-aDZr0RBC3sMGJOU10BvG7eZIlWLK/i51HRIfScE2lVhfts2dQTreowLiJJd+UYg/tHKxS470IbzpuKmd0MiD6g==",
"version": "2.4.11",
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.4.11.tgz",
"integrity": "sha512-RJhaTnY8byzxDt4bDVb7AFPHkPcjOPK3xBip4ZRTrN3TEfyhjLRm3r3mqknqydgVTB74XG8l4jMLwEACEeihVg==",
"cpu": [
"arm64"
],
@@ -222,9 +222,9 @@
}
},
"node_modules/@biomejs/cli-win32-x64": {
"version": "2.4.9",
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.4.9.tgz",
"integrity": "sha512-NS4g/2G9SoQ4ktKtz31pvyc/rmgzlcIDCGU/zWbmHJAqx6gcRj2gj5Q/guXhoWTzCUaQZDIqiCQXHS7BcGYc0w==",
"version": "2.4.11",
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.4.11.tgz",
"integrity": "sha512-A8D3JM/00C2KQgUV3oj8Ba15EHEYwebAGCy5Sf9GAjr5Y3+kJIYOiESoqRDeuRZueuMdCsbLZIUqmPhpYXJE9A==",
"cpu": [
"x64"
],
+1 -1
View File
@@ -7,7 +7,7 @@
"lint:fix": "cd backend && npm run lint:fix && cd ../frontend && npm run lint:fix"
},
"devDependencies": {
"@biomejs/biome": "^2.4.9",
"@biomejs/biome": "^2.4.11",
"husky": "^9.1.0",
"lint-staged": "^16.4.0"
},