/* ============================================================================ 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> = { 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(" · "); }