wulf-pulse/components/mobile/EngagementRecentMeetings.tsx
lorentz 81079ad89f fix(08-02): coerce Postgres numeric hours_worked to Number before toFixed
Postgres returns NUMERIC columns as strings via pg, so calling
.toFixed(1) on time_entries.hours_worked from /api/engagement/user/[userId]
threw at runtime. The period-level hours in the same response are already
parseFloat'd; the recentEntries and matchedEntries arrays pass rows through
verbatim, so wrap with Number() at render.
2026-05-07 22:02:52 -04:00

142 lines
6 KiB
TypeScript

'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">{Number(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>
);
}