diff --git a/app/mobile/engagement/[userId]/page.tsx b/app/mobile/engagement/[userId]/page.tsx
index e3452da..632b67a 100644
--- a/app/mobile/engagement/[userId]/page.tsx
+++ b/app/mobile/engagement/[userId]/page.tsx
@@ -7,6 +7,9 @@ import { EngagementPeriodChips, type EngagementPeriod } from '@/components/mobil
import { EngagementProfileSkeleton } from '@/components/mobile/EngagementProfileSkeleton';
import { EngagementProfileHeader } from '@/components/mobile/EngagementProfileHeader';
import { EngagementProfileMetricGrid } from '@/components/mobile/EngagementProfileMetricGrid';
+import { EngagementProfileBreakdown } from '@/components/mobile/EngagementProfileBreakdown';
+import { EngagementRecentEntries } from '@/components/mobile/EngagementRecentEntries';
+import { EngagementRecentMeetings } from '@/components/mobile/EngagementRecentMeetings';
interface ApiResponse {
user: {
@@ -201,7 +204,24 @@ export default function MobileEngagementUserProfilePage({
daysWorked={daysWorkedForPeriod}
meetingsAttended={Number(meetingsAttended)}
/>
- {/* Task 2 will mount EngagementProfileBreakdown + EngagementRecentEntries + EngagementRecentMeetings here. */}
+
+
+
>
)}
diff --git a/components/mobile/EngagementProfileBreakdown.tsx b/components/mobile/EngagementProfileBreakdown.tsx
new file mode 100644
index 0000000..6c8b12f
--- /dev/null
+++ b/components/mobile/EngagementProfileBreakdown.tsx
@@ -0,0 +1,96 @@
+'use client';
+
+/* EngagementProfileBreakdown — phase 08 (D-14..D-17).
+ * Purpose: Single Card with three labeled subsections — Time / Communication / Meetings.
+ * Rows use py-2 (UI-SPEC override of D-16's py-1.5). After-hours and Zoom rows are
+ * conditional based on value presence (D-15, D-17). */
+
+import { Card, CardContent } from '@/components/ui/card';
+
+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;
+}
+
+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 (
+
+
{label}
+ {value}
+
+ );
+}
+
+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 (
+
+
+ {/* Time */}
+
+ Time
+
+
+
+
+ {utilizationPct !== null && (
+
+ )}
+
+
+
+ {/* Communication */}
+
+ Communication
+
+
+
+ {showAfterHours && (
+
+
-
+ After-hours · {props.afterHoursMessagesPct}% messages, {props.afterHoursMeetingsPct}% meetings
+
+
+
+ )}
+
+
+
+ {/* Meetings */}
+
+ Meetings
+
+
+
+
+ {showZoom && (
+
+ )}
+
+
+
+
+ );
+}
diff --git a/components/mobile/EngagementRecentEntries.tsx b/components/mobile/EngagementRecentEntries.tsx
new file mode 100644
index 0000000..dbf216c
--- /dev/null
+++ b/components/mobile/EngagementRecentEntries.tsx
@@ -0,0 +1,103 @@
+'use client';
+
+/* EngagementRecentEntries — phase 08 (D-18..D-21).
+ * Purpose: Collapsible list of up to 10 recent time entries. Tap-to-expand
+ * reveals full notes, title, company, and start timestamp. Local Set
+ * state tracks expanded IDs — period changes do not reset expansion (D-20). */
+
+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 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)
+}
+
+export function EngagementRecentEntries({ entries }: EngagementRecentEntriesProps) {
+ const tz = useUserTimezone();
+ const [expandedIds, setExpandedIds] = useState>(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 (
+
+
+ Recent time entries
+ {entries.length === 0 ? (
+ No time entries in the last 30 days
+ ) : (
+
+ {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 (
+ -
+ toggle(id)}>
+
+
+
+
+ {entry.title &&
Title: {entry.title}
}
+ {entry.company_name && Company: {entry.company_name}
}
+ {entry.notes && {entry.notes}
}
+ {entry.start_date_time && (
+
+ Started:{' '}
+ {formatInUserTimezone(entry.start_date_time, tz, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' })}
+
+ )}
+
+
+
+ );
+ })}
+
+ )}
+
+
+ );
+}
diff --git a/components/mobile/EngagementRecentMeetings.tsx b/components/mobile/EngagementRecentMeetings.tsx
new file mode 100644
index 0000000..4b4ebb6
--- /dev/null
+++ b/components/mobile/EngagementRecentMeetings.tsx
@@ -0,0 +1,142 @@
+'use client';
+
+/* EngagementRecentMeetings — phase 08 (D-18..D-21).
+ * Purpose: Collapsible list of up to 10 recent Teams meetings. Tap-to-expand
+ * reveals matched time entries and attendee list. Local Set
+ * state tracks expanded IDs — period changes do not reset expansion (D-20). */
+
+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 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[];
+}
+
+export function EngagementRecentMeetings({ meetings }: EngagementRecentMeetingsProps) {
+ const tz = useUserTimezone();
+ const [expandedIds, setExpandedIds] = useState>(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 (
+
+
+ Recent meetings
+ {meetings.length === 0 ? (
+ No meetings recorded
+ ) : (
+
+ {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 (
+ -
+ toggle(id)}>
+
+
+
+
+ {meeting.matchedEntries.length > 0 && (
+
+
Matched time entries:
+
+ {meeting.matchedEntries.map((te, j) => (
+ -
+ {te.hours_worked.toFixed(1)}h
+ {te.company_name && · {te.company_name}}
+ {te.notes && · {te.notes.split('\n')[0]?.slice(0, 80) ?? ''}}
+
+ ))}
+
+
+ )}
+ {visibleParticipants.length > 0 && (
+
+ Attendees:{' '}
+ {visibleParticipants.join(', ')}
+ {moreCount > 0 ? ` and ${moreCount} more` : ''}
+
+ )}
+ {durationLabel && (
+
+ Duration: {durationLabel}
+
+ )}
+
+
+
+ );
+ })}
+
+ )}
+
+
+ );
+}