Compare commits

...

12 Commits

Author SHA1 Message Date
Khalid Abdi 46c32b432c chore(release): v0.2.5
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 21:54:47 +03:00
Khalid Abdi 5bbfe551fc release workflow: publish multi-arch images; settings: confirm language switch
- release.yml: add QEMU + platforms: linux/amd64,linux/arm64 to both
  build-push steps so the published images work on Apple Silicon (fixes
  'no matching manifest for linux/arm64/v8' on docker compose pull).
- Settings -> Profile language picker is now a select that opens a
  confirmation dialog before applying, instead of switching instantly.
- CLAUDE.md: require a dated docs changelog entry for every release.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 21:54:47 +03:00
Khalid Abdi 7838dd68a5 chore(release): v0.2.4
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 21:00:52 +03:00
Khalid Abdi 06810f861e backend: make docker compose host ports configurable
Mirror the existing POSTGRES_PORT override for the backend, frontend and adminer
host ports (BACKEND_PORT / FRONTEND_PORT / ADMINER_PORT) so a port clash on
`docker compose up -d` can be fixed via .env without editing the compose file.
Documented in .env.example and the README.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 21:00:18 +03:00
Khalid Abdi 2bb03633ff backend: detect updates from Docker Hub + add a Check for updates button
GET /api/version now reads the latest version from Docker Hub image tags (the
actual update channel clinics pull), falling back to the GitHub release if
Docker Hub is unreachable, with a shorter 1h cache and a `?refresh=1` bypass.
Settings → About & updates gains a "Check for updates" button that forces a
fresh, cache-bypassing lookup.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 21:00:18 +03:00
Khalid Abdi dc5f55f87d frontend: paginate the Activity and Invoices pages
Extract the Patients pagination into a reusable ListPagination component
(components/ui/list-pagination.tsx, carrying the pageWindow helper) and use it
on the Activity feed and the Invoices list (10/page, search resets to page 1).
Patients is refactored onto the same component, removing the duplicated block.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 21:00:18 +03:00
Khalid Abdi a4970df334 frontend: add a language switcher to Profile settings
A Language section in the Profile panel offers English / Français, wired to
i18n.changeLanguage (persisted to localStorage via the detector cache).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 21:00:01 +03:00
Khalid Abdi 6c074b54f3 frontend: add French translation and language i18n keys
Register `fr` in the i18n config (resources + supportedLngs) and add a full
French translation (locales/fr/translation.json) with exact key parity to
English (1661 keys; placeholders and plural suffixes preserved). Also adds the
shared `common.pagination.*` keys, `settings.version.checkNow`, and
`settings.profile.language.*`, and drops the now-unused `patients.pagination.*`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 21:00:01 +03:00
Khalid Abdi f1c9f8af55 chore(release): v0.2.3
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 19:37:23 +03:00
Khalid Abdi 1c65f72ccf backend: render patient card from searchPatients on a unique match
"Show me <name>'s medical record" relied on the model chaining
searchPatients → getPatient, but Gemini Flash often calls searchPatients
then emits the canned closing line ("Here's the record.") without the
second tool call, so no data-patientCard part is ever written and no card
renders. (The prior Gemini fix only covered the empty-schema list tools.)

searchPatients now writes the record card (data-patientCard, or
data-recordGraph in graph mode) and a source directly when EXACTLY ONE
patient matches — mirroring getPatient — so the common name-lookup flow no
longer depends on a second tool call. Multiple/zero matches keep returning
the disambiguation list. The tool description and system prompt tell the
model the card is already shown on a unique match so it doesn't double-render.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 19:36:49 +03:00
Khalid Abdi f12285b8c6 chore(release): v0.2.2
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 20:53:13 +03:00
Khalid Abdi 75910f7fb0 backend: fix AI chat cards not rendering on Gemini (empty tool schemas)
The card-emitting chat tools (listAppointments, listTasks,
listPrescriptions, getClinicInfo, getAnalytics, listInventory) used an
empty `z.object({})` parameter schema. Google Gemini can't emit a
function call for a tool whose JSON schema has no properties — it prints
the call as `tool_code` text instead of invoking it — so the tool never
ran and the frontend never received the data part to render a card.

