mirror of
https://github.com/temetro/temetro.git
synced 2026-08-12 03:26:53 +00:00
3aa699aefe
real Live card, persistent chat history Analytics & earnings: - Analytics now carries real money computed from invoices (billed/paid/ outstanding + by-month); new Earnings section on the Analysis page drawn with the project's Bklit chart components (shared EarningsChart). AI agent reaches the whole clinic: - new read tools getClinicInfo / getAnalytics / listInventory render clinic, analytics (with a Bklit earnings chart) and inventory cards in chat - proposeInvoice turns an uploaded purchase/medication list into an invoice (new "invoice" action-preview kind → createInvoice); invoices/appointments auto-create/link a patient (ensurePatient) so they hit the Patients page Live card: - plots real data — patients checked in today — via GET /api/analytics/live (polled); value pill clamped and margins widened so nothing spills the card Persistent AI chat history (Claude-style): - ai_chat_threads + ai_chat_messages (migration 0016); per-user, org-scoped thread CRUD under /api/chat/threads - chat panel owns a thread id, loads /?thread=<id>, and auto-saves after each exchange; sidebar lists past chats (open/delete), "New chat" starts fresh Verified with backend typecheck + frontend tsc + next build. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
331 lines
9.2 KiB
TypeScript
331 lines
9.2 KiB
TypeScript
"use client";
|
|
|
|
import { curveMonotoneX } from "@visx/curve";
|
|
|
|
// biome-ignore lint/suspicious/noExplicitAny: d3 curve factory type
|
|
type CurveFactory = any;
|
|
|
|
import { AreaClosed, LinePath } from "@visx/shape";
|
|
import { motion } from "motion/react";
|
|
import { useCallback, useId, useMemo } from "react";
|
|
import { chartCssVars, useChart } from "./chart-context";
|
|
|
|
export type Momentum = "up" | "down" | "flat";
|
|
|
|
export interface MomentumColors {
|
|
up: string;
|
|
down: string;
|
|
flat: string;
|
|
}
|
|
|
|
export function detectMomentum(
|
|
data: Record<string, unknown>[],
|
|
dataKey: string,
|
|
lookback = 20
|
|
): Momentum {
|
|
if (data.length < 5) {
|
|
return "flat";
|
|
}
|
|
const start = Math.max(0, data.length - lookback);
|
|
let min = Number.POSITIVE_INFINITY;
|
|
let max = Number.NEGATIVE_INFINITY;
|
|
for (let i = start; i < data.length; i++) {
|
|
const v = data[i]?.[dataKey];
|
|
if (typeof v === "number") {
|
|
if (v < min) {
|
|
min = v;
|
|
}
|
|
if (v > max) {
|
|
max = v;
|
|
}
|
|
}
|
|
}
|
|
const range = max - min;
|
|
if (range === 0) {
|
|
return "flat";
|
|
}
|
|
const tailStart = Math.max(start, data.length - 5);
|
|
const first = (data[tailStart]?.[dataKey] as number) ?? 0;
|
|
const last = (data.at(-1)?.[dataKey] as number) ?? 0;
|
|
const delta = last - first;
|
|
const threshold = range * 0.12;
|
|
if (delta > threshold) {
|
|
return "up";
|
|
}
|
|
if (delta < -threshold) {
|
|
return "down";
|
|
}
|
|
return "flat";
|
|
}
|
|
|
|
export interface LiveLineProps {
|
|
/** Key in data to use for y values */
|
|
dataKey: string;
|
|
/** Stroke color. Default: var(--chart-line-primary) */
|
|
stroke?: string;
|
|
/** Stroke width. Default: 2 */
|
|
strokeWidth?: number;
|
|
/** Curve function. Default: curveMonotoneX */
|
|
curve?: CurveFactory;
|
|
/** Show gradient fill under the curve. Default: true */
|
|
fill?: boolean;
|
|
/** Show pulsing live dot at the right edge. Default: true */
|
|
pulse?: boolean;
|
|
/** Radius of the live dot. Default: 4 */
|
|
dotSize?: number;
|
|
/** Show value badge pill at the live tip. Default: true */
|
|
badge?: boolean;
|
|
/** Value label formatter for the badge */
|
|
formatValue?: (v: number) => string;
|
|
/**
|
|
* When set, the line/fill color changes based on momentum direction.
|
|
* Overrides `stroke` for the line and fill (dot always uses momentum colors).
|
|
*/
|
|
momentumColors?: MomentumColors;
|
|
}
|
|
|
|
LiveLine.displayName = "LiveLine";
|
|
|
|
export function LiveLine({
|
|
dataKey,
|
|
stroke = chartCssVars.linePrimary,
|
|
strokeWidth = 2,
|
|
curve = curveMonotoneX,
|
|
fill = true,
|
|
pulse = true,
|
|
dotSize = 4,
|
|
badge = true,
|
|
formatValue = (v: number) => v.toFixed(2),
|
|
momentumColors,
|
|
}: LiveLineProps) {
|
|
const {
|
|
data,
|
|
xScale,
|
|
yScale,
|
|
innerWidth,
|
|
innerHeight,
|
|
xAccessor,
|
|
lines,
|
|
tooltipData,
|
|
} = useChart();
|
|
|
|
const isScrubbing = tooltipData !== null;
|
|
|
|
const uid = useId();
|
|
const gradientId = `live-line-grad-${uid}`;
|
|
const areaGradientId = `live-area-grad-${uid}`;
|
|
const fadeId = `live-fade-${uid}`;
|
|
const fadeMaskId = `live-fade-mask-${uid}`;
|
|
|
|
const getX = useCallback(
|
|
(d: Record<string, unknown>) => xScale(xAccessor(d)) ?? 0,
|
|
[xScale, xAccessor]
|
|
);
|
|
|
|
const getY = useCallback(
|
|
(d: Record<string, unknown>) => {
|
|
const v = d[dataKey];
|
|
return typeof v === "number" ? (yScale(v) ?? 0) : 0;
|
|
},
|
|
[dataKey, yScale]
|
|
);
|
|
|
|
// The second-to-last point is the "now" position (live tip).
|
|
// The last point is the queued future point for the fade-out zone.
|
|
const nowPoint = data.length >= 2 ? data.at(-2) : data.at(-1);
|
|
const liveValue =
|
|
nowPoint && typeof nowPoint[dataKey] === "number"
|
|
? (nowPoint[dataKey] as number)
|
|
: 0;
|
|
|
|
const liveDotX = nowPoint ? (xScale(xAccessor(nowPoint)) ?? 0) : innerWidth;
|
|
const liveDotY = yScale(liveValue) ?? 0;
|
|
|
|
const momentum = useMemo(
|
|
() => detectMomentum(data, dataKey),
|
|
[data, dataKey]
|
|
);
|
|
|
|
const defaultMomentumColors: MomentumColors = {
|
|
up: "var(--chart-1)",
|
|
down: "var(--chart-5)",
|
|
flat: stroke,
|
|
};
|
|
const dotMomentumColors = momentumColors ?? defaultMomentumColors;
|
|
const dotColor = dotMomentumColors[momentum];
|
|
|
|
// Find the line config for this dataKey to get the resolved stroke
|
|
const lineConfig = lines.find((l) => l.dataKey === dataKey);
|
|
const baseStroke = lineConfig?.stroke ?? stroke;
|
|
const resolvedStroke = momentumColors ? momentumColors[momentum] : baseStroke;
|
|
|
|
return (
|
|
<>
|
|
<defs>
|
|
<linearGradient id={gradientId} x1="0" x2="0" y1="0" y2="1">
|
|
<stop offset="0%" stopColor={resolvedStroke} stopOpacity={1} />
|
|
<stop offset="100%" stopColor={resolvedStroke} stopOpacity={0.6} />
|
|
</linearGradient>
|
|
<linearGradient id={areaGradientId} x1="0" x2="0" y1="0" y2="1">
|
|
<stop offset="0%" stopColor={resolvedStroke} stopOpacity={0.1} />
|
|
<stop offset="100%" stopColor={resolvedStroke} stopOpacity={0} />
|
|
</linearGradient>
|
|
<linearGradient id={fadeId} x1="0" x2="1" y1="0" y2="0">
|
|
<stop offset="0%" stopColor="white" stopOpacity={0} />
|
|
<stop offset="4%" stopColor="white" stopOpacity={1} />
|
|
{liveDotX < innerWidth - 1 ? (
|
|
<>
|
|
<stop
|
|
offset={`${(liveDotX / innerWidth) * 100}%`}
|
|
stopColor="white"
|
|
stopOpacity={1}
|
|
/>
|
|
<stop offset="100%" stopColor="white" stopOpacity={0} />
|
|
</>
|
|
) : (
|
|
<stop offset="100%" stopColor="white" stopOpacity={1} />
|
|
)}
|
|
</linearGradient>
|
|
<mask id={fadeMaskId}>
|
|
<rect
|
|
fill={`url(#${fadeId})`}
|
|
height={innerHeight + 40}
|
|
width={innerWidth}
|
|
x={0}
|
|
y={-20}
|
|
/>
|
|
</mask>
|
|
</defs>
|
|
|
|
{/* Area fill */}
|
|
{fill && data.length > 1 && (
|
|
<g mask={`url(#${fadeMaskId})`}>
|
|
<AreaClosed
|
|
curve={curve}
|
|
data={data}
|
|
fill={`url(#${areaGradientId})`}
|
|
strokeWidth={0}
|
|
x={getX}
|
|
y={getY}
|
|
yScale={yScale}
|
|
/>
|
|
</g>
|
|
)}
|
|
|
|
{/* Line */}
|
|
{data.length > 1 && (
|
|
<g mask={`url(#${fadeMaskId})`}>
|
|
<LinePath
|
|
curve={curve}
|
|
data={data}
|
|
stroke={`url(#${gradientId})`}
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={strokeWidth}
|
|
x={getX}
|
|
y={getY}
|
|
/>
|
|
</g>
|
|
)}
|
|
|
|
{/* Dashed horizontal line at current value */}
|
|
<line
|
|
opacity={0.25}
|
|
stroke={resolvedStroke}
|
|
strokeDasharray="4,4"
|
|
strokeWidth={1}
|
|
x1={0}
|
|
x2={innerWidth}
|
|
y1={liveDotY}
|
|
y2={liveDotY}
|
|
/>
|
|
|
|
{/* Live indicator (dot + badge) — dims when crosshair is active */}
|
|
<motion.g
|
|
animate={{ opacity: isScrubbing ? 0.25 : 1 }}
|
|
transition={{ duration: 0.3, ease: "easeInOut" }}
|
|
>
|
|
{/* Pulsing dot */}
|
|
<g>
|
|
{pulse && (
|
|
<circle
|
|
cx={liveDotX}
|
|
cy={liveDotY}
|
|
fill="none"
|
|
opacity={0.4}
|
|
r={dotSize * 2}
|
|
stroke={dotColor}
|
|
strokeWidth={1.5}
|
|
>
|
|
<animate
|
|
attributeName="r"
|
|
dur="1.5s"
|
|
from={String(dotSize)}
|
|
repeatCount="indefinite"
|
|
to={String(dotSize * 3.5)}
|
|
/>
|
|
<animate
|
|
attributeName="opacity"
|
|
dur="1.5s"
|
|
from="0.5"
|
|
repeatCount="indefinite"
|
|
to="0"
|
|
/>
|
|
</circle>
|
|
)}
|
|
<circle
|
|
cx={liveDotX}
|
|
cy={liveDotY}
|
|
fill={dotColor}
|
|
opacity={0.1}
|
|
r={dotSize + 2}
|
|
/>
|
|
<circle
|
|
cx={liveDotX}
|
|
cy={liveDotY}
|
|
fill={dotColor}
|
|
r={dotSize}
|
|
stroke={chartCssVars.background}
|
|
strokeWidth={2}
|
|
/>
|
|
</g>
|
|
|
|
{/* Badge — use popover vars so text is never white-on-white. Flip it to
|
|
the left of the dot when it would overflow the right edge, so the
|
|
value never spills outside the card. */}
|
|
{badge &&
|
|
(() => {
|
|
const badgeWidth = formatValue(liveValue).length * 7.5 + 16;
|
|
const flip = liveDotX + 12 + badgeWidth > innerWidth;
|
|
const badgeX = flip ? liveDotX - 12 - badgeWidth : liveDotX + 12;
|
|
return (
|
|
<g transform={`translate(${badgeX},${liveDotY})`}>
|
|
<rect
|
|
fill="var(--popover)"
|
|
height={24}
|
|
opacity={0.95}
|
|
rx={6}
|
|
width={badgeWidth}
|
|
x={0}
|
|
y={-12}
|
|
/>
|
|
<text
|
|
fill="var(--popover-foreground)"
|
|
fontFamily="SF Mono, Menlo, Monaco, monospace"
|
|
fontSize={11}
|
|
fontWeight={500}
|
|
x={8}
|
|
y={4}
|
|
>
|
|
{formatValue(liveValue)}
|
|
</text>
|
|
</g>
|
|
);
|
|
})()}
|
|
</motion.g>
|
|
</>
|
|
);
|
|
}
|
|
|
|
export default LiveLine;
|