mirror of
https://github.com/temetro/temetro.git
synced 2026-08-06 00:47:41 +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;
|
||||
}
|
||||
|
||||
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> {
|
||||
const now = new Date();
|
||||
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 {
|
||||
patients: {
|
||||
total: patientsTotal,
|
||||
@@ -117,5 +192,6 @@ export async function getAnalytics(orgId: string): Promise<Analytics> {
|
||||
},
|
||||
prescriptions: { total: rxTotal, active: rxActive },
|
||||
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
|
||||
// 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[];
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user