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
This commit is contained in:
lorentz 2026-05-07 20:46:28 -04:00
parent b1a6e6a3c3
commit 3247c92486
2 changed files with 275 additions and 0 deletions

View file

@ -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<EngagementPeriod>('D30'); // D-09
const [data, setData] = useState<ApiResponse | null>(null);
const [loading, setLoading] = useState<boolean>(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<number>(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 (
<main className="px-4 pb-safe">
<div className="py-12 text-center space-y-3">
<h1 className="text-xl font-semibold">User not found</h1>
<p className="text-sm text-muted-foreground">This profile is no longer available.</p>
<Link href="/mobile/engagement" className="text-sm text-primary underline-offset-4 hover:underline inline-block min-h-[44px] py-3">
Back to Engagement
</Link>
</div>
</main>
);
}
// ── Error: 500/network — show skeleton + Retry ──────────────────────
if (errorState === 'failed' && !data) {
return (
<main className="px-4 pb-safe">
<EngagementPeriodChips period={period} onPeriodChange={setPeriod} />
<EngagementProfileSkeleton />
<button
type="button"
className="mt-4 inline-flex items-center justify-center min-h-[44px] px-4 py-2 rounded-md bg-primary text-primary-foreground text-sm font-semibold"
onClick={() => setRetryNonce((n) => n + 1)}
>
Retry
</button>
</main>
);
}
// 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 <discovery> 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 (
<main className="px-4 pb-safe">
<h1 className="text-xl font-semibold pt-4 pb-2">
{data?.user.displayName ?? ' '}
</h1>
<EngagementPeriodChips period={period} onPeriodChange={setPeriod} />
<div className="space-y-4">
{loading || !data ? (
<EngagementProfileSkeleton />
) : (
<>
{/* Task 1b will mount EngagementProfileHeader + EngagementProfileMetricGrid here.
Task 2 will add EngagementProfileBreakdown + EngagementRecentEntries + EngagementRecentMeetings. */}
<div className="text-sm text-muted-foreground py-2">
Loaded: {data.user.displayName}. Header and metric grid wired in Task 1b.
</div>
</>
)}
</div>
</main>
);
}

View file

@ -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 (
<div className="space-y-4">
{/* Identity header skeleton */}
<Card className="py-0 shadow-none">
<CardContent className="px-4 py-4 flex items-center gap-3">
<Skeleton className="h-14 w-14 rounded-full shrink-0" />
<div className="flex-1 space-y-2 min-w-0">
<Skeleton className="h-5 w-40" />
<Skeleton className="h-3 w-32" />
<Skeleton className="h-3 w-48" />
</div>
</CardContent>
</Card>
{/* 2×2 metric grid skeleton */}
<div className="grid grid-cols-2 gap-3">
{[0, 1, 2, 3].map((i) => (
<Card key={i} className="py-0 shadow-none">
<CardContent className="px-4 py-4">
<Skeleton className="h-7 w-16" />
<Skeleton className="h-3 w-24 mt-2" />
</CardContent>
</Card>
))}
</div>
{/* Breakdown card skeleton */}
<Card className="py-0 shadow-none">
<CardContent className="px-4 py-4 space-y-4">
<div className="space-y-2">
<Skeleton className="h-3 w-24" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-full" />
</div>
<div className="space-y-2 border-t border-border pt-3">
<Skeleton className="h-3 w-32" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-full" />
</div>
<div className="space-y-2 border-t border-border pt-3">
<Skeleton className="h-3 w-28" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-full" />
</div>
</CardContent>
</Card>
{/* Two list skeletons */}
{[0, 1].map((i) => (
<Card key={i} className="py-0 shadow-none">
<CardContent className="px-4 py-4 space-y-3">
<Skeleton className="h-4 w-32" />
{[0, 1, 2].map((j) => (
<div key={j} className="flex items-center justify-between">
<Skeleton className="h-3 w-40" />
<Skeleton className="h-3 w-12" />
</div>
))}
</CardContent>
</Card>
))}
</div>
);
}