Files
2026-09-11 02:46:56 +02:00

228 lines
7.3 KiB
TypeScript

/* ============================================================================
OPPORTUNITIES - view model, status derivation
----------------------------------------------------------------------------
PURE. Strapi access lives in ./opportunities.server.ts.
Draft section 8 asks for a status tag of Open / Closed, and the listing
additionally distinguishes a call that has been announced but has not opened
yet. The status is derived from `openDate` / `deadline` so it cannot go
stale; `opportunityStatus` in the CMS is required and defaults to
"upcoming", so it is used as an editorial CLOSE and as the fallback when a
call carries no dates at all - never as a blanket override, which would let
a forgotten default hide an open call.
========================================================================= */
import {
formatDate,
formatFileSize,
type Locale,
type TranslationKey,
} from "~/i18n";
import type { CmsFile, CmsImage, CmsRichText } from "../_shared/cms";
/* ---------------------------------------------------------------------------
TYPES
------------------------------------------------------------------------ */
export type OpportunityStatus = "open" | "closed" | "upcoming";
/** The filter tabs: All - Open - Upcoming - Closed. */
export type OpportunityStatusFilter = "all" | OpportunityStatus;
export const OPPORTUNITY_STATUS_FILTERS: readonly OpportunityStatusFilter[] = [
"all",
"open",
"upcoming",
"closed",
];
export function isOpportunityStatusFilter(
value: unknown,
): value is OpportunityStatusFilter {
return (
typeof value === "string" &&
(OPPORTUNITY_STATUS_FILTERS as readonly string[]).includes(value)
);
}
/**
* `opportunity.opportunityType` in the CMS, verbatim. Only
* `network-membership` carries behaviour - it is what the "Join the SHARP
* Network" card links to when the CMS holds such an entry.
*/
export type OpportunityType =
| "call-eoi"
| "network-membership"
| "training"
| "grant"
| "other";
export const OPPORTUNITY_TYPES = [
"call-eoi",
"network-membership",
"training",
"grant",
"other",
] as const satisfies readonly OpportunityType[];
export interface OpportunityCardData {
readonly slug: string;
readonly title: string;
readonly summary?: string;
readonly status: OpportunityStatus;
/** ISO date the call opens - `openDate` in the CMS. */
readonly openDate?: string;
/** ISO date applications close. */
readonly deadline?: string;
readonly opportunityType?: OpportunityType;
readonly image?: CmsImage;
}
export interface OpportunityDetailData extends OpportunityCardData {
readonly documentId: string;
readonly description?: CmsRichText;
readonly eligibility?: CmsRichText;
readonly howToApply?: CmsRichText;
/** An external application portal. When set, the on-site form is not shown. */
readonly externalApplicationUrl?: string;
/** ToR, guidelines, annexes - `attachments` in the CMS. */
readonly attachments: readonly CmsFile[];
}
/* ---------------------------------------------------------------------------
STATUS
------------------------------------------------------------------------ */
export interface OpportunityWindow {
readonly openDate?: string | null;
readonly deadline?: string | null;
/** `opportunity.opportunityStatus`. Required in the CMS, default "upcoming". */
readonly status?: OpportunityStatus | null;
}
/** A deadline is inclusive: a call closes at the end of its closing day. */
function endOfDay(value: string): number {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return Number.NaN;
date.setUTCHours(23, 59, 59, 999);
return date.getTime();
}
/**
* Precedence, in order:
* 1. an editorial "closed" - a call can be suspended or closed early, and
* that decision must win over a deadline that has not passed yet;
* 2. the dates, when the entry carries any - they cannot go stale;
* 3. whatever `opportunityStatus` says, for a call with no dates at all.
*/
export function deriveOpportunityStatus(
entry: OpportunityWindow,
now: Date = new Date(),
): OpportunityStatus {
if (entry.status === "closed") return "closed";
const at = now.getTime();
let dated = false;
if (entry.openDate) {
const opens = new Date(entry.openDate).getTime();
if (!Number.isNaN(opens)) {
dated = true;
if (at < opens) return "upcoming";
}
}
if (entry.deadline) {
const closes = endOfDay(entry.deadline);
if (!Number.isNaN(closes)) {
dated = true;
if (at > closes) return "closed";
}
}
return dated ? "open" : (entry.status ?? "open");
}
/** Catalogue key for the Open / Upcoming / Closed badge. */
export function opportunityStatusKey(
status: OpportunityStatus,
): TranslationKey {
return status === "open"
? "opportunities.statusOpen"
: status === "upcoming"
? "filters.upcoming"
: "opportunities.statusClosed";
}
/** Label of the card's call to action - only an open call invites an apply. */
export function opportunityActionKey(
status: OpportunityStatus,
): TranslationKey {
return status === "open" ? "actions.apply" : "actions.viewDetails";
}
/* ---------------------------------------------------------------------------
LIST OPERATIONS
------------------------------------------------------------------------ */
function deadlineTime(item: OpportunityCardData): number {
const value = item.deadline ? new Date(item.deadline).getTime() : Number.NaN;
return Number.isNaN(value) ? Number.POSITIVE_INFINITY : value;
}
const STATUS_ORDER: Readonly<Record<OpportunityStatus, number>> = {
open: 0,
upcoming: 1,
closed: 2,
};
/** Open calls first with the nearest deadline at the top, closed ones last. */
export function sortOpportunities(
items: readonly OpportunityCardData[],
): OpportunityCardData[] {
return [...items].sort((a, b) => {
const byStatus = STATUS_ORDER[a.status] - STATUS_ORDER[b.status];
if (byStatus !== 0) return byStatus;
return a.status === "closed"
? deadlineTime(b) - deadlineTime(a)
: deadlineTime(a) - deadlineTime(b);
});
}
export function filterOpportunities(
items: readonly OpportunityCardData[],
status: OpportunityStatusFilter,
): OpportunityCardData[] {
if (status === "all") return [...items];
return items.filter((item) => item.status === status);
}
/** The CMS entry the "Join the SHARP Network" card should link to, if any. */
export function findNetworkOpportunity(
items: readonly OpportunityCardData[],
): OpportunityCardData | undefined {
return items.find((item) => item.opportunityType === "network-membership");
}
/* ---------------------------------------------------------------------------
FORMATTING
------------------------------------------------------------------------ */
/** "12 March 2026" - a deadline is a calendar day, never a time of day. */
export function formatOpportunityDate(
value: string | null | undefined,
locale: Locale,
): string {
return formatDate(value, locale, { dateStyle: "long", timeZone: "UTC" });
}
/** "PDF · 1.2 MB", or just the type when the upload records no size. */
export function formatAttachmentMeta(file: CmsFile, locale: Locale): string {
const size =
file.sizeBytes > 0 ? formatFileSize(file.sizeBytes, locale) : undefined;
return [file.kind || undefined, size].filter(Boolean).join(" · ");
}