From d5a71c9448d142a4ca1b8b1c8a0a516443a8265c Mon Sep 17 00:00:00 2001 From: Moheeb Zara Date: Sat, 16 May 2026 22:42:07 -0700 Subject: [PATCH] feat(attendance): richer /attendance (here-now, search, day filter, grouping) Per request. /attendance is now a client view: a 'Here now' section with host badges + inline self check-in / check-in-as-host / check-out with a note; and a history with name search, a day picker, grouped-by- day headers, per-visit duration, status, notes, and a live visits/members summary for the current filter. New pure tested helpers dayKey (tz-stable UTC bucket) + visitDurationMinutes (+5 tests, suite 459). --- __tests__/presence-logic.test.ts | 26 +++ app/(app)/attendance/attendance-client.tsx | 228 +++++++++++++++++++++ app/(app)/attendance/page.tsx | 95 +-------- lib/presence-logic.ts | 23 +++ 4 files changed, 288 insertions(+), 84 deletions(-) create mode 100644 app/(app)/attendance/attendance-client.tsx diff --git a/__tests__/presence-logic.test.ts b/__tests__/presence-logic.test.ts index 19ab4eb..7c09862 100644 --- a/__tests__/presence-logic.test.ts +++ b/__tests__/presence-logic.test.ts @@ -3,6 +3,8 @@ import { presenceStatus, hostEligibility, summarizePresence, + dayKey, + visitDurationMinutes, PRESENCE_MAX_OPEN_HOURS, } from '@/lib/presence-logic' @@ -63,3 +65,27 @@ describe('summarizePresence', () => { expect(summarizePresence([], NOW)).toEqual({ present: 0, hosts: 0 }) }) }) + +describe('dayKey', () => { + it('returns the UTC date portion, stable regardless of runtime tz', () => { + expect(dayKey('2026-05-17T23:30:00Z')).toBe('2026-05-17') + expect(dayKey('2026-01-02T00:00:00+00:00')).toBe('2026-01-02') + }) + it('returns empty for unparseable input', () => { + expect(dayKey('not-a-date')).toBe('') + expect(dayKey('')).toBe('') + }) +}) + +describe('visitDurationMinutes', () => { + it('uses checkout when present', () => { + expect(visitDurationMinutes('2026-05-17T10:00:00Z', '2026-05-17T11:30:00Z')).toBe(90) + }) + it('uses now when still open', () => { + expect(visitDurationMinutes(hoursAgo(2), null, NOW)).toBe(120) + }) + it('never negative; unparseable -> 0', () => { + expect(visitDurationMinutes('2026-05-17T12:00:00Z', '2026-05-17T11:00:00Z')).toBe(0) + expect(visitDurationMinutes('nope', null, NOW)).toBe(0) + }) +}) diff --git a/app/(app)/attendance/attendance-client.tsx b/app/(app)/attendance/attendance-client.tsx new file mode 100644 index 0000000..9a5efca --- /dev/null +++ b/app/(app)/attendance/attendance-client.tsx @@ -0,0 +1,228 @@ +'use client' + +import { useMemo, useState } from 'react' +import { useRouter } from 'next/navigation' +import { toast } from 'sonner' +import { PageHeader, PageTitle } from '@/components/ui/page-title' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Empty, EmptyHeader, EmptyMedia, EmptyTitle, EmptyDescription } from '@/components/ui/empty' +import { CalendarCheck } from 'lucide-react' +import { checkIn, checkOut } from '@/lib/actions' +import { dayKey, visitDurationMinutes } from '@/lib/presence-logic' + +type Present = { id: string; name: string; isHost: boolean; isMe: boolean; checkedInAt: string; note: string | null } +type Row = { + id: string + name: string + isHost: boolean + status: 'present' | 'checked_out' | 'stale' + checkedInAt: string + checkedOutAt: string | null + checkInNote: string | null + checkOutNote: string | null +} + +const input = + 'w-full bg-background border border-border text-foreground font-sans text-sm rounded px-3 py-2 focus:outline-none focus:border-primary' + +const STATUS: Record = { + present: { label: 'Here now', cls: 'text-primary' }, + checked_out: { label: 'Checked out', cls: 'text-muted-foreground' }, + stale: { label: 'No checkout', cls: 'text-amber-600' }, +} + +function fmtDur(mins: number): string { + if (mins < 60) return `${mins}m` + return `${Math.floor(mins / 60)}h ${mins % 60}m` +} +function fmtTime(iso: string) { + return new Date(iso).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) +} +function fmtDayHeading(key: string) { + const d = new Date(key + 'T00:00:00Z') + return d.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric', timeZone: 'UTC' }) +} + +export function AttendanceClient({ present, history }: { present: Present[]; history: Row[] }) { + const router = useRouter() + const [busy, setBusy] = useState(false) + const [note, setNote] = useState('') + const [query, setQuery] = useState('') + const [day, setDay] = useState('') // '' = all days + + const me = present.find(p => p.isMe) + const amHere = !!me + const hosts = present.filter(p => p.isHost).length + + async function act(fn: () => Promise<{ error?: string } | { data: unknown }>, ok: string) { + setBusy(true) + const res = await fn() + setBusy(false) + if ('error' in res && res.error) return toast.error(res.error) + setNote('') + toast.success(ok) + router.refresh() + } + + const filtered = useMemo(() => { + const q = query.trim().toLowerCase() + return history.filter(r => { + if (q && !r.name.toLowerCase().includes(q)) return false + if (day && dayKey(r.checkedInAt) !== day) return false + return true + }) + }, [history, query, day]) + + const groups = useMemo(() => { + const m = new Map() + for (const r of filtered) { + const k = dayKey(r.checkedInAt) || 'unknown' + ;(m.get(k) ?? m.set(k, []).get(k)!).push(r) + } + return [...m.entries()].sort((a, b) => b[0].localeCompare(a[0])) + }, [filtered]) + + const uniqueMembers = useMemo(() => new Set(filtered.map(r => r.name)).size, [filtered]) + + return ( + <> + + Attendance + + +
+ {/* Here now + self check-in/out */} +
+

+ Here now ({present.length}{hosts > 0 ? ` · ${hosts} hosting` : ''}) +

+
+ {present.length === 0 ? ( +

Nobody is checked in right now.

+ ) : ( +
    + {present.map(p => ( +
  • + + {p.name}{p.isMe ? ' (you)' : ''} + {p.note && {p.note}} + + + {p.isHost && Host} + since {fmtTime(p.checkedInAt)} + +
  • + ))} +
+ )} +
+ setNote(e.target.value)} + /> +
+ {amHere ? ( + + ) : ( + <> + + + + )} +
+
+
+
+ + {/* History with search + day filter */} +
+
+

