wulf-pulse/components/mobile/EngagementHoursSparkline.tsx

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>
);
}