Give those tools a shared `emptyToolArgs` schema with one optional,
ignored field so the schema is non-empty and Gemini calls them. execute
bodies are unchanged; other providers ignore the extra field. This is the
same fix already documented for previewImport.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 20:52:22 +03:00
22 changed files with 2543 additions and 155 deletions
+7
View File
@@ -35,6 +35,11 @@ jobs:
id: meta
run: echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
# QEMU lets the amd64 runner emulate arm64 so the images below build for
# both platforms (Intel + Apple Silicon self-hosters).
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Buildx
uses: docker/setup-buildx-action@v3
@@ -49,6 +54,7 @@ jobs:
with:
context: ./backend
push: true
platforms: linux/amd64,linux/arm64
tags: |
${{ env.REGISTRY_NAMESPACE }}/temetro-backend:${{ steps.meta.outputs.version }}
${{ env.REGISTRY_NAMESPACE }}/temetro-backend:latest
@@ -58,6 +64,7 @@ jobs:
with:
context: ./frontend
push: true
platforms: linux/amd64,linux/arm64
tags: |
${{ env.REGISTRY_NAMESPACE }}/temetro-frontend:${{ steps.meta.outputs.version }}
${{ env.REGISTRY_NAMESPACE }}/temetro-frontend:latest
+57
View File
@@ -7,6 +7,63 @@ for how releases are cut and published.
## [Unreleased]
## [0.2.5] — 2026-07-01
### Fixed
- **Multi-arch Docker images** — the `release` workflow now builds and publishes
`khalidxv/temetro-backend` and `khalidxv/temetro-frontend` for both
`linux/amd64` and `linux/arm64`. Previously the images were amd64-only, so
`docker compose pull` on Apple Silicon failed with *no matching manifest for
linux/arm64/v8* and fell back to building from source.
### Changed
- **Language switcher** in Settings → Profile is now a **select** that asks for
confirmation before switching the interface language, instead of applying the
change instantly on a button tap.
## [0.2.4] — 2026-06-29
### Added
- **Pagination** on the Activity and Invoices pages (10 per page), matching the
Patients page, via a shared `ListPagination` component.
- **French (Français)** interface language, with a language switcher in
Settings → Profile. The choice persists on the device.
- **"Check for updates"** button in Settings → About & updates that forces a
fresh check.
### Changed
- **Update detection** now reads the latest version from **Docker Hub** image
tags (the channel clinics actually pull), falling back to the GitHub release
if Docker Hub is unreachable. This fixes "About & updates" showing *Up to
date* when a newer image was already published.
- **docker compose** host ports are now configurable (`BACKEND_PORT`,
`FRONTEND_PORT`, `ADMINER_PORT`, alongside `POSTGRES_PORT`) so a port clash on
`docker compose up -d` can be resolved from `.env` without editing the file.
## [0.2.3] — 2026-06-29
### Fixed
- **AI chat patient record cards** now render on Google Gemini for name
lookups. "Show me <name>'s medical record" relied on the model chaining
`searchPatients``getPatient`, but Gemini often called `searchPatients`
and then emitted only a canned closing line ("Here's the record.") without
the second tool call — so no card was ever drawn. `searchPatients` now
displays the record card directly when exactly one patient matches, so the
flow no longer depends on a follow-up tool call. (The previous Gemini fix in
0.2.2 only covered the empty-schema list tools.)
## [0.2.2] — 2026-06-28
### Fixed
- **AI chat record cards** now render on Google Gemini. The card-emitting
chat tools (`listAppointments`, `listTasks`, `listPrescriptions`,
`getClinicInfo`, `getAnalytics`, `listInventory`) used an empty parameter
schema; Gemini can't emit a function call for a schema with no properties,
so it printed the call as `tool_code` text instead of invoking the tool —
leaving replies as plain text (e.g. "Show today's schedule" leaked a raw
`<tool_code>` block) with no cards. The tools now share a non-empty schema
so Gemini calls them; other providers are unaffected.
## [0.2.1] — 2026-06-27
### Fixed
+5
View File
@@ -80,6 +80,11 @@ accurate (e.g. a new backend route needs an `content/docs/api/*.mdx` entry; a UI
in the matching guide; status changes belong in the roadmap). Commit docs changes inside that
repo, separately from this one.
**Every release must also get a dated entry in the docs changelog**
(`content/docs/changelog.mdx`, newest first) — not just the monorepo `CHANGELOG.md`. When you cut a
version (see "Always release after pushing"), add a matching, user-facing section to that page in the
same session so `../temetro/docs` never falls behind the shipped version.
## Running the stack
From `backend/`: ensure a `.env` exists (`cp .env.example .env`, then set `BETTER_AUTH_SECRET` via
+5 -3
View File
@@ -73,9 +73,11 @@ missing secrets on first start. Then open:
Prefer to **build from source** (for development)? Use `docker compose up
--build` instead. Migrations apply automatically on backend start.
> **Port conflict?** If another Postgres holds host port `5432`, set
> `POSTGRES_PORT` (e.g. `5433`) in `backend/.env`. The app still talks to
> Postgres internally on `db:5432`; only the published host port changes.
> **Port conflict?** If host ports `5432`, `4000` or `3000` are already in use,
> set `POSTGRES_PORT`, `BACKEND_PORT` and/or `FRONTEND_PORT` (e.g. `5433` /
> `4001` / `3001`) in `backend/.env`. The services still talk to each other on
> their internal ports (`db:5432`, `backend:4000`); only the published host
> ports change.
### Access from other computers (hospital LAN)
+4 -2
View File
@@ -26,9 +26,11 @@ FRONTEND_URL=http://localhost:3000
PORT=4000
NODE_ENV=development
# Host port Postgres is published on by docker compose. Change it if 5432 is
# already in use on your machine (the app still talks to Postgres internally).
# Host ports docker compose publishes. Change any that are already in use on
# this machine (the services still talk to each other on their internal ports).
POSTGRES_PORT=5432
BACKEND_PORT=4000
FRONTEND_PORT=3000
# --- Patient wallet relay -------------------------------------------------
# The URL baked into the QR a patient scans to import their record. Their phone
+11 -3
View File
@@ -25,6 +25,12 @@
#
# Optional DB browser (Adminer) lives behind a profile:
# docker compose --profile tools up adminer # http://localhost:8080
#
# Host ports are configurable to avoid clashing with software already running on
# this machine. Override any of them in a .env file (or inline), e.g.:
# POSTGRES_PORT=5433 BACKEND_PORT=4001 FRONTEND_PORT=3001 docker compose up -d
# Only the published host port changes; the services still talk to each other on
# their internal ports (db:5432, backend:4000).
services:
db:
@@ -76,7 +82,8 @@ services:
# Persists uploaded files across restarts/rebuilds.
- temetro_uploads:/var/lib/temetro/uploads
ports:
- "4000:4000"
# Host port is configurable to avoid clashing with an existing service.
- "${BACKEND_PORT:-4000}:4000"
frontend:
image: khalidxv/temetro-frontend:${TEMETRO_VERSION:-latest}
@@ -90,7 +97,8 @@ services:
depends_on:
- backend
ports:
- "3000:3000"
# Host port is configurable to avoid clashing with an existing service.
- "${FRONTEND_PORT:-3000}:3000"
adminer:
image: adminer:5
@@ -99,7 +107,7 @@ services:
depends_on:
- db
ports:
- "8080:8080"
- "${ADMINER_PORT:-8080}:8080"
volumes:
temetro_pgdata:
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "temetro-backend",
"version": "0.2.1",
"version": "0.2.5",
"private": true,
"type": "module",
"description": "temetro backend — Express + Postgres API with Better Auth (email/password, organizations) and org-scoped patient records.",
+4 -1
View File
@@ -107,7 +107,10 @@ function systemPrompt(
"",
"Display tools (read-only):",
"- getPatient: when asked about a specific patient by file number / MRN.",
"- searchPatients: when given a name; then getPatient on the match.",
"- searchPatients: when given a name. If exactly one patient matches it",
" already shows that patient's record card — don't call getPatient again,",
" just confirm. Only call getPatient yourself for a direct file number / MRN,",
" or to pick one of several matches it returns.",
"- getPatientLabs: when asked about labs/results/trends.",
"- listAppointments: when asked to see the schedule / upcoming visits.",
"- listTasks: when asked to see open tasks / to-dos.",
+64 -18
View File
@@ -1,6 +1,8 @@
// GET /api/version — reports the running version and whether a newer release
// exists on GitHub. Public (no PHI); the frontend uses it for the Settings
// "About & Updates" panel and the optional update banner.
// GET /api/version — reports the running version and whether a newer image is
// available. The latest version is read from Docker Hub (the actual update
// channel: clinics run `docker compose pull`), falling back to the GitHub
// release if Docker Hub's API is unreachable. Public (no PHI); the frontend uses
// it for the Settings "About & Updates" panel and the optional update banner.
import { createRequire } from "node:module";
import { Router } from "express";
@@ -13,16 +15,24 @@ const require = createRequire(import.meta.url);
const pkg = require("../../package.json") as { version?: string };
const CURRENT = env.APP_VERSION ?? pkg.version ?? "0.0.0";
const LATEST_RELEASE_URL =
// The published image whose tags reflect what `docker compose pull` would fetch.
const DOCKERHUB_TAGS_URL =
"https://hub.docker.com/v2/repositories/khalidxv/temetro-backend/tags?page_size=100";
// GitHub release of a given version — used for the human-readable "what's new"
// link, and as a fallback source for the latest version.
const GITHUB_LATEST_RELEASE_URL =
"https://api.github.com/repos/temetro/temetro/releases/latest";
const CACHE_TTL = 6 * 60 * 60 * 1000; // 6h — releases are infrequent.
const releaseUrlFor = (version: string) =>
`https://github.com/temetro/temetro/releases/tag/v${version}`;
const CACHE_TTL = 60 * 60 * 1000; // 1h — surface a new release reasonably fast.
const ERROR_TTL = 10 * 60 * 1000; // back off ~10m after a failed lookup.
type LatestInfo = { latest: string | null; releaseUrl: string | null };
let cache: { at: number; ttl: number; info: LatestInfo } | null = null;
function parseSemver(v: string): [number, number, number] | null {
const m = v.trim().replace(/^v/, "").match(/^(\d+)\.(\d+)\.(\d+)/);
const m = v.trim().replace(/^v/, "").match(/^(\d+)\.(\d+)\.(\d+)$/);
return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
}
@@ -38,18 +48,52 @@ function isNewer(latest: string, current: string): boolean {
return false;
}
async function fetchLatest(): Promise<LatestInfo> {
if (cache && Date.now() - cache.at < cache.ttl) return cache.info;
// Highest strict X.Y.Z tag in the list (ignores `latest` and any non-semver).
function maxSemver(versions: string[]): string | null {
let best: string | null = null;
for (const v of versions) {
if (parseSemver(v) && (best === null || isNewer(v, best))) best = v;
}
return best;
}
// Primary source: the published Docker Hub image tags.
async function fetchFromDockerHub(): Promise<string | null> {
const res = await fetch(DOCKERHUB_TAGS_URL, {
headers: { Accept: "application/json", "User-Agent": "temetro" },
signal: AbortSignal.timeout(5000),
});
if (!res.ok) throw new Error(`Docker Hub responded ${res.status}`);
const body = (await res.json()) as { results?: Array<{ name?: string }> };
const names = (body.results ?? [])
.map((r) => r.name)
.filter((n): n is string => typeof n === "string");
return maxSemver(names);
}
// Fallback source: the latest GitHub release tag (used if Docker Hub is blocked).
async function fetchFromGitHub(): Promise<string | null> {
const res = await fetch(GITHUB_LATEST_RELEASE_URL, {
headers: { Accept: "application/vnd.github+json", "User-Agent": "temetro" },
signal: AbortSignal.timeout(5000),
});
if (!res.ok) throw new Error(`GitHub responded ${res.status}`);
const body = (await res.json()) as { tag_name?: string };
return body.tag_name ? body.tag_name.replace(/^v/, "") : null;
}
async function fetchLatest(force = false): Promise<LatestInfo> {
if (!force && cache && Date.now() - cache.at < cache.ttl) return cache.info;
try {
const res = await fetch(LATEST_RELEASE_URL, {
headers: { Accept: "application/vnd.github+json", "User-Agent": "temetro" },
signal: AbortSignal.timeout(5000),
});
if (!res.ok) throw new Error(`GitHub responded ${res.status}`);
const body = (await res.json()) as { tag_name?: string; html_url?: string };
let latest: string | null = null;
try {
latest = await fetchFromDockerHub();
} catch {
latest = await fetchFromGitHub();
}
const info: LatestInfo = {
latest: body.tag_name ? body.tag_name.replace(/^v/, "") : null,
releaseUrl: body.html_url ?? null,
latest,
releaseUrl: latest ? releaseUrlFor(latest) : null,
};
cache = { at: Date.now(), ttl: CACHE_TTL, info };
return info;
@@ -63,8 +107,10 @@ async function fetchLatest(): Promise<LatestInfo> {
const router = Router();
router.get("/", async (_req, res) => {
const { latest, releaseUrl } = await fetchLatest();
router.get("/", async (req, res) => {
// `?refresh=1` powers the "Check for updates" button — bypass the cache.
const force = req.query.refresh === "1" || req.query.refresh === "true";
const { latest, releaseUrl } = await fetchLatest(force);
res.json({
current: CURRENT,
latest,
+53 -14
View File
@@ -40,6 +40,18 @@ export type ToolContext = {
writer: UIMessageStreamWriter;
};
// Shared schema for tools that take no real arguments. Google Gemini cannot
// emit a function call when a tool's parameter schema has no properties — it
// prints the call as `tool_code` text instead of invoking it (see the
// previewImport note below). One optional, ignored field keeps the schema
// non-empty so Gemini calls the tool; other providers ignore the extra field.
const emptyToolArgs = z.object({
filter: z
.string()
.optional()
.describe("Optional free-text filter (ignored — the full list is shown)"),
});
// Compact, model-facing projection of a patient (Veil-redacted upstream). Keeps
// clinical signal, drops bulky arrays the model rarely needs verbatim.
function forModel(p: Patient) {
@@ -177,10 +189,12 @@ export function createChatTools(ctx: ToolContext) {
},
}),
// Search the clinic's patients by name or file number.
// Search the clinic's patients by name or file number. On a unique match this
// also displays that patient's record card directly (see below), so the common
// "show me <name>'s record" flow doesn't depend on a follow-up getPatient call.
searchPatients: tool({
description:
"Search the clinic's patients by name fragment. Returns matches with file numbers so you can then call getPatient.",
"Search the clinic's patients by name fragment. If EXACTLY ONE patient matches, this already displays their full record card — do NOT call getPatient again; just confirm in one sentence. If multiple match, it returns the matches (with file numbers) so you can disambiguate or call getPatient on the right one.",
inputSchema: z.object({
query: z.string().describe("Name or file-number fragment to match"),
}),
@@ -192,17 +206,42 @@ export function createChatTools(ctx: ToolContext) {
scopeProviderId,
);
const q = query.trim().toLowerCase();
const matches = all
// Keep the full Patient objects so a unique match can render a card.
const matched = all
.filter(
(p) =>
p.name.toLowerCase().includes(q) ||
p.fileNumber.toLowerCase().includes(q),
)
.slice(0, 10)
.map((p) => {
const r = veil.redactPatient(p);
return { fileNumber: r.fileNumber, name: r.name, status: p.status };
});
.slice(0, 10);
// Unique match → show the record card right here. Gemini often skips the
// search→getPatient hand-off and just emits a canned sentence, leaving the
// clinician with no card; rendering it directly removes that dependency.
if (matched.length === 1) {
const patient = matched[0]!;
step(`Looking up patient ${patient.fileNumber}`);
writer.write(
mode === "graph"
? { type: "data-recordGraph", data: patient }
: { type: "data-patientCard", data: patient },
);
const sourceId = addSource(
`${patient.name} · MRN ${patient.fileNumber}`,
"patient",
);
return {
count: 1,
shownCard: true,
sourceId,
patient: forModel(veil.redactPatient(patient)),
};
}
const matches = matched.map((p) => {
const r = veil.redactPatient(p);
return { fileNumber: r.fileNumber, name: r.name, status: p.status };
});
step(`Found ${matches.length} match(es)`);
return { count: matches.length, matches };
},
@@ -213,7 +252,7 @@ export function createChatTools(ctx: ToolContext) {
listAppointments: tool({
description:
"Display the clinic's appointments. Use when the clinician asks to see the schedule, upcoming visits, or today's appointments.",
inputSchema: z.object({}),
inputSchema: emptyToolArgs,
execute: async () => {
step("Loading appointments");
const all = await appointments.listAppointments(orgId);
@@ -235,7 +274,7 @@ export function createChatTools(ctx: ToolContext) {
listTasks: tool({
description:
"Display the care-team task list. Use when the clinician asks to see open tasks, to-dos, or what's assigned.",
inputSchema: z.object({}),
inputSchema: emptyToolArgs,
execute: async () => {
step("Loading tasks");
const all = await tasks.listTasks(orgId, {
@@ -258,7 +297,7 @@ export function createChatTools(ctx: ToolContext) {
listPrescriptions: tool({
description:
"Display prescriptions for the clinic. Use when the clinician asks to see prescriptions or medications prescribed.",
inputSchema: z.object({}),
inputSchema: emptyToolArgs,
execute: async () => {
if (demographicsOnly) {
return { found: false as const, reason: "not_authorized" as const };
@@ -454,7 +493,7 @@ export function createChatTools(ctx: ToolContext) {
getClinicInfo: tool({
description:
"Get the clinic's name and basic info. Use when the clinician asks about their clinic/organization (e.g. 'what's my clinic called?').",
inputSchema: z.object({}),
inputSchema: emptyToolArgs,
execute: async () => {
step("Loading clinic info");
const [org] = await db
@@ -479,7 +518,7 @@ export function createChatTools(ctx: ToolContext) {
getAnalytics: tool({
description:
"Retrieve the clinic's analytics AND earnings — patient/appointment/prescription/task counts plus money billed, paid, and outstanding (from invoices), with a by-month earnings trend. Use for KPIs, earnings, revenue, or performance questions.",
inputSchema: z.object({}),
inputSchema: emptyToolArgs,
execute: async () => {
step("Loading clinic analytics");
const data = await analytics.getAnalytics(orgId);
@@ -492,7 +531,7 @@ export function createChatTools(ctx: ToolContext) {
listInventory: tool({
description:
"List the clinic's inventory (medications/supplies, stock levels, reorder thresholds). Use for stock, low-stock, or reorder questions.",
inputSchema: z.object({}),
inputSchema: emptyToolArgs,
execute: async () => {
step("Loading inventory");
const items = await inventory.listInventory(orgId);
+24 -2
View File
@@ -28,6 +28,7 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { ListPagination } from "@/components/ui/list-pagination";
import {
type ActivityEntityType,
type ActivityEntry,
@@ -108,10 +109,14 @@ function DetailRow({ label, value }: { label: string; value: string }) {
);
}
// Entries shown per page in the activity feed before paginating.
const PAGE_SIZE = 10;
export function ActivityView() {
const { t } = useTranslation();
const [entries, setEntries] = useState<ActivityEntry[]>([]);
const [selected, setSelected] = useState<ActivityEntry | null>(null);
const [page, setPage] = useState(1);
useEffect(() => {
let active = true;
@@ -153,6 +158,15 @@ export function ActivityView() {
];
}, [entries, t]);
// Client-side pagination over the feed (10/page). `page` is clamped at render
// so a shrinking feed never leaves us past the last page.
const totalPages = Math.max(1, Math.ceil(entries.length / PAGE_SIZE));
const safePage = Math.min(page, totalPages);
const pageRows = entries.slice(
(safePage - 1) * PAGE_SIZE,
safePage * PAGE_SIZE,
);
return (
<div className="mx-auto flex w-full max-w-3xl flex-col gap-10 px-6 py-10">
<div>
@@ -173,10 +187,11 @@ export function ActivityView() {
{t("activity.empty")}
</div>
) : (
<div>
<ol className="flex flex-col">
{entries.map((entry, i) => {
{pageRows.map((entry, i) => {
const Icon = entityIcon[entry.entityType] ?? FileText;
const isLast = i === entries.length - 1;
const isLast = i === pageRows.length - 1;
const context = [
entry.actorName,
entry.patientName &&
@@ -226,6 +241,13 @@ export function ActivityView() {
);
})}
</ol>
<ListPagination
onPageChange={setPage}
page={safePage}
pageSize={PAGE_SIZE}
total={entries.length}
/>
</div>
)}
<Dialog
+26 -2
View File
@@ -11,6 +11,7 @@ import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { ListPagination } from "@/components/ui/list-pagination";
import {
formatInvoiceDate,
formatMoney,
@@ -30,10 +31,14 @@ const statusVariant: Record<
void: "destructive",
};
// Invoices shown per page before paginating.
const PAGE_SIZE = 10;
export function InvoicesView() {
const { t } = useTranslation();
const [list, setList] = useState<Invoice[]>([]);
const [query, setQuery] = useState("");
const [page, setPage] = useState(1);
const [loadError, setLoadError] = useState<string | null>(null);
const [selected, setSelected] = useState<Invoice | null>(null);
@@ -70,6 +75,15 @@ export function InvoicesView() {
);
}, [list, search]);
// Client-side pagination over the filtered list (10/page); clamp at render so a
// shrinking list never leaves us past the last page.
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
const safePage = Math.min(page, totalPages);
const pageRows = filtered.slice(
(safePage - 1) * PAGE_SIZE,
safePage * PAGE_SIZE,
);
const kpis = useMemo(() => {
const unpaid = list
.filter((i) => i.status === "draft" || i.status === "sent")
@@ -124,7 +138,10 @@ export function InvoicesView() {
<Search className="-translate-y-1/2 absolute top-1/2 left-3 size-4 text-muted-foreground" />
<Input
className="w-full pl-9 sm:w-64"
onChange={(event) => setQuery(event.target.value)}
onChange={(event) => {
setQuery(event.target.value);
setPage(1);
}}
placeholder={t("invoices.searchPlaceholder")}
value={query}
/>
@@ -161,7 +178,7 @@ export function InvoicesView() {
</div>
<div className="divide-y divide-border overflow-hidden rounded-2xl border bg-card/30">
{filtered.map((inv) => (
{pageRows.map((inv) => (
<button
className="flex w-full items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-accent/50"
key={inv.id}
@@ -199,6 +216,13 @@ export function InvoicesView() {
)}
</div>
<ListPagination
onPageChange={setPage}
page={safePage}
pageSize={PAGE_SIZE}
total={filtered.length}
/>
<InvoiceFormDialog
invoice={editing ?? undefined}
mode={formMode}
+9 -93
View File
@@ -1,6 +1,6 @@
"use client";
import { ChevronLeft, ChevronRight, Plus, Search, Smartphone } from "lucide-react";
import { Plus, Search, Smartphone } from "lucide-react";
import { useSearchParams } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
@@ -12,34 +12,12 @@ import { PatientDetailSheet } from "@/components/patients/patient-detail-sheet";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
} from "@/components/ui/pagination";
import { ListPagination } from "@/components/ui/list-pagination";
import { listPatients, type Patient } from "@/lib/patients";
// Rows shown per page on the patients table before paginating.
const PAGE_SIZE = 10;
// Page numbers to render, with `null` marking an ellipsis gap. Keeps the first,
// last, and a small window around the current page so the control stays compact
// even with many pages.
function pageWindow(current: number, total: number): (number | null)[] {
if (total <= 7) {
return Array.from({ length: total }, (_, i) => i + 1);
}
const pages: (number | null)[] = [1];
const start = Math.max(2, current - 1);
const end = Math.min(total - 1, current + 1);
if (start > 2) pages.push(null);
for (let p = start; p <= end; p++) pages.push(p);
if (end < total - 1) pages.push(null);
pages.push(total);
return pages;
}
type BadgeVariant = "success" | "info" | "outline";
// Colour the status for at-a-glance scanning: active patients read as success
@@ -273,75 +251,13 @@ export function PatientsView() {
</table>
</div>
{!loading && !loadError && patients.length > PAGE_SIZE ? (
<div className="mt-4 flex flex-col items-center justify-between gap-3 sm:flex-row">
<p className="text-xs text-muted-foreground">
{t("patients.pagination.summary", {
from: (safePage - 1) * PAGE_SIZE + 1,
to: Math.min(safePage * PAGE_SIZE, patients.length),
total: patients.length,
})}
</p>
<Pagination
aria-label={t("patients.pagination.label")}
className="mx-0 w-auto justify-end"
>
<PaginationContent>
<PaginationItem>
<Button
aria-label={t("patients.pagination.previous")}
className="gap-1"
disabled={safePage === 1}
onClick={() => setPage(Math.max(1, safePage - 1))}
size="sm"
type="button"
variant="ghost"
>
<ChevronLeft className="size-4" />
<span className="max-sm:hidden">
{t("patients.pagination.previous")}
</span>
</Button>
</PaginationItem>
{pageWindow(safePage, totalPages).map((p, i) =>
p === null ? (
<PaginationItem key={`ellipsis-${i}`}>
<PaginationEllipsis />
</PaginationItem>
) : (
<PaginationItem key={p}>
<Button
aria-current={p === safePage ? "page" : undefined}
aria-label={t("patients.pagination.page", { page: p })}
onClick={() => setPage(p)}
size="icon-sm"
type="button"
variant={p === safePage ? "outline" : "ghost"}
>
{p}
</Button>
</PaginationItem>
)
)}
<PaginationItem>
<Button
aria-label={t("patients.pagination.next")}
className="gap-1"
disabled={safePage === totalPages}
onClick={() => setPage(Math.min(totalPages, safePage + 1))}
size="sm"
type="button"
variant="ghost"
>
<span className="max-sm:hidden">
{t("patients.pagination.next")}
</span>
<ChevronRight className="size-4" />
</Button>
</PaginationItem>
</PaginationContent>
</Pagination>
</div>
{!loading && !loadError ? (
<ListPagination
onPageChange={setPage}
page={safePage}
pageSize={PAGE_SIZE}
total={patients.length}
/>
) : null}
<PatientFormDialog
@@ -6,7 +6,15 @@ import { useTranslation } from "react-i18next";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { Input } from "@/components/ui/input";
import {
Select,
SelectItem,
SelectPopup,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { DeleteAccountDialog } from "@/components/settings/delete-account-dialog";
import {
CopyField,
@@ -16,6 +24,7 @@ import {
ToggleRow,
} from "@/components/settings/settings-parts";
import { authClient } from "@/lib/auth-client";
import { supportedLanguages } from "@/lib/i18n/config";
import {
getSettings,
saveSettings,
@@ -53,16 +62,24 @@ const DEFAULT_PREFS: UserPreferences = {
};
export function ProfilePanel() {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const { data: session } = authClient.useSession();
const user = session?.user;
// The active UI language — `i18n.changeLanguage` persists the choice to
// localStorage (the detector's cache), so it survives reloads.
const activeLang = i18n.resolvedLanguage ?? i18n.language;
const [prefs, setPrefs] = useState<UserPreferences>(DEFAULT_PREFS);
const [baseline, setBaseline] = useState<UserPreferences>(DEFAULT_PREFS);
const [name, setName] = useState("");
const [baselineName, setBaselineName] = useState("");
const [saving, setSaving] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
// A language picked in the Select but not yet applied — its presence opens the
// confirmation dialog. The Select stays bound to `activeLang`, so cancelling
// (clearing this) automatically reverts the shown selection.
const [pendingLang, setPendingLang] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
@@ -211,6 +228,32 @@ export function ProfilePanel() {
</SettingsCard>
</SettingsSection>
<SettingsSection
description={t("settings.profile.language.description")}
title={t("settings.profile.language.title")}
>
<SettingsCard className="space-y-1.5 p-5">
<FieldLabel>{t("settings.profile.language.label")}</FieldLabel>
<Select
onValueChange={(value) => {
if (value !== activeLang) setPendingLang(value);
}}
value={activeLang}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectPopup>
{supportedLanguages.map((lng) => (
<SelectItem key={lng} value={lng}>
{t(`settings.profile.language.${lng}`)}
</SelectItem>
))}
</SelectPopup>
</Select>
</SettingsCard>
</SettingsSection>
<SettingsSection
description={t("settings.profile.patientNotificationsDescription")}
title={t("settings.profile.patientNotifications")}
@@ -287,6 +330,27 @@ export function ProfilePanel() {
<DeleteAccountDialog onOpenChange={setDeleteOpen} open={deleteOpen} />
<ConfirmDialog
cancelLabel={t("settings.profile.language.cancel")}
confirmLabel={t("settings.profile.language.confirmCta")}
description={
pendingLang
? t("settings.profile.language.confirmBody", {
language: t(`settings.profile.language.${pendingLang}`),
})
: undefined
}
onConfirm={() => {
if (pendingLang) void i18n.changeLanguage(pendingLang);
}}
onOpenChange={(open) => {
if (!open) setPendingLang(null);
}}
open={pendingLang !== null}
title={t("settings.profile.language.confirmTitle")}
variant="default"
/>
{dirty ? (
<div className="sticky bottom-4 z-10">
<div className="flex items-center justify-between gap-4 rounded-2xl border border-border bg-background/95 px-4 py-3 shadow-lg backdrop-blur">
@@ -1,5 +1,6 @@
"use client";
import { RefreshCw } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
@@ -8,6 +9,7 @@ import {
SettingsCard,
SettingsSection,
} from "@/components/settings/settings-parts";
import { Button } from "@/components/ui/button";
import { getNetworkInfo, getVersionInfo, type VersionInfo } from "@/lib/version";
import { cn } from "@/lib/utils";
@@ -45,6 +47,7 @@ export function VersionPanel() {
const { t } = useTranslation();
const [info, setInfo] = useState<VersionInfo | null>(null);
const [loading, setLoading] = useState(true);
const [checking, setChecking] = useState(false);
const [networkUrls, setNetworkUrls] = useState<string[]>([]);
useEffect(() => {
@@ -54,6 +57,17 @@ export function VersionPanel() {
.finally(() => setLoading(false));
}, []);
// "Check for updates" — force a fresh, cache-bypassing lookup on the backend.
const checkForUpdates = () => {
setChecking(true);
getVersionInfo(true)
.then(setInfo)
.catch(() => {
/* keep the last known info on a failed manual check */
})
.finally(() => setChecking(false));
};
// The most reliable shareable URL is the one the browser is already using —
// unless that's localhost, in which case we fall back to the backend's
// detected LAN addresses (accurate when not running in Docker's bridge net).
@@ -72,7 +86,7 @@ export function VersionPanel() {
.catch(() => setNetworkUrls([]));
}, [localShareUrl]);
const statusBadge = loading ? (
const statusBadge = loading || checking ? (
<Badge tone="muted">{t("settings.version.checking")}</Badge>
) : !info || info.latest === null ? (
<Badge tone="muted">{t("settings.version.offline")}</Badge>
@@ -122,6 +136,20 @@ export function VersionPanel() {
</a>
) : null}
</div>
<div className="flex justify-end border-t border-border pt-4">
<Button
disabled={loading || checking}
onClick={checkForUpdates}
size="sm"
type="button"
variant="outline"
>
<RefreshCw className={cn("size-4", checking && "animate-spin")} />
{checking
? t("settings.version.checking")
: t("settings.version.checkNow")}
</Button>
</div>
</SettingsCard>
</SettingsSection>
+125
View File
@@ -0,0 +1,125 @@
"use client";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
} from "@/components/ui/pagination";
// Page numbers to render, with `null` marking an ellipsis gap. Keeps the first,
// last, and a small window around the current page so the control stays compact
// even with many pages.
function pageWindow(current: number, total: number): (number | null)[] {
if (total <= 7) {
return Array.from({ length: total }, (_, i) => i + 1);
}
const pages: (number | null)[] = [1];
const start = Math.max(2, current - 1);
const end = Math.min(total - 1, current + 1);
if (start > 2) pages.push(null);
for (let p = start; p <= end; p++) pages.push(p);
if (end < total - 1) pages.push(null);
pages.push(total);
return pages;
}
type ListPaginationProps = {
/** Current (already-clamped) page, 1-based. */
page: number;
pageSize: number;
/** Total number of items across all pages. */
total: number;
onPageChange: (page: number) => void;
};
// Shared client-side pagination control (summary line + prev/page/next nav).
// Renders nothing when everything fits on one page. Used by the Patients,
// Activity and Invoices lists.
export function ListPagination({
page,
pageSize,
total,
onPageChange,
}: ListPaginationProps) {
const { t } = useTranslation();
if (total <= pageSize) return null;
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const safePage = Math.min(Math.max(1, page), totalPages);
return (
<div className="mt-4 flex flex-col items-center justify-between gap-3 sm:flex-row">
<p className="text-xs text-muted-foreground">
{t("common.pagination.summary", {
from: (safePage - 1) * pageSize + 1,
to: Math.min(safePage * pageSize, total),
total,
})}
</p>
<Pagination
aria-label={t("common.pagination.label")}
className="mx-0 w-auto justify-end"
>
<PaginationContent>
<PaginationItem>
<Button
aria-label={t("common.pagination.previous")}
className="gap-1"
disabled={safePage === 1}
onClick={() => onPageChange(Math.max(1, safePage - 1))}
size="sm"
type="button"
variant="ghost"
>
<ChevronLeft className="size-4" />
<span className="max-sm:hidden">
{t("common.pagination.previous")}
</span>
</Button>
</PaginationItem>
{pageWindow(safePage, totalPages).map((p, i) =>
p === null ? (
<PaginationItem key={`ellipsis-${i}`}>
<PaginationEllipsis />
</PaginationItem>
) : (
<PaginationItem key={p}>
<Button
aria-current={p === safePage ? "page" : undefined}
aria-label={t("common.pagination.page", { page: p })}
onClick={() => onPageChange(p)}
size="icon-sm"
type="button"
variant={p === safePage ? "outline" : "ghost"}
>
{p}
</Button>
</PaginationItem>
)
)}
<PaginationItem>
<Button
aria-label={t("common.pagination.next")}
className="gap-1"
disabled={safePage === totalPages}
onClick={() => onPageChange(Math.min(totalPages, safePage + 1))}
size="sm"
type="button"
variant="ghost"
>
<span className="max-sm:hidden">
{t("common.pagination.next")}
</span>
<ChevronRight className="size-4" />
</Button>
</PaginationItem>
</PaginationContent>
</Pagination>
</div>
);
}
+7 -2
View File
@@ -5,14 +5,19 @@ import LanguageDetector from "i18next-browser-languagedetector";
import { initReactI18next } from "react-i18next";
import en from "./locales/en/translation.json";
import fr from "./locales/fr/translation.json";
export const defaultNS = "translation";
// Add new languages here (and a matching JSON under locales/<lng>/translation.json).
export const resources = {
en: { translation: en },
fr: { translation: fr },
} as const;
// Languages offered in the Settings → Profile switcher (label rendered there).
export const supportedLanguages = ["en", "fr"] as const;
if (!i18n.isInitialized) {
i18n
.use(LanguageDetector)
@@ -21,8 +26,8 @@ if (!i18n.isInitialized) {
resources,
defaultNS,
fallbackLng: "en",
// Only `en` exists today; keep this in sync with `resources` as languages grow.
supportedLngs: ["en"],
// Keep this in sync with `resources` as languages grow.
supportedLngs: ["en", "fr"],
interpolation: { escapeValue: false },
detection: {
order: ["localStorage", "navigator", "htmlTag"],
+20 -8
View File
@@ -6,7 +6,14 @@
"signIn": "Sign in",
"signUp": "Sign up",
"backToSignIn": "Back to sign in",
"addedByAi": "Added by AI"
"addedByAi": "Added by AI",
"pagination": {
"label": "Pages",
"previous": "Previous",
"next": "Next",
"page": "Page {{page}}",
"summary": "Showing {{from}}{{to}} of {{total}}"
}
},
"auth": {
"login": {
@@ -222,13 +229,6 @@
"loading": "Loading patients…",
"empty": "No patients found.",
"loadError": "Failed to load patients.",
"pagination": {
"label": "Patient pages",
"previous": "Previous",
"next": "Next",
"page": "Page {{page}}",
"summary": "Showing {{from}}{{to}} of {{total}}"
},
"columns": {
"name": "Name",
"mrn": "MRN",
@@ -1520,6 +1520,7 @@
"current": "Current version",
"latest": "Latest release",
"checking": "Checking for updates…",
"checkNow": "Check for updates",
"upToDate": "Up to date",
"updateAvailable": "Update available",
"offline": "Couldn't reach the update server",
@@ -1683,6 +1684,17 @@
"professionalLinks": "Professional links",
"professionalLinksHint": "Registry or institutional profiles used to verify your identity. They are never shown to patients.",
"addLink": "Add link",
"language": {
"title": "Language",
"description": "The language temetro's interface is shown in on this device.",
"label": "Display language",
"en": "English",
"fr": "Français",
"confirmTitle": "Change display language?",
"confirmBody": "Switch the interface to {{language}}? The app will reload its text in the new language.",
"confirmCta": "Change language",
"cancel": "Cancel"
},
"patientNotifications": "Patient notifications",
"patientNotificationsDescription": "Emails sent to patients about their records, results, and pending approvals",
"accountNotifications": "Account notifications",
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -14,8 +14,8 @@ export type NetworkInfo = {
urls: string[];
};
export function getVersionInfo(): Promise<VersionInfo> {
return apiFetch<VersionInfo>("/api/version");
export function getVersionInfo(force = false): Promise<VersionInfo> {
return apiFetch<VersionInfo>(`/api/version${force ? "?refresh=1" : ""}`);
}
export function getNetworkInfo(): Promise<NetworkInfo> {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "frontend",
"version": "0.2.1",
"version": "0.2.5",
"private": true,
"scripts": {
"dev": "next dev",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "temetro",
"version": "0.2.1",
"version": "0.2.5",
"private": true,
"devDependencies": {
"shadcn": "^4.11.0"