From fbd2e8c67f6f29c4ee94894c709bbcb54aaf66ad Mon Sep 17 00:00:00 2001 From: Adnan Erlangga Raharja Date: Thu, 28 May 2026 19:15:13 +0200 Subject: [PATCH] Color and sort deadlines for positions --- .../src/app/components/OpenPositionCard.tsx | 14 +++++- .../src/app/components/PositionInfoCard.tsx | 16 +++++-- frontend/apply/src/app/page.tsx | 13 ++++- frontend/apply/src/app/styles/card.module.css | 12 +++++ frontend/apply/src/app/utils/dateFormat.ts | 48 +++++++++++++++++++ 5 files changed, 96 insertions(+), 7 deletions(-) diff --git a/frontend/apply/src/app/components/OpenPositionCard.tsx b/frontend/apply/src/app/components/OpenPositionCard.tsx index 6668a0b..5c10448 100644 --- a/frontend/apply/src/app/components/OpenPositionCard.tsx +++ b/frontend/apply/src/app/components/OpenPositionCard.tsx @@ -4,7 +4,7 @@ import { useState } from "react"; import type { Position } from "@/utils/types"; import { useTranslation } from "react-i18next"; import "@/i18n/config"; -import { formatDate } from "@/utils/dateFormat"; +import { formatDate, getDeadlineUrgency } from "@/utils/dateFormat"; import Image from "next/image"; import { getImageUrl } from "@/utils/imageUrl"; import Link from "next/link"; @@ -16,6 +16,13 @@ type Props = { const OpenPositionCard = ({ position }: Props) => { const [showInfo, setShowInfo] = useState(false); const { t } = useTranslation(); + const deadlineUrgency = getDeadlineUrgency(position.recruitment_end); + const deadlineClassName = + deadlineUrgency === "urgent" + ? styles.deadlineUrgent + : deadlineUrgency === "soon" + ? styles.deadlineSoon + : styles.deadlineDefault; return (
@@ -41,7 +48,10 @@ const OpenPositionCard = ({ position }: Props) => {

- {t("openPositionCard.deadline")}: {formatDate(position.recruitment_end)} + {t("openPositionCard.deadline")}:{" "} + + {formatDate(position.recruitment_end)} +

@@ -49,7 +56,10 @@ export default function PositionInfoCard({

{t("applicationForm.applicationDeadline")}:{" "} - {formatDate(position.recruitment_end)} {deadlineSuffix} + + {formatDate(position.recruitment_end)} + {" "} + {deadlineSuffix}

{t("common.termOfOffice")}: {formatDate(position.term_from)} —{" "} @@ -105,4 +115,4 @@ export default function PositionInfoCard({ )}

); -} \ No newline at end of file +} diff --git a/frontend/apply/src/app/page.tsx b/frontend/apply/src/app/page.tsx index 1464a1c..a5f365d 100644 --- a/frontend/apply/src/app/page.tsx +++ b/frontend/apply/src/app/page.tsx @@ -1,5 +1,5 @@ "use client"; -import { useState, useEffect } from "react"; +import { useState, useEffect, useMemo } from "react"; import styles from "./page.module.css"; import ApplicationCard from "./components/ApplicationCard"; import LoadingRam from "./components/LoadingRam"; @@ -10,6 +10,7 @@ import { useIsLoggedIn } from "@/utils/auth"; import type { Position, Application } from "@/utils/types"; import { useTranslation } from "react-i18next"; import "@/i18n/config"; +import { parseDeadlineDate } from "@/utils/dateFormat"; type Tab = "Open Positions" | "My Applications" | "My Positions"; @@ -25,6 +26,14 @@ export default function Home() { const [applicationsError, setApplicationsError] = useState( null, ); + const sortedOpenPositions = useMemo(() => { + return [...openPositions].sort((a, b) => { + const aDeadline = parseDeadlineDate(a.recruitment_end)?.getTime(); + const bDeadline = parseDeadlineDate(b.recruitment_end)?.getTime(); + + return (aDeadline ?? Infinity) - (bDeadline ?? Infinity); + }); + }, [openPositions]); useEffect(() => { if (!isLoggedIn) { @@ -137,7 +146,7 @@ export default function Home() { {openPositions.length === 0 ? (

{t("homePage.noOpenPositions")}

) : ( - openPositions.map((position) => ( + sortedOpenPositions.map((position) => ( )) )} diff --git a/frontend/apply/src/app/styles/card.module.css b/frontend/apply/src/app/styles/card.module.css index 54770ee..1d74f89 100644 --- a/frontend/apply/src/app/styles/card.module.css +++ b/frontend/apply/src/app/styles/card.module.css @@ -79,6 +79,18 @@ white-space: nowrap; } +.deadlineDefault { + color: var(--black); +} + +.deadlineSoon { + color: #FBC02D; +} + +.deadlineUrgent { + color: var(--error-500); +} + @media screen and (max-width: 767px) { .cardRightSection { align-items: flex-start; diff --git a/frontend/apply/src/app/utils/dateFormat.ts b/frontend/apply/src/app/utils/dateFormat.ts index e964f76..14655a6 100644 --- a/frontend/apply/src/app/utils/dateFormat.ts +++ b/frontend/apply/src/app/utils/dateFormat.ts @@ -1,5 +1,9 @@ import i18n from '../i18n/config'; +const dateOnlyPattern = /^(\d{4})-(\d{2})-(\d{2})$/; +const urgentDeadlineHours = 48; +const soonDeadlineDays = 5; + /** * Get the current locale from i18n */ @@ -8,6 +12,50 @@ const getLocale = (): string => { return language === 'sv' ? 'sv-SE' : 'en-GB'; }; +/** + * Parse a deadline string. Date-only deadlines are treated as the end of that + * local calendar day, since they represent the last day to apply. + */ +export const parseDeadlineDate = (dateString: string): Date | null => { + if (!dateString) return null; + + const dateOnlyMatch = dateOnlyPattern.exec(dateString); + if (dateOnlyMatch) { + const [, year, month, day] = dateOnlyMatch; + return new Date( + Number(year), + Number(month) - 1, + Number(day), + 23, + 59, + 59, + 999, + ); + } + + const date = new Date(dateString); + return isNaN(date.getTime()) ? null : date; +}; + +export const getDeadlineUrgency = ( + dateString: string, +): "default" | "soon" | "urgent" => { + const deadline = parseDeadlineDate(dateString); + if (!deadline) return "default"; + + const hoursRemaining = (deadline.getTime() - Date.now()) / (1000 * 60 * 60); + + if (hoursRemaining < urgentDeadlineHours) { + return "urgent"; + } + + if (hoursRemaining <= soonDeadlineDays * 24) { + return "soon"; + } + + return "default"; +}; + /** * Format a date string to a human-readable format based on the current i18n locale * @param dateString - ISO date string ("2026-09-01")