feat(07-02): add 7 engagement components (chips, summary card, sparkline, sort, search, user row + skeleton)

This commit is contained in:
lorentz 2026-05-03 22:53:33 -04:00
parent ccd2177977
commit d63789224e
7 changed files with 379 additions and 0 deletions

View file

@ -0,0 +1,105 @@
'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>
);
}

View file

@ -0,0 +1,45 @@
'use client';
/* EngagementPeriodChips phase 07 (ENG-02).
* Purpose: 3-chip period selector (7d/30d/90d) sticky below the page H1.
* Maps 1:1 to data-layer period_type values D7/D30/D90 (D-04).
* Props: period, onPeriodChange. Pure presentational page owns refetch logic. */
export type EngagementPeriod = 'D7' | 'D30' | 'D90';
export interface EngagementPeriodChipsProps {
period: EngagementPeriod;
onPeriodChange: (next: EngagementPeriod) => void;
}
const CHIPS: ReadonlyArray<{ value: EngagementPeriod; label: string }> = [
{ value: 'D7', label: '7d' },
{ value: 'D30', label: '30d' },
{ value: 'D90', label: '90d' },
];
export function EngagementPeriodChips({ period, onPeriodChange }: EngagementPeriodChipsProps) {
return (
<div className="sticky top-0 z-10 bg-background pt-2 pb-3 -mx-4 px-4 flex gap-2 min-h-[44px]">
{CHIPS.map(chip => {
const isActive = chip.value === period;
return (
<button
key={chip.value}
type="button"
role="button"
aria-pressed={isActive}
onClick={() => { if (!isActive) onPeriodChange(chip.value); }}
className={
isActive
? 'bg-primary text-primary-foreground rounded-full px-3 py-1.5 text-[10px] font-semibold'
: 'bg-muted text-foreground hover:bg-muted/80 rounded-full px-3 py-1.5 text-[10px] font-semibold'
}
>
{chip.label}
</button>
);
})}
</div>
);
}

View file

@ -0,0 +1,49 @@
'use client';
/* EngagementSearchInput phase 07 (ENG-04).
* Purpose: Search input with leading Search icon. Debounced 300ms before emitting onChange.
* Page applies the filter client-side on loaded users (D-21).
* Props: value (controlled string), onChange (debounced callback). */
import { useEffect, useState } from 'react';
import { Search } from 'lucide-react';
import { Input } from '@/components/ui/input';
export interface EngagementSearchInputProps {
value: string;
onChange: (next: string) => void;
}
export function EngagementSearchInput({ value, onChange }: EngagementSearchInputProps) {
// Local immediate state for the input; debounce flushes to onChange
const [local, setLocal] = useState<string>(value);
// Keep local state synced when the parent resets (e.g. "Clear search" CTA on no-matches state)
useEffect(() => {
setLocal(value);
}, [value]);
// 300ms debounce per D-21
useEffect(() => {
if (local === value) return;
const id = setTimeout(() => { onChange(local); }, 300);
return () => clearTimeout(id);
}, [local, value, onChange]);
return (
<div className="relative">
<Search
className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none"
aria-hidden="true"
/>
<Input
type="search"
placeholder="Search by name or email"
aria-label="Search team members by name or email"
className="pl-9"
value={local}
onChange={(e) => setLocal(e.target.value)}
/>
</div>
);
}

View file

@ -0,0 +1,45 @@
'use client';
/* EngagementSortChips phase 07 (ENG-04).
* Purpose: 3-chip sort selector (Hours/Name/Utilization). Same chip styling as period chips.
* Maps to /api/engagement/users sort/order params per D-20.
* Props: value, onChange. Pure presentational. */
export type EngagementSortKey = 'hours' | 'name' | 'utilization';
export interface EngagementSortChipsProps {
value: EngagementSortKey;
onChange: (next: EngagementSortKey) => void;
}
const SORTS: ReadonlyArray<{ key: EngagementSortKey; label: string }> = [
{ key: 'hours', label: 'Hours' },
{ key: 'name', label: 'Name' },
{ key: 'utilization', label: 'Utilization' },
];
export function EngagementSortChips({ value, onChange }: EngagementSortChipsProps) {
return (
<div className="flex gap-2 items-center min-h-[44px]">
{SORTS.map(({ key, label }) => {
const isActive = key === value;
return (
<button
key={key}
type="button"
role="button"
aria-pressed={isActive}
onClick={() => { if (!isActive) onChange(key); }}
className={
isActive
? 'bg-primary text-primary-foreground rounded-full px-3 py-1.5 text-[10px] font-semibold'
: 'bg-muted text-foreground hover:bg-muted/80 rounded-full px-3 py-1.5 text-[10px] font-semibold'
}
>
{label}
</button>
);
})}
</div>
);
}

View file

