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.
This commit is contained in:
@@ -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 })
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
Reference in New Issue
Block a user