From 3247c92486484174e7c03c8d2b1268b5e67b5919 Mon Sep 17 00:00:00 2001 From: lorentz Date: Thu, 7 May 2026 20:46:28 -0400 Subject: [PATCH] feat(08-02): page shell + skeleton + period/fetch wiring (Task 1a) - New app/mobile/engagement/[userId]/page.tsx with fetch + error states + retryNonce - New EngagementProfileSkeleton with header/metric/breakdown/list skeletons - 404 renders 'User not found' + back link; 500 renders sonner toast + Retry - D-04 comment: relies on App Router default scrollRestoration - D-01/D-22 guard rails: EngagementUserRow.tsx and data endpoint untouched --- app/mobile/engagement/[userId]/page.tsx | 198 ++++++++++++++++++ .../mobile/EngagementProfileSkeleton.tsx | 77 +++++++ 2 files changed, 275 insertions(+) create mode 100644 app/mobile/engagement/[userId]/page.tsx create mode 100644 components/mobile/EngagementProfileSkeleton.tsx diff --git a/app/mobile/engagement/[userId]/page.tsx b/app/mobile/engagement/[userId]/page.tsx new file mode 100644 index 0000000..c8b0626 --- /dev/null +++ b/app/mobile/engagement/[userId]/page.tsx @@ -0,0 +1,198 @@ +'use client'; + +import { use, useEffect, useState } from 'react'; +import Link from 'next/link'; +import { toast } from 'sonner'; +import { EngagementPeriodChips, type EngagementPeriod } from '@/components/mobile/EngagementPeriodChips'; +import { EngagementProfileSkeleton } from '@/components/mobile/EngagementProfileSkeleton'; + +interface ApiResponse { + user: { + id: string; + displayName: string; + email: string; + jobTitle: string | null; + department: string | null; + accountEnabled: boolean | null; + autotaskResourceId: number | null; + }; + afterHours: { messages: number; meetings: number; messagesPct: number; meetingsPct: number }; + snapshots: Array<{ + period_type: string; + period_end: string; // verified present — migration 041 line 18 + teams_chat_messages: number | null; + teams_private_messages: number | null; + emails_sent: number | null; + teams_meetings_attended: number | null; + teams_meetings_organized: number | null; + meeting_duration_seconds: number | null; + after_hours_messages: number | null; + }>; + hours: { + d7: { total: number; billable: number }; + d30: { total: number; billable: number }; + d90: { total: number; billable: number }; + } | null; + recentEntries: Array<{ + entry_date: string; + hours_worked: number; + billable: boolean | null; + notes: string | null; + title: string | null; + start_date_time: string | null; + end_date_time: string | null; + company_name: string | null; + }>; + recentTeamsMeetings: Array<{ + subject: string | null; + startTime: string; + durationMinutes: number | null; + attendeeCount: number; + clientAttendeeCount: number; + hasClientAttendees: boolean; + clientCompanies: string[]; + participantNames: string[]; + matchedEntries: Array<{ + hours_worked: number; + billable: boolean | null; + notes: string | null; + title: string | null; + company_name: string | null; + start_date_time: string | null; + end_date_time: string | null; + }>; + }>; + zoom: { calls: { d7: { total: number }; d30: { total: number }; d90: { total: number } } } | null; +} + +export default function MobileEngagementUserProfilePage({ + params, +}: { + params: Promise<{ userId: string }>; +}) { + // D-04: rely on App Router default scrollRestoration + const { userId } = use(params); // Next.js 16: params is a Promise — unwrap with React.use + + const [period, setPeriod] = useState('D30'); // D-09 + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [errorState, setErrorState] = useState<'none' | 'not-found' | 'failed'>('none'); + // retryNonce: incrementing this re-runs the fetch effect without changing `period`. + // Used by the Retry button in the failed-state branch (issue-7 fix). + const [retryNonce, setRetryNonce] = useState(0); + + useEffect(() => { + let cancelled = false; + setLoading(true); + setErrorState('none'); + fetch(`/api/engagement/user/${encodeURIComponent(userId)}?period=${period}`) + .then(async (res) => { + if (cancelled) return; + if (res.status === 404) { + setErrorState('not-found'); + setData(null); + return; + } + if (!res.ok) { + throw new Error(`HTTP ${res.status}`); + } + const json = (await res.json()) as ApiResponse; + setData(json); + }) + .catch((err) => { + if (cancelled) return; + console.error('[mobile/engagement/profile] fetch failed', err); + setErrorState('failed'); + toast.error('Failed to load profile — tap to retry'); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { cancelled = true; }; + }, [userId, period, retryNonce]); + + // ── Error: 404 ────────────────────────────────────────────────────── + if (errorState === 'not-found') { + return ( +
+
+

User not found

+

This profile is no longer available.

+ + Back to Engagement + +
+
+ ); + } + + // ── Error: 500/network — show skeleton + Retry ────────────────────── + if (errorState === 'failed' && !data) { + return ( +
+ + + +
+ ); + } + + // Helper to derive period-scoped values once data is loaded. + const periodKey = period === 'D7' ? 'd7' : period === 'D90' ? 'd90' : 'd30'; + const hoursForPeriod = data?.hours?.[periodKey]?.total ?? 0; + const billableForPeriod = data?.hours?.[periodKey]?.billable ?? 0; + const daysWorkedForPeriod = data + ? new Set(data.recentEntries.map((e) => String(e.entry_date).slice(0, 10))).size + : 0; + const snapshotForPeriod = data?.snapshots.find((s) => s.period_type === period) ?? null; + const meetingsAttended = snapshotForPeriod?.teams_meetings_attended ?? 0; + + // Last-active derivation for the header (D-06): + // most recent of (recentEntries[0].entry_date, latest snapshot.period_end). + // If `period_end` ever turns out to be missing from the API at runtime, fall + // back to recentEntries[0].entry_date alone (executor can simplify this block + // — see above). + let lastActiveAt: string | null = null; + if (data) { + const candidates: number[] = []; + if (data.recentEntries[0]) candidates.push(new Date(data.recentEntries[0].entry_date).getTime()); + const latestSnapshot = data.snapshots + .filter((s) => s.period_end) + .map((s) => new Date(s.period_end).getTime()) + .filter((n) => Number.isFinite(n)) + .sort((a, b) => b - a)[0]; + if (latestSnapshot) candidates.push(latestSnapshot); + if (candidates.length > 0) { + lastActiveAt = new Date(Math.max(...candidates)).toISOString(); + } + } + + return ( +
+

