wulf-pulse/.planning/phases/08-engagement-user-profile-new/08-02-PLAN.md

74 KiB
Raw Blame History

phase plan type wave depends_on files_modified autonomous requirements requirements_addressed user_setup must_haves
08-engagement-user-profile-new 02 execute 2
01
app/mobile/engagement/[userId]/page.tsx
components/mobile/EngagementProfileSkeleton.tsx
components/mobile/EngagementProfileHeader.tsx
components/mobile/EngagementProfileMetricGrid.tsx
components/mobile/EngagementProfileBreakdown.tsx
components/mobile/EngagementRecentEntries.tsx
components/mobile/EngagementRecentMeetings.tsx
false
ENG-06
ENG-07
ENG-08
ENG-06
ENG-07
ENG-08
truths artifacts key_links
Tapping a row in /mobile/engagement navigates to /mobile/engagement/{graphUserId} and renders a real page (ENG-06, ENG-07; SC#1, SC#2)
The browser back gesture from the profile returns to the overview at the prior scroll position (ENG-07; SC#2) — verified manually via the Wave-2 checkpoint task
The profile renders single-column in this order: H1 → period chips (sticky) → identity header card → 2×2 metric grid → activity breakdown card → recent time entries section → recent meetings section (ENG-06; SC#3)
Identity header shows the avatar (Graph photo via /api/mobile/engagement/user/[userId]/photo, or initials fallback on img onError), display name, jobTitle (when present), department (when present, omitted otherwise), email as mailto: link, and last-active row when a signal exists (D-05, D-06, D-07)
The 4 hero metric cards (Hours worked / Billable hours / Days worked / Meetings attended) render in a 2-column grid with gap-3 and the values come from the existing /api/engagement/user/[userId]?period={D7|D30|D90} response (D-11, D-12, D-22)
Selecting 7d/30d/90d on the chip strip refetches /api/engagement/user/[userId]?period={D7|D30|D90} and recomputes metrics + breakdown; recent-items lists remain bound to 10 each regardless of period (D-19, interaction-contracts §period-chip-selection)
Activity breakdown renders three labeled subsections (Time / Communication / Meetings) inside one Card with the rows, after-hours line, and presence-row hide rules from D-14..D-17
Recent time entries and Recent meetings sections render up to 10 collapsed rows; tapping a row expands it inline using shadcn Collapsible; collapse state lives in component-local Set<string>; period changes do NOT collapse expanded rows (D-18, D-19, D-20)
404 from the data endpoint renders an inline 'User not found' page with a Back-to-Engagement link (D-24)
500 / network failure renders a sonner toast and an inline Retry button that re-runs the fetch via a `retryNonce` state increment (D-24)
Photo endpoint returning non-200 silently falls back to initials — no toast, no error UI (D-25, D-26)
EngagementUserRow.tsx is NOT modified by this plan (D-01)
/api/engagement/user/[userId]/route.ts is NOT modified by this plan (CONTEXT.md 'no new data', D-22)
path provides contains min_lines
app/mobile/engagement/[userId]/page.tsx Mobile profile page (real Next.js App Router page, not a modal) 'use client' 120
path provides contains
components/mobile/EngagementProfileSkeleton.tsx Full-page skeleton (header + 4 metric cards + breakdown + 2 list skeletons) Skeleton
path provides contains
components/mobile/EngagementProfileHeader.tsx Identity header card (avatar/initials, name, jobTitle, department, email, last active) EngagementProfileHeader
path provides contains
components/mobile/EngagementProfileMetricGrid.tsx 2×2 grid of 4 hero metric cards grid-cols-2
path provides contains
components/mobile/EngagementProfileBreakdown.tsx Single Card with Time / Communication / Meetings subsections EngagementProfileBreakdown
path provides contains
components/mobile/EngagementRecentEntries.tsx Collapsible list (up to 10) of recent time entries Collapsible
path provides contains
components/mobile/EngagementRecentMeetings.tsx Collapsible list (up to 10) of recent Teams meetings Collapsible
from to via pattern
app/mobile/engagement/[userId]/page.tsx /api/engagement/user/[userId] fetch in useEffect on mount + on period change + on retryNonce change fetch(`/api/engagement/user/${userId}?period=
from to via pattern
app/mobile/engagement/[userId]/page.tsx components/mobile/EngagementPeriodChips.tsx import EngagementPeriodChips EngagementPeriodChips
from to via pattern
components/mobile/EngagementProfileHeader.tsx /api/mobile/engagement/user/[userId]/photo <img src={...}/photo} onError={fallbackToInitials} /api/mobile/engagement/user/
from to via pattern
components/mobile/EngagementProfileHeader.tsx components/mobile/EngagementUserRow.tsx#getInitials named import import { getInitials
Build the mobile Engagement user profile at `/mobile/engagement/[userId]` — a real, shareable page (not a modal) — and the six new components it composes: `EngagementProfileSkeleton`, `EngagementProfileHeader`, `EngagementProfileMetricGrid`, `EngagementProfileBreakdown`, `EngagementRecentEntries`, `EngagementRecentMeetings`.

Per ENG-06/07/08 (REQUIREMENTS.md), this page replaces the desktop user-detail modal pattern on mobile so the device back gesture restores scroll on the overview. It reuses the existing /api/engagement/user/[userId] endpoint (no new data, no endpoint modifications) and the photo proxy from Plan 01.

Purpose: Deliver Phase 8's user-facing surface — the page Phase 7's EngagementUserRow.tsx already links to (<Link href="/mobile/engagement/{graphUserId}">).

Output:

  • 1 new page at app/mobile/engagement/[userId]/page.tsx
  • 6 new components under components/mobile/Engagement*
  • No modifications to existing files except the page (which is new) and the new components (which are new). Explicitly do NOT touch EngagementUserRow.tsx (D-01) or app/api/engagement/user/[userId]/route.ts (D-22).

<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_context>

@.planning/phases/08-engagement-user-profile-new/08-CONTEXT.md @.planning/phases/08-engagement-user-profile-new/08-UI-SPEC.md @CLAUDE.md

/api/engagement/user/[userId] response shape (the EXISTING endpoint, NOT modified):

Source: app/api/engagement/user/[userId]/route.ts lines 483576 (verified 2026-05-07)

Snapshot rows in snapshots[] are returned as raw DB rows (line 499:

snapshots: snapshotsResult.rows). The columns come from migration 041 — they

include the snake_case column period_end (verified at planning time:

migrations/041_create_engagement_tables.sql line 18 declares period_end DATE NOT NULL).

type EngagementUserDetailResponse = {
  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;   // 0100, rounded
    meetingsPct: number;   // 0100, rounded
  };
  snapshots: Array<{
    period_type: 'D7' | 'D30' | 'D90' | string;  // raw DB rows, snake_case
    period_end: string;                           // ISO date 'YYYY-MM-DD' — verified present (migration 041)
    teams_chat_messages: number;
    teams_private_messages: number;
    emails_sent: number;
    teams_meetings_attended: number;
    teams_meetings_organized: number;
    after_hours_messages: number;
    [key: string]: unknown;
  }>;
  hours: {
    d7:  { total: number; billable: number };
    d30: { total: number; billable: number };
    d90: { total: number; billable: number };
  } | null;
  recentEntries: Array<{
    entry_date: string;             // ISO date or 'YYYY-MM-DD'
    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;              // ISO
    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;
    }>;
  }>;
  meetingCounts: { total: number; withClients: number };
  dailyActivity: Array<{ date: string; meetings: number; zoomCalls: number; hours: number; meetingMins: number }>;
  zoom: {
    calls:    { d7: ZoomCallBucket; d30: ZoomCallBucket; d90: ZoomCallBucket };
    meetings: { d7: ZoomMeetBucket; d30: ZoomMeetBucket; d90: ZoomMeetBucket };
    topClients: Array<{ companyName: string; callCount: number; meetingCount: number }>;
    recentCalls: unknown[];
    recentMeetings: unknown[];
  } | null;  // null when isZoomConfigured() is false OR tables missing
  peerMax: { ... } | null;  // Phase 8 IGNORES this (D-22)
  trend:   { hours: number; billable: number; meetings: number; calls: number };
};

Period mapping for accessing nested per-period buckets:

- period prop value 'D7' → access .hours.d7, .zoom.calls.d7, .zoom.meetings.d7

- period prop value 'D30' → access .hours.d30, .zoom.calls.d30, .zoom.meetings.d30

- period prop value 'D90' → access .hours.d90, .zoom.calls.d90, .zoom.meetings.d90

Period-scoped fields:

- hours: from response.hours[periodKey] where periodKey = period.toLowerCase()

- meetings attended: snapshot row matching period_type === period (response.snapshots)

- days worked: COUNT distinct entry_date in recentEntries (already filtered server-side

to the period's window because the endpoint passes periodDays to the recentEntries

query)

- after-hours: response.afterHours (already period-scoped server-side)

From components/mobile/EngagementPeriodChips.tsx (existing, unchanged):

export type EngagementPeriod = 'D7' | 'D30' | 'D90';
export interface EngagementPeriodChipsProps {
  period: EngagementPeriod;
  onPeriodChange: (next: EngagementPeriod) => void;
}
export function EngagementPeriodChips(props: EngagementPeriodChipsProps): JSX.Element;

From components/mobile/EngagementUserRow.tsx (existing, unchanged):

export function getInitials(displayName: string): string;  // "Jordan Walsh" → "JW"

From lib/hooks/use-user-timezone.ts (existing, unchanged):

export function useUserTimezone(): string;  // returns IANA tz like 'America/New_York'
export function formatInUserTimezone(
  input: string | number | Date,
  tz: string,
  options?: Intl.DateTimeFormatOptions,
  locale?: string,                     // defaults 'en-US'
): string;

shadcn primitives (existing in components/ui/):

  • Card, CardContent (from '@/components/ui/card')
  • Skeleton (from '@/components/ui/skeleton')
  • Collapsible, CollapsibleContent, CollapsibleTrigger (from '@/components/ui/collapsible')
  • Badge (from '@/components/ui/badge')

@app/api/engagement/user/[userId]/route.ts @components/mobile/EngagementUserRow.tsx @components/mobile/EngagementPeriodChips.tsx @components/mobile/EngagementSummaryCard.tsx @app/mobile/engagement/page.tsx @lib/hooks/use-user-timezone.ts @components/ui/card.tsx @components/ui/skeleton.tsx @components/ui/collapsible.tsx @components/ui/badge.tsx

## Out of scope for this plan (documentation only)

UI-SPEC §Typography revision notes (r1) calls out updating text-[10px] in components/mobile/EngagementUserRow.tsx (the avatar-initials non-standard size) to the standard text-xs token. D-01 forbids modifying that file in this phase. That update is deferred to a future Phase 7 patch or a Phase 11 polish phase. Phase 8 will not touch EngagementUserRow.tsx. No task or acceptance criterion in this plan should attempt to apply that change. The acceptance criteria below explicitly assert via git diff --name-only that the file is untouched (D-01 guard rail).

Task 1a: Page shell + Skeleton + period/fetch wiring (no Header/MetricGrid yet) app/mobile/engagement/[userId]/page.tsx, components/mobile/EngagementProfileSkeleton.tsx - .planning/phases/08-engagement-user-profile-new/08-CONTEXT.md (D-01..D-13, D-22..D-26) - .planning/phases/08-engagement-user-profile-new/08-UI-SPEC.md (Layout Structure, Typography, Color, Component Inventory, Interaction Contracts, Copywriting Contract, Date/Time Formatting) - app/api/engagement/user/[userId]/route.ts (THE response shape — read the JSON object built at lines 483576 to confirm field names: `displayName`, `jobTitle`, `department`, `email`, `hours.d7/d30/d90.{total,billable}`, `afterHours`, `snapshots`. Note: `snapshots` is `snapshotsResult.rows` (line 499) — raw DB rows from `engagement_snapshots`) - migrations/041_create_engagement_tables.sql (CONFIRM `engagement_snapshots.period_end` exists — verified at planning time at line 18: `period_end DATE NOT NULL`. The page relies on this column via the snapshot rows for the last-active fallback.) - components/mobile/EngagementPeriodChips.tsx (the chip component, props, sticky classes) - app/mobile/engagement/page.tsx (Phase 7 overview page — copy the fetch / error / loading state wiring style verbatim) - CLAUDE.md (Frontend section: 'use client' + useState + fetch; no SWR; sonner for toasts) Re-confirm the snapshot field name BEFORE writing the page. Run: `grep -nE 'period_end|snapshot.*end' app/api/engagement/user/[userId]/route.ts` Verified at planning time: - The endpoint does NOT remap snapshot rows; it returns `snapshots: snapshotsResult.rows` (line 499). Therefore the API response includes the raw DB column name `period_end`. - Migration 041 line 18: `period_end DATE NOT NULL`. Confirmed. Therefore: the inline `ApiResponse` type below uses `period_end: string` on snapshot rows. If at execution time the executor finds the field is absent (unlikely — but in case the endpoint changes), they MUST fall back to deriving last-active from `recentEntries[0].entry_date` only and remove the snapshot branch from the `lastActiveAt` computation. The action below documents both code paths so the executor can choose. Create TWO files in this task.

File 1: components/mobile/EngagementProfileSkeleton.tsx

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

File 2: app/mobile/engagement/[userId]/page.tsx

The page itself — 'use client', useState + useEffect + fetch, no SWR. In this task the page imports ONLY EngagementProfileSkeleton and EngagementPeriodChipsEngagementProfileHeader and EngagementProfileMetricGrid are added in Task 1b. While they are missing, the loaded-data branch renders a TODO placeholder so the page still type-checks and builds.

Required imports:

'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';

(Task 1b will add: EngagementProfileHeader, EngagementProfileMetricGrid. Task 2 will add: EngagementProfileBreakdown, EngagementRecentEntries, EngagementRecentMeetings.)

Inline response type (kept private to the page; do NOT export from the existing endpoint file because that would modify it and violate D-22):

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

Component shape:

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

Notes:

  • 'use client' at the very top.
  • params is a Promise in Next.js 16; unwrap with React.use(params) (named import use). This matches the existing endpoint at app/api/engagement/user/[userId]/route.ts line 7 ({ params }: { params: Promise<{ userId: string }> }).
  • D-04 (scroll restoration): no custom code in this task. Next.js App Router default scrollRestoration: true handles the device back gesture. Do NOT add sessionStorage workarounds. The // D-04: rely on App Router default scrollRestoration comment near the top of the function makes the decision visible to the checker.
  • D-13: hoursForPeriod, billableForPeriod, etc. all default to 0 — the page never renders for these.
  • The placeholder <div>...Loaded: …</div> is removed in Task 1b when the real Header + MetricGrid are mounted.
  • retryNonce increment forces the useEffect to run again because it is part of the dependency array — this is a deterministic, non-magic refetch trigger (issue-7 fix). test -f /opt/stacks/pulse/app/mobile/engagement/[userId]/page.tsx && test -f /opt/stacks/pulse/components/mobile/EngagementProfileSkeleton.tsx && grep -q "EngagementProfileSkeleton" "/opt/stacks/pulse/app/mobile/engagement/[userId]/page.tsx" && grep -q "EngagementPeriodChips" "/opt/stacks/pulse/app/mobile/engagement/[userId]/page.tsx" && grep -q "retryNonce" "/opt/stacks/pulse/app/mobile/engagement/[userId]/page.tsx" && grep -q "EngagementProfileSkeleton" "/opt/stacks/pulse/components/mobile/EngagementProfileSkeleton.tsx" && npx tsc --noEmit --pretty && npm run build <acceptance_criteria>
    • File app/mobile/engagement/[userId]/page.tsx exists.
    • File components/mobile/EngagementProfileSkeleton.tsx exists.
    • grep -c "'use client'" app/mobile/engagement/[userId]/page.tsx returns 1.
    • grep -c "'use client'" components/mobile/EngagementProfileSkeleton.tsx returns 1.
    • grep -c "EngagementPeriodChips" app/mobile/engagement/[userId]/page.tsx returns at least 2 (import + JSX).
    • grep -c "EngagementProfileSkeleton" app/mobile/engagement/[userId]/page.tsx returns at least 2.
    • grep -c "useState<EngagementPeriod>('D30')" app/mobile/engagement/[userId]/page.tsx returns 1 (D-09 default).
    • grep -c "fetch(\/api/engagement/user/" app/mobile/engagement/[userId]/page.tsxreturns at least 1, AND the line includes?period=`.
    • grep -c "retryNonce" app/mobile/engagement/[userId]/page.tsx returns at least 3 (state declaration, deps array, onClick handler — issue-7 fix).
    • grep -c "setRetryNonce((n) => n + 1)" app/mobile/engagement/[userId]/page.tsx returns at least 1 (the Retry click handler — issue-7 fix).
    • grep -c "User not found" app/mobile/engagement/[userId]/page.tsx returns at least 1 (D-24 404 copy).
    • grep -c "Back to Engagement" app/mobile/engagement/[userId]/page.tsx returns at least 1 (D-24 404 link).
    • grep -c "toast.error" app/mobile/engagement/[userId]/page.tsx returns at least 1 (D-24 500 toast).
    • grep -c "Retry" app/mobile/engagement/[userId]/page.tsx returns at least 1 (D-24 retry button).
    • grep -c "scrollRestoration" app/mobile/engagement/[userId]/page.tsx returns at least 1 (D-04 comment).
    • grep -c "period_end" app/mobile/engagement/[userId]/page.tsx returns at least 1 (snapshot last-active derivation).
    • git diff --name-only -- components/mobile/EngagementUserRow.tsx produces no output (D-01 guard rail).
    • git diff --name-only -- app/api/engagement/user/[userId]/route.ts produces no output (D-22 guard rail).
    • npx tsc --noEmit --pretty exits 0.
    • npm run build exits 0. </acceptance_criteria> Visiting /mobile/engagement/{validId} after login renders: H1 (display name) → sticky period chips → full skeleton during load. After load, the placeholder div confirms the data fetch round-trip. 404 → not-found page + back link. 500 → skeleton + toast + Retry button that increments retryNonce and re-triggers the fetch effect. EngagementUserRow.tsx and the data endpoint are untouched. Type-check and build both pass.
Task 1b: Identity header + 2×2 metric grid + page wiring components/mobile/EngagementProfileHeader.tsx, components/mobile/EngagementProfileMetricGrid.tsx, app/mobile/engagement/[userId]/page.tsx - app/mobile/engagement/[userId]/page.tsx (the page from Task 1a — read it AS IT EXISTS so you know which state/data is already available before mounting Header + MetricGrid) - .planning/phases/08-engagement-user-profile-new/08-CONTEXT.md (D-05..D-13) - .planning/phases/08-engagement-user-profile-new/08-UI-SPEC.md (Layout Structure §identity-card, §Typography r1, §Color §accent reservation, §Date/Time Formatting, §Copywriting Contract rows for Hero metric labels, mailto, Last active) - components/mobile/EngagementUserRow.tsx (named export `getInitials`) - components/mobile/EngagementSummaryCard.tsx (visual reference for big-number-over-small-label pattern) - lib/hooks/use-user-timezone.ts (signature + the helper `formatInUserTimezone`) Create TWO components and modify the page to mount them.

File 1: components/mobile/EngagementProfileHeader.tsx

Identity header card per UI-SPEC §Layout (identity-card section), §Color (mailto uses text-primary), §Typography (display name = text-xl font-semibold, secondary rows = text-xs text-muted-foreground for department/jobTitle/last active, mailto = text-sm text-primary), §Date/Time Formatting (last active relative ≤7d / absolute >7d via formatInUserTimezone).

Props:

export interface EngagementProfileHeaderProps {
  displayName: string;
  email: string;
  jobTitle: string | null;
  department: string | null;
  // Last-active source: caller derives from response (most recent of recentEntries[0].entry_date,
  // or the most-recent snapshots[].period_end). null if no signal at all.
  lastActiveAt: string | null;  // ISO date string or null (D-06)
  // Used to build the photo URL — userId is the same value as the route segment
  userId: string;
}

Implementation requirements:

  1. Avatar block (left, h-14 w-14 rounded-full):
    • State: const [photoFailed, setPhotoFailed] = useState(false);
    • When !photoFailed: render <img src={\/api/mobile/engagement/user/${userId}/photo`} alt={displayName} className="h-14 w-14 rounded-full object-cover" onError={() => setPhotoFailed(true)} />`
    • When photoFailed === true: render the initials span:
      <span aria-hidden="true" className="h-14 w-14 rounded-full bg-muted flex items-center justify-center shrink-0 text-xs font-semibold text-foreground">
        {getInitials(displayName)}
      </span>
      
    • Import getInitials from @/components/mobile/EngagementUserRow.
  2. Identity stack (right, flex-1 min-w-0 space-y-1):
    • <p className="text-xl font-semibold truncate">{displayName}</p> (UI-SPEC r1: text-xl, not text-lg)
    • If jobTitle: <p className="text-xs text-muted-foreground truncate">{jobTitle}</p> (D-06 row 1)
    • If department: <p className="text-xs text-muted-foreground truncate">{department}</p> (D-06 row 2; raw value, no prefix label per copywriting contract)
    • <a href={\mailto:${email}`} className="text-sm text-primary underline-offset-4 hover:underline truncate block min-h-[44px] flex items-center">{email}` (UI-SPEC mailto styling; 44px touch target floor)
    • If lastActiveAt: render last-active row using useUserTimezone() and the rule:
      • Compute const ms = Date.now() - new Date(lastActiveAt).getTime();
      • If ms <= 7 * 24 * 60 * 60 * 1000: relative — use date-fns formatDistanceToNow(new Date(lastActiveAt), { addSuffix: true }) and prefix with "Active " → e.g. "Active 2 hours ago"
      • Else: absolute — formatInUserTimezone(lastActiveAt, tz, { month: 'short', day: 'numeric', year: 'numeric' }) and prefix with "Last active " → e.g. "Last active May 5, 2026"
      • Render: <p className="text-xs text-muted-foreground">{label}</p>
  3. Card layout: <Card className="py-0 shadow-none"><CardContent className="px-4 py-4 flex items-center gap-3"> ... </CardContent></Card>
  4. Mark 'use client' at top.
  5. Imports: Card, CardContent from @/components/ui/card; getInitials from @/components/mobile/EngagementUserRow; useUserTimezone, formatInUserTimezone from @/lib/hooks/use-user-timezone; formatDistanceToNow from date-fns; useState from react.

File 2: components/mobile/EngagementProfileMetricGrid.tsx

2×2 grid of 4 hero metric cards per D-11/D-12, copywriting contract row 3.

Props:

export interface EngagementProfileMetricGridProps {
  hoursWorked: number;       // already period-scoped by caller
  billableHours: number;
  daysWorked: number;
  meetingsAttended: number;
}

Implementation:

  • Outer: <div className="grid grid-cols-2 gap-3">
  • Each cell mirrors EngagementSummaryCard.tsx structure:
    <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>
    
  • Order (per D-12): top-left "Hours worked" (hoursWorked.toFixed(1) + 'h'), top-right "Billable hours" (billableHours.toFixed(1) + 'h'), bottom-left "Days worked" (daysWorked.toString()), bottom-right "Meetings attended" (meetingsAttended.toString())
  • D-13: when value is 0 or null/undefined → render 0 (or 0.0h for hour values), NEVER .
  • 'use client' at top.

File 3 (modify): app/mobile/engagement/[userId]/page.tsx

Add two imports at the top alongside the existing imports from Task 1a:

import { EngagementProfileHeader } from '@/components/mobile/EngagementProfileHeader';
import { EngagementProfileMetricGrid } from '@/components/mobile/EngagementProfileMetricGrid';

Replace the placeholder <div> from Task 1a (the one that says "Header and metric grid wired in Task 1b") with the two real sections, in this exact order:

<EngagementProfileHeader
  displayName={data.user.displayName}
  email={data.user.email}
  jobTitle={data.user.jobTitle}
  department={data.user.department}
  lastActiveAt={lastActiveAt}
  userId={userId}
/>
<EngagementProfileMetricGrid
  hoursWorked={hoursForPeriod}
  billableHours={billableForPeriod}
  daysWorked={daysWorkedForPeriod}
  meetingsAttended={Number(meetingsAttended)}
/>
{/* Task 2 will mount EngagementProfileBreakdown + EngagementRecentEntries + EngagementRecentMeetings here. */}

Rules:

  • Do NOT change the page's existing fetch/state/error wiring.
  • Do NOT modify EngagementUserRow.tsx (D-01 guard rail).
  • Do NOT modify app/api/engagement/user/[userId]/route.ts (D-22 guard rail). test -f /opt/stacks/pulse/components/mobile/EngagementProfileHeader.tsx && test -f /opt/stacks/pulse/components/mobile/EngagementProfileMetricGrid.tsx && grep -q "EngagementProfileHeader" "/opt/stacks/pulse/app/mobile/engagement/[userId]/page.tsx" && grep -q "EngagementProfileMetricGrid" "/opt/stacks/pulse/app/mobile/engagement/[userId]/page.tsx" && grep -q "EngagementProfileHeader" "/opt/stacks/pulse/components/mobile/EngagementProfileHeader.tsx" && grep -q "grid-cols-2" "/opt/stacks/pulse/components/mobile/EngagementProfileMetricGrid.tsx" && npx tsc --noEmit --pretty && npm run build <acceptance_criteria>
    • File components/mobile/EngagementProfileHeader.tsx exists.
    • File components/mobile/EngagementProfileMetricGrid.tsx exists.
    • grep -c "'use client'" components/mobile/EngagementProfileHeader.tsx returns 1.
    • grep -c "'use client'" components/mobile/EngagementProfileMetricGrid.tsx returns 1.
    • grep -c "/api/mobile/engagement/user/" components/mobile/EngagementProfileHeader.tsx returns at least 1 (photo URL).
    • grep -c "onError" components/mobile/EngagementProfileHeader.tsx returns at least 1 (initials fallback wiring).
    • grep -c "getInitials" components/mobile/EngagementProfileHeader.tsx returns at least 2 (import + call).
    • grep -c "grid-cols-2 gap-3" components/mobile/EngagementProfileMetricGrid.tsx returns at least 1.
    • grep -c "text-2xl font-semibold" components/mobile/EngagementProfileMetricGrid.tsx returns at least 1.
    • grep -c "Hours worked" components/mobile/EngagementProfileMetricGrid.tsx returns at least 1.
    • grep -c "Billable hours" components/mobile/EngagementProfileMetricGrid.tsx returns at least 1.
    • grep -c "Days worked" components/mobile/EngagementProfileMetricGrid.tsx returns at least 1.
    • grep -c "Meetings attended" components/mobile/EngagementProfileMetricGrid.tsx returns at least 1.
    • grep -c "EngagementProfileHeader" app/mobile/engagement/[userId]/page.tsx returns at least 2 (import + JSX).
    • grep -c "EngagementProfileMetricGrid" app/mobile/engagement/[userId]/page.tsx returns at least 2 (import + JSX).
    • grep -c "Header and metric grid wired in Task 1b" app/mobile/engagement/[userId]/page.tsx returns 0 (placeholder removed).
    • git diff --name-only -- components/mobile/EngagementUserRow.tsx produces no output (D-01 guard rail).
    • git diff --name-only -- app/api/engagement/user/[userId]/route.ts produces no output (D-22 guard rail).
    • npx tsc --noEmit --pretty exits 0.
    • npm run build exits 0. </acceptance_criteria> Visiting /mobile/engagement/{validId} now renders: H1 → sticky chips → identity header card with avatar (photo or initials) → 2×2 metric grid with the 4 hero metrics for the selected period. Period chip change refetches and recomputes metrics. Task 2 will add the breakdown card and recent-items sections.
Task 2: Activity breakdown + Recent entries + Recent meetings + page wiring components/mobile/EngagementProfileBreakdown.tsx, components/mobile/EngagementRecentEntries.tsx, components/mobile/EngagementRecentMeetings.tsx, app/mobile/engagement/[userId]/page.tsx - app/mobile/engagement/[userId]/page.tsx (the page from Tasks 1a + 1b — read it AS IT EXISTS so you know what state/data is already available before adding the three sections; you will also modify it in this task to add the three component imports + JSX slots) - .planning/phases/08-engagement-user-profile-new/08-CONTEXT.md (D-14..D-21) - .planning/phases/08-engagement-user-profile-new/08-UI-SPEC.md (Layout Structure §Activity-breakdown subsections, §Interaction Contracts §Tap-to-expand, §Copywriting Contract for breakdown labels and empty-state copy, §Date/Time Formatting) - components/ui/collapsible.tsx (the shadcn Collapsible API — confirm imports `Collapsible, CollapsibleContent, CollapsibleTrigger`) - components/ui/badge.tsx (Badge variants — `variant="secondary"` per UI-SPEC for the Billable badge) - lib/hooks/use-user-timezone.ts (useUserTimezone + formatInUserTimezone signatures) Create THREE components and modify the existing page (from Tasks 1a + 1b) to mount them.

File 1: components/mobile/EngagementProfileBreakdown.tsx

Single Card with three labeled subsections per D-14..D-17. UI-SPEC overrides D-16 to use py-2 (not py-1.5) for metric rows.

Props:

export interface EngagementProfileBreakdownProps {
  // Time subsection
  hoursWorked: number;
  billableHours: number;
  daysWorked: number;
  // Communication subsection
  teamsMessages: number;       // chat + private summed by caller
  emailsSent: number;
  afterHoursMessagesPct: number;
  afterHoursMeetingsPct: number;
  // Meetings subsection
  meetingsAttended: number;
  meetingsOrganized: number;
  meetingDurationSeconds: number;
  // Optional: only render Zoom row if non-null (D-17 presence rule)
  zoomCalls: number | null;
}

Structure (exact JSX skeleton — the executor must match this row-and-section shape):

'use client';

import { Card, CardContent } from '@/components/ui/card';

const subsectionLabel = "text-sm font-semibold text-muted-foreground mb-2";
const metricRow = "flex justify-between text-sm py-2";

function MetricRow({ label, value }: { label: string; value: string }) {
  return (
    <div className={metricRow}>
      <dt className="text-muted-foreground">{label}</dt>
      <dd className="text-foreground tabular-nums">{value}</dd>
    </div>
  );
}

export function EngagementProfileBreakdown(props: EngagementProfileBreakdownProps) {
  const utilizationPct = props.hoursWorked > 0
    ? Math.round((props.billableHours / props.hoursWorked) * 100)
    : null;
  const meetingHours = props.meetingDurationSeconds / 3600;
  const showAfterHours = props.afterHoursMessagesPct > 0 || props.afterHoursMeetingsPct > 0;
  const showZoom = props.zoomCalls !== null && props.zoomCalls !== undefined;

  return (
    <Card className="py-0 shadow-none">
      <CardContent className="px-4 py-4 space-y-4">
        {/* Time */}
        <section>
          <h3 className={subsectionLabel}>Time</h3>
          <dl>
            <MetricRow label="Hours worked"   value={`${props.hoursWorked.toFixed(1)}h`} />
            <MetricRow label="Billable hours" value={`${props.billableHours.toFixed(1)}h`} />
            <MetricRow label="Days worked"    value={String(props.daysWorked)} />
            {utilizationPct !== null && (
              <MetricRow label={`Utilization · ${utilizationPct}%`} value={`${utilizationPct}%`} />
            )}
          </dl>
        </section>

        {/* Communication */}
        <section className="border-t border-border pt-3">
          <h3 className={subsectionLabel}>Communication</h3>
          <dl>
            <MetricRow label="Teams messages" value={String(props.teamsMessages)} />
            <MetricRow label="Emails sent"    value={String(props.emailsSent)} />
            {showAfterHours && (
              <div className={metricRow}>
                <dt className="text-muted-foreground">
                  After-hours · {props.afterHoursMessagesPct}% messages, {props.afterHoursMeetingsPct}% meetings
                </dt>
                <dd className="text-foreground tabular-nums" />
              </div>
            )}
          </dl>
        </section>

        {/* Meetings */}
        <section className="border-t border-border pt-3">
          <h3 className={subsectionLabel}>Meetings</h3>
          <dl>
            <MetricRow label="Meetings attended"  value={String(props.meetingsAttended)} />
            <MetricRow label="Meetings organized" value={String(props.meetingsOrganized)} />
            <MetricRow label="Meeting duration"   value={`${meetingHours.toFixed(1)}h`} />
            {showZoom && (
              <MetricRow label="Zoom calls" value={String(props.zoomCalls)} />
            )}
          </dl>
        </section>
      </CardContent>
    </Card>
  );
}

Rules per D-13/D-17:

  • Hours / billable / days / meetings (first-class metrics): always render with 0
  • Utilization: hide row only when hoursWorked === 0 (utilizationPct null)
  • After-hours: hide entire row when both pcts are 0 (D-15)
  • Zoom calls: hide row when zoomCalls === null (presence signal — D-17)

File 2: components/mobile/EngagementRecentEntries.tsx

Tap-to-expand list of up to 10 recent time entries (D-18, D-19, D-20, D-21).

export interface RecentTimeEntry {
  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;
}

export interface EngagementRecentEntriesProps {
  entries: RecentTimeEntry[];   // caller passes recentEntries.slice(0, 10)
}

Structure:

'use client';

import { useState } from 'react';
import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { useUserTimezone, formatInUserTimezone } from '@/lib/hooks/use-user-timezone';

export function EngagementRecentEntries({ entries }: EngagementRecentEntriesProps) {
  const tz = useUserTimezone();
  const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());

  const toggle = (id: string) => {
    setExpandedIds((prev) => {
      const next = new Set(prev);
      if (next.has(id)) next.delete(id);
      else next.add(id);
      return next;
    });
  };

  // ID derivation: caller doesn't pass an explicit id, so derive a stable string per row.
  const idFor = (e: RecentTimeEntry, i: number) =>
    `${e.entry_date}|${e.start_date_time ?? ''}|${i}`;

  return (
    <Card className="py-0 shadow-none">
      <CardContent className="px-4 py-4">
        <h3 className="text-sm font-semibold text-foreground mb-3">Recent time entries</h3>
        {entries.length === 0 ? (
          <p className="text-sm text-muted-foreground py-2">No time entries in the last 30 days</p>
        ) : (
          <ul className="space-y-1">
            {entries.slice(0, 10).map((entry, i) => {
              const id = idFor(entry, i);
              const open = expandedIds.has(id);
              const dateLabel = formatInUserTimezone(entry.entry_date, tz, { month: 'short', day: 'numeric' });
              const isBillable = entry.billable !== false;  // null defaults true
              const oneLine = entry.notes
                ? entry.notes.split('\n')[0]?.slice(0, 80) ?? ''
                : (entry.title ?? '');

              return (
                <li key={id}>
                  <Collapsible open={open} onOpenChange={() => toggle(id)}>
                    <CollapsibleTrigger asChild>
                      <button
                        type="button"
                        className="w-full text-left flex items-center justify-between gap-2 min-h-[44px] py-2"
                      >
                        <span className="flex-1 min-w-0 flex items-baseline gap-2">
                          <span className="text-sm font-medium tabular-nums shrink-0">{dateLabel}</span>
                          <span className="text-sm text-muted-foreground tabular-nums shrink-0">
                            {entry.hours_worked.toFixed(1)}h
                          </span>
                          {isBillable && (
                            <Badge variant="secondary" className="shrink-0">Billable</Badge>
                          )}
                          <span className="text-sm text-muted-foreground truncate">{oneLine}</span>
                        </span>
                      </button>
                    </CollapsibleTrigger>
                    <CollapsibleContent className="text-sm text-muted-foreground pb-3 pl-2 space-y-1">
                      {entry.title    && <p><span className="font-medium text-foreground">Title:</span> {entry.title}</p>}
                      {entry.company_name && <p><span className="font-medium text-foreground">Company:</span> {entry.company_name}</p>}
                      {entry.notes    && <p className="whitespace-pre-wrap">{entry.notes}</p>}
                      {entry.start_date_time && (
                        <p>
                          <span className="font-medium text-foreground">Started:</span>{' '}
                          {formatInUserTimezone(entry.start_date_time, tz, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' })}
                        </p>
                      )}
                    </CollapsibleContent>
                  </Collapsible>
                </li>
              );
            })}
          </ul>
        )}
      </CardContent>
    </Card>
  );
}

Notes:

  • D-19: entries.slice(0, 10) — the caller may pass more; we hard-bound here as defence.
  • D-20: local Set<string> state for expanded IDs — no URL state, no router push.
  • D-21: empty state copy "No time entries in the last 30 days" (UI-SPEC copywriting contract — hardcoded; period context is implicit from chips above).

File 3: components/mobile/EngagementRecentMeetings.tsx

Same pattern as Recent entries, for recentTeamsMeetings. The full inline JSX skeleton below mirrors File 2 in fidelity (issue-5 fix). Executor must match this structure.

export interface RecentMeeting {
  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;
  }>;
}

export interface EngagementRecentMeetingsProps {
  meetings: RecentMeeting[];
}

Structure (exact JSX skeleton — match this row-and-section shape exactly):

'use client';

import { useState } from 'react';
import { Card, CardContent } from '@/components/ui/card';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { useUserTimezone, formatInUserTimezone } from '@/lib/hooks/use-user-timezone';

export function EngagementRecentMeetings({ meetings }: EngagementRecentMeetingsProps) {
  const tz = useUserTimezone();
  const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());

  const toggle = (id: string) => {
    setExpandedIds((prev) => {
      const next = new Set(prev);
      if (next.has(id)) next.delete(id);
      else next.add(id);
      return next;
    });
  };

  // ID derivation: caller doesn't pass an explicit id, so derive a stable string per row.
  const idFor = (m: RecentMeeting, i: number) =>
    `${m.startTime}|${m.subject ?? ''}|${i}`;

  // Format a duration in minutes as "Hh Mm" / "Mm" — used in the collapsed summary
  const fmtDuration = (mins: number | null): string => {
    if (mins === null || mins === undefined || !Number.isFinite(mins) || mins <= 0) return '';
    const h = Math.floor(mins / 60);
    const m = Math.round(mins % 60);
    if (h > 0 && m > 0) return `${h}h ${m}m`;
    if (h > 0) return `${h}h`;
    return `${m}m`;
  };

  return (
    <Card className="py-0 shadow-none">
      <CardContent className="px-4 py-4">
        <h3 className="text-sm font-semibold text-foreground mb-3">Recent meetings</h3>
        {meetings.length === 0 ? (
          <p className="text-sm text-muted-foreground py-2">No meetings recorded</p>
        ) : (
          <ul className="space-y-1">
            {meetings.slice(0, 10).map((meeting, i) => {
              const id = idFor(meeting, i);
              const open = expandedIds.has(id);
              const subjectLabel = meeting.subject ?? '(no subject)';
              const startLabel = formatInUserTimezone(meeting.startTime, tz, {
                month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit',
              });
              const durationLabel = fmtDuration(meeting.durationMinutes);
              const attendeeLabel = meeting.attendeeCount > 0
                ? `${meeting.attendeeCount} attendee${meeting.attendeeCount === 1 ? '' : 's'}`
                : '';

              const visibleParticipants = meeting.participantNames.slice(0, 5);
              const moreCount = Math.max(0, meeting.participantNames.length - 5);

              return (
                <li key={id}>
                  <Collapsible open={open} onOpenChange={() => toggle(id)}>
                    <CollapsibleTrigger asChild>
                      <button
                        type="button"
                        className="w-full text-left flex items-center justify-between gap-2 min-h-[44px] py-2"
                      >
                        <span className="flex-1 min-w-0 flex flex-col">
                          <span className="text-sm font-medium truncate">{subjectLabel}</span>
                          <span className="text-xs text-muted-foreground tabular-nums">
                            {startLabel}
                            {durationLabel ? ` · ${durationLabel}` : ''}
                            {attendeeLabel ? ` · ${attendeeLabel}` : ''}
                          </span>
                        </span>
                      </button>
                    </CollapsibleTrigger>
                    <CollapsibleContent className="text-sm text-muted-foreground pb-3 pl-2 space-y-2">
                      {meeting.matchedEntries.length > 0 && (
                        <div>
                          <p className="font-medium text-foreground mb-1">Matched time entries:</p>
                          <ul className="space-y-1">
                            {meeting.matchedEntries.map((te, j) => (
                              <li key={j} className="flex flex-wrap gap-x-2">
                                <span className="text-foreground tabular-nums">{te.hours_worked.toFixed(1)}h</span>
                                {te.company_name && <span>· {te.company_name}</span>}
                                {te.notes && <span className="truncate">· {te.notes.split('\n')[0]?.slice(0, 80) ?? ''}</span>}
                              </li>
                            ))}
                          </ul>
                        </div>
                      )}
                      {visibleParticipants.length > 0 && (
                        <p>
                          <span className="font-medium text-foreground">Attendees:</span>{' '}
                          {visibleParticipants.join(', ')}
                          {moreCount > 0 ? ` and ${moreCount} more` : ''}
                        </p>
                      )}
                      {durationLabel && (
                        <p>
                          <span className="font-medium text-foreground">Duration:</span> {durationLabel}
                        </p>
                      )}
                    </CollapsibleContent>
                  </Collapsible>
                </li>
              );
            })}
          </ul>
        )}
      </CardContent>
    </Card>
  );
}

Notes (mirroring Recent entries):

  • D-19: meetings.slice(0, 10) hard bound.
  • D-20: local Set<string> state, no URL state.
  • D-21: empty state copy "No meetings recorded" (UI-SPEC copywriting contract — hardcoded).
  • The expanded "Attendees" and "Matched time entries" rows reuse the existing participantNames and matchedEntries arrays from the response — no new endpoint fields are introduced.
  • Zoom call linkage is NOT rendered in this iteration: the existing endpoint populates meeting.matchedEntries (Teams meeting → time entry overlap) but not Zoom-call linkage on Teams meetings. That cross-reference is a Phase-9+ enhancement.

File 4 (modify): app/mobile/engagement/[userId]/page.tsx

Add three imports at the top:

import { EngagementProfileBreakdown } from '@/components/mobile/EngagementProfileBreakdown';
import { EngagementRecentEntries } from '@/components/mobile/EngagementRecentEntries';
import { EngagementRecentMeetings } from '@/components/mobile/EngagementRecentMeetings';

Inside the <>...</> block in the loaded-data branch (where Task 1b's comment says Task 2 will mount EngagementProfileBreakdown ...), replace the comment with the three sections, in this exact order, between <EngagementProfileMetricGrid .../> and the closing fragment:

<EngagementProfileBreakdown
  hoursWorked={hoursForPeriod}
  billableHours={billableForPeriod}
  daysWorked={daysWorkedForPeriod}
  teamsMessages={
    Number(snapshotForPeriod?.teams_chat_messages ?? 0) +
    Number(snapshotForPeriod?.teams_private_messages ?? 0)
  }
  emailsSent={Number(snapshotForPeriod?.emails_sent ?? 0)}
  afterHoursMessagesPct={data.afterHours.messagesPct}
  afterHoursMeetingsPct={data.afterHours.meetingsPct}
  meetingsAttended={Number(snapshotForPeriod?.teams_meetings_attended ?? 0)}
  meetingsOrganized={Number(snapshotForPeriod?.teams_meetings_organized ?? 0)}
  meetingDurationSeconds={Number(snapshotForPeriod?.meeting_duration_seconds ?? 0)}
  zoomCalls={data.zoom ? Number(data.zoom.calls[periodKey].total) : null}
/>
<EngagementRecentEntries entries={data.recentEntries} />
<EngagementRecentMeetings meetings={data.recentTeamsMeetings} />

Rules:

  • Do NOT change the page's existing imports list other than adding the three new component imports.
  • Do NOT change the period state, fetch, retryNonce, or skeleton wiring.
  • Do NOT add any new endpoints or modify the existing data endpoint (D-22 guard rail).
  • Do NOT modify EngagementUserRow.tsx (D-01 guard rail). test -f /opt/stacks/pulse/components/mobile/EngagementProfileBreakdown.tsx && test -f /opt/stacks/pulse/components/mobile/EngagementRecentEntries.tsx && test -f /opt/stacks/pulse/components/mobile/EngagementRecentMeetings.tsx && grep -q "EngagementProfileBreakdown" "/opt/stacks/pulse/app/mobile/engagement/[userId]/page.tsx" && grep -q "EngagementRecentEntries" "/opt/stacks/pulse/app/mobile/engagement/[userId]/page.tsx" && grep -q "EngagementRecentMeetings" "/opt/stacks/pulse/app/mobile/engagement/[userId]/page.tsx" && grep -q "EngagementProfileBreakdown" "/opt/stacks/pulse/components/mobile/EngagementProfileBreakdown.tsx" && grep -q "EngagementRecentEntries" "/opt/stacks/pulse/components/mobile/EngagementRecentEntries.tsx" && grep -q "EngagementRecentMeetings" "/opt/stacks/pulse/components/mobile/EngagementRecentMeetings.tsx" && npx tsc --noEmit --pretty && npm run build <acceptance_criteria>
    • File components/mobile/EngagementProfileBreakdown.tsx exists.
    • File components/mobile/EngagementRecentEntries.tsx exists.
    • File components/mobile/EngagementRecentMeetings.tsx exists.
    • grep -c "'use client'" components/mobile/EngagementProfileBreakdown.tsx returns 1.
    • grep -c "'use client'" components/mobile/EngagementRecentEntries.tsx returns 1.
    • grep -c "'use client'" components/mobile/EngagementRecentMeetings.tsx returns 1.
    • grep -c ">Time<" components/mobile/EngagementProfileBreakdown.tsx returns at least 1, AND grep -c ">Communication<" components/mobile/EngagementProfileBreakdown.tsx returns at least 1, AND grep -c ">Meetings<" components/mobile/EngagementProfileBreakdown.tsx returns at least 1 (the three subsection headers).
    • grep -c "After-hours" components/mobile/EngagementProfileBreakdown.tsx returns at least 1.
    • grep -c "py-2" components/mobile/EngagementProfileBreakdown.tsx returns at least 1 (UI-SPEC override of D-16).
    • grep -c "border-t border-border" components/mobile/EngagementProfileBreakdown.tsx returns at least 2 (the two inter-section dividers).
    • grep -c "Collapsible" components/mobile/EngagementRecentEntries.tsx returns at least 2 (import + JSX).
    • grep -c "Collapsible" components/mobile/EngagementRecentMeetings.tsx returns at least 2.
    • grep -c "Set<string>" components/mobile/EngagementRecentEntries.tsx returns at least 1 (D-20 expand-state).
    • grep -c "Set<string>" components/mobile/EngagementRecentMeetings.tsx returns at least 1.
    • grep -c "slice(0, 10)" components/mobile/EngagementRecentEntries.tsx returns at least 1 (D-19 bound).
    • grep -c "slice(0, 10)" components/mobile/EngagementRecentMeetings.tsx returns at least 1.
    • grep -c "Recent time entries" components/mobile/EngagementRecentEntries.tsx returns at least 1.
    • grep -c "Recent meetings" components/mobile/EngagementRecentMeetings.tsx returns at least 1.
    • grep -c "(no subject)" components/mobile/EngagementRecentMeetings.tsx returns at least 1 (subject fallback per issue-5 spec).
    • grep -c "Matched time entries" components/mobile/EngagementRecentMeetings.tsx returns at least 1 (expanded matchedEntries section per issue-5 spec).
    • grep -c "Attendees" components/mobile/EngagementRecentMeetings.tsx returns at least 1 (expanded participants section per issue-5 spec).
    • grep -c "No time entries in the last 30 days" components/mobile/EngagementRecentEntries.tsx returns at least 1 (D-21 empty copy).
    • grep -c "No meetings recorded" components/mobile/EngagementRecentMeetings.tsx returns at least 1.
    • grep -c "Billable" components/mobile/EngagementRecentEntries.tsx returns at least 1 (Badge usage).
    • grep -c "useUserTimezone" components/mobile/EngagementRecentEntries.tsx returns at least 2 (import + call).
    • grep -c "useUserTimezone" components/mobile/EngagementRecentMeetings.tsx returns at least 2 (import + call).
    • grep -c "EngagementProfileBreakdown" app/mobile/engagement/[userId]/page.tsx returns at least 2 (import + JSX).
    • grep -c "EngagementRecentEntries" app/mobile/engagement/[userId]/page.tsx returns at least 2.
    • grep -c "EngagementRecentMeetings" app/mobile/engagement/[userId]/page.tsx returns at least 2.
    • git diff --name-only -- components/mobile/EngagementUserRow.tsx produces no output (D-01 guard rail).
    • git diff --name-only -- app/api/engagement/user/[userId]/route.ts produces no output (D-22, CONTEXT.md "no new data" guard rail).
    • npx tsc --noEmit --pretty exits 0.
    • npm run build exits 0. </acceptance_criteria> The full Phase 8 profile page renders. Below the 2×2 metric grid the page now shows: an activity-breakdown Card with three subsections (Time / Communication / Meetings) including the after-hours row inside Communication and the optional Zoom-calls row in Meetings; a Recent time entries Card with up to 10 collapsible rows (Billable badge, date, hours, one-line preview; tap to reveal title/company/notes/start time); and a Recent meetings Card with up to 10 collapsible rows (subject or '(no subject)', start datetime, duration, attendee count; tap to reveal matched entries and attendees). Period chip changes recompute breakdown values; recent sections stay 10/10. EngagementUserRow.tsx and the data endpoint are untouched. Build and type-check pass.
Task 3: Verify scroll restoration on back gesture (D-04 / SC#2) (no files modified — manual verification of behaviour delivered by Tasks 1a/1b/2) A real Next.js App Router page at `/mobile/engagement/[userId]` that replaces the modal pattern. Per D-04 the plan relies on Next.js's default `scrollRestoration: true` to restore scroll position on the overview when the user navigates back. SC#2 ("device back gesture returns to overview at the prior scroll position") is a load-bearing phase Success Criterion and the only way to verify it is hands-on. Manual verification only — no code changes in this task. The executor pauses here and asks the user to perform the steps in `` below in a phone-width browser, then resumes based on the user's reply per ``.
If the user reports `OK`, SC#2 is satisfied and the phase can ship.

If the user reports `BROKEN: scroll resets`, the executor MUST stop and
return control to the planner so a follow-up plan can add the
`sessionStorage`-based scroll-restoration shim allowed by CONTEXT.md
D-04's fallback clause. Do NOT attempt to fix it inline in this task.
1. Run `npm run dev` (Pulse runs on http://localhost:3100). 2. Sign in as any authenticated user. 3. Open `/mobile/engagement` in a phone-width browser (Chrome DevTools device emulator on iPhone 15 Pro is fine). 4. Scroll halfway down the user list (verify multiple rows are off the top of the viewport). 5. Tap any user row → land on the new `/mobile/engagement/[userId]` profile page. Confirm the page renders with header → period chips → identity card → 2×2 metric grid → breakdown card → recent entries → recent meetings. 6. Press the browser back button (or use the OS back gesture if testing on a real phone). 7. Confirm the overview list restored at the same scroll position you left it at — NOT scrolled back to the top. User confirms scroll position restored on back navigation per the steps in ``. Reply with one of: - `OK` — scroll restoration works as expected, SC#2 satisfied. - `BROKEN: scroll resets` — the overview scrolled back to the top. The planner will spawn a follow-up plan to add a `sessionStorage`-based scroll-restoration shim (per CONTEXT.md D-04 fallback clause) before the phase ships. - `BROKEN: ` — describe what you observed; planner will triage. User has replied with `OK` (SC#2 satisfied — phase ready to ship) OR with `BROKEN: ...` (executor returns control to the planner for a follow-up plan that adds the sessionStorage scroll-restoration shim before shipping).

<threat_model>

Trust Boundaries

Boundary Description
browser → /mobile/engagement/[userId] (page render) Authenticated browser session; userId from URL is untrusted input rendered into JSX and used in client-side fetches
browser client → /api/engagement/user/[userId] (existing endpoint) Already-authenticated existing endpoint; gated by Better Auth middleware (page route is NOT in /api/mobile public list — middleware enforces session)
browser client → /api/mobile/engagement/user/[userId]/photo Auth gate enforced by Plan 01's handler

STRIDE Threat Register

Threat ID Category Component Severity Disposition Mitigation Plan
T-08-09 Information Disclosure (PII in URL/referer) profile page medium mitigate The URL contains the Graph user id (an opaque GUID-like string), NOT the email or display name — so referer leakage to outbound links exposes only the opaque id. The page DOES render the email as visible text inside a mailto: anchor; this is intentional for the manager workflow but means the email is in the rendered DOM. No additional logging of email is introduced. Mitigation: do not put email or displayName into query strings or document.title beyond the H1.
T-08-10 Information Disclosure (DOM logging) profile page low mitigate The page uses console.error('[mobile/engagement/profile] fetch failed', err) only on fetch failure — err is an Error object that does NOT contain response body or PII (HTTP status only via the thrown message). The full data response is never console.log-ed. Verified by acceptance: no console.log appears in the new page.
T-08-11 Cross-site Scripting (notes rendering) EngagementRecentEntries low mitigate Time-entry notes may contain operator-typed text. Rendered as React text content inside <p> (auto-escaped by React) and inside a whitespace-pre-wrap paragraph — never via dangerouslySetInnerHTML. Verified by acceptance: no dangerouslySetInnerHTML in any new component.
T-08-12 Tampering (userId path param) profile page → /api/engagement/user/[userId] low accept Browser passes userId from URL via encodeURIComponent into the fetch. The existing endpoint already exists, ships in production, and uses parameterized SQL via postgresClient.query(... [userId]) — no SQL injection surface to introduce. No change.
T-08-13 Information Disclosure (404 oracle) profile page low mitigate A 404 from /api/engagement/user/[userId] (user does not exist) is rendered as a user-friendly "User not found" page with a back link — NOT an error message that distinguishes 404 from other states. The page does not differentiate "this id is malformed" vs "this id was deleted" vs "this id never existed".
T-08-14 Spoofing (page reachable without auth) profile page route high mitigate The page lives at /mobile/engagement/[userId]/page.tsx. The middleware.ts whitelists /api/mobile/* (NOT /mobile/*), so the existing middleware redirects unauthenticated browsers to /auth/sign-in?callbackUrl=... BEFORE the page renders. Verified by reading middleware.ts lines 643 (publicRoutes) — /mobile is NOT in the list, only /api/mobile. No new auth surface needed.
T-08-15 Repudiation profile page low accept Read-only page; no mutations. No audit log needed.
T-08-16 Denial of Service (large recentEntries arrays) profile page render low mitigate The endpoint returns up to 500 recent entries server-side (LIMIT 500). Phase 8 hard-bounds rendering with entries.slice(0, 10) in both Recent components. Memory cost ~10 collapsible nodes — bounded constant.
T-08-17 Photo endpoint cache key cross-tenant leakage photo <img> rendering low accept Cache-Control: private, max-age=3600 (set in Plan 01) prevents shared cache pollution. On a kiosk/shared device, the next user could see the previous user's cached photo if they navigate to the same userId — but that scenario already exposes the page content itself, so the photo is not an additional leak. Documented as accepted.

Block-on-high check: T-08-14 (spoofing the page) is the only high severity threat and is mitigated by the existing middleware.ts redirect (no new code needed in this plan; verified by reading middleware.ts which gates everything not in publicRoutes). No unmitigated highs remain. </threat_model>

## Phase Plan 02 Verification

Wave-2 complete when:

  • All 6 new component files exist under components/mobile/Engagement*
  • app/mobile/engagement/[userId]/page.tsx exists, imports all 6 components, and renders them in the order: Header → MetricGrid → Breakdown → RecentEntries → RecentMeetings (with Skeleton during load)
  • All 7 files (page + 6 components) start with 'use client'
  • Period chip changes refetch via useEffect dependency on period
  • Retry button increments retryNonce which is in the fetch effect's deps array (issue-7 fix)
  • Recent items hard-bounded to 10 each (slice(0, 10)); period changes do NOT clear expanded state (they DO refetch — but the Sets persist because they're on different components from the data that drives metrics)
  • 404 from data endpoint renders inline "User not found" + Back to Engagement link (D-24)
  • 500 / network failure renders sonner toast.error + Retry button (D-24)
  • Photo <img> has onError handler that swaps to initials (D-25)
  • No modifications to components/mobile/EngagementUserRow.tsx (D-01)
  • No modifications to app/api/engagement/user/[userId]/route.ts (D-22)
  • No dangerouslySetInnerHTML introduced
  • npx tsc --noEmit --pretty exits 0
  • npm run build exits 0
  • Task 3 checkpoint: human confirms scroll restoration works on back gesture (or reports BROKEN so planner can add a sessionStorage shim before ship)

<success_criteria> After this plan:

  1. (SC#1) Tapping any row in /mobile/engagement (Phase 7's EngagementUserRow's Link href="/mobile/engagement/{graphUserId}") navigates to /mobile/engagement/{graphUserId} and renders the new profile page.
  2. (SC#2) The profile is a real Next.js page (not a modal). Pressing the device back gesture / browser back button returns to the overview at the prior scroll position. No sessionStorage shim is added — App Router default scrollRestoration: true is sufficient (D-04). Verified by Task 3 checkpoint. If the checkpoint reports BROKEN, the planner spawns a follow-up plan to add the sessionStorage workaround before shipping.
  3. (SC#3) The profile renders single-column in this exact order: identity header → period selector (sticky) → 2×2 metric grid → activity breakdown card (3 subsections) → recent time entries → recent meetings. All data sourced from the existing /api/engagement/user/[userId]?period={D7|D30|D90} endpoint plus the photo proxy (Plan 01) — no new data endpoints.
  4. ENG-06: route is /mobile/engagement/[userId] (segment form, shareable URL); single-column layout matches the prescribed order
  5. ENG-07: real page, not a modal — replaces desktop user-detail modal pattern on mobile so back gesture works
  6. ENG-08: profile reuses existing engagement profile data endpoints; no new data </success_criteria>
After completion, create `.planning/phases/08-engagement-user-profile-new/08-02-SUMMARY.md` following the GSD summary template. Note any deviations from the action text — for example, if Task 3's checkpoint reports BROKEN and a sessionStorage shim was added (D-04 fallback), record that decision and where the shim lives.