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