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";
|
|
|
|
|
|
|
2026-09-16 20:32:56 +02:00
|
|
|
|
/**
|
|
|
|
|
|
* api::event.eventType - what kind of session this is. The redesign prints it
|
|
|
|
|
|
* as a kicker above the title ("SUQ Dialogue", "Training"), so the label map
|
|
|
|
|
|
* lives with the rest of the route copy, in both locales.
|
|
|
|
|
|
*/
|
|
|
|
|
|
export const EVENT_TYPES = [
|
|
|
|
|
|
"suq-dialogue",
|
|
|
|
|
|
"training",
|
|
|
|
|
|
"law-session",
|
|
|
|
|
|
"network-meeting",
|
|
|
|
|
|
"launch",
|
|
|
|
|
|
"other",
|
|
|
|
|
|
] as const;
|
|
|
|
|
|
|
|
|
|
|
|
export type EventType = (typeof EVENT_TYPES)[number];
|
|
|
|
|
|
|
2026-09-11 02:46:56 +02:00
|
|
|
|
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;
|
2026-09-16 20:32:56 +02:00
|
|
|
|
/** Kicker above the title. Absent when the CMS leaves the type unset. */
|
|
|
|
|
|
readonly eventType?: EventType;
|
2026-09-11 02:46:56 +02:00
|
|
|
|
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);
|
|
|
|
|
|
}
|