105 lines
3.8 KiB
TypeScript
105 lines
3.8 KiB
TypeScript
'use client';
|
|
|
|
/* EngagementHoursSparkline — phase 07 (ENG-05).
|
|
* Purpose: Custom inline SVG sparkline for daily hours trend over the selected period.
|
|
* One series, no axes, no tooltips, no animation. DASH-04 (no recharts on mobile).
|
|
* Renders the sparkline card per UI-SPEC: label row (left + right) + 48px-tall SVG.
|
|
* Props: points (Plan 01 SparklinePoint[]), period (D7|D30|D90 — drives the period_label). */
|
|
|
|
import { Card, CardContent } from '@/components/ui/card';
|
|
import type { SparklinePoint } from '@/app/api/mobile/engagement/trend/route';
|
|
|
|
export interface EngagementHoursSparklineProps {
|
|
points: SparklinePoint[];
|
|
period: 'D7' | 'D30' | 'D90';
|
|
}
|
|
|
|
const PERIOD_LABEL: Record<EngagementHoursSparklineProps['period'], string> = {
|
|
D7: '7 days',
|
|
D30: '30 days',
|
|
D90: '90 days',
|
|
};
|
|
|
|
function formatLatestValue(points: SparklinePoint[]): string {
|
|
// Find last point with hours > 0
|
|
let latest: SparklinePoint | null = null;
|
|
for (let i = points.length - 1; i >= 0; i--) {
|
|
if (points[i]!.hours > 0) { latest = points[i]!; break; }
|
|
}
|
|
if (!latest) return '—';
|
|
|
|
const todayIso = new Date().toISOString().slice(0, 10); // "YYYY-MM-DD" UTC
|
|
const isToday = latest.date === todayIso;
|
|
const hoursLabel = `${latest.hours.toFixed(1)}h`;
|
|
if (isToday) return `${hoursLabel} today`;
|
|
|
|
// shortDate: "May 2"
|
|
const [y, m, d] = latest.date.split('-').map(Number);
|
|
const dt = new Date(Date.UTC(y!, m! - 1, d!));
|
|
const short = dt.toLocaleDateString('en-US', { month: 'short', day: 'numeric', timeZone: 'UTC' });
|
|
return `${hoursLabel} ${short}`;
|
|
}
|
|
|
|
function buildPath(points: SparklinePoint[], svgWidth: number, svgHeight: number): string {
|
|
if (points.length === 0) return '';
|
|
const maxHours = Math.max(...points.map(p => p.hours), 0);
|
|
// 4px top margin + 4px bottom margin per UI-SPEC
|
|
const yScale = maxHours === 0 ? 0 : (svgHeight - 8) / maxHours;
|
|
const xStep = points.length === 1 ? 0 : svgWidth / (points.length - 1);
|
|
|
|
return points.map((pt, i) => {
|
|
const x = points.length === 1 ? svgWidth / 2 : i * xStep;
|
|
const y = svgHeight - 4 - (pt.hours * yScale); // baseline 4px above bottom
|
|
return `${i === 0 ? 'M' : 'L'} ${x.toFixed(2)},${y.toFixed(2)}`;
|
|
}).join(' ');
|
|
}
|
|
|
|
export function EngagementHoursSparkline({ points, period }: EngagementHoursSparklineProps) {
|
|
const periodLabel = PERIOD_LABEL[period];
|
|
const allZero = points.length === 0 || points.every(p => p.hours === 0);
|
|
const SVG_W = 300;
|
|
const SVG_H = 48;
|
|
|
|
return (
|
|
<Card className="py-0 shadow-none">
|
|
<CardContent className="px-4 py-3">
|
|
<div className="flex justify-between items-center mb-2 gap-2">
|
|
<p className="text-xs text-muted-foreground">{`Hours trend · last ${periodLabel}`}</p>
|
|
<p className="text-xs text-muted-foreground">{formatLatestValue(points)}</p>
|
|
</div>
|
|
|
|
{allZero ? (
|
|
<p className="text-xs text-muted-foreground text-center py-3">No activity</p>
|
|
) : (
|
|
<svg
|
|
viewBox={`0 0 ${SVG_W} ${SVG_H}`}
|
|
preserveAspectRatio="none"
|
|
className="h-12 w-full"
|
|
role="presentation"
|
|
aria-hidden="true"
|
|
>
|
|
{/* Baseline at y=46 (2px from bottom) per UI-SPEC */}
|
|
<line
|
|
x1="0"
|
|
y1={SVG_H - 2}
|
|
x2={SVG_W}
|
|
y2={SVG_H - 2}
|
|
className="text-muted-foreground/20"
|
|
stroke="currentColor"
|
|
strokeWidth="1"
|
|
fill="none"
|
|
/>
|
|
{/* Series path — stroke-primary stroke-2 fill-none */}
|
|
<path
|
|
d={buildPath(points, SVG_W, SVG_H)}
|
|
className="stroke-primary"
|
|
strokeWidth="2"
|
|
fill="none"
|
|
vectorEffect="non-scaling-stroke"
|
|
/>
|
|
</svg>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|