+ {data?.user.displayName ?? ' '} +

+ + +
+ {loading || !data ? ( + + ) : ( + <> + {/* Task 1b will mount EngagementProfileHeader + EngagementProfileMetricGrid here. + Task 2 will add EngagementProfileBreakdown + EngagementRecentEntries + EngagementRecentMeetings. */} +
+ Loaded: {data.user.displayName}. Header and metric grid wired in Task 1b. +
+ + )} +
+
+ ); +} diff --git a/components/mobile/EngagementProfileSkeleton.tsx b/components/mobile/EngagementProfileSkeleton.tsx new file mode 100644 index 0000000..196b04e --- /dev/null +++ b/components/mobile/EngagementProfileSkeleton.tsx @@ -0,0 +1,77 @@ +'use client'; + +/* EngagementProfileSkeleton — phase 08 (D-23). + * Purpose: Full-page loading skeleton matching the final layout — + * header skeleton + 4 metric-card skeletons (2×2) + breakdown card skeleton + + * 2 list-section skeletons. Period chips render OUTSIDE this skeleton (they + * drive the fetch). */ + +import { Card, CardContent } from '@/components/ui/card'; +import { Skeleton } from '@/components/ui/skeleton'; + +export function EngagementProfileSkeleton() { + return ( +
+ {/* Identity header skeleton */} + + + +
+ + + +
+
+
+ + {/* 2×2 metric grid skeleton */} +
+ {[0, 1, 2, 3].map((i) => ( + + + + + + + ))} +
+ + {/* Breakdown card skeleton */} + + +
+ + + + +
+
+ + + +
+
+ + + +
+
+
+ + {/* Two list skeletons */} + {[0, 1].map((i) => ( + + + + {[0, 1, 2].map((j) => ( +
+ + +
+ ))} +
+
+ ))} +
+ ); +}