wulf-pulse/.planning/phases/07-engagement-overview-new/07-03-PLAN.md

39 KiB

phase plan type wave depends_on files_modified autonomous requirements must_haves
07-engagement-overview-new 03 execute 3
07-01
07-02
app/mobile/engagement/page.tsx
true
ENG-01
ENG-02
ENG-03
ENG-04
ENG-05
ENG-09
truths artifacts key_links
Tapping the Engagement entry in the More drawer routes to /mobile/engagement and the page renders inside the Phase 2 mobile shell
Page shows H1 'Engagement' (text-sm font-semibold) above a sticky 3-chip period selector (7d / 30d / 90d, default 30d active)
Page renders 4 summary cards stacked single-column: Active users, Total Graph hours, Total Autotask hours, Hours / active user
Page renders one compact hours-trend sparkline card scoped to the selected period at the top of the user list
Page renders sort chips (Hours / Name / Utilization) and a debounced search input above the per-employee list
Per-employee list shows stacked rows (avatar+name+role+hours+hours-bar); tapping a row navigates to /mobile/engagement/[graphUserId]
Changing the period chip refetches summary, trend, and users (page resets to 1)
Changing the sort chip refetches the users list (page resets to 1)
Search input debounces 300ms and filters the loaded user set client-side; 'No matches' inline state with Clear search button when the filter zeros out
Infinite scroll: IntersectionObserver on a sentinel triggers ?page=N+1 when last row enters viewport; 'Load more' fallback button is also present
Initial load shows 4 summary card skeletons + 1 sparkline skeleton + 5 user-row skeletons
Empty state ('No engagement data for this period') renders when summary.activeUsers === 0 AND users.length === 0; period chips remain interactive
When summary.configured === false, a 'Engagement sync not configured' banner replaces the data sections
Fetch errors fire toast.error per failure; Load more button label flips to 'Retry'
BottomNav.tsx is NOT modified (Engagement is reachable from More drawer only — ENG-09)
MoreDrawer.tsx is NOT modified (already routes to /mobile/engagement per Phase 2 DRAWER-03)
path provides exports min_lines
app/mobile/engagement/page.tsx Mobile engagement overview page (real refactor, not desktop adaptation)
default function MobileEngagementPage
200
from to via pattern
app/mobile/engagement/page.tsx /api/mobile/engagement/summary fetch in useEffect /api/mobile/engagement/summary
from to via pattern
app/mobile/engagement/page.tsx /api/mobile/engagement/trend fetch in useEffect /api/mobile/engagement/trend
from to via pattern
app/mobile/engagement/page.tsx /api/engagement/users fetch with period+sort+page params (D-16, D-17) /api/engagement/users
from to via pattern
app/mobile/engagement/page.tsx components/mobile/Engagement* components named imports from @/components/mobile/Engagement* from '@/components/mobile/Engagement
from to via pattern
app/mobile/engagement/page.tsx MobileEngagementSummary type import type from '@/app/api/mobile/engagement/summary/route' import type.*MobileEngagementSummary
Build the mobile Engagement overview page that orchestrates Plan 01's endpoints + Plan 02's components into the shipping screen. This is the page the manager actually uses: H1 + sticky period chips + 4 stacked summary cards + 1 compact sparkline + sort chips + search + the per-employee list with infinite scroll + empty/configured banners + error toasts.

Per CONTEXT.md ENG-01, this is a "real refactor, not a thin adaptation of the ~1300-line desktop page." Build phone-first against the data sources, do NOT port desktop app/engagement/page.tsx (D-35: untouched).

Per ENG-09, Engagement is NOT on the bottom nav — it's reached from the More drawer which Phase 2 already wired (D-01, D-02).

Purpose: Wire all the pieces into the shipping page. Match Phase 4 / Phase 6 mobile-page structure ('use client', useState + useEffect + fetch, IntersectionObserver, sonner toasts on error, no SWR/react-query per CLAUDE.md + D-37, no Zod per D-38).

Output: app/mobile/engagement/page.tsx — single new file.

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

@.planning/STATE.md @.planning/ROADMAP.md @.planning/REQUIREMENTS.md @.planning/phases/07-engagement-overview-new/07-CONTEXT.md @.planning/phases/07-engagement-overview-new/07-UI-SPEC.md @CLAUDE.md @DESIGN.md

Plan 01 endpoints (Wave 1) — types imported from these

@app/api/mobile/engagement/summary/route.ts @app/api/mobile/engagement/trend/route.ts

Existing endpoint reused as-is (D-16, D-34 — DO NOT MODIFY)

