initial commit
This commit is contained in:
@@ -0,0 +1,408 @@
|
||||
/* ============================================================================
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user