wulf-pulse/components/mobile/EngagementRecentEntries.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

103 lines
4.5 KiB
TypeScript

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