228 lines
7.3 KiB
TypeScript
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(" · ");
|
||
|
|
}
|