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).
89 lines
3.3 KiB
TypeScript
89 lines
3.3 KiB
TypeScript
// Pure, dependency-free logic for the presence/attendance feature. No
|
|
// Supabase/React/Next. Unit-tested directly so the "who is present" and
|
|
// host-eligibility rules cannot silently drift.
|
|
|
|
// An open visit older than this is treated as a forgotten check-out: the
|
|
// member is no longer counted as present and the next check-in auto-closes
|
|
// it. Chosen over a cron job (no background infra, deterministic, testable).
|
|
export const PRESENCE_MAX_OPEN_HOURS = 18
|
|
|
|
export type PresenceState = 'present' | 'checked_out' | 'stale'
|
|
|
|
// `present` = open and within the freshness window (currently here)
|
|
// `checked_out` = explicitly checked out
|
|
// `stale` = open but past the window (forgotten check-out; not present)
|
|
export function presenceStatus(
|
|
checkedInAt: string,
|
|
checkedOutAt: string | null | undefined,
|
|
now?: Date | string,
|
|
maxOpenHours: number = PRESENCE_MAX_OPEN_HOURS,
|
|
): PresenceState {
|
|
if (checkedOutAt) return 'checked_out'
|
|
const inMs = new Date(checkedInAt).getTime()
|
|
if (Number.isNaN(inMs)) return 'stale'
|
|
const t = now == null ? Date.now() : new Date(now).getTime()
|
|
const ageHours = (t - inMs) / 3_600_000
|
|
return ageHours >= maxOpenHours ? 'stale' : 'present'
|
|
}
|
|
|
|
export type HostEligibility = { ok: true } | { ok: false; reason: string }
|
|
|
|
// Checking in as a host may require an active access card on file, depending
|
|
// on the per-space `host_requires_card` setting (default true). A plain
|
|
// (non-host) check-in is always allowed.
|
|
export function hostEligibility(input: {
|
|
asHost: boolean
|
|
hasActiveCard: boolean
|
|
hostRequiresCard: boolean
|
|
}): HostEligibility {
|
|
if (!input.asHost) return { ok: true }
|
|
if (input.hostRequiresCard && !input.hasActiveCard) {
|
|
return {
|
|
ok: false,
|
|
reason:
|
|
'Hosting requires an active access card on file. Ask a door manager to add one, or check in without hosting.',
|
|
}
|
|
}
|
|
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(
|
|
rows: Array<{ checked_in_at: string; checked_out_at: string | null; is_host: boolean }>,
|
|
now?: Date | string,
|
|
): { present: number; hosts: number } {
|
|
let present = 0
|
|
let hosts = 0
|
|
for (const r of rows) {
|
|
if (presenceStatus(r.checked_in_at, r.checked_out_at, now) === 'present') {
|
|
present++
|
|
if (r.is_host) hosts++
|
|
}
|
|
}
|
|
return { present, hosts }
|
|
}
|