@app/api/engagement/users/route.ts

Plan 02 components (Wave 2) — imported by name

@components/mobile/EngagementPeriodChips.tsx @components/mobile/EngagementSummaryCard.tsx @components/mobile/EngagementHoursSparkline.tsx @components/mobile/EngagementSortChips.tsx @components/mobile/EngagementSearchInput.tsx @components/mobile/EngagementUserRow.tsx @components/mobile/EngagementUserRowSkeleton.tsx

Phase shell (DO NOT MODIFY) — verify Engagement reachability after page lands

@app/mobile/layout.tsx @components/mobile/BottomNav.tsx @components/mobile/MoreDrawer.tsx

Pattern references (page-level orchestration)

@app/mobile/analyzer/page.tsx @app/mobile/tickets/page.tsx

import type { MobileEngagementSummary } from '@/app/api/mobile/engagement/summary/route';
import type { SparklinePoint, EngagementTrendResponse } from '@/app/api/mobile/engagement/trend/route';
import type { EngagementPeriod } from '@/components/mobile/EngagementPeriodChips';
import type { EngagementSortKey } from '@/components/mobile/EngagementSortChips';
import { EngagementPeriodChips } from '@/components/mobile/EngagementPeriodChips';
import { EngagementSummaryCard } from '@/components/mobile/EngagementSummaryCard';
import { EngagementHoursSparkline } from '@/components/mobile/EngagementHoursSparkline';
import { EngagementSortChips } from '@/components/mobile/EngagementSortChips';
import { EngagementSearchInput } from '@/components/mobile/EngagementSearchInput';
import { EngagementUserRow } from '@/components/mobile/EngagementUserRow';
import { EngagementUserRowSkeleton } from '@/components/mobile/EngagementUserRowSkeleton';
interface EngagementUserApiRow {
  graphUserId: string;
  displayName: string;
  email: string;            // existing endpoint returns 'email', not 'userEmail'
  jobTitle: string | null;
  billableHours: number;
  hoursWorked: number;
  // ...other fields the page does NOT consume
}

