Compare commits

..

2 Commits

Author SHA1 Message Date
Khalid Abdi bffed5525d chore: release v0.12.1
Bump root/backend/frontend to 0.12.1 and move the CHANGELOG notes under a dated
0.12.1 heading (centered wallet-sync stepper, timeline record history, and the
landing-page globe deferral). Adds the radix-ui dependency pulled in by the
Origin UI stepper/timeline primitives.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 20:51:04 +03:00
Khalid Abdi 591b2f9170 frontend: center wallet-sync stepper and record-history timeline
Replace the bespoke left-aligned DialogStepper with the Origin UI Stepper
primitive (components/ui/stepper.tsx) so the numbered indicators sit centered
inline with their labels. Rebuild the patient sheet's Record History section as
a vertical Timeline (components/ui/timeline.tsx) showing the actor name, an
entity-type icon, the action, and the date, replacing the flat avatar list.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 20:50:54 +03:00
9 changed files with 1861 additions and 182 deletions
+18
View File
@@ -7,6 +7,24 @@ for how releases are cut and published.
## [Unreleased]
## [0.12.1] — 2026-07-10
### Changed
- **Centered wallet-sync stepper.** The in-dialog "Sync to wallet" stepper (`DialogStepper` in
`frontend/components/wallet/wallet-sync-step.tsx`) now uses a proper Stepper primitive
(`components/ui/stepper.tsx`), so the numbered indicators sit centered inline with their labels
instead of the previous left-aligned look.
- **Record history is now a timeline.** The patient sheet's **Record history** section
(`RecordHistory` in `frontend/components/patients/patient-detail.tsx`) renders as a vertical
timeline (`components/ui/timeline.tsx`) — who made the change, an entity-type icon, what happened,
and when — replacing the flat avatar list.
### Performance
- **Landing page: defer the 3D globe.** The Temetro Network globe (three.js) on the marketing site
now mounts only when its section scrolls near the viewport (IntersectionObserver), instead of on
hydration — removing ~730 KB of JS and its main-thread execution from initial page load. (Landing
page lives in the sibling `temetro/landing-page` repo.)
## [0.12.0] — 2026-07-09
### Added
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "temetro-backend",
"version": "0.12.0",
"version": "0.12.1",
"private": true,
"type": "module",
"description": "temetro backend — Express + Postgres API with Better Auth (email/password, organizations) and org-scoped patient records.",
+62 -21
View File
@@ -2,12 +2,18 @@
import {
ArrowLeftRight,
CalendarDays,
FileDown,
type LucideIcon,
ListTodo,
Mic,
Network,
NotebookPen,
Pencil,
Pill,
Send,
Trash2,
UserRound,
} from "lucide-react";
import { type ReactNode, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
@@ -15,7 +21,21 @@ import { useTranslation } from "react-i18next";
import { Sparkline } from "@/components/chat/sparkline";
import { AttachmentsSection } from "@/components/patients/patient-files";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { type ActivityEntry, listPatientActivity } from "@/lib/activity";
import {
Timeline,
TimelineContent,
TimelineDate,
TimelineHeader,
TimelineIndicator,
TimelineItem,
TimelineSeparator,
TimelineTitle,
} from "@/components/ui/timeline";
import {
type ActivityEntityType,
type ActivityEntry,
listPatientActivity,
} from "@/lib/activity";
import { printPatientSummary } from "@/lib/patient-pdf";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -122,8 +142,18 @@ function TrendBlock({ trend }: { trend: Trend }) {
);
}
// Which icon marks each kind of audited change on the timeline.
const historyIcon: Record<ActivityEntityType, LucideIcon> = {
appointment: CalendarDays,
note: NotebookPen,
patient: UserRound,
prescription: Pill,
task: ListTodo,
};
// The patient's record history: every audited add/change on this chart, newest
// first. Reuses the clinic activity log scoped to this file number.
// first. Reuses the clinic activity log scoped to this file number, laid out as
// a vertical timeline — who made the change, what happened, and when.
function RecordHistory({ fileNumber }: { fileNumber: string }) {
const { t } = useTranslation();
const [entries, setEntries] = useState<ActivityEntry[] | null>(null);
@@ -152,25 +182,36 @@ function RecordHistory({ fileNumber }: { fileNumber: string }) {
{t("patientCard.history.empty")}
</p>
) : (
<ol className="flex flex-col gap-3">
{entries.map((e) => (
<li className="flex items-start gap-3" key={e.id}>
<Avatar className="mt-0.5 size-7 shrink-0">
<AvatarFallback className="text-[11px]">
{e.actorInitials}
</AvatarFallback>
</Avatar>
<div className="flex min-w-0 flex-1 flex-col">
<span className="text-foreground text-sm">
<span className="font-medium">{e.actorName}</span> {e.action}
</span>
<span className="text-muted-foreground text-xs">
{new Date(e.createdAt).toLocaleString()}
</span>
</div>
</li>
))}
</ol>
// Every entry is a past, audited event, so mark them all completed
// (filled indicators) by seeding the active step past the last item.
<Timeline defaultValue={entries.length}>
{entries.map((e, i) => {
const Icon = historyIcon[e.entityType] ?? Pencil;
return (
<TimelineItem
className="group-data-[orientation=vertical]/timeline:ms-10"
key={e.id}
step={i + 1}
>
<TimelineHeader>
<TimelineSeparator className="group-data-[orientation=vertical]/timeline:-left-7 group-data-[orientation=vertical]/timeline:h-[calc(100%-1.5rem-0.25rem)] group-data-[orientation=vertical]/timeline:translate-y-6.5" />
<TimelineTitle className="mt-0.5">
{e.actorName}
</TimelineTitle>
<TimelineIndicator className="group-data-[orientation=vertical]/timeline:-left-7 flex size-6 items-center justify-center border-none bg-primary/10 text-primary group-data-completed/timeline-item:bg-primary group-data-completed/timeline-item:text-primary-foreground">
<Icon size={14} />
</TimelineIndicator>
</TimelineHeader>
<TimelineContent>
{e.action}
<TimelineDate className="mt-1 mb-0">
{new Date(e.createdAt).toLocaleString()}
</TimelineDate>
</TimelineContent>
</TimelineItem>
);
})}
</Timeline>
)}
</Section>
);
+293
View File
@@ -0,0 +1,293 @@
"use client";
import { CheckIcon, LoaderCircleIcon } from "lucide-react";
import { Slot } from "radix-ui";
import * as React from "react";
import { createContext, useContext } from "react";
import { cn } from "@/lib/utils";
// Types
type StepperContextValue = {
activeStep: number;
setActiveStep: (step: number) => void;
orientation: "horizontal" | "vertical";
};
type StepItemContextValue = {
step: number;
state: StepState;
isDisabled: boolean;
isLoading: boolean;
};
type StepState = "active" | "completed" | "inactive" | "loading";
// Contexts
const StepperContext = createContext<StepperContextValue | undefined>(
undefined,
);
const StepItemContext = createContext<StepItemContextValue | undefined>(
undefined,
);
const useStepper = () => {
const context = useContext(StepperContext);
if (!context) {
throw new Error("useStepper must be used within a Stepper");
}
return context;
};
const useStepItem = () => {
const context = useContext(StepItemContext);
if (!context) {
throw new Error("useStepItem must be used within a StepperItem");
}
return context;
};
// Components
interface StepperProps extends React.HTMLAttributes<HTMLDivElement> {
defaultValue?: number;
value?: number;
onValueChange?: (value: number) => void;
orientation?: "horizontal" | "vertical";
}
function Stepper({
defaultValue = 0,
value,
onValueChange,
orientation = "horizontal",
className,
...props
}: StepperProps) {
const [activeStep, setInternalStep] = React.useState(defaultValue);
const setActiveStep = React.useCallback(
(step: number) => {
if (value === undefined) {
setInternalStep(step);
}
onValueChange?.(step);
},
[value, onValueChange],
);
const currentStep = value ?? activeStep;
return (
<StepperContext.Provider
value={{
activeStep: currentStep,
orientation,
setActiveStep,
}}
>
<div
className={cn(
"group/stepper inline-flex data-[orientation=horizontal]:w-full data-[orientation=horizontal]:flex-row data-[orientation=vertical]:flex-col",
className,
)}
data-orientation={orientation}
data-slot="stepper"
{...props}
/>
</StepperContext.Provider>
);
}
// StepperItem
interface StepperItemProps extends React.HTMLAttributes<HTMLDivElement> {
step: number;
completed?: boolean;
disabled?: boolean;
loading?: boolean;
}
function StepperItem({
step,
completed = false,
disabled = false,
loading = false,
className,
children,
...props
}: StepperItemProps) {
const { activeStep } = useStepper();
const state: StepState =
completed || step < activeStep
? "completed"
: activeStep === step
? "active"
: "inactive";
const isLoading = loading && step === activeStep;
return (
<StepItemContext.Provider
value={{ isDisabled: disabled, isLoading, state, step }}
>
<div
className={cn(
"group/step flex items-center group-data-[orientation=horizontal]/stepper:flex-row group-data-[orientation=vertical]/stepper:flex-col",
className,
)}
data-slot="stepper-item"
data-state={state}
{...(isLoading ? { "data-loading": true } : {})}
{...props}
>
{children}
</div>
</StepItemContext.Provider>
);
}
// StepperTrigger
interface StepperTriggerProps
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
asChild?: boolean;
}
function StepperTrigger({
asChild = false,
className,
children,
...props
}: StepperTriggerProps) {
const { setActiveStep } = useStepper();
const { step, isDisabled } = useStepItem();
if (asChild) {
const Comp = asChild ? Slot.Root : "span";
return (
<Comp className={className} data-slot="stepper-trigger">
{children}
</Comp>
);
}
return (
<button
className={cn(
"inline-flex items-center gap-3 rounded-full outline-none focus-visible:z-10 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50",
className,
)}
data-slot="stepper-trigger"
disabled={isDisabled}
onClick={() => setActiveStep(step)}
type="button"
{...props}
>
{children}
</button>
);
}
// StepperIndicator
interface StepperIndicatorProps extends React.HTMLAttributes<HTMLDivElement> {
asChild?: boolean;
}
function StepperIndicator({
asChild = false,
className,
children,
...props
}: StepperIndicatorProps) {
const { state, step, isLoading } = useStepItem();
return (
<span
className={cn(
"relative flex size-6 shrink-0 items-center justify-center rounded-full bg-muted font-medium text-muted-foreground text-xs data-[state=active]:bg-primary data-[state=completed]:bg-primary data-[state=active]:text-primary-foreground data-[state=completed]:text-primary-foreground",
className,
)}
data-slot="stepper-indicator"
data-state={state}
{...props}
>
{asChild ? (
children
) : (
<>
<span className="transition-all group-data-[state=completed]/step:scale-0 group-data-loading/step:scale-0 group-data-[state=completed]/step:opacity-0 group-data-loading/step:opacity-0 group-data-loading/step:transition-none">
{step}
</span>
<CheckIcon
aria-hidden="true"
className="absolute scale-0 opacity-0 transition-all group-data-[state=completed]/step:scale-100 group-data-[state=completed]/step:opacity-100"
size={16}
/>
{isLoading && (
<span className="absolute transition-all">
<LoaderCircleIcon
aria-hidden="true"
className="animate-spin"
size={14}
/>
</span>
)}
</>
)}
</span>
);
}
// StepperTitle
function StepperTitle({
className,
...props
}: React.HTMLAttributes<HTMLHeadingElement>) {
return (
<h3
className={cn("font-medium text-sm", className)}
data-slot="stepper-title"
{...props}
/>
);
}
// StepperDescription
function StepperDescription({
className,
...props
}: React.HTMLAttributes<HTMLParagraphElement>) {
return (
<p
className={cn("text-muted-foreground text-sm", className)}
data-slot="stepper-description"
{...props}
/>
);
}
// StepperSeparator
function StepperSeparator({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn(
"m-0.5 bg-muted group-data-[orientation=horizontal]/stepper:h-0.5 group-data-[orientation=vertical]/stepper:h-12 group-data-[orientation=horizontal]/stepper:w-full group-data-[orientation=vertical]/stepper:w-0.5 group-data-[orientation=horizontal]/stepper:flex-1 group-data-[state=completed]/step:bg-primary",
className,
)}
data-slot="stepper-separator"
{...props}
/>
);
}
export {
Stepper,
StepperDescription,
StepperIndicator,
StepperItem,
StepperSeparator,
StepperTitle,
StepperTrigger,
};
+210
View File
@@ -0,0 +1,210 @@
"use client";
import { Slot } from "radix-ui";
import * as React from "react";
import { cn } from "@/lib/utils";
// Types
type TimelineContextValue = {
activeStep: number;
setActiveStep: (step: number) => void;
};
// Context
const TimelineContext = React.createContext<TimelineContextValue | undefined>(
undefined,
);
const useTimeline = () => {
const context = React.useContext(TimelineContext);
if (!context) {
throw new Error("useTimeline must be used within a Timeline");
}
return context;
};
// Components
interface TimelineProps extends React.HTMLAttributes<HTMLDivElement> {
defaultValue?: number;
value?: number;
onValueChange?: (value: number) => void;
orientation?: "horizontal" | "vertical";
}
function Timeline({
defaultValue = 1,
value,
onValueChange,
orientation = "vertical",
className,
...props
}: TimelineProps) {
const [activeStep, setInternalStep] = React.useState(defaultValue);
const setActiveStep = React.useCallback(
(step: number) => {
if (value === undefined) {
setInternalStep(step);
}
onValueChange?.(step);
},
[value, onValueChange],
);
const currentStep = value ?? activeStep;
return (
<TimelineContext.Provider
value={{ activeStep: currentStep, setActiveStep }}
>
<div
className={cn(
"group/timeline flex data-[orientation=horizontal]:w-full data-[orientation=horizontal]:flex-row data-[orientation=vertical]:flex-col",
className,
)}
data-orientation={orientation}
data-slot="timeline"
{...props}
/>
</TimelineContext.Provider>
);
}
// TimelineContent
function TimelineContent({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("text-muted-foreground text-sm", className)}
data-slot="timeline-content"
{...props}
/>
);
}
// TimelineDate
interface TimelineDateProps extends React.HTMLAttributes<HTMLTimeElement> {
asChild?: boolean;
}
function TimelineDate({
asChild = false,
className,
...props
}: TimelineDateProps) {
const Comp = asChild ? Slot.Root : "time";
return (
<Comp
className={cn(
"mb-1 block font-medium text-muted-foreground text-xs group-data-[orientation=vertical]/timeline:max-sm:h-4",
className,
)}
data-slot="timeline-date"
{...props}
/>
);
}
// TimelineHeader
function TimelineHeader({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div className={cn(className)} data-slot="timeline-header" {...props} />
);
}
// TimelineIndicator
interface TimelineIndicatorProps extends React.HTMLAttributes<HTMLDivElement> {
asChild?: boolean;
}
function TimelineIndicator({
asChild = false,
className,
children,
...props
}: TimelineIndicatorProps) {
return (
<div
aria-hidden="true"
className={cn(
"group-data-[orientation=horizontal]/timeline:-top-6 group-data-[orientation=horizontal]/timeline:-translate-y-1/2 group-data-[orientation=vertical]/timeline:-left-6 group-data-[orientation=vertical]/timeline:-translate-x-1/2 absolute size-4 rounded-full border-2 border-primary/20 group-data-[orientation=vertical]/timeline:top-0 group-data-[orientation=horizontal]/timeline:left-0 group-data-completed/timeline-item:border-primary",
className,
)}
data-slot="timeline-indicator"
{...props}
>
{children}
</div>
);
}
// TimelineItem
interface TimelineItemProps extends React.HTMLAttributes<HTMLDivElement> {
step: number;
}
function TimelineItem({ step, className, ...props }: TimelineItemProps) {
const { activeStep } = useTimeline();
return (
<div
className={cn(
"group/timeline-item relative flex flex-1 flex-col gap-0.5 group-data-[orientation=vertical]/timeline:ms-8 group-data-[orientation=horizontal]/timeline:mt-8 group-data-[orientation=horizontal]/timeline:not-last:pe-8 group-data-[orientation=vertical]/timeline:not-last:pb-12 has-[+[data-completed]]:[&_[data-slot=timeline-separator]]:bg-primary",
className,
)}
data-completed={step <= activeStep || undefined}
data-slot="timeline-item"
{...props}
/>
);
}
// TimelineSeparator
function TimelineSeparator({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
aria-hidden="true"
className={cn(
"group-data-[orientation=horizontal]/timeline:-top-6 group-data-[orientation=horizontal]/timeline:-translate-y-1/2 group-data-[orientation=vertical]/timeline:-left-6 group-data-[orientation=vertical]/timeline:-translate-x-1/2 absolute self-start bg-primary/10 group-last/timeline-item:hidden group-data-[orientation=horizontal]/timeline:h-0.5 group-data-[orientation=vertical]/timeline:h-[calc(100%-1rem-0.25rem)] group-data-[orientation=horizontal]/timeline:w-[calc(100%-1rem-0.25rem)] group-data-[orientation=vertical]/timeline:w-0.5 group-data-[orientation=horizontal]/timeline:translate-x-4.5 group-data-[orientation=vertical]/timeline:translate-y-4.5",
className,
)}
data-slot="timeline-separator"
{...props}
/>
);
}
// TimelineTitle
function TimelineTitle({
className,
...props
}: React.HTMLAttributes<HTMLHeadingElement>) {
return (
<h3
className={cn("font-medium text-sm", className)}
data-slot="timeline-title"
{...props}
/>
);
}
export {
Timeline,
TimelineContent,
TimelineDate,
TimelineHeader,
TimelineIndicator,
TimelineItem,
TimelineSeparator,
TimelineTitle,
};
+26 -42
View File
@@ -5,57 +5,41 @@ import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { DialogFooter, DialogPanel } from "@/components/ui/dialog";
import { cn } from "@/lib/utils";
import {
Stepper,
StepperIndicator,
StepperItem,
StepperSeparator,
StepperTitle,
} from "@/components/ui/stepper";
import type { UseWalletSync } from "./use-wallet-sync";
// Two-step header shown at the top of a dialog when the selected patient has a
// linked wallet: "Details" → "Sync to wallet".
// linked wallet: "Details" → "Sync to wallet". Centered so the numbered
// indicators sit inline with their labels.
export function DialogStepper({ step }: { step: "form" | "wallet" }) {
const { t } = useTranslation();
const steps = [
{ key: "form", label: t("walletSync.step1") },
{ key: "wallet", label: t("walletSync.step2") },
] as const;
const activeIndex = step === "form" ? 0 : 1;
{ value: 1, label: t("walletSync.step1") },
{ value: 2, label: t("walletSync.step2") },
];
const activeValue = step === "form" ? 1 : 2;
return (
<div className="mt-3 flex items-center gap-2">
{steps.map((s, i) => {
const done = i < activeIndex;
const active = i === activeIndex;
return (
<div className="flex flex-1 items-center gap-2" key={s.key}>
<span
className={cn(
"flex size-5 shrink-0 items-center justify-center rounded-full text-[11px] font-semibold transition-colors",
done && "bg-primary text-primary-foreground",
active && "bg-primary/15 text-primary ring-1 ring-primary/40",
!done && !active && "bg-muted text-muted-foreground",
)}
>
{done ? <Check className="size-3" /> : i + 1}
</span>
<span
className={cn(
"text-xs font-medium",
active || done ? "text-foreground" : "text-muted-foreground",
)}
>
{s.label}
</span>
{i < steps.length - 1 && (
<span
className={cn(
"ml-1 h-px flex-1",
done ? "bg-primary/40" : "bg-border",
)}
/>
)}
</div>
);
})}
</div>
<Stepper className="mx-auto mt-3 max-w-sm" value={activeValue}>
{steps.map(({ value, label }, i) => (
<StepperItem className="not-last:flex-1" key={value} step={value}>
<span className="inline-flex items-center gap-2">
<StepperIndicator />
<StepperTitle className="whitespace-nowrap text-xs">
{label}
</StepperTitle>
</span>
{i < steps.length - 1 && <StepperSeparator className="mx-3" />}
</StepperItem>
))}
</Stepper>
);
}
+1248 -116
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "frontend",
"version": "0.12.0",
"version": "0.12.1",
"private": true,
"scripts": {
"dev": "next dev",
@@ -52,6 +52,7 @@
"nanoid": "^5.1.11",
"next": "16.2.6",
"next-themes": "^0.4.6",
"radix-ui": "^1.6.2",
"react": "19.2.4",
"react-day-picker": "^10.0.1",
"react-dom": "19.2.4",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "temetro",
"version": "0.12.0",
"version": "0.12.1",
"private": true,
"devDependencies": {
"shadcn": "^4.11.0"