+ History · {filtered.length} visit{filtered.length === 1 ? '' : 's'} · {uniqueMembers} member{uniqueMembers === 1 ? '' : 's'} +

+
+ setQuery(e.target.value)} + /> + setDay(e.target.value)} + /> + {(query || day) && ( + + )} +
+
+ + {filtered.length === 0 ? ( + + + + {history.length === 0 ? 'No visits recorded yet' : 'No visits match'} + + {history.length === 0 + ? 'When members check in, their visits appear here.' + : 'Try a different name or day, or clear the filters.'} + + + + ) : ( +
+ {groups.map(([key, rows]) => ( +
+

+ {key === 'unknown' ? 'Unknown date' : fmtDayHeading(key)} · {rows.length} +

+
+ {rows.map(r => { + const s = STATUS[r.status] + return ( +
+
+
+ {r.name} + {r.isHost && Host} + {s.label} +
+ {(r.checkInNote || r.checkOutNote) && ( +

+ {r.checkInNote ? `in: ${r.checkInNote}` : ''} + {r.checkInNote && r.checkOutNote ? ' · ' : ''} + {r.checkOutNote ? `out: ${r.checkOutNote}` : ''} +

+ )} +
+
+ {fmtTime(r.checkedInAt)}{r.checkedOutAt ? `–${fmtTime(r.checkedOutAt)}` : ''} +
+ {fmtDur(visitDurationMinutes(r.checkedInAt, r.checkedOutAt))} + {!r.checkedOutAt && r.status === 'present' ? ' so far' : r.checkedOutAt ? ' visit' : ''} +
+
+ ) + })} +
+
+ ))} +
+ )} +
+
+ + ) +} diff --git a/app/(app)/attendance/page.tsx b/app/(app)/attendance/page.tsx index d2f7990..aeb542b 100644 --- a/app/(app)/attendance/page.tsx +++ b/app/(app)/attendance/page.tsx @@ -1,96 +1,23 @@ import { redirect } from 'next/navigation' import { createClient } from '@/lib/supabase/server' -import { PageHeader, PageTitle } from '@/components/ui/page-title' -import { Badge } from '@/components/ui/badge' -import { Empty, EmptyHeader, EmptyMedia, EmptyTitle, EmptyDescription } from '@/components/ui/empty' -import { CalendarCheck } from 'lucide-react' -import { listAttendance } from '@/lib/actions' +import { listAttendance, listPresentNow } from '@/lib/actions' +import { AttendanceClient } from './attendance-client' export const dynamic = 'force-dynamic' -type Row = { - id: string - name: string - isHost: boolean - status: 'present' | 'checked_out' | 'stale' - checkedInAt: string - checkedOutAt: string | null - checkInNote: string | null - checkOutNote: string | null -} - -const STATUS: Record = { - present: { label: 'Here now', cls: 'text-primary' }, - checked_out: { label: 'Checked out', cls: 'text-muted-foreground' }, - stale: { label: 'No checkout', cls: 'text-amber-600' }, -} - -function dur(inAt: string, outAt: string | null): string { - const end = outAt ? new Date(outAt).getTime() : Date.now() - const mins = Math.max(0, Math.round((end - new Date(inAt).getTime()) / 60000)) - if (mins < 60) return `${mins}m` - const h = Math.floor(mins / 60) - return `${h}h ${mins % 60}m` -} - export default async function AttendancePage() { const supabase = await createClient() const { data: { user } } = await supabase.auth.getUser() if (!user) redirect('/login') - const res = await listAttendance() - const rows = ('data' in res ? res.data : []) as Row[] + const [presentRes, historyRes] = await Promise.all([listPresentNow(), listAttendance()]) + const present = ('data' in presentRes ? presentRes.data : []) as { + id: string; name: string; isHost: boolean; isMe: boolean; checkedInAt: string; note: string | null + }[] + const history = ('data' in historyRes ? historyRes.data : []) as { + id: string; name: string; isHost: boolean; status: 'present' | 'checked_out' | 'stale' + checkedInAt: string; checkedOutAt: string | null; checkInNote: string | null; checkOutNote: string | null + }[] - return ( - <> - - Attendance - - -
- {rows.length === 0 ? ( - - - - No visits recorded yet - - When members check in from the dashboard, their visits appear here. - - - - ) : ( -
- {rows.map(r => { - const s = STATUS[r.status] - return ( -
-
-
- {r.name} - {r.isHost && Host} - {s.label} -
- {(r.checkInNote || r.checkOutNote) && ( -

- {r.checkInNote ? `in: ${r.checkInNote}` : ''} - {r.checkInNote && r.checkOutNote ? ' · ' : ''} - {r.checkOutNote ? `out: ${r.checkOutNote}` : ''} -

- )} -
-
- {new Date(r.checkedInAt).toLocaleString()} -
- {r.checkedOutAt - ? `${dur(r.checkedInAt, r.checkedOutAt)} visit` - : r.status === 'present' ? `${dur(r.checkedInAt, null)} so far` : 'open'} -
-
- ) - })} -
- )} -
- - ) + return } diff --git a/lib/presence-logic.ts b/lib/presence-logic.ts index 7387bd7..5849bec 100644 --- a/lib/presence-logic.ts +++ b/lib/presence-logic.ts @@ -47,6 +47,29 @@ export function hostEligibility(input: { return { ok: true } } +// Stable day bucket for grouping/filtering attendance. Uses the UTC date +// portion of the ISO timestamp so it is deterministic (not affected by the +// runtime's timezone) and testable. Returns '' for an unparseable value. +export function dayKey(iso: string): string { + return /^\d{4}-\d{2}-\d{2}/.test(iso) ? iso.slice(0, 10) : '' +} + +// Visit length in whole minutes (end = checkout, or `now` if still open). +// Never negative; unparseable input -> 0. +export function visitDurationMinutes( + checkedInAt: string, + checkedOutAt: string | null | undefined, + now?: Date | string, +): number { + const start = new Date(checkedInAt).getTime() + if (Number.isNaN(start)) return 0 + const endMs = checkedOutAt + ? new Date(checkedOutAt).getTime() + : now == null ? Date.now() : new Date(now).getTime() + if (Number.isNaN(endMs)) return 0 + return Math.max(0, Math.floor((endMs - start) / 60000)) +} + // Count how many visits are currently present, and how many of those are // hosting. Stale/checked-out rows are excluded. export function summarizePresence(