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

    + )} +
    +
    +
  • + ); + })} +
+ )} +
+
+ ); +}