Compare commits

...

8 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
20 changed files with 2462 additions and 140 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
+33
View File
@@ -7,6 +7,39 @@ 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
+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.3",
"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.",
+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,
+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.3",
"version": "0.2.5",
"private": true,
"scripts": {
"dev": "next dev",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "temetro",
"version": "0.2.3",
"version": "0.2.5",
"private": true,
"devDependencies": {
"shadcn": "^4.11.0"