Files
hope-events/frontend/src/lib/sessions.ts
T
joshuaandClaude Opus 5.5 f99b84858e Session gaps: per-session waitlist, door session picker, all-sessions choice
- Waitlist per session: entries for pick-sessions tickets name a session;
  offers use that session's free places, never run past its start, and
  claim links tick it. Held offers count towards session capacity.
- At the door: the Select items pop-up has the session picker, with
  today's session preselected for new attendees.
- On pick-sessions events every ticket shows the session list straight
  away. "All sessions" tickets record the ticked sessions
  (RegistrationOption.sessionIds) at one price; scanning, attendance,
  certificates, reminders and capacity follow them.
- Fix: per-session stock limits weren't enforced (takenCount filtered
  WaitlistEntry by a sessionId column that didn't exist, and the error
  was swallowed), and the public event page lost its stock info.
- Fix: editing a door registration with picked sessions couldn't be saved.
- Fix: the offline-scanning service worker served /_next/static cache-first
  forever, so dev browsers that had opened the scanner ran stale code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 11:38:21 +02:00

203 lines
9.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { Event, EventSession, LateJoinInfo } from "@/types";
// Multi-day events — browser-side helpers. The rules live in backend utils/sessions.js;
// latePrice below mirrors it so prices shown match what the server charges.
export type SessionPattern = {
frequency: "daily" | "weekly";
interval: number; // every N days/weeks
weekdays: number[]; // weekly only: 0 = Sunday … 6 = Saturday
startDate: string; // YYYY-MM-DD (local)
startTime: string; // HH:MM
endTime: string; // HH:MM (an end before the start runs past midnight)
endsBy: "count" | "until";
count: number;
until: string; // YYYY-MM-DD (local)
};
export type SessionDraft = {
id?: string;
label: string;
startsAt: string; // datetime-local value (YYYY-MM-DDTHH:MM, local)
endsAt: string;
location: string;
cancelled: boolean;
capacity: string; // "pick sessions" events; blank = no limit
hasCheckIns?: boolean;
};
const MAX_GENERATED = 200;
function localDT(d: Date): string {
const p = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}T${p(d.getHours())}:${p(d.getMinutes())}`;
}
function atTime(day: Date, hhmm: string): Date {
const [h, m] = hhmm.split(":").map(Number);
const d = new Date(day);
d.setHours(h || 0, m || 0, 0, 0);
return d;
}
/** Turns a repeat pattern into session rows (local time). */
export function generateSessions(p: SessionPattern): SessionDraft[] {
const [y, mo, d] = p.startDate.split("-").map(Number);
if (!y || !mo || !d || !p.startTime || !p.endTime) return [];
const first = new Date(y, mo - 1, d);
const until = p.endsBy === "until" && p.until ? (() => { const [uy, um, ud] = p.until.split("-").map(Number); return new Date(uy, um - 1, ud, 23, 59); })() : null;
const limit = p.endsBy === "count" ? Math.min(Math.max(1, p.count || 1), MAX_GENERATED) : MAX_GENERATED;
const interval = Math.max(1, p.interval || 1);
const weekdays = p.frequency === "weekly" ? (p.weekdays.length ? [...p.weekdays].sort() : [first.getDay()]) : [];
const out: SessionDraft[] = [];
const push = (day: Date) => {
const start = atTime(day, p.startTime);
let end = atTime(day, p.endTime);
if (end <= start) end = new Date(end.getTime() + 24 * 60 * 60 * 1000);
out.push({ label: "", startsAt: localDT(start), endsAt: localDT(end), location: "", cancelled: false, capacity: "" });
};
if (p.frequency === "daily") {
for (let i = 0, day = new Date(first); out.length < limit; i++, day.setDate(day.getDate() + interval)) {
if (until && day > until) break;
push(day);
if (i > 5000) break;
}
return out;
}
// Weekly: walk week by week from the week containing the start date (weeks start Sunday).
const weekStart = new Date(first);
weekStart.setDate(first.getDate() - first.getDay());
for (let w = 0; out.length < limit && w < 1000; w += interval) {
for (const wd of weekdays) {
const day = new Date(weekStart);
day.setDate(weekStart.getDate() + w * 7 + wd);
if (day < first) continue;
if (until && day > until) return out;
if (out.length >= limit) return out;
push(day);
}
}
return out;
}
export function toSessionDraft(s: EventSession & { hasCheckIns?: boolean }): SessionDraft {
return { id: s.id, label: s.label || "", startsAt: localDT(new Date(s.startsAt)), endsAt: localDT(new Date(s.endsAt)), location: s.location || "", cancelled: !!(s.cancelled || s.cancelledAt), capacity: s.capacity ? String(s.capacity) : "", hasCheckIns: s.hasCheckIns };
}
/** Sessions that aren't cancelled, earliest first. */
export function activeSessions<T extends { startsAt: string; cancelled?: boolean; cancelledAt?: string | null }>(sessions: T[] | undefined | null): T[] {
return (sessions || []).filter(s => !(s.cancelled || s.cancelledAt)).sort((a, b) => new Date(a.startsAt).getTime() - new Date(b.startsAt).getTime());
}
/** "Week 1" — the session's label, else its position among active sessions. */
export function sessionLabel(session: EventSession, sessions: EventSession[] | undefined | null): string {
if (session.label) return session.label;
const idx = activeSessions(sessions).findIndex(s => s.id === session.id);
return `Session ${idx >= 0 ? idx + 1 : ""}`.trim();
}
const dayFmt = new Intl.DateTimeFormat("en-GB", { weekday: "short", day: "numeric", month: "short" });
const timeFmt = new Intl.DateTimeFormat("en-GB", { hour: "2-digit", minute: "2-digit", hour12: false });
/** "Wed 4 Nov, 18:30–20:30" */
export function formatSessionWhen(s: { startsAt: string; endsAt: string }): string {
const start = new Date(s.startsAt);
const end = new Date(s.endsAt);
const sameDay = start.toDateString() === end.toDateString();
return sameDay
? `${dayFmt.format(start)}, ${timeFmt.format(start)}–${timeFmt.format(end)}`
: `${dayFmt.format(start)} ${timeFmt.format(start)} – ${dayFmt.format(end)} ${timeFmt.format(end)}`;
}
/** "5 sessions · Wed 4 Nov – Wed 2 Dec" for event cards; null for single-date events. */
export function scheduleSummary(event: Pick<Event, "scheduleType" | "sessions">): string | null {
if (event.scheduleType !== "sessions") return null;
const active = activeSessions(event.sessions);
if (active.length === 0) return null;
const first = dayFmt.format(new Date(active[0].startsAt));
const last = dayFmt.format(new Date(active[active.length - 1].startsAt));
return active.length === 1 ? `1 session · ${first}` : `${active.length} sessions · ${first} – ${last}`;
}
/** When public registration closes (falls back for older API responses). */
export function registrationClosesAt(event: Pick<Event, "registrationClosesAt" | "registrationDeadline" | "endDate">): Date {
if (event.registrationClosesAt) return new Date(event.registrationClosesAt);
const end = new Date(event.endDate);
const deadline = event.registrationDeadline ? new Date(event.registrationDeadline) : null;
return deadline && deadline < end ? deadline : end;
}
/** A main ticket's price for a late joiner — same rule as the backend. */
export function latePrice(price: number, info: LateJoinInfo | null | undefined): number {
if (!info || !info.active || !(price > 0)) return price;
if (info.mode === "fixed") return Math.min(price, Math.max(0, Number(info.fixedPrice)));
if (info.mode === "prorata") {
if (!info.totalSessions) return price;
const rounded = Math.round((price * info.sessionsLeft / info.totalSessions) / 10) * 10;
return Math.min(price, Math.max(10, rounded));
}
return price;
}
export const REMINDER_OPTIONS = [
{ minutes: 60, label: "1 hour before" },
{ minutes: 180, label: "3 hours before" },
{ minutes: 1440, label: "1 day before" },
] as const;
export const CERTIFICATE_TEMPLATES = [
{ id: "classic", name: "Classic", blurb: "Double border, serif type" },
{ id: "modern", name: "Modern", blurb: "Clean, with a colour bar" },
{ id: "church", name: "Church", blurb: "Logo-led and soft" },
{ id: "kids", name: "Kids", blurb: "Bright and playful" },
] as const;
export const DEFAULT_CERTIFICATE_BODY = "This certifies that {name} has completed {event}, attending {sessionsAttended} of {totalSessions} sessions.";
/** Label of the session open for check-in today (whole-day rule, browser time), or null. */
export function currentSessionLabel(event: Pick<Event, "scheduleType" | "sessions"> | null | undefined): string | null {
if (!event || event.scheduleType !== "sessions") return null;
const today = new Date().toDateString();
const s = activeSessions(event.sessions).find(x => new Date(x.startsAt).toDateString() === today);
return s ? sessionLabel(s, event.sessions) : null;
}
/** The session of a multi-day event open for check-in today (browser's local day), or null. */
export function todaysSession(event: Pick<Event, "scheduleType" | "sessions"> | null | undefined): EventSession | null {
if (!event || event.scheduleType !== "sessions") return null;
const today = new Date().toDateString();
const now = Date.now();
const list = activeSessions(event.sessions).filter(x => new Date(x.startsAt).toDateString() === today);
return list.find(s => now <= new Date(s.endsAt).getTime()) || list[list.length - 1] || null;
}
/**
* Whether people tick sessions when booking this event ("pick sessions" attendance).
* Every ticket shows the sessions; only per-session tickets are charged per session.
*/
export function choosesSessions(event: Pick<Event, "scheduleType" | "attendanceMode"> | null | undefined): boolean {
return event?.scheduleType === "sessions" && event?.attendanceMode === "pick";
}
/** Whether an option is bought per session (only in "pick sessions" events). */
export function isPickOption(event: Pick<Event, "scheduleType" | "attendanceMode"> | null | undefined, option: { sessionMode?: "all" | "pick" } | null | undefined): boolean {
return event?.scheduleType === "sessions" && event?.attendanceMode === "pick" && option?.sessionMode === "pick";
}
/**
* Sessions a pick option can still be booked for, with places left (null = no limit).
* Public: before the session starts; staff: until it ends.
*/
export function bookableSessions(event: Pick<Event, "sessions">, option: { sessionAvailability?: Record<string, number> }, staff = false): (EventSession & { left: number | null })[] {
const now = Date.now();
return activeSessions(event.sessions)
.filter(s => new Date(staff ? s.endsAt : s.startsAt).getTime() > now)
.map(s => {
const limits = [s.placesLeft, option.sessionAvailability?.[s.id]].filter((n): n is number => typeof n === "number");
return { ...s, left: limits.length ? Math.min(...limits) : null };
});
}