From 8ab0552cf8bf576d03eb0267df109db3018ea7cd Mon Sep 17 00:00:00 2001 From: Khalid Abdi Date: Mon, 8 Jun 2026 19:20:15 +0300 Subject: [PATCH] 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 --- backend/src/services/analytics.ts | 76 +++++++++++++++++++ backend/src/types/analytics.ts | 9 +++ .../components/analysis/analysis-view.tsx | 42 ++++++++++ frontend/components/analysis/bar-chart.tsx | 59 ++++++++++++++ frontend/lib/analytics.ts | 6 ++ frontend/lib/i18n/locales/en/translation.json | 7 ++ 6 files changed, 199 insertions(+) create mode 100644 frontend/components/analysis/bar-chart.tsx diff --git a/backend/src/services/analytics.ts b/backend/src/services/analytics.ts index 6c048c9..3a1c8cc 100644 --- a/backend/src/services/analytics.ts +++ b/backend/src/services/analytics.ts @@ -17,6 +17,22 @@ async function countWhere(table: PgTable, where: SQL): Promise { 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 { const now = new Date(); const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1); @@ -103,6 +119,65 @@ export async function getAnalytics(orgId: string): Promise { ), ]); + // 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 { patients: { total: patientsTotal, @@ -117,5 +192,6 @@ export async function getAnalytics(orgId: string): Promise { }, prescriptions: { total: rxTotal, active: rxActive }, tasks: { open: tasksOpen, done: tasksDone }, + trends: { patientsByMonth, appointmentsByWeekday }, }; } diff --git a/backend/src/types/analytics.ts b/backend/src/types/analytics.ts index e5d499b..10358f0 100644 --- a/backend/src/types/analytics.ts +++ b/backend/src/types/analytics.ts @@ -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 // are aggregates over the active clinic's real data (no fabricated financials). export type Analytics = { @@ -20,4 +23,10 @@ export type Analytics = { open: 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[]; + }; }; diff --git a/frontend/components/analysis/analysis-view.tsx b/frontend/components/analysis/analysis-view.tsx index 107c865..a2d644b 100644 --- a/frontend/components/analysis/analysis-view.tsx +++ b/frontend/components/analysis/analysis-view.tsx @@ -3,6 +3,7 @@ import { type ReactNode, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; +import { BarChart } from "@/components/analysis/bar-chart"; import { Card } from "@/components/ui/card"; 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 ( +
+
+

{title}

+

{description}

+
+ {children} +
+ ); +} + export function AnalysisView() { const { t } = useTranslation(); const [data, setData] = useState(null); @@ -92,6 +114,16 @@ export function AnalysisView() { /> + + + +
+ + + +
d.count)); + const hasData = data.some((d) => d.count > 0); + + if (data.length === 0 || !hasData) { + return ( +
+ {emptyLabel} +
+ ); + } + + return ( +
+ {data.map((d, i) => { + const pct = (d.count / max) * 100; + return ( +
+ + {d.count} + +
+
0 ? 4 : 0)}%` }} + title={`${d.label}: ${d.count}`} + /> +
+ {d.label} +
+ ); + })} +
+ ); +} diff --git a/frontend/lib/analytics.ts b/frontend/lib/analytics.ts index 5b8cec1..acc8f95 100644 --- a/frontend/lib/analytics.ts +++ b/frontend/lib/analytics.ts @@ -2,6 +2,8 @@ import { apiFetch } from "@/lib/api-client"; // Server-computed clinic analytics. Mirrors the backend `src/types/analytics.ts`. // All figures are aggregates over the active clinic's real data. +export type TrendPoint = { label: string; count: number }; + export type Analytics = { patients: { total: number; @@ -22,6 +24,10 @@ export type Analytics = { open: number; done: number; }; + trends: { + patientsByMonth: TrendPoint[]; + appointmentsByWeekday: TrendPoint[]; + }; }; export function getAnalytics(): Promise { diff --git a/frontend/lib/i18n/locales/en/translation.json b/frontend/lib/i18n/locales/en/translation.json index 58ac36f..e4bb44b 100644 --- a/frontend/lib/i18n/locales/en/translation.json +++ b/frontend/lib/i18n/locales/en/translation.json @@ -402,6 +402,13 @@ "description": "Care-team workload", "open": "Open", "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": {