From d63789224e82c156be20d8f5777a3e5f76749273 Mon Sep 17 00:00:00 2001 From: lorentz Date: Sun, 3 May 2026 22:53:33 -0400 Subject: [PATCH] feat(07-02): add 7 engagement components (chips, summary card, sparkline, sort, search, user row + skeleton) --- .../mobile/EngagementHoursSparkline.tsx | 105 ++++++++++++++++++ components/mobile/EngagementPeriodChips.tsx | 45 ++++++++ components/mobile/EngagementSearchInput.tsx | 49 ++++++++ components/mobile/EngagementSortChips.tsx | 45 ++++++++ components/mobile/EngagementSummaryCard.tsx | 25 +++++ components/mobile/EngagementUserRow.tsx | 87 +++++++++++++++ .../mobile/EngagementUserRowSkeleton.tsx | 23 ++++ 7 files changed, 379 insertions(+) create mode 100644 components/mobile/EngagementHoursSparkline.tsx create mode 100644 components/mobile/EngagementPeriodChips.tsx create mode 100644 components/mobile/EngagementSearchInput.tsx create mode 100644 components/mobile/EngagementSortChips.tsx create mode 100644 components/mobile/EngagementSummaryCard.tsx create mode 100644 components/mobile/EngagementUserRow.tsx create mode 100644 components/mobile/EngagementUserRowSkeleton.tsx diff --git a/components/mobile/EngagementHoursSparkline.tsx b/components/mobile/EngagementHoursSparkline.tsx new file mode 100644 index 0000000..7e801b5 --- /dev/null +++ b/components/mobile/EngagementHoursSparkline.tsx @@ -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 = { + 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 ( + + +
+

{`Hours trend · last ${periodLabel}`}

+

{formatLatestValue(points)}

+
+ + {allZero ? ( +

No activity

+ ) : ( + + )} +
+
+ ); +} diff --git a/components/mobile/EngagementPeriodChips.tsx b/components/mobile/EngagementPeriodChips.tsx new file mode 100644 index 0000000..e319307 --- /dev/null +++ b/components/mobile/EngagementPeriodChips.tsx @@ -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 ( +
+ {CHIPS.map(chip => { + const isActive = chip.value === period; + return ( + + ); + })} +
+ ); +} diff --git a/components/mobile/EngagementSearchInput.tsx b/components/mobile/EngagementSearchInput.tsx new file mode 100644 index 0000000..ae9ecbd --- /dev/null +++ b/components/mobile/EngagementSearchInput.tsx @@ -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(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 ( +
+
+ ); +} diff --git a/components/mobile/EngagementSortChips.tsx b/components/mobile/EngagementSortChips.tsx new file mode 100644 index 0000000..0f9b6d6 --- /dev/null +++ b/components/mobile/EngagementSortChips.tsx @@ -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 ( +
+ {SORTS.map(({ key, label }) => { + const isActive = key === value; + return ( + + ); + })} +
+ ); +} diff --git a/components/mobile/EngagementSummaryCard.tsx b/components/mobile/EngagementSummaryCard.tsx new file mode 100644 index 0000000..32bd05a --- /dev/null +++ b/components/mobile/EngagementSummaryCard.tsx @@ -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 ( + + +

{value}

+

{label}

+
+
+ ); +} diff --git a/components/mobile/EngagementUserRow.tsx b/components/mobile/EngagementUserRow.tsx new file mode 100644 index 0000000..2b0242d --- /dev/null +++ b/components/mobile/EngagementUserRow.tsx @@ -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 ( + + {/* Top line: avatar + identity + hours value */} +
+ +
+ + {user.displayName} + + + {hoursLabel} + +
+
+ + {/* Role line — render only if jobTitle present (UI-SPEC: "render nothing (no empty line) if absent") */} + {user.jobTitle && ( +

+ {user.jobTitle} +

+ )} + + {/* Hours bar */} +
+ + + ); +} diff --git a/components/mobile/EngagementUserRowSkeleton.tsx b/components/mobile/EngagementUserRowSkeleton.tsx new file mode 100644 index 0000000..31f69b1 --- /dev/null +++ b/components/mobile/EngagementUserRowSkeleton.tsx @@ -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 ( +
+
+ +
+ + +
+
+ + +
+ ); +}