feat(08-02): activity breakdown + recent entries + meetings + page wiring (Task 2)

- New EngagementProfileBreakdown: Time/Communication/Meetings subsections, after-hours
  and Zoom conditional rows, py-2 per UI-SPEC override
- New EngagementRecentEntries: collapsible list up to 10, Billable badge, Set<string>
  expand state, empty-state copy
- New EngagementRecentMeetings: collapsible list up to 10, matched entries + attendees
  in expanded view, (no subject) fallback, Set<string> expand state
- Page updated: 3 new component imports + breakdown/entries/meetings mounted in order
- No dangerouslySetInnerHTML; D-01/D-22 guard rails untouched
This commit is contained in:
lorentz 2026-05-07 20:50:56 -04:00
parent df78ab8fa5
commit 0be0c1f7f8
4 changed files with 362 additions and 1 deletions

View file

@ -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. */}
<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} />
</>
)}
</div>

View file

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

View file

@ -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<string>
* 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<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>
);
}

View file

@ -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<string>
* 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<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>
);
}