mirror of
https://github.com/temetro/temetro.git
synced 2026-08-26 02:17:22 +00:00
feat(analysis): add trend charts to the Analysis page
The Analysis page only showed KPI numbers. Add two real time-series and render them as dependency-free bar charts (matching components/chat/sparkline.tsx): - backend: GET /api/analytics now returns `trends.patientsByMonth` (new patients per month over the last 6 months) and `trends.appointmentsByWeekday` (appointments per day for the current week), bucketed in JS from one query each. - frontend: new components/analysis/bar-chart.tsx and two chart sections on the Analysis view (Patient growth, Appointments this week), with i18n keys. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -17,6 +17,22 @@ async function countWhere(table: PgTable, where: SQL): Promise<number> {
|
|||||||
return row?.value ?? 0;
|
return row?.value ?? 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const MONTH_LABELS = [
|
||||||
|
"Jan",
|
||||||
|
"Feb",
|
||||||
|
"Mar",
|
||||||
|
"Apr",
|
||||||
|
"May",
|
||||||
|
"Jun",
|
||||||
|
"Jul",
|
||||||
|
"Aug",
|
||||||
|
"Sep",
|
||||||
|
"Oct",
|
||||||
|
"Nov",
|
||||||
|
"Dec",
|
||||||
|
];
|
||||||
|
const WEEKDAY_LABELS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
||||||
|
|
||||||
export async function getAnalytics(orgId: string): Promise<Analytics> {
|
export async function getAnalytics(orgId: string): Promise<Analytics> {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
|
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||||
@@ -103,6 +119,65 @@ export async function getAnalytics(orgId: string): Promise<Analytics> {
|
|||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// New patients per month over the last 6 months (oldest → newest). One query,
|
||||||
|
// bucketed in JS by the patient's createdAt month.
|
||||||
|
const sixMonthsAgo = new Date(now.getFullYear(), now.getMonth() - 5, 1);
|
||||||
|
const months = Array.from({ length: 6 }, (_, i) => {
|
||||||
|
const d = new Date(now.getFullYear(), now.getMonth() - 5 + i, 1);
|
||||||
|
return {
|
||||||
|
key: `${d.getFullYear()}-${pad(d.getMonth() + 1)}`,
|
||||||
|
label: MONTH_LABELS[d.getMonth()]!,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const patientRows = await db
|
||||||
|
.select({ createdAt: patients.createdAt })
|
||||||
|
.from(patients)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(patients.organizationId, orgId),
|
||||||
|
gte(patients.createdAt, sixMonthsAgo),
|
||||||
|
)!,
|
||||||
|
);
|
||||||
|
const monthCounts = new Map(months.map((m) => [m.key, 0]));
|
||||||
|
for (const r of patientRows) {
|
||||||
|
const d = r.createdAt;
|
||||||
|
const key = `${d.getFullYear()}-${pad(d.getMonth() + 1)}`;
|
||||||
|
if (monthCounts.has(key)) monthCounts.set(key, monthCounts.get(key)! + 1);
|
||||||
|
}
|
||||||
|
const patientsByMonth = months.map((m) => ({
|
||||||
|
label: m.label,
|
||||||
|
count: monthCounts.get(m.key) ?? 0,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Appointments per day across the current week (Sun → Sat). One query,
|
||||||
|
// bucketed in JS by the appointment's date key.
|
||||||
|
const weekDays = Array.from({ length: 7 }, (_, i) => {
|
||||||
|
const d = new Date(
|
||||||
|
startOfWeek.getFullYear(),
|
||||||
|
startOfWeek.getMonth(),
|
||||||
|
startOfWeek.getDate() + i,
|
||||||
|
);
|
||||||
|
return { key: keyOf(d), label: WEEKDAY_LABELS[d.getDay()]! };
|
||||||
|
});
|
||||||
|
const apptRows = await db
|
||||||
|
.select({ date: appointments.date })
|
||||||
|
.from(appointments)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(appointments.organizationId, orgId),
|
||||||
|
gte(appointments.date, weekStartKey),
|
||||||
|
lte(appointments.date, weekEndKey),
|
||||||
|
)!,
|
||||||
|
);
|
||||||
|
const dayCounts = new Map(weekDays.map((d) => [d.key, 0]));
|
||||||
|
for (const r of apptRows) {
|
||||||
|
if (dayCounts.has(r.date)) dayCounts.set(r.date, dayCounts.get(r.date)! + 1);
|
||||||
|
}
|
||||||
|
const appointmentsByWeekday = weekDays.map((d) => ({
|
||||||
|
label: d.label,
|
||||||
|
count: dayCounts.get(d.key) ?? 0,
|
||||||
|
}));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
patients: {
|
patients: {
|
||||||
total: patientsTotal,
|
total: patientsTotal,
|
||||||
@@ -117,5 +192,6 @@ export async function getAnalytics(orgId: string): Promise<Analytics> {
|
|||||||
},
|
},
|
||||||
prescriptions: { total: rxTotal, active: rxActive },
|
prescriptions: { total: rxTotal, active: rxActive },
|
||||||
tasks: { open: tasksOpen, done: tasksDone },
|
tasks: { open: tasksOpen, done: tasksDone },
|
||||||
|
trends: { patientsByMonth, appointmentsByWeekday },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
// A single bar/point in a time-series chart (e.g. one month or one weekday).
|
||||||
|
export type TrendPoint = { label: string; count: number };
|
||||||
|
|
||||||
// Server-computed clinic analytics returned by GET /api/analytics. All figures
|
// Server-computed clinic analytics returned by GET /api/analytics. All figures
|
||||||
// are aggregates over the active clinic's real data (no fabricated financials).
|
// are aggregates over the active clinic's real data (no fabricated financials).
|
||||||
export type Analytics = {
|
export type Analytics = {
|
||||||
@@ -20,4 +23,10 @@ export type Analytics = {
|
|||||||
open: number;
|
open: number;
|
||||||
done: number;
|
done: number;
|
||||||
};
|
};
|
||||||
|
// Time-series for charts: new patients over the last 6 months, and
|
||||||
|
// appointments per day across the current week.
|
||||||
|
trends: {
|
||||||
|
patientsByMonth: TrendPoint[];
|
||||||
|
appointmentsByWeekday: TrendPoint[];
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { type ReactNode, useEffect, useState } from "react";
|
import { type ReactNode, useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import { BarChart } from "@/components/analysis/bar-chart";
|
||||||
import { Card } from "@/components/ui/card";
|
import { Card } from "@/components/ui/card";
|
||||||
import { type Analytics, getAnalytics } from "@/lib/analytics";
|
import { type Analytics, getAnalytics } from "@/lib/analytics";
|
||||||
|
|
||||||
@@ -45,6 +46,27 @@ function Section({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A full-width section that frames a single chart in a card.
|
||||||
|
function ChartSection({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<section className="flex flex-col gap-3">
|
||||||
|
<div>
|
||||||
|
<h2 className="font-semibold text-lg tracking-tight">{title}</h2>
|
||||||
|
<p className="text-muted-foreground text-sm">{description}</p>
|
||||||
|
</div>
|
||||||
|
<Card className="p-5">{children}</Card>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function AnalysisView() {
|
export function AnalysisView() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [data, setData] = useState<Analytics | null>(null);
|
const [data, setData] = useState<Analytics | null>(null);
|
||||||
@@ -92,6 +114,16 @@ export function AnalysisView() {
|
|||||||
/>
|
/>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
|
<ChartSection
|
||||||
|
description={t("analysis.charts.patientGrowthDescription")}
|
||||||
|
title={t("analysis.charts.patientGrowthTitle")}
|
||||||
|
>
|
||||||
|
<BarChart
|
||||||
|
data={data?.trends.patientsByMonth ?? []}
|
||||||
|
emptyLabel={t("analysis.charts.empty")}
|
||||||
|
/>
|
||||||
|
</ChartSection>
|
||||||
|
|
||||||
<Section
|
<Section
|
||||||
description={t("analysis.appointments.description")}
|
description={t("analysis.appointments.description")}
|
||||||
title={t("analysis.appointments.title")}
|
title={t("analysis.appointments.title")}
|
||||||
@@ -114,6 +146,16 @@ export function AnalysisView() {
|
|||||||
/>
|
/>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
|
<ChartSection
|
||||||
|
description={t("analysis.charts.weeklyAppointmentsDescription")}
|
||||||
|
title={t("analysis.charts.weeklyAppointmentsTitle")}
|
||||||
|
>
|
||||||
|
<BarChart
|
||||||
|
data={data?.trends.appointmentsByWeekday ?? []}
|
||||||
|
emptyLabel={t("analysis.charts.empty")}
|
||||||
|
/>
|
||||||
|
</ChartSection>
|
||||||
|
|
||||||
<Section
|
<Section
|
||||||
description={t("analysis.prescriptions.description")}
|
description={t("analysis.prescriptions.description")}
|
||||||
title={t("analysis.prescriptions.title")}
|
title={t("analysis.prescriptions.title")}
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { TrendPoint } from "@/lib/analytics";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
// A small dependency-free vertical bar chart (matches the spirit of
|
||||||
|
// components/chat/sparkline.tsx). Bars scale to the series max; each shows its
|
||||||
|
// value above and its label below. Themed with semantic tokens.
|
||||||
|
export function BarChart({
|
||||||
|
data,
|
||||||
|
className,
|
||||||
|
emptyLabel,
|
||||||
|
}: {
|
||||||
|
data: TrendPoint[];
|
||||||
|
className?: string;
|
||||||
|
emptyLabel: string;
|
||||||
|
}) {
|
||||||
|
const max = Math.max(1, ...data.map((d) => d.count));
|
||||||
|
const hasData = data.some((d) => d.count > 0);
|
||||||
|
|
||||||
|
if (data.length === 0 || !hasData) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex h-44 items-center justify-center text-muted-foreground text-sm",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{emptyLabel}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("flex h-44 items-end gap-2 sm:gap-3", className)}>
|
||||||
|
{data.map((d, i) => {
|
||||||
|
const pct = (d.count / max) * 100;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex h-full flex-1 flex-col items-center gap-1.5"
|
||||||
|
key={`${d.label}-${i}`}
|
||||||
|
>
|
||||||
|
<span className="font-medium text-foreground text-xs tabular-nums">
|
||||||
|
{d.count}
|
||||||
|
</span>
|
||||||
|
<div className="flex w-full flex-1 items-end">
|
||||||
|
<div
|
||||||
|
className="w-full rounded-t-md bg-primary/80 transition-colors hover:bg-primary"
|
||||||
|
style={{ height: `${Math.max(pct, d.count > 0 ? 4 : 0)}%` }}
|
||||||
|
title={`${d.label}: ${d.count}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-muted-foreground text-xs">{d.label}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@ import { apiFetch } from "@/lib/api-client";
|
|||||||
|
|
||||||
// Server-computed clinic analytics. Mirrors the backend `src/types/analytics.ts`.
|
// Server-computed clinic analytics. Mirrors the backend `src/types/analytics.ts`.
|
||||||
// All figures are aggregates over the active clinic's real data.
|
// All figures are aggregates over the active clinic's real data.
|
||||||
|
export type TrendPoint = { label: string; count: number };
|
||||||
|
|
||||||
export type Analytics = {
|
export type Analytics = {
|
||||||
patients: {
|
patients: {
|
||||||
total: number;
|
total: number;
|
||||||
@@ -22,6 +24,10 @@ export type Analytics = {
|
|||||||
open: number;
|
open: number;
|
||||||
done: number;
|
done: number;
|
||||||
};
|
};
|
||||||
|
trends: {
|
||||||
|
patientsByMonth: TrendPoint[];
|
||||||
|
appointmentsByWeekday: TrendPoint[];
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getAnalytics(): Promise<Analytics> {
|
export function getAnalytics(): Promise<Analytics> {
|
||||||
|
|||||||
@@ -402,6 +402,13 @@
|
|||||||
"description": "Care-team workload",
|
"description": "Care-team workload",
|
||||||
"open": "Open",
|
"open": "Open",
|
||||||
"completed": "Completed"
|
"completed": "Completed"
|
||||||
|
},
|
||||||
|
"charts": {
|
||||||
|
"patientGrowthTitle": "Patient growth",
|
||||||
|
"patientGrowthDescription": "New patients per month over the last 6 months",
|
||||||
|
"weeklyAppointmentsTitle": "Appointments this week",
|
||||||
|
"weeklyAppointmentsDescription": "Bookings per day for the current week",
|
||||||
|
"empty": "No data yet"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"activity": {
|
"activity": {
|
||||||
|
|||||||
Reference in New Issue
Block a user