interface EngagementUsersResponse {
  users: EngagementUserApiRow[];
  pagination: {
    page: number;
    pageSize: number;
    total: number;
    totalPages: number;     // present when total > 0; existing endpoint returns this
  };
}
const SORT_TO_API: Record<EngagementSortKey, { sort: string; order: 'asc' | 'desc' }> = {
  Hours:       { sort: 'billable_hours', order: 'desc' },
  Name:        { sort: 'display_name',   order: 'asc'  },
  Utilization: { sort: 'billable_hours', order: 'desc' },
};
Task 1: Create app/mobile/engagement/page.tsx (orchestrates Plan 01 endpoints + Plan 02 components) - .planning/phases/07-engagement-overview-new/07-UI-SPEC.md (full file — Page Layout Order section is the spec; copy strings, accessibility, error toast labels) - .planning/phases/07-engagement-overview-new/07-CONTEXT.md (D-01, D-03, D-16, D-17, D-18, D-21, D-22, D-23, D-24, D-25, D-26, D-27, D-30, D-31, D-37) - app/mobile/analyzer/page.tsx (closest pattern reference — IntersectionObserver loop, error/Retry, skeleton render block, useCallback fetch, toast.error) - app/mobile/tickets/page.tsx lines 1-100 (filter + URL pattern reference; URL syncing NOT used in this phase per D-21 note) - app/api/engagement/users/route.ts (existing route — confirm response shape `users[]` + `pagination.{page,pageSize,total,totalPages}`) - components/mobile/BottomNav.tsx (verify no Engagement entry — must NOT be modified per D-02 / ENG-09) - components/mobile/MoreDrawer.tsx (verify Engagement entry exists — DO NOT modify per D-01) app/mobile/engagement/page.tsx Create new file `app/mobile/engagement/page.tsx`. Use the EXACT structure below — every section maps to a UI-SPEC layout block. NO router pushes for state (per D-21 note: period/sort/search are component state only, scale doesn't warrant deep-linking). NO SWR / react-query (D-37 / CLAUDE.md). NO Zod (D-38).
**File header:**
```tsx
'use client';

/* MobileEngagementPage — phase 07 (ENG-01..05, ENG-09).
 * Purpose: Phone-first refactor of /engagement — period chips + 4 stacked summary cards +
 *          compact sparkline + sortable/searchable per-employee list with infinite scroll.
 *          Real refactor, not a thin adaptation of the ~1300-line desktop page (ENG-01).
 *          Reachable from More drawer only — Engagement is NOT on the bottom nav (ENG-09).
 *          Per D-01..D-31. */

import { useEffect, useState, useCallback, useRef, useMemo } from 'react';
import { Loader2, Users } from 'lucide-react';
import { toast } from 'sonner';

import type { MobileEngagementSummary } from '@/app/api/mobile/engagement/summary/route';
import type { SparklinePoint, EngagementTrendResponse } from '@/app/api/mobile/engagement/trend/route';
import { EngagementPeriodChips, type EngagementPeriod } from '@/components/mobile/EngagementPeriodChips';
import { EngagementSummaryCard } from '@/components/mobile/EngagementSummaryCard';
import { EngagementHoursSparkline } from '@/components/mobile/EngagementHoursSparkline';
import { EngagementSortChips, type EngagementSortKey } from '@/components/mobile/EngagementSortChips';
import { EngagementSearchInput } from '@/components/mobile/EngagementSearchInput';
import { EngagementUserRow } from '@/components/mobile/EngagementUserRow';
import { EngagementUserRowSkeleton } from '@/components/mobile/EngagementUserRowSkeleton';
import { Card, CardContent } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
```

**Inline types (existing /api/engagement/users response — D-16):**

```tsx
interface EngagementUserApiRow {
  graphUserId: string;
  displayName: string;
  email: string;
  jobTitle: string | null;
  billableHours: number;
  hoursWorked: number;
}

interface EngagementUsersResponse {
  users: EngagementUserApiRow[];
  pagination: {
    page: number;
    pageSize: number;
    total: number;
    totalPages?: number;
  };
}

// Sort key → API param mapping (D-20)
const SORT_TO_API: Record<EngagementSortKey, { sort: string; order: 'asc' | 'desc' }> = {
  Hours:       { sort: 'billable_hours', order: 'desc' },
  Name:        { sort: 'display_name',   order: 'asc'  },
  Utilization: { sort: 'billable_hours', order: 'desc' },
};

const SUMMARY_LABELS = {
  activeUsers:        'Active users',
  totalGraphHours:    'Total Graph hours',
  totalAutotaskHours: 'Total Autotask hours',
  hoursPerActiveUser: 'Hours / active user',
} as const;
```

**Component body (state + fetches + render):**

```tsx
export default function MobileEngagementPage() {
  // ── State ─────────────────────────────────────────────────────────────
  const [period, setPeriod] = useState<EngagementPeriod>('D30');               // D-04 default
  const [sortKey, setSortKey] = useState<EngagementSortKey>('Hours');          // D-20 default
  const [searchQuery, setSearchQuery] = useState<string>('');                  // D-21

  const [summary, setSummary] = useState<MobileEngagementSummary | null>(null);
  const [summaryLoading, setSummaryLoading] = useState<boolean>(true);

  const [trendPoints, setTrendPoints] = useState<SparklinePoint[]>([]);
  const [trendLoading, setTrendLoading] = useState<boolean>(true);

  const [users, setUsers] = useState<EngagementUserApiRow[]>([]);
  const [usersLoading, setUsersLoading] = useState<boolean>(true);
  const [currentPage, setCurrentPage] = useState<number>(1);
  const [hasMore, setHasMore] = useState<boolean>(false);
  const [loadingMore, setLoadingMore] = useState<boolean>(false);
  const [loadMoreError, setLoadMoreError] = useState<boolean>(false);

  // ── Fetchers ──────────────────────────────────────────────────────────
  const loadSummary = useCallback(async (p: EngagementPeriod) => {
    setSummaryLoading(true);
    try {
      const r = await fetch(`/api/mobile/engagement/summary?period=${p}`);
      if (!r.ok) throw new Error(`HTTP ${r.status}`);
      const data: MobileEngagementSummary = await r.json();
      setSummary(data);
    } catch (e) {
      console.error('Engagement summary fetch failed:', e);
      toast.error('Failed to load engagement summary');     // D-25
    } finally {
      setSummaryLoading(false);
    }
  }, []);

  const loadTrend = useCallback(async (p: EngagementPeriod) => {
    setTrendLoading(true);
    try {
      const r = await fetch(`/api/mobile/engagement/trend?period=${p}`);
      if (!r.ok) throw new Error(`HTTP ${r.status}`);
      const data: EngagementTrendResponse = await r.json();
      setTrendPoints(data.points);
    } catch (e) {
      console.error('Engagement trend fetch failed:', e);
      toast.error('Failed to load hours trend');             // D-25
    } finally {
      setTrendLoading(false);
    }
  }, []);

  const loadUsersPage1 = useCallback(async (p: EngagementPeriod, sk: EngagementSortKey) => {
    setUsersLoading(true);
    setLoadMoreError(false);
    try {
      const { sort, order } = SORT_TO_API[sk];
      const sp = new URLSearchParams({ period: p, sort, order, page: '1' });
      const r = await fetch(`/api/engagement/users?${sp.toString()}`);
      if (!r.ok) throw new Error(`HTTP ${r.status}`);
      const data: EngagementUsersResponse = await r.json();
      setUsers(data.users);
      setCurrentPage(1);
      const totalPages = data.pagination.totalPages
        ?? Math.ceil(data.pagination.total / data.pagination.pageSize);
      setHasMore(1 < totalPages);
    } catch (e) {
      console.error('Engagement users fetch failed:', e);
      toast.error('Failed to load engagement users');        // D-25
    } finally {
      setUsersLoading(false);
    }
  }, []);

  const loadMoreUsers = useCallback(async () => {
    if (loadingMore || !hasMore) return;
    setLoadingMore(true);
    setLoadMoreError(false);
    try {
      const next = currentPage + 1;
      const { sort, order } = SORT_TO_API[sortKey];
      const sp = new URLSearchParams({ period, sort, order, page: String(next) });
      const r = await fetch(`/api/engagement/users?${sp.toString()}`);
      if (!r.ok) throw new Error(`HTTP ${r.status}`);
      const data: EngagementUsersResponse = await r.json();
      setUsers(prev => [...prev, ...data.users]);
      setCurrentPage(next);
      const totalPages = data.pagination.totalPages
        ?? Math.ceil(data.pagination.total / data.pagination.pageSize);
      setHasMore(next < totalPages);
    } catch (e) {
      console.error('Engagement users load-more fetch failed:', e);
      toast.error('Failed to load more team members');       // D-25
      setLoadMoreError(true);
    } finally {
      setLoadingMore(false);
    }
  }, [loadingMore, hasMore, currentPage, sortKey, period]);

  // ── Effects ───────────────────────────────────────────────────────────

  // Period change: refetch all three (summary + trend + users page 1) — D-04
  useEffect(() => {
    void loadSummary(period);
    void loadTrend(period);
    void loadUsersPage1(period, sortKey);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [period]);

  // Sort change: refetch users only (D-20: summary/trend are period-scoped, not sort-scoped)
  useEffect(() => {
    void loadUsersPage1(period, sortKey);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [sortKey]);

  // IntersectionObserver — D-18 (mirrors Phase 4/6 pattern, rootMargin '200px')
  const sentinelRef = useRef<HTMLDivElement | null>(null);
  useEffect(() => {
    const node = sentinelRef.current;
    if (!node) return;
    const observer = new IntersectionObserver(
      (entries) => {
        if (entries[0]?.isIntersecting && hasMore && !loadingMore && !usersLoading) {
          void loadMoreUsers();
        }
      },
      { rootMargin: '200px' },
    );
    observer.observe(node);
    return () => observer.disconnect();
  }, [hasMore, loadingMore, usersLoading, loadMoreUsers]);

  // ── Derived data ──────────────────────────────────────────────────────

  // Client-side search filter (D-21): applies on displayName + email of loaded users
  const filteredUsers = useMemo(() => {
    const q = searchQuery.trim().toLowerCase();
    if (!q) return users;
    return users.filter(u =>
      u.displayName.toLowerCase().includes(q) || u.email.toLowerCase().includes(q),
    );
  }, [users, searchQuery]);

  // maxHours for hours bar normalization (UI-SPEC: largest billableHours in current page set)
  const maxHours = useMemo(() => {
    return filteredUsers.reduce((m, u) => Math.max(m, u.billableHours), 0);
  }, [filteredUsers]);

  // Empty-state predicate (D-26): both summary.activeUsers AND users.length === 0
  const showEmptyState = !summaryLoading && !usersLoading
    && summary !== null && summary.activeUsers === 0 && users.length === 0;

  // Not-configured banner predicate (D-27)
  const showNotConfiguredBanner = !summaryLoading && summary !== null && summary.configured === false;

  // Summary card values (formatted strings — Plan 02's SummaryCard accepts pre-formatted)
  const summaryCard1 = summary ? String(summary.activeUsers)                       : '0';
  const summaryCard2 = summary ? `${summary.totalGraphHours.toFixed(1)}h`          : '0.0h';
  const summaryCard3 = summary ? `${summary.totalAutotaskHours.toFixed(1)}h`       : '0.0h';
  const summaryCard4 = summary
    ? (summary.activeUsers === 0 ? '—' : `${summary.hoursPerActiveUser.toFixed(1)}h`)  // D-08
    : '—';

  // ── Render ────────────────────────────────────────────────────────────

  return (
    <div className="px-4 py-4 space-y-4">                                          {/* D-30 */}
      {/* H1 — D-31, scrolls away under the sticky chips */}
      <h1 className="text-sm font-semibold">Engagement</h1>                        {/* D-29 */}

      {/* Sticky period chips — D-05 */}
      <EngagementPeriodChips period={period} onPeriodChange={setPeriod} />

      {showNotConfiguredBanner ? (
        // Not-configured banner (D-27) — replaces all data sections
        <div className="rounded-xl border bg-card px-4 py-4 space-y-1">
          <p className="text-sm font-semibold">Engagement sync not configured</p>
          <p className="text-xs text-muted-foreground">
            Set MSGRAPH_* environment variables and restart.
            <a
              href="/admin"
              target="_blank"
              rel="noopener noreferrer"
              aria-label="Open Admin on desktop"
              className="underline ml-1"
            >
              Open Admin
            </a>
          </p>
        </div>
      ) : (
        <>
          {/* Section 3 — Summary cards (D-08, D-23) */}
          {summaryLoading ? (
            <div className="space-y-3">
              {Array.from({ length: 4 }).map((_, i) => (
                <Card key={i} className="py-0 shadow-none">
                  <CardContent className="px-4 py-4 space-y-2">
                    <Skeleton className="h-8 w-20" />
                    <Skeleton className="h-3 w-28" />
                  </CardContent>
                </Card>
              ))}
            </div>
          ) : (
            <div className="space-y-3">
              <EngagementSummaryCard value={summaryCard1} label={SUMMARY_LABELS.activeUsers} />
              <EngagementSummaryCard value={summaryCard2} label={SUMMARY_LABELS.totalGraphHours} />
              <EngagementSummaryCard value={summaryCard3} label={SUMMARY_LABELS.totalAutotaskHours} />
              <EngagementSummaryCard value={summaryCard4} label={SUMMARY_LABELS.hoursPerActiveUser} />
            </div>
          )}

          {/* Section 4 — Sparkline (D-11..D-15, D-23) */}
          {trendLoading ? (
            <Card className="py-0 shadow-none">
              <CardContent className="px-4 py-3 space-y-2">
                <div className="flex justify-between gap-2">
                  <Skeleton className="h-3 w-32" />
                  <Skeleton className="h-3 w-16" />
                </div>
                <Skeleton className="h-12 w-full mt-2" />
              </CardContent>
            </Card>
          ) : (
            <EngagementHoursSparkline points={trendPoints} period={period} />
          )}

          {/* Section 5 — Sort + search (D-20, D-21) */}
          <div className="space-y-2">
            <EngagementSortChips activeSort={sortKey} onSortChange={setSortKey} />
            <EngagementSearchInput value={searchQuery} onChange={setSearchQuery} />
          </div>

          {/* Section 6 — User list (D-19, D-22, D-23, D-26) */}
          {showEmptyState ? (
            // Empty state (D-26)
            <div className="flex flex-col items-center justify-center py-12 text-center space-y-3">
              <Users className="h-8 w-8 text-muted-foreground/50" aria-hidden="true" />
              <div className="space-y-1">
                <p className="text-sm font-semibold">No engagement data for this period</p>
                <p className="text-xs text-muted-foreground">
                  Try a different period or trigger a sync from
                  <a
                    href="/admin"
                    target="_blank"
                    rel="noopener noreferrer"
                    aria-label="Open Admin on desktop"
                    className="underline ml-1"
                  >Admin</a>
                </p>
              </div>
            </div>
          ) : usersLoading ? (
            <div className="divide-y border rounded-xl overflow-hidden">
              {Array.from({ length: 5 }).map((_, i) => <EngagementUserRowSkeleton key={i} />)}
            </div>
          ) : filteredUsers.length === 0 && searchQuery.trim() !== '' ? (
            // No-matches inline state (D-22) — preserves the list border + rounding
            <div className="border rounded-xl overflow-hidden">
              <div className="px-4 py-6 text-center space-y-2">
                <p className="text-sm text-muted-foreground">{`No matches for "${searchQuery}"`}</p>
                <button
                  type="button"
                  onClick={() => setSearchQuery('')}
                  className="text-xs font-semibold text-primary underline"
                >
                  Clear search
                </button>
              </div>
            </div>
          ) : (
            <>
              <div className="divide-y border rounded-xl overflow-hidden">
                {filteredUsers.map(u => (
                  <EngagementUserRow
                    key={u.graphUserId}
                    user={{
                      graphUserId: u.graphUserId,
                      displayName: u.displayName,
                      userEmail: u.email,
                      jobTitle: u.jobTitle,
                      billableHours: u.billableHours,
                      hoursWorked: u.hoursWorked,
                    }}
                    maxHours={maxHours}
                  />
                ))}
              </div>

              {/* Sentinel — D-18 */}
              <div ref={sentinelRef} aria-hidden="true" />

              {/* Loading-more spinner — D-24 */}
              {loadingMore && (
                <div className="flex justify-center py-2">
                  <Loader2 className="w-4 h-4 animate-spin text-muted-foreground" aria-hidden="true" />
                </div>
              )}

              {/* Load more fallback button — D-18, D-25 */}
              {hasMore && (
                <button
                  type="button"
                  onClick={() => void loadMoreUsers()}
                  disabled={loadingMore}
                  aria-label="Load more team members"
                  className="w-full py-3 rounded-xl border text-sm font-semibold hover:bg-muted/50 transition-colors disabled:opacity-50 min-h-[44px]"
                >
                  {loadMoreError ? 'Retry' : loadingMore ? 'Loading…' : 'Load more'}
                </button>
              )}
            </>
          )}
        </>
      )}
    </div>
  );
}
```

**Critical orchestration notes:**

- **Period change** (D-04): triggers `loadSummary + loadTrend + loadUsersPage1` via the `useEffect([period])`.
- **Sort change** (D-20): triggers `loadUsersPage1` only — summary/trend are period-scoped.
- **Search change** (D-21): NEVER triggers a fetch — filter is purely client-side via `useMemo`.
- **Initial mount**: the period effect runs with default `D30`, kicking off all three fetches simultaneously (no waterfall).
- **No URL syncing** (per D-21 note in CONTEXT.md): period/sort/search live in component state. Mirrors the lower data scale relative to Tickets (Phase 4 deep-links because filters are diverse + URL-shareable; Engagement state is simpler).
- **No router push, no useSearchParams**: Phase 4 uses URL-synced filters, Phase 7 does NOT.

**Per ENG-09 / D-02:** This page does NOT modify `BottomNav.tsx`. Engagement entry stays in More drawer (Phase 2 already wired DRAWER-03).

**Per D-01 / D-35:** This page does NOT modify `MoreDrawer.tsx` or any desktop `app/engagement/*` files.

**Per D-30 / UI-SPEC:** Page container is `px-4 py-4 space-y-4`. The H1 is `text-sm font-semibold` (D-29 — keeps font-size count at 4: `text-sm`, `text-xs`, `text-[10px]`, `text-2xl`).

**Per D-37 / D-38:** No SWR, no react-query, no Zod.
npx tsc --noEmit --pretty - File exists: `test -f app/mobile/engagement/page.tsx` - Has `'use client';` at top: `head -1 app/mobile/engagement/page.tsx | grep -q "'use client';"` - Imports all 7 Plan 02 components: `grep -E "EngagementPeriodChips|EngagementSummaryCard|EngagementHoursSparkline|EngagementSortChips|EngagementSearchInput|EngagementUserRow|EngagementUserRowSkeleton" app/mobile/engagement/page.tsx | wc -l` ≥ 7 - Imports types from Plan 01 endpoints: `grep -F "from '@/app/api/mobile/engagement/summary/route'" app/mobile/engagement/page.tsx && grep -F "from '@/app/api/mobile/engagement/trend/route'" app/mobile/engagement/page.tsx` - Default export: `grep -E "export default function MobileEngagementPage" app/mobile/engagement/page.tsx` - Page H1 with copy "Engagement" + class `text-sm font-semibold`: `grep -F '

Engagement

' app/mobile/engagement/page.tsx` - Page container classes verbatim (D-30): `grep -F 'className="px-4 py-4 space-y-4"' app/mobile/engagement/page.tsx` - All 4 summary card labels present (D-08): `grep -F "Active users" app/mobile/engagement/page.tsx && grep -F "Total Graph hours" app/mobile/engagement/page.tsx && grep -F "Total Autotask hours" app/mobile/engagement/page.tsx && grep -F "Hours / active user" app/mobile/engagement/page.tsx` - All 4 toast.error labels (D-25): `grep -F "Failed to load engagement summary" app/mobile/engagement/page.tsx && grep -F "Failed to load hours trend" app/mobile/engagement/page.tsx && grep -F "Failed to load engagement users" app/mobile/engagement/page.tsx && grep -F "Failed to load more team members" app/mobile/engagement/page.tsx` - Empty state copy (D-26): `grep -F "No engagement data for this period" app/mobile/engagement/page.tsx` - Not-configured copy (D-27): `grep -F "Engagement sync not configured" app/mobile/engagement/page.tsx` - No-matches copy (D-22): `grep -F "No matches for" app/mobile/engagement/page.tsx && grep -F "Clear search" app/mobile/engagement/page.tsx` - Default period is D30 (D-04): `grep -E "useState\('D30'\)" app/mobile/engagement/page.tsx` - Default sort is Hours (D-20): `grep -E "useState\('Hours'\)" app/mobile/engagement/page.tsx` - SORT_TO_API mapping present and includes display_name asc + billable_hours desc: `grep -E "display_name.*asc|billable_hours.*desc" app/mobile/engagement/page.tsx | wc -l` ≥ 2 - Fetches all 3 endpoints: `grep -F "/api/mobile/engagement/summary?period=" app/mobile/engagement/page.tsx && grep -F "/api/mobile/engagement/trend?period=" app/mobile/engagement/page.tsx && grep -F "/api/engagement/users?" app/mobile/engagement/page.tsx` - IntersectionObserver with rootMargin '200px' (D-18): `grep -F "rootMargin: '200px'" app/mobile/engagement/page.tsx` - Load more button + classes (D-18): `grep -F 'className="w-full py-3 rounded-xl border text-sm font-semibold hover:bg-muted/50 transition-colors disabled:opacity-50 min-h-[44px]"' app/mobile/engagement/page.tsx` - Load more aria-label "Load more team members": `grep -F 'aria-label="Load more team members"' app/mobile/engagement/page.tsx` - Retry / Loading… / Load more labels: `grep -E "'Retry'|'Loading…'|'Load more'" app/mobile/engagement/page.tsx | wc -l` ≥ 3 - User list container has `divide-y border rounded-xl overflow-hidden`: `grep -F 'divide-y border rounded-xl overflow-hidden' app/mobile/engagement/page.tsx` - Card 4 zero-users renders "—" (D-08): `grep -E "activeUsers === 0.*'—'" app/mobile/engagement/page.tsx` - Hours formatted with .toFixed(1): `grep -E "toFixed\(1\)" app/mobile/engagement/page.tsx | wc -l` ≥ 3 - Search filter is client-side useMemo (D-21): `grep -E "useMemo|filteredUsers" app/mobile/engagement/page.tsx` - NO Zod, NO SWR, NO react-query (D-37, D-38): `! grep -E "from 'zod'|from 'swr'|from '@tanstack/react-query'" app/mobile/engagement/page.tsx` - NO router push for state (D-21 note): `! grep -E "router\.push.*setPeriod|router\.push.*sort" app/mobile/engagement/page.tsx` - NO useSearchParams (period/sort/search are component state only): `! grep -E "useSearchParams|useRouter" app/mobile/engagement/page.tsx` - BottomNav.tsx is NOT modified (Engagement is NOT a tab — ENG-09): `! grep -F "Engagement" components/mobile/BottomNav.tsx` (confirms current state preserved) - MoreDrawer.tsx still routes to /mobile/engagement (D-01): `grep -F "/mobile/engagement" components/mobile/MoreDrawer.tsx` - File length ≥ 200 lines (orchestration + render block is substantive): `wc -l < app/mobile/engagement/page.tsx | awk '{ if ($1 < 200) exit 1; else exit 0 }'` - `npx tsc --noEmit --pretty` exits 0 npx tsc --noEmit --pretty Page file written. Type-check passes. Manually visiting `/mobile/engagement` while logged in shows H1 + sticky chips + 4 stacked summary cards + sparkline + sort/search + user rows. Period chip change refetches all three datasets. Sort chip change refetches users. Search debounces 300ms and filters in place. Empty/error/not-configured states render per UI-SPEC. BottomNav and MoreDrawer remain unchanged. No type errors anywhere in the project.

<threat_model>

Trust Boundaries

Boundary Description
Authenticated browser session → /api/mobile/engagement/* Page issues GET fetches with cookie-based session; new endpoints already gated by requireAuth() (Plan 01)
Authenticated browser session → /api/engagement/users (existing) Reused as-is per D-16; this endpoint does NOT call requireAuth() (D-33 inherited risk) — middleware.ts is the only auth gate
User input (search query) → DOM Rendered via React JSX text interpolation (auto-escaped); no DB writes, no URL injection
Page state → Link href graphUserId flows into next/link href; not user-controlled (comes from API response)

STRIDE Threat Register

Threat ID Category Component Disposition Mitigation Plan
T-07-11 Spoofing/AuthN new mobile endpoints mitigate Plan 01's two new endpoints call requireAuth() first. middleware.ts also blocks unauth access to /api/* not in the public list. Verify with curl: GET /api/mobile/engagement/summary unauthenticated returns 401 / redirect.
T-07-12 Information Disclosure (inherited) reuse of /api/engagement/users (D-16, D-33) accept Existing desktop endpoint lacks requireAuth() (D-33). Reuse from this mobile page does NOT introduce new exposure: middleware.ts already requires session for /api/* (the endpoint is not in the public list per CLAUDE.md). Out of scope to fix per D-34 + PROJECT.md ("Restyling or replacing the desktop pages…"). Mirrors Phase 6 IDOR T-06P03-02 disposition pattern. Document as a STATE.md follow-up; recommend a future security phase for /api/engagement/* requireAuth() retrofit.
T-07-13 XSS / DOM injection search query, user displayName/email/jobTitle, sparkline values mitigate All user-supplied strings rendered via React JSX text interpolation (auto-escaped). Search query embedded in copy via template literal (No matches for "${searchQuery}") — React escapes the closing tags. No dangerouslySetInnerHTML, no innerHTML, no eval.
T-07-14 Tampering / Open redirect "Open Admin" + "Admin" links in banner/empty-state mitigate Both links use target="_blank" + rel="noopener noreferrer" + aria-label. The href is a hardcoded relative path /admin (not user-controlled).
T-07-15 DoS / Re-render storm period/sort change cancellation accept Period/sort change does not cancel inflight fetches — the latest setState wins because useEffect re-runs and React renders the latest state. A user thrashing chips fires multiple fetches but the result of the most recent setState is what renders. Acceptable for the data scale (≤50 staff, period in {7/30/90}). Documented as accept; revisit if perf measurements warrant AbortController.
T-07-16 DoS / Unbounded list infinite scroll mitigate Page-based pagination with size 50 from existing endpoint; hasMore derived from pagination.totalPages. Sentinel triggers ONE page-advance request per intersection (guard: `if (loadingMore
</threat_model>
- `app/mobile/engagement/page.tsx` exists, `npx tsc --noEmit --pretty` exits 0 - File is `'use client';` - Visiting `/mobile/engagement` (logged in) renders the full page in the Phase 2 shell - Period chip change refetches summary + trend + users (verify in browser network panel) - Sort chip change refetches users only (no summary/trend hits) - Search input is debounced 300ms and applies a client-side filter (verify by typing fast and watching no fetches fire) - Empty state: simulate by setting period=D7 with no data — both summary.activeUsers === 0 AND users === [] → empty card renders, period chips remain interactive - Not-configured: simulate by unsetting MSGRAPH env vars — banner replaces data sections - Error: kill the trend endpoint (e.g., 500 response) → toast.error fires + Load more flips to "Retry" if a users error - Linking: tapping a user row navigates to `/mobile/engagement/[graphUserId]` (Phase 8 destination — page may 404 in this phase, that's expected and validates the link wiring) - Verify Phase 2 reachability: open More drawer, tap "Engagement" → lands on `/mobile/engagement` (DRAWER-03 confirmation) - Verify ENG-09: Engagement is NOT in the bottom nav (`! grep -F "Engagement" components/mobile/BottomNav.tsx`)

<success_criteria>

  • One new file: app/mobile/engagement/page.tsx (no other files modified)
  • npx tsc --noEmit --pretty exits 0
  • Page renders inside Phase 2 mobile shell at /mobile/engagement
  • All Plan 02 components consumed
  • All Plan 01 endpoints called with period={D7|D30|D90} query param
  • Existing /api/engagement/users reused as-is (no modifications to that file)
  • BottomNav.tsx and MoreDrawer.tsx unchanged
  • All UI-SPEC copy strings present verbatim
  • All UI-SPEC class strings on inline elements present verbatim
  • Loading / empty / not-configured / error / no-matches states all wired
  • Infinite scroll + Load more fallback both functional </success_criteria>
After completion, create `.planning/phases/07-engagement-overview-new/07-03-SUMMARY.md` documenting: - The single file created (`app/mobile/engagement/page.tsx`) - The orchestration model (3 fetches on mount/period change, 1 fetch on sort change, 0 fetches on search) - Confirmed reachability via More drawer (DRAWER-03) - Confirmed Engagement is NOT in BottomNav (ENG-09) - Inherited risk T-07-12 (IDOR on /api/engagement/users) flagged for STATE.md follow-up - File length and any deviations from UI-SPEC (none expected)