From 654255f837a2acd5e131315260acf6325f25bfac Mon Sep 17 00:00:00 2001 From: Moheeb Zara Date: Sat, 16 May 2026 22:12:28 -0700 Subject: [PATCH] feat(presence): pure presence-logic (status/host-eligibility/summary) + 11 tests presenceStatus(in,out,now,maxOpenHours) -> present|checked_out|stale (>=window boundary = stale; unparseable in-time = stale, never present); hostEligibility honoring the per-space host_requires_card toggle; summarizePresence counts present + hosts. PRESENCE_MAX_OPEN_HOURS=18. Suite 421 -> 432. --- __tests__/presence-logic.test.ts | 65 ++++++++++++++++++++++++++++++++ lib/presence-logic.ts | 65 ++++++++++++++++++++++++++++++++ 2 files changed, 130 insertions(+) create mode 100644 __tests__/presence-logic.test.ts create mode 100644 lib/presence-logic.ts diff --git a/__tests__/presence-logic.test.ts b/__tests__/presence-logic.test.ts new file mode 100644 index 0000000..19ab4eb --- /dev/null +++ b/__tests__/presence-logic.test.ts @@ -0,0 +1,65 @@ +import { describe, it, expect } from 'vitest' +import { + presenceStatus, + hostEligibility, + summarizePresence, + PRESENCE_MAX_OPEN_HOURS, +} from '@/lib/presence-logic' + +const NOW = '2026-05-17T20:00:00Z' +const hoursAgo = (h: number) => new Date(Date.parse(NOW) - h * 3_600_000).toISOString() + +describe('presenceStatus', () => { + it('an explicit checkout short-circuits to checked_out', () => { + expect(presenceStatus(hoursAgo(1), hoursAgo(0.5), NOW)).toBe('checked_out') + // even a stale-age open-then-closed visit is checked_out + expect(presenceStatus(hoursAgo(50), hoursAgo(40), NOW)).toBe('checked_out') + }) + it('open and fresh = present', () => { + expect(presenceStatus(hoursAgo(0), null, NOW)).toBe('present') + expect(presenceStatus(hoursAgo(PRESENCE_MAX_OPEN_HOURS - 0.1), null, NOW)).toBe('present') + }) + it('open at/over the window = stale (>= boundary)', () => { + expect(presenceStatus(hoursAgo(PRESENCE_MAX_OPEN_HOURS), null, NOW)).toBe('stale') + expect(presenceStatus(hoursAgo(PRESENCE_MAX_OPEN_HOURS + 5), null, NOW)).toBe('stale') + }) + it('respects a custom window', () => { + expect(presenceStatus(hoursAgo(3), null, NOW, 2)).toBe('stale') + expect(presenceStatus(hoursAgo(1), null, NOW, 2)).toBe('present') + }) + it('an unparseable check-in time is treated as not-present (stale), never present', () => { + expect(presenceStatus('not-a-date', null, NOW)).toBe('stale') + }) +}) + +describe('hostEligibility', () => { + it('a non-host check-in is always allowed', () => { + expect(hostEligibility({ asHost: false, hasActiveCard: false, hostRequiresCard: true }).ok).toBe(true) + }) + it('host requires a card when the space requires it and there is none', () => { + const r = hostEligibility({ asHost: true, hasActiveCard: false, hostRequiresCard: true }) + expect(r.ok).toBe(false) + if (!r.ok) expect(r.reason).toMatch(/active access card/i) + }) + it('host with a card is allowed', () => { + expect(hostEligibility({ asHost: true, hasActiveCard: true, hostRequiresCard: true }).ok).toBe(true) + }) + it('host without a card is allowed when the space does not require one', () => { + expect(hostEligibility({ asHost: true, hasActiveCard: false, hostRequiresCard: false }).ok).toBe(true) + }) +}) + +describe('summarizePresence', () => { + it('counts only present rows and the hosts among them', () => { + const rows = [ + { checked_in_at: hoursAgo(1), checked_out_at: null, is_host: true }, // present host + { checked_in_at: hoursAgo(2), checked_out_at: null, is_host: false }, // present + { checked_in_at: hoursAgo(3), checked_out_at: hoursAgo(1), is_host: true }, // checked out + { checked_in_at: hoursAgo(40), checked_out_at: null, is_host: true }, // stale + ] + expect(summarizePresence(rows, NOW)).toEqual({ present: 2, hosts: 1 }) + }) + it('empty list = zero', () => { + expect(summarizePresence([], NOW)).toEqual({ present: 0, hosts: 0 }) + }) +}) diff --git a/lib/presence-logic.ts b/lib/presence-logic.ts new file mode 100644 index 0000000..7387bd7 --- /dev/null +++ b/lib/presence-logic.ts @@ -0,0 +1,65 @@ +// 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 } +} + +// 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 } +}