@ -0,0 +1,25 @@
'use client';
/* EngagementSummaryCard phase 07 (ENG-03).
* Purpose: Single summary card with big number + label, stacked single-column.
* Used 4× on the page: Active users, Total Graph hours, Total Autotask hours, Hours / active user (D-08).
* Card has no shadow, only border (matches FinanceRow density per D-10).
* Props: value (display string), label (display string). */
import { Card, CardContent } from '@/components/ui/card';
export interface EngagementSummaryCardProps {
value: string; // pre-formatted: "42", "128.5h", "—"
label: string; // "Active users", "Total Graph hours", etc.
}
export function EngagementSummaryCard({ value, label }: EngagementSummaryCardProps) {
return (
<Card className="py-0 shadow-none">
<CardContent className="px-4 py-4">
<p className="text-2xl font-semibold text-foreground leading-none">{value}</p>
<p className="text-xs text-muted-foreground mt-2">{label}</p>
</CardContent>
</Card>
);
}

View file

@ -0,0 +1,87 @@
'use client';
/* EngagementUserRow phase 07 (ENG-04).
* Purpose: Per-employee row card avatar (initials) + name + role + hours + hours bar.
* Entire row is a Link to /mobile/engagement/[graphUserId] (D-19; Phase 8 owns destination).
* Hours bar width = (billableHours / maxHours) * 100% bounded at 100%.
* Props: user (EngagementUserRowData shape), maxHours (largest billableHours in current page set; computed by parent). */
import Link from 'next/link';
export interface EngagementUserRowData {
graphUserId: string;
displayName: string;
userEmail: string;
jobTitle: string | null;
billableHours: number;
hoursWorked: number;
}
export interface EngagementUserRowProps {
user: EngagementUserRowData;
maxHours: number; // largest billableHours in the loaded set (parent computes)
}
/**
* getInitials first letter of first word + first letter of last word of displayName,
* uppercased. E.g. "Jordan Walsh" "JW", "Alex" "A". Exported for Phase 8 reuse
* (the user profile header may share the avatar identity block per UI-SPEC).
*/
export function getInitials(displayName: string): string {
const parts = displayName.trim().split(/\s+/).filter(Boolean);
if (parts.length === 0) return '??';
if (parts.length === 1) return (parts[0]![0] ?? '?').toUpperCase();
const first = parts[0]![0] ?? '';
const last = parts[parts.length - 1]![0] ?? '';
return (first + last).toUpperCase();
}
export function EngagementUserRow({ user, maxHours }: EngagementUserRowProps) {
const initials = getInitials(user.displayName);
const hoursLabel = `${user.billableHours.toFixed(1)}h`;
const barWidthPct = maxHours > 0
? Math.min(100, (user.billableHours / maxHours) * 100)
: 0;
return (
<Link
href={`/mobile/engagement/${user.graphUserId}`}
className="block px-4 py-3 hover:bg-muted/50 transition-colors active:bg-muted/50"
>
{/* Top line: avatar + identity + hours value */}
<div className="flex items-center gap-3">
<span
aria-hidden="true"
className="h-8 w-8 rounded-full bg-muted flex items-center justify-center shrink-0 text-[10px] font-semibold text-foreground"
>
{initials}
</span>
<div className="flex-1 min-w-0 flex items-baseline gap-2">
<span className="text-sm font-semibold truncate flex-1">
{user.displayName}
</span>
<span className="text-sm font-semibold shrink-0 text-right">
{hoursLabel}
</span>
</div>
</div>
{/* Role line — render only if jobTitle present (UI-SPEC: "render nothing (no empty line) if absent") */}
{user.jobTitle && (
<p className="text-xs text-muted-foreground truncate pl-11 mt-0.5">
{user.jobTitle}
</p>
)}
{/* Hours bar */}
<div className="mt-2">
<div className="h-1.5 rounded-full bg-muted overflow-hidden" role="presentation" aria-hidden="true">
<div
className="h-full rounded-full bg-primary transition-all duration-300"
style={{ width: `${barWidthPct}%` }}
/>
</div>
</div>
</Link>
);
}

View file

@ -0,0 +1,23 @@
'use client';
/* EngagementUserRowSkeleton phase 07 (D-23).
* Purpose: Skeleton placeholder matching EngagementUserRow shape. Renders 5 instances on initial load.
* Props: none purely presentational. */
import { Skeleton } from '@/components/ui/skeleton';
export function EngagementUserRowSkeleton() {
return (
<div className="px-4 py-3 space-y-2">
<div className="flex items-center gap-3">
<Skeleton className="h-8 w-8 rounded-full" />
<div className="flex-1 flex items-center justify-between gap-2">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-4 w-12" />
</div>
</div>
<Skeleton className="h-3 w-24 ml-11" />
<Skeleton className="h-1.5 w-full mt-2" />
</div>
);
}