Compare commits

...

5 Commits

Author SHA1 Message Date
Khalid Abdi 07cfd5d335 ci: build release images on native arm64 runners, not QEMU
The release build emulated linux/arm64 with QEMU on the amd64 runner. A
runner/QEMU update started crashing 'npm ci' under emulation (illegal
instruction) and the build then hung for hours instead of failing, so
v0.17.0 never published. Build each arch on its native runner instead
(amd64 on ubuntu-24.04, arm64 on the free ubuntu-24.04-arm) and merge the
per-arch digests into one manifest with buildx imagetools. Add per-job
timeouts so a stuck build can never run for hours again.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 02:12:27 +03:00
Khalid Abdi c5812511b5 chore: release v0.17.0
Hardware barcode scanner in the pharmacy Add-item dialog, Separated Panels
in Care team settings, and a pointer cursor on Activity rows. Also fixes
the README changelog badge, which was stuck at 0.14.0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 20:53:53 +03:00
Khalid Abdi 196a22bffd frontend: pointer cursor on Activity feed rows
Activity rows are native buttons, which default to cursor:default; add
cursor-pointer so hovering an activity shows the pointer.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 20:42:00 +03:00
Khalid Abdi 531cbbad7f frontend: Separated Panels in Care team settings
Apply the same COSS Separated Panels treatment used in the other settings
frames (Preferences, AI) to the Care team section: pass separated to its
SettingsSection so the cards render as distinct panels on a muted tray.
The members list keeps p-0/overflow-hidden so its divided rows sit flush
inside their panel.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 20:42:00 +03:00
Khalid Abdi 65975177cf inventory: hardware (keyboard-wedge) barcode scanner in Add-item
Replace the camera scanner in the Add inventory item dialog with support
for a USB barcode scanner attached to the computer. A wedge scanner types
the code as a fast keystroke burst ending in Enter; a global keydown
listener (active while the dialog is open) buffers the burst by timing and
routes it through the existing GS1 parser, so scanning a medication
auto-fills barcode/expiry/lot without focusing any field. The barcode
input also parses on Enter for manual entry. The camera BarcodeScanner
component stays (still used by patient wallet import). i18n: replace the
inventory.dialog.scan label with a scanHint across all five locales.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 20:42:00 +03:00
14 changed files with 221 additions and 79 deletions
+104 -28
View File
@@ -3,6 +3,11 @@
# Trigger: push a semver tag, e.g.
# git tag v0.1.0 && git push origin v0.1.0
#
# Multi-arch (amd64 + arm64) is built on NATIVE runners — amd64 on ubuntu-24.04,
# arm64 on ubuntu-24.04-arm — and merged into a manifest. We do NOT emulate arm64
# with QEMU anymore: a runner/QEMU update started crashing `npm ci` under
# emulation ("illegal instruction") and hanging the build for hours.
#
# The frontend image bakes NO API URL — it resolves the backend from the host
# the browser uses at runtime — so one published image works for every clinic.
#
@@ -18,27 +23,30 @@ on:
tags:
- "v*.*.*"
permissions:
contents: write # create the GitHub Release (the update check reads this)
env:
REGISTRY_NAMESPACE: khalidxv
jobs:
publish:
runs-on: ubuntu-latest
# One build per (image × platform) on the platform's native runner, pushed to
# Docker Hub by digest (no tag yet). The merge job stitches the per-arch
# digests into a single tagged multi-arch manifest.
build:
name: Build ${{ matrix.image }} (${{ matrix.platform }})
runs-on: ${{ matrix.platform == 'linux/arm64' && 'ubuntu-24.04-arm' || 'ubuntu-24.04' }}
timeout-minutes: 40
strategy:
fail-fast: false
matrix:
image: [backend, frontend]
platform: [linux/amd64, linux/arm64]
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Derive version from tag
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: Prepare platform pair
run: |
platform="${{ matrix.platform }}"
echo "PLATFORM_PAIR=${platform//\//-}" >> "$GITHUB_ENV"
- name: Set up Buildx
uses: docker/setup-buildx-action@v3
@@ -49,25 +57,93 @@ jobs:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build & push backend
- name: Build & push by digest
id: build
uses: docker/build-push-action@v6
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
context: ./${{ matrix.image }}
platforms: ${{ matrix.platform }}
provenance: false
outputs: type=image,name=${{ env.REGISTRY_NAMESPACE }}/temetro-${{ matrix.image }},push-by-digest=true,name-canonical=true,push=true
- name: Build & push frontend
uses: docker/build-push-action@v6
- name: Export digest
run: |
mkdir -p "${{ runner.temp }}/digests"
digest="${{ steps.build.outputs.digest }}"
touch "${{ runner.temp }}/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
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
name: digests-${{ matrix.image }}-${{ env.PLATFORM_PAIR }}
path: ${{ runner.temp }}/digests/*
if-no-files-found: error
retention-days: 1
# Combine the per-arch digests for each image into one multi-arch manifest and
# tag it (X.Y.Z + latest).
merge:
name: Merge ${{ matrix.image }} manifest
runs-on: ubuntu-latest
timeout-minutes: 15
needs: build
strategy:
fail-fast: false
matrix:
image: [backend, frontend]
steps:
- name: Derive version from tag
id: meta
run: echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
- name: Download digests
uses: actions/download-artifact@v4
with:
path: ${{ runner.temp }}/digests
pattern: digests-${{ matrix.image }}-*
merge-multiple: true
- name: Set up Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Create manifest list and push
working-directory: ${{ runner.temp }}/digests
env:
IMAGE: ${{ env.REGISTRY_NAMESPACE }}/temetro-${{ matrix.image }}
VERSION: ${{ steps.meta.outputs.version }}
run: |
docker buildx imagetools create \
-t "$IMAGE:$VERSION" \
-t "$IMAGE:latest" \
$(printf "$IMAGE@sha256:%s " *)
- name: Inspect
env:
IMAGE: ${{ env.REGISTRY_NAMESPACE }}/temetro-${{ matrix.image }}
VERSION: ${{ steps.meta.outputs.version }}
run: docker buildx imagetools inspect "$IMAGE:$VERSION"
# Both images are published — now cut the GitHub Release with the changelog.
release:
name: GitHub Release
runs-on: ubuntu-latest
timeout-minutes: 10
needs: merge
permissions:
contents: write # create the GitHub Release (the update check reads this)
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Derive version from tag
id: meta
run: echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
# Pull this version's section out of CHANGELOG.md so the release has real,
# human-written notes (the auto "Full Changelog" link is still appended
+18
View File
@@ -7,6 +7,24 @@ for how releases are cut and published.
## [Unreleased]
## [0.17.0] — 2026-07-19
### Changed
- **Pharmacy Add-item now uses a hardware barcode scanner.** The inventory Add-item dialog is
built for a USB barcode scanner connected to the computer (a keyboard wedge): scanning a
medication types the code as a fast keystroke burst ending in Enter, which is parsed and
auto-fills the Barcode/NDC, expiry (AI 17), and lot (AI 10) fields — no need to focus any input
first. The barcode field also parses on Enter for manual entry. This replaces the camera scanner
in this dialog (the camera scanner stays for importing a patient's wallet code)
(`frontend/components/pharmacy/add-inventory-dialog.tsx`).
- **Care team settings use Separated Panels.** The Care team section now renders as distinct
bordered panels on a muted tray, matching the Preferences and AI settings frames
(`frontend/components/settings/settings-care-team.tsx`).
### Fixed
- **Pointer cursor on Activity rows.** Hovering an entry in the Activity feed now shows the pointer
cursor (`frontend/components/activity/activity-view.tsx`).
## [0.16.0] — 2026-07-18
### Added
+1 -1
View File
@@ -9,7 +9,7 @@ information as rich record cards — backed by a **patient-owned data model**.
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](./LICENSE)
[![Docker images](https://img.shields.io/badge/Docker%20Hub-khalidxv-2496ED?logo=docker&logoColor=white)](https://hub.docker.com/u/khalidxv)
[![Changelog](https://img.shields.io/badge/changelog-0.14.0-success)](./CHANGELOG.md)
[![Changelog](https://img.shields.io/badge/changelog-0.17.0-success)](./CHANGELOG.md)
![temetro AI chat](./.github/assets/screenshot-chat.png)
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "temetro-backend",
"version": "0.16.0",
"version": "0.17.0",
"private": true,
"type": "module",
"description": "temetro backend — Express + Postgres API with Better Auth (email/password, organizations) and org-scoped patient records.",
@@ -212,7 +212,7 @@ export function ActivityView() {
<button
className={cn(
"-mx-2 flex-1 rounded-lg px-2 py-1 text-start transition-colors hover:bg-accent/40",
"-mx-2 flex-1 cursor-pointer rounded-lg px-2 py-1 text-start transition-colors hover:bg-accent/40",
isLast ? "pb-1" : "mb-5",
)}
onClick={() => setSelected(entry)}
@@ -1,10 +1,14 @@
"use client";
import { ScanLine } from "lucide-react";
import { type FormEvent, type ReactNode, useState } from "react";
import {
type FormEvent,
type ReactNode,
useCallback,
useEffect,
useState,
} from "react";
import { useTranslation } from "react-i18next";
import { BarcodeScanner } from "@/components/scan/barcode-scanner";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -56,7 +60,6 @@ export function AddInventoryDialog({
// Lot/batch parsed from a GS1 scan. No dedicated field, so it rides along in
// notes (surfaced on the item detail).
const [notes, setNotes] = useState("");
const [scannerOpen, setScannerOpen] = useState(false);
const reset = () => {
setName("");
@@ -73,18 +76,68 @@ export function AddInventoryDialog({
// A scanned medication barcode: store the code, and when it's a GS1
// DataMatrix, auto-fill expiry (AI 17) and lot (AI 10, -> notes).
const handleScan = (raw: string) => {
setScannerOpen(false);
const gs1 = parseGs1(raw);
if (gs1) {
setBarcode(gs1.gtin ?? raw);
if (gs1.expiry) setExpiresAt(gs1.expiry);
if (gs1.lot) setNotes((n) => n || `Lot: ${gs1.lot}`);
} else {
setBarcode(raw.trim());
}
notify.success(t("inventory.dialog.scannedTitle"), gs1?.gtin ?? raw.trim());
};
const handleScan = useCallback(
(raw: string) => {
const gs1 = parseGs1(raw);
if (gs1) {
setBarcode(gs1.gtin ?? raw);
if (gs1.expiry) setExpiresAt(gs1.expiry);
if (gs1.lot) setNotes((n) => n || `Lot: ${gs1.lot}`);
} else {
setBarcode(raw.trim());
}
notify.success(
t("inventory.dialog.scannedTitle"),
gs1?.gtin ?? raw.trim(),
);
},
[t],
);
// Hardware barcode scanners are keyboard wedges: they "type" the code as a
// fast burst of keystrokes terminated by Enter. While the dialog is open we
// watch keydowns globally and, when a fast burst ends in Enter, treat the
// buffer as a scan — so the pharmacist can just scan a medication without
// first focusing any field. The timing gate (keys < 50ms apart) keeps
// ordinary typing out of the buffer; once a burst is detected we swallow the
// characters so they don't leak into the focused input.
useEffect(() => {
if (!open) return;
let buffer = "";
let lastTime = 0;
const MAX_GAP_MS = 50;
const MIN_LEN = 6;
const onKeyDown = (event: KeyboardEvent) => {
const now = Date.now();
const fast = now - lastTime <= MAX_GAP_MS;
lastTime = now;
if (event.key === "Enter") {
if (fast && buffer.length >= MIN_LEN) {
event.preventDefault();
event.stopPropagation();
handleScan(buffer);
}
buffer = "";
return;
}
if (
event.key.length === 1 &&
!event.metaKey &&
!event.ctrlKey &&
!event.altKey
) {
buffer = fast ? buffer + event.key : event.key;
if (fast && buffer.length >= 2) event.preventDefault();
} else {
buffer = "";
}
};
window.addEventListener("keydown", onKeyDown, true);
return () => window.removeEventListener("keydown", onKeyDown, true);
}, [open, handleScan]);
const submit = (event: FormEvent) => {
event.preventDefault();
@@ -114,7 +167,6 @@ export function AddInventoryDialog({
};
return (
<>
<Dialog
onOpenChange={(o) => {
onOpenChange(o);
@@ -204,23 +256,24 @@ export function AddInventoryDialog({
</div>
<Field label={t("inventory.dialog.barcode")}>
<div className="flex items-center gap-2">
<Input
inputMode="numeric"
onChange={(event) => setBarcode(event.target.value)}
placeholder={t("inventory.dialog.barcodePlaceholder")}
value={barcode}
/>
<Button
aria-label={t("inventory.dialog.scan")}
onClick={() => setScannerOpen(true)}
size="icon"
type="button"
variant="outline"
>
<ScanLine className="size-4" />
</Button>
</div>
<Input
inputMode="numeric"
onChange={(event) => setBarcode(event.target.value)}
// A hardware scanner sends Enter after the code; parse it here
// (instead of submitting the form) so a scan into this field is
// handled like the global wedge burst.
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
if (barcode.trim()) handleScan(barcode);
}
}}
placeholder={t("inventory.dialog.barcodePlaceholder")}
value={barcode}
/>
<span className="text-muted-foreground text-xs">
{t("inventory.dialog.scanHint")}
</span>
</Field>
</DialogPanel>
@@ -235,11 +288,5 @@ export function AddInventoryDialog({
</form>
</DialogPopup>
</Dialog>
<BarcodeScanner
onDetected={handleScan}
onOpenChange={setScannerOpen}
open={scannerOpen}
/>
</>
);
}
@@ -136,6 +136,7 @@ export function CareTeamPanel({
return (
<SettingsSection
description={t("settings.careTeam.description")}
separated
title={t("settings.careTeam.title")}
>
{error && (
@@ -157,7 +158,7 @@ export function CareTeamPanel({
</SettingsCard>
)}
<SettingsCard className="divide-y divide-border">
<SettingsCard className="divide-y divide-border overflow-hidden p-0">
{loading ? (
<p className="p-6 text-center text-sm text-muted-foreground">
{t("settings.careTeam.loading")}
@@ -713,7 +713,7 @@
"expires": "تنتهي الصلاحية",
"barcode": "الباركود / NDC",
"barcodePlaceholder": "امسح أو اكتب رمزًا",
"scan": "مسح الباركود",
"scanHint": "وصّل ماسح باركود USB وامسح الدواء — أو اكتب الرمز.",
"scannedTitle": "تم مسح الباركود",
"cancel": "إلغاء",
"submit": "إضافة عنصر",
@@ -697,7 +697,7 @@
"expires": "Verfällt",
"barcode": "Barcode / NDC",
"barcodePlaceholder": "Code scannen oder eingeben",
"scan": "Barcode scannen",
"scanHint": "Schließen Sie einen USB-Barcode-Scanner an und scannen Sie das Medikament oder geben Sie den Code ein.",
"scannedTitle": "Barcode gescannt",
"cancel": "Abbrechen",
"submit": "Artikel hinzufügen",
@@ -697,7 +697,7 @@
"expires": "Expires",
"barcode": "Barcode / NDC",
"barcodePlaceholder": "Scan or type a code",
"scan": "Scan barcode",
"scanHint": "Connect a USB barcode scanner and scan the medication — or type the code.",
"scannedTitle": "Barcode scanned",
"cancel": "Cancel",
"submit": "Add item",
@@ -697,7 +697,7 @@
"expires": "Expire le",
"barcode": "Code-barres / NDC",
"barcodePlaceholder": "Scanner ou saisir un code",
"scan": "Scanner le code-barres",
"scanHint": "Branchez un lecteur de code-barres USB et scannez le médicament — ou saisissez le code.",
"scannedTitle": "Code-barres scanné",
"cancel": "Annuler",
"submit": "Ajouter l'article",
@@ -697,7 +697,7 @@
"expires": "Dhacaya",
"barcode": "Barcode / NDC",
"barcodePlaceholder": "Iskaan garee ama qor koodh",
"scan": "Iskaan garee barcode",
"scanHint": "Ku xir iskaanka barcode-ka USB oo iskaan garee daawada — ama qor koodhka.",
"scannedTitle": "Barcode la iskaan gareeyay",
"cancel": "Jooji",
"submit": "Ku dar shay",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "frontend",
"version": "0.16.0",
"version": "0.17.0",
"private": true,
"scripts": {
"dev": "next dev",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "temetro",
"version": "0.16.0",
"version": "0.17.0",
"private": true,
"devDependencies": {
"shadcn": "^4.11.0"