Files
sharper.ly/apps/web/app/routes/_events-opportunities/events.ts
T

409 lines
13 KiB
TypeScript
Raw Normal View History

2026-09-11 02:46:56 +02:00
/* ============================================================================
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);
}