409 lines
13 KiB
TypeScript
409 lines
13 KiB
TypeScript
/* ============================================================================
|
||
EVENTS - view model, status derivation, formatting
|
||
----------------------------------------------------------------------------
|
||
PURE. No server import, so the same helpers run in the loader and in the
|
||
component. Everything that talks to Strapi lives in ./events.server.ts.
|
||
|
||
The three calendar states the draft asks for (Upcoming / Ongoing /
|
||
Completed, draft section 4) are DERIVED from the dates rather than stored,
|
||
so an editor cannot forget to move an event to "Completed" the morning
|
||
after it happened. The registration state (Open / Closed / Full) is a real
|
||
editorial decision and does come from the CMS.
|
||
========================================================================= */
|
||
|
||
import {
|
||
formatDate,
|
||
formatDateRange,
|
||
translate,
|
||
type Locale,
|
||
type TranslationKey,
|
||
} from "~/i18n";
|
||
|
||
import type { CmsImage, CmsRichText } from "../_shared/cms";
|
||
import { getRouteCopy } from "./copy";
|
||
|
||
/* ---------------------------------------------------------------------------
|
||
TIME ZONE
|
||
The project is nationwide Libya, which sits on UTC+2 all year (no DST since
|
||
2013). Pinning the zone keeps the server-rendered string and the hydrated
|
||
one identical whatever the reader's device is set to, and shows the local
|
||
time of the room the event is held in.
|
||
------------------------------------------------------------------------ */
|
||
|
||
export const EVENT_TIME_ZONE = "Africa/Tripoli";
|
||
const EVENT_UTC_OFFSET = "+02:00";
|
||
|
||
/* ---------------------------------------------------------------------------
|
||
TYPES
|
||
------------------------------------------------------------------------ */
|
||
|
||
/** How an event is attended. Mirrors the CMS enumeration. */
|
||
export type EventFormat = "in-person" | "online" | "hybrid";
|
||
|
||
/**
|
||
* Editorial registration state. The five values are the CMS enumeration
|
||
* `event.registrationStatus`, verbatim - draft 4 names only the first three,
|
||
* but an editor can also mark a full event as taking a waitlist, or say that
|
||
* an event needs no registration at all.
|
||
*/
|
||
export type EventRegistrationStatus =
|
||
| "open"
|
||
| "closed"
|
||
| "full"
|
||
| "waitlist"
|
||
| "not-required";
|
||
|
||
export const EVENT_REGISTRATION_STATUSES = [
|
||
"open",
|
||
"closed",
|
||
"full",
|
||
"waitlist",
|
||
"not-required",
|
||
] as const satisfies readonly EventRegistrationStatus[];
|
||
|
||
/** Derived from the dates, never stored. */
|
||
export type EventTimeStatus = "upcoming" | "ongoing" | "completed";
|
||
|
||
/** The filter tabs: All - Upcoming - Ongoing - Completed (draft 4). */
|
||
export type EventStatusFilter = "all" | EventTimeStatus;
|
||
|
||
export const EVENT_STATUS_FILTERS: readonly EventStatusFilter[] = [
|
||
"all",
|
||
"upcoming",
|
||
"ongoing",
|
||
"completed",
|
||
];
|
||
|
||
export function isEventStatusFilter(value: unknown): value is EventStatusFilter {
|
||
return (
|
||
typeof value === "string" &&
|
||
(EVENT_STATUS_FILTERS as readonly string[]).includes(value)
|
||
);
|
||
}
|
||
|
||
/** What a card needs. Plain and serialisable - it crosses the loader boundary. */
|
||
export interface EventCardData {
|
||
readonly slug: string;
|
||
readonly title: string;
|
||
readonly summary?: string;
|
||
/** ISO 8601. Date-only when `allDay`. */
|
||
readonly start: string;
|
||
readonly end?: string;
|
||
readonly allDay: boolean;
|
||
readonly format?: EventFormat;
|
||
/** The venue. Absent for an online-only event - the CMS has no `city`. */
|
||
readonly place?: string;
|
||
/** Name of the related region entry, not a free-text string. */
|
||
readonly region?: string;
|
||
readonly registrationStatus: EventRegistrationStatus;
|
||
readonly timeStatus: EventTimeStatus;
|
||
/** Set on a past event that has a write-up - drives the "Read recap" label. */
|
||
readonly recapUrl?: string;
|
||
readonly image?: CmsImage;
|
||
}
|
||
|
||
/** Everything the detail page renders. */
|
||
export interface EventDetailData extends EventCardData {
|
||
readonly documentId: string;
|
||
/** The long body. There is no external registration portal field in the CMS. */
|
||
readonly description?: CmsRichText;
|
||
readonly onlineUrl?: string;
|
||
readonly registrationDeadline?: string;
|
||
readonly venue?: string;
|
||
/** Editorial: the CMS offers a waitlist once the event is full. */
|
||
readonly waitlistEnabled: boolean;
|
||
}
|
||
|
||
/* ---------------------------------------------------------------------------
|
||
TIME
|
||
------------------------------------------------------------------------ */
|
||
|
||
const DATE_ONLY = /^\d{4}-\d{2}-\d{2}$/;
|
||
|
||
/** True for a CMS `date` field ("2026-03-12"), false for a `datetime`. */
|
||
export function isDateOnly(value: string | null | undefined): boolean {
|
||
return typeof value === "string" && DATE_ONLY.test(value.trim());
|
||
}
|
||
|
||
/** "2026-03-12" for an instant, read in Libyan local time. */
|
||
function libyanCalendarDay(value: Date): string {
|
||
return new Intl.DateTimeFormat("en-CA", {
|
||
timeZone: EVENT_TIME_ZONE,
|
||
year: "numeric",
|
||
month: "2-digit",
|
||
day: "2-digit",
|
||
}).format(value);
|
||
}
|
||
|
||
/** The last instant of the Libyan calendar day that contains `value`. */
|
||
function endOfLibyanDay(value: Date): Date {
|
||
return new Date(
|
||
`${libyanCalendarDay(value)}T23:59:59.999${EVENT_UTC_OFFSET}`,
|
||
);
|
||
}
|
||
|
||
interface EventInterval {
|
||
readonly start: string;
|
||
readonly end?: string;
|
||
readonly allDay: boolean;
|
||
}
|
||
|
||
/**
|
||
* When an event is over.
|
||
* - explicit `end` on a timed event -> that instant
|
||
* - all-day (with or without `end`) -> the end of the last day
|
||
* - timed event with no `end` -> the end of its own day, so an
|
||
* evening dialogue reads "Ongoing" for the rest of that day and
|
||
* "Completed" from midnight, rather than the instant it starts.
|
||
*/
|
||
export function eventEndsAt(event: EventInterval): Date | null {
|
||
const start = new Date(event.start);
|
||
if (Number.isNaN(start.getTime())) return null;
|
||
|
||
if (event.allDay) {
|
||
const last = event.end ? new Date(event.end) : start;
|
||
return endOfLibyanDay(Number.isNaN(last.getTime()) ? start : last);
|
||
}
|
||
|
||
if (event.end) {
|
||
const end = new Date(event.end);
|
||
if (!Number.isNaN(end.getTime())) return end;
|
||
}
|
||
|
||
return endOfLibyanDay(start);
|
||
}
|
||
|
||
/** Upcoming / Ongoing / Completed for an instant in time. */
|
||
export function deriveTimeStatus(
|
||
event: EventInterval,
|
||
now: Date = new Date(),
|
||
): EventTimeStatus {
|
||
const start = new Date(event.start);
|
||
if (Number.isNaN(start.getTime())) return "upcoming";
|
||
|
||
const end = eventEndsAt(event) ?? start;
|
||
const at = now.getTime();
|
||
|
||
if (at < start.getTime()) return "upcoming";
|
||
return at <= end.getTime() ? "ongoing" : "completed";
|
||
}
|
||
|
||
/* ---------------------------------------------------------------------------
|
||
FORMATTING
|
||
------------------------------------------------------------------------ */
|
||
|
||
/**
|
||
* "12 March 2026, 14:00" / "12–14 March 2026" for an all-day range.
|
||
* `withTimeZone` adds the zone abbreviation - use it on the detail page,
|
||
* where the reader is deciding whether they can attend.
|
||
*/
|
||
export function formatEventWhen(
|
||
event: EventInterval,
|
||
locale: Locale,
|
||
options: { readonly withTimeZone?: boolean } = {},
|
||
): string {
|
||
if (event.allDay) {
|
||
// A date-only value parses as UTC midnight, so it is read back in UTC to
|
||
// land on the day the editor typed.
|
||
return formatDateRange(event.start, event.end ?? null, locale, {
|
||
dateStyle: "long",
|
||
timeZone: "UTC",
|
||
});
|
||
}
|
||
|
||
// `dateStyle`/`timeStyle` cannot be combined with `timeZoneName`, so the
|
||
// components are spelled out.
|
||
const parts: Intl.DateTimeFormatOptions = {
|
||
day: "numeric",
|
||
month: "long",
|
||
year: "numeric",
|
||
hour: "2-digit",
|
||
minute: "2-digit",
|
||
timeZone: EVENT_TIME_ZONE,
|
||
...(options.withTimeZone ? { timeZoneName: "short" } : {}),
|
||
};
|
||
|
||
if (event.end && !isDateOnly(event.end)) {
|
||
return formatDateRange(event.start, event.end, locale, parts);
|
||
}
|
||
|
||
return formatDate(event.start, locale, parts);
|
||
}
|
||
|
||
/** The short date used in a confirmation sentence ("… on 12 March 2026"). */
|
||
export function formatEventDay(
|
||
event: EventInterval,
|
||
locale: Locale,
|
||
): string {
|
||
return formatDate(event.start, locale, {
|
||
dateStyle: "long",
|
||
timeZone: event.allDay ? "UTC" : EVENT_TIME_ZONE,
|
||
});
|
||
}
|
||
|
||
/* ---------------------------------------------------------------------------
|
||
LIST OPERATIONS
|
||
The calendar of a two-year project is small enough to sort, filter and
|
||
paginate in memory, which keeps the derived Upcoming/Ongoing/Completed
|
||
states honest - Strapi cannot express them without duplicating the rule in
|
||
a query. `EVENTS_FETCH_LIMIT` in ./events.server.ts is the ceiling.
|
||
------------------------------------------------------------------------ */
|
||
|
||
const MS_PER_DAY = 86_400_000;
|
||
|
||
function startTime(event: EventCardData): number {
|
||
const value = new Date(event.start).getTime();
|
||
return Number.isNaN(value) ? 0 : value;
|
||
}
|
||
|
||
/**
|
||
* Upcoming and ongoing events first, soonest at the top; completed ones after
|
||
* them, most recent first. Filtering to a single tab keeps that tab's order.
|
||
*/
|
||
export function sortEvents(
|
||
events: readonly EventCardData[],
|
||
): EventCardData[] {
|
||
return [...events].sort((a, b) => {
|
||
const aPast = a.timeStatus === "completed";
|
||
const bPast = b.timeStatus === "completed";
|
||
if (aPast !== bPast) return aPast ? 1 : -1;
|
||
return aPast
|
||
? startTime(b) - startTime(a)
|
||
: startTime(a) - startTime(b);
|
||
});
|
||
}
|
||
|
||
export interface EventFilters {
|
||
readonly status: EventStatusFilter;
|
||
readonly region: string | null;
|
||
}
|
||
|
||
export function filterEvents(
|
||
events: readonly EventCardData[],
|
||
filters: EventFilters,
|
||
): EventCardData[] {
|
||
return events.filter((event) => {
|
||
if (filters.status !== "all" && event.timeStatus !== filters.status) {
|
||
return false;
|
||
}
|
||
if (filters.region && event.region !== filters.region) return false;
|
||
return true;
|
||
});
|
||
}
|
||
|
||
/** Every region that actually has an event, in the reader's collation order. */
|
||
export function collectRegions(
|
||
events: readonly EventCardData[],
|
||
locale: Locale,
|
||
): string[] {
|
||
const regions = new Set<string>();
|
||
for (const event of events) {
|
||
if (event.region) regions.add(event.region);
|
||
}
|
||
return [...regions].sort((a, b) => a.localeCompare(b, locale));
|
||
}
|
||
|
||
/* ---------------------------------------------------------------------------
|
||
REGISTRATION
|
||
------------------------------------------------------------------------ */
|
||
|
||
/**
|
||
* True when the on-site form should be shown at all. "not-required" means the
|
||
* reader simply turns up, so there is nothing to submit; "closed" speaks for
|
||
* itself; "full" and "waitlist" still take names for the waiting list.
|
||
*/
|
||
export function isRegistrationOpen(event: EventCardData): boolean {
|
||
return (
|
||
event.timeStatus !== "completed" &&
|
||
(event.registrationStatus === "open" ||
|
||
event.registrationStatus === "full" ||
|
||
event.registrationStatus === "waitlist")
|
||
);
|
||
}
|
||
|
||
/**
|
||
* The label of the card's call to action. Draft section 4 names all four:
|
||
* "Register", "Apply", "Read recap" and, for an event whose registration has
|
||
* not opened or has closed, plain "View details".
|
||
*/
|
||
export function eventActionKey(event: EventCardData): TranslationKey {
|
||
if (event.timeStatus === "completed") {
|
||
return event.recapUrl ? "actions.readRecap" : "actions.viewDetails";
|
||
}
|
||
|
||
if (
|
||
event.registrationStatus === "full" ||
|
||
event.registrationStatus === "waitlist"
|
||
) {
|
||
return "events.joinWaitlist";
|
||
}
|
||
if (
|
||
event.registrationStatus === "closed" ||
|
||
event.registrationStatus === "not-required"
|
||
) {
|
||
return "actions.viewDetails";
|
||
}
|
||
return "actions.register";
|
||
}
|
||
|
||
/** Catalogue key for the Upcoming / Ongoing / Completed badge. */
|
||
export function eventTimeStatusKey(status: EventTimeStatus): TranslationKey {
|
||
return status === "upcoming"
|
||
? "filters.upcoming"
|
||
: status === "ongoing"
|
||
? "filters.ongoing"
|
||
: "filters.completed";
|
||
}
|
||
|
||
/**
|
||
* The registration badge label.
|
||
*
|
||
* Not a catalogue KEY on its own: the shared catalogue names Open / Closed /
|
||
* Full, and `event.registrationStatus` has two states beyond those, so
|
||
* "not-required" comes from this section's copy in ./copy.ts and "waitlist"
|
||
* reads "Full" - which is what a reader needs to know about the places left.
|
||
*/
|
||
export function eventRegistrationLabel(
|
||
status: EventRegistrationStatus,
|
||
locale: Locale,
|
||
): string {
|
||
if (status === "not-required") {
|
||
return getRouteCopy(locale).eventDetail.registrationNotRequired;
|
||
}
|
||
|
||
const key: TranslationKey =
|
||
status === "open"
|
||
? "events.statusOpen"
|
||
: status === "closed"
|
||
? "events.statusClosed"
|
||
: "events.statusFull";
|
||
|
||
return translate(locale, key);
|
||
}
|
||
|
||
/**
|
||
* True when a submission joins the waitlist rather than taking a place.
|
||
*
|
||
* KNOWN SERVER-SIDE LIMITATION: the CMS middleware
|
||
* (apps/cms/src/api/event-registration/middlewares/sanitize-submission.ts)
|
||
* hard-sets `registrationState: "confirmed"` on every create and the field is
|
||
* not in the client whitelist, so this intent CANNOT be transmitted. The page
|
||
* tells the reader the truth about the EVENT (it is full, this is a waitlist
|
||
* request); the stored row still says "confirmed" until that middleware is
|
||
* fixed, and staff triage it. Nothing here fakes a waitlist flag the API
|
||
* would reject.
|
||
*/
|
||
export function isWaitlisted(event: EventCardData): boolean {
|
||
return (
|
||
event.registrationStatus === "full" ||
|
||
event.registrationStatus === "waitlist"
|
||
);
|
||
}
|
||
|
||
/** Days until an event starts - used only to decide "registers soon" copy. */
|
||
export function daysUntil(event: EventInterval, now: Date = new Date()): number {
|
||
const start = new Date(event.start).getTime();
|
||
if (Number.isNaN(start)) return 0;
|
||
return Math.ceil((start - now.getTime()) / MS_PER_DAY);
|
||
}
|