diff --git a/backend/src/index.js b/backend/src/index.js index cd9a56be..e9a66ebb 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -951,16 +951,29 @@ export async function createApp(options = {}) { status = 'published'; } + // Handle urgency sorting separately since it requires application-level logic + const isUrgencySort = sort === 'urgency'; + const dbSort = isUrgencySort ? undefined : sort; + const dbOrder = isUrgencySort ? undefined : order; + const items = campaignRepository.list({ active: activeFilter, q, - sort, - order, + sort: dbSort, + order: dbOrder, category, tags, status, }); - const payload = paginateItems(items, req.query); + + // Apply urgency sorting if requested + let sortedItems = items; + if (isUrgencySort) { + const { sortByUrgency } = await import('./utils/urgency.js'); + sortedItems = sortByUrgency(items); + } + + const payload = paginateItems(sortedItems, req.query); shortCache.set(cacheKey, { expiresAt: Date.now() + shortCacheTtlMs, payload, diff --git a/backend/src/utils/urgency.js b/backend/src/utils/urgency.js new file mode 100644 index 00000000..41855669 --- /dev/null +++ b/backend/src/utils/urgency.js @@ -0,0 +1,113 @@ +/** + * Urgency calculation utilities for backend campaign sorting. + * Matches the frontend urgency logic for consistent behavior. + */ + +/** + * Check if campaign is ending soon (< 24 hours) + * @param {string | null} endDate + * @param {Date} [now] + * @returns {boolean} + */ +function isEndingSoon(endDate, now = new Date()) { + if (!endDate) return false; + const end = new Date(endDate); + if (Number.isNaN(end.getTime())) return false; + + const remaining = end.getTime() - now.getTime(); + const TWENTY_FOUR_HOURS_MS = 24 * 60 * 60 * 1000; + return remaining > 0 && remaining < TWENTY_FOUR_HOURS_MS; +} + +/** + * Check if campaign is filling fast (> 85% capacity) + * @param {number} participantCount + * @param {number} maxParticipants + * @returns {boolean} + */ +function isFillingFast(participantCount, maxParticipants) { + if (!maxParticipants || maxParticipants === 0) return false; + if (participantCount === undefined || participantCount === null) return false; + + const fillPercentage = participantCount / maxParticipants; + return fillPercentage > 0.85; +} + +/** + * Check if campaign just launched (< 48 hours since start) + * @param {string | null} startDate + * @param {Date} [now] + * @returns {boolean} + */ +function isJustLaunched(startDate, now = new Date()) { + if (!startDate) return false; + const start = new Date(startDate); + if (Number.isNaN(start.getTime())) return false; + + const elapsed = now.getTime() - start.getTime(); + if (elapsed < 0) return false; // Not started yet + + const FORTY_EIGHT_HOURS_MS = 48 * 60 * 60 * 1000; + return elapsed < FORTY_EIGHT_HOURS_MS; +} + +/** + * Calculate urgency score for sorting. + * Higher score = more urgent. + * @param {object} campaign + * @param {string | null} campaign.endDate + * @param {string | null} campaign.startDate + * @param {number} [campaign.participantCount] + * @param {number} [campaign.maxParticipants] + * @param {Date} [now] + * @returns {number} + */ +export function calculateUrgencyScore(campaign, now = new Date()) { + if (!campaign) return 0; + + const { endDate, startDate, participantCount = 0, maxParticipants = 0 } = campaign; + + // Priority 1: Ending soon - highest score based on time remaining + if (isEndingSoon(endDate, now)) { + const end = new Date(endDate); + const remainingMs = end.getTime() - now.getTime(); + // Score: 1,000,000 - seconds remaining (sooner = higher score) + return 1_000_000 - Math.floor(remainingMs / 1000); + } + + // Priority 2: Filling fast - medium score based on fill percentage + if (isFillingFast(participantCount, maxParticipants)) { + const percentage = Math.min(Math.round((participantCount / maxParticipants) * 100), 100); + // Score: 500,000 + percentage * 100 (fuller = higher score) + return 500_000 + percentage * 100; + } + + // Priority 3: Just launched - lower scores are just slightly elevated + if (isJustLaunched(startDate, now)) { + return 1000; + } + + return 0; +} + +/** + * Sort campaigns by urgency (descending - most urgent first) + * @param {Array} campaigns + * @param {Date} [now] + * @returns {Array} + */ +export function sortByUrgency(campaigns, now = new Date()) { + return campaigns.slice().sort((a, b) => { + const scoreA = calculateUrgencyScore(a, now); + const scoreB = calculateUrgencyScore(b, now); + + // Higher urgency score first + if (scoreB !== scoreA) return scoreB - scoreA; + + // Fallback: featured campaigns first + if (a.featured !== b.featured) return a.featured ? -1 : 1; + + // Final fallback: ID ascending + return Number(a.id) - Number(b.id); + }); +} diff --git a/frontend/src/Explore.jsx b/frontend/src/Explore.jsx index 9f9463a1..eb5479a4 100644 --- a/frontend/src/Explore.jsx +++ b/frontend/src/Explore.jsx @@ -13,7 +13,7 @@ import './Explore.css'; const CAMPAIGNS_PER_PAGE = 9; const RAIL_LIMIT = 6; -const VALID_SORT_KEYS = new Set(['newest', 'oldest', 'name_asc', 'name_desc', 'reward_desc']); +const VALID_SORT_KEYS = new Set(['newest', 'oldest', 'name_asc', 'name_desc', 'reward_desc', 'urgency']); function normalizeSortKey(raw) { return VALID_SORT_KEYS.has(raw) ? raw : 'newest'; diff --git a/frontend/src/Landing.css b/frontend/src/Landing.css index 64ea8c5c..e71e5489 100644 --- a/frontend/src/Landing.css +++ b/frontend/src/Landing.css @@ -847,6 +847,14 @@ gap: 1rem; } +.campaign-card-badges { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 0.5rem; + flex-shrink: 0; +} + .campaign-card-eyebrow { margin: 0 0 0.3rem; color: var(--text-muted); diff --git a/frontend/src/components/CampaignCard.jsx b/frontend/src/components/CampaignCard.jsx index ab03f701..a86200d2 100644 --- a/frontend/src/components/CampaignCard.jsx +++ b/frontend/src/components/CampaignCard.jsx @@ -1,6 +1,7 @@ import { useId } from 'react'; import { Link } from 'react-router-dom'; import StatusBadge from './StatusBadge'; +import UrgencyBadge from './UrgencyBadge'; function formatDate(value) { if (!value) return ''; @@ -33,7 +34,10 @@ export default function CampaignCard({ campaign }) { - +
+ + +

{description}

diff --git a/frontend/src/components/CampaignFilters.jsx b/frontend/src/components/CampaignFilters.jsx index f4a2bb56..18e39855 100644 --- a/frontend/src/components/CampaignFilters.jsx +++ b/frontend/src/components/CampaignFilters.jsx @@ -94,6 +94,7 @@ export default function CampaignFilters({ + @@ -118,6 +119,8 @@ export function sortKeyToApiParams(key) { return { sort: 'name', order: 'desc' }; case 'reward_desc': return { sort: 'reward_per_action', order: 'desc' }; + case 'urgency': + return { sort: 'urgency', order: 'desc' }; case 'newest': default: return { sort: 'created_at', order: 'desc' }; diff --git a/frontend/src/components/UrgencyBadge.css b/frontend/src/components/UrgencyBadge.css new file mode 100644 index 00000000..7d9ac8ef --- /dev/null +++ b/frontend/src/components/UrgencyBadge.css @@ -0,0 +1,76 @@ +.urgency-badge { + display: inline-flex; + align-items: center; + gap: 0.25rem; + padding: 0.25rem 0.625rem; + border-radius: 0.375rem; + font-size: 0.75rem; + font-weight: 600; + line-height: 1.25; + white-space: nowrap; + transition: transform 0.2s ease; +} + +.urgency-badge:hover { + transform: scale(1.05); +} + +.urgency-badge-icon { + font-size: 0.875rem; +} + +/* Ending Soon - Red theme */ +.urgency-badge--ending-soon { + background-color: #fee; + color: #c00; + border: 1px solid #fcc; +} + +@media (prefers-color-scheme: dark) { + .urgency-badge--ending-soon { + background-color: rgba(239, 68, 68, 0.15); + color: #fca5a5; + border-color: rgba(239, 68, 68, 0.3); + } +} + +/* Filling Fast - Orange theme */ +.urgency-badge--filling-fast { + background-color: #fff4e6; + color: #c2410c; + border: 1px solid #fed7aa; +} + +@media (prefers-color-scheme: dark) { + .urgency-badge--filling-fast { + background-color: rgba(249, 115, 22, 0.15); + color: #fdba74; + border-color: rgba(249, 115, 22, 0.3); + } +} + +/* Just Launched - Green theme */ +.urgency-badge--just-launched { + background-color: #f0fdf4; + color: #15803d; + border: 1px solid #bbf7d0; +} + +@media (prefers-color-scheme: dark) { + .urgency-badge--just-launched { + background-color: rgba(34, 197, 94, 0.15); + color: #86efac; + border-color: rgba(34, 197, 94, 0.3); + } +} + +/* Accessibility: Respect reduced motion preference */ +@media (prefers-reduced-motion: reduce) { + .urgency-badge { + transition: none; + } + + .urgency-badge:hover { + transform: none; + } +} diff --git a/frontend/src/components/UrgencyBadge.jsx b/frontend/src/components/UrgencyBadge.jsx new file mode 100644 index 00000000..d1d230ac --- /dev/null +++ b/frontend/src/components/UrgencyBadge.jsx @@ -0,0 +1,62 @@ +import { useEffect, useState } from 'react'; +import { getUrgencyBadge, BADGE_TYPES, getTimeRemaining, formatCountdown } from '../utils/urgencyUtils'; +import './UrgencyBadge.css'; + +/** + * UrgencyBadge component displays time-sensitive signals for campaigns + * @param {object} props + * @param {object} props.campaign - Campaign data + */ +export default function UrgencyBadge({ campaign }) { + const [badge, setBadge] = useState(() => getUrgencyBadge(campaign)); + + useEffect(() => { + // Update badge immediately + const updateBadge = () => { + setBadge(getUrgencyBadge(campaign)); + }; + + updateBadge(); + + // For "Ending Soon" badges, update every minute + const initialBadge = getUrgencyBadge(campaign); + if (initialBadge?.type === BADGE_TYPES.ENDING_SOON) { + const intervalId = setInterval(updateBadge, 60_000); // 60 seconds + return () => clearInterval(intervalId); + } + + return undefined; + }, [campaign]); + + if (!badge) return null; + + // Render badge based on type + switch (badge.type) { + case BADGE_TYPES.ENDING_SOON: + return ( + + + {badge.data.label} + + ); + + case BADGE_TYPES.FILLING_FAST: + return ( + + + {badge.data.label} + + ); + + case BADGE_TYPES.JUST_LAUNCHED: + return ( + + + {badge.data.label} + + ); + + default: + return null; + } +} diff --git a/frontend/src/lib/apiClient.js b/frontend/src/lib/apiClient.js index 85c860bb..b841889b 100644 --- a/frontend/src/lib/apiClient.js +++ b/frontend/src/lib/apiClient.js @@ -70,7 +70,7 @@ async function request(url, options = {}) { * q?: string, * page?: number, * limit?: number, - * sort?: 'name' | 'created_at' | 'updated_at' | 'reward_per_action' | 'id', + * sort?: 'name' | 'created_at' | 'updated_at' | 'reward_per_action' | 'id' | 'urgency', * order?: 'asc' | 'desc' * }} [params] */ diff --git a/frontend/src/utils/urgencyUtils.js b/frontend/src/utils/urgencyUtils.js new file mode 100644 index 00000000..7bf3989c --- /dev/null +++ b/frontend/src/utils/urgencyUtils.js @@ -0,0 +1,193 @@ +/** + * Urgency badge utilities for campaigns. + * Provides logic for determining urgency signals and formatting countdown timers. + */ + +/** + * Calculate time remaining until a date + * @param {string | Date} endDate - The end date + * @param {Date} [now=new Date()] - Current time (injectable for testing) + * @returns {{ hours: number, minutes: number, totalMs: number }} + */ +export function getTimeRemaining(endDate, now = new Date()) { + if (!endDate) return null; + + const end = new Date(endDate); + if (Number.isNaN(end.getTime())) return null; + + const totalMs = end.getTime() - now.getTime(); + if (totalMs < 0) return null; + + const hours = Math.floor(totalMs / (1000 * 60 * 60)); + const minutes = Math.floor((totalMs % (1000 * 60 * 60)) / (1000 * 60)); + + return { hours, minutes, totalMs }; +} + +/** + * Format countdown timer for "Ending Soon" badge + * @param {number} hours + * @param {number} minutes + * @returns {string} + */ +export function formatCountdown(hours, minutes) { + if (hours === 0 && minutes === 0) return 'Ending soon'; + if (hours === 0) return `${minutes}m`; + if (minutes === 0) return `${hours}h`; + return `${hours}h ${minutes}m`; +} + +/** + * Check if campaign is ending soon (< 24 hours) + * @param {string | Date} endDate + * @param {Date} [now=new Date()] + * @returns {boolean} + */ +export function isEndingSoon(endDate, now = new Date()) { + const remaining = getTimeRemaining(endDate, now); + if (!remaining) return false; + + const TWENTY_FOUR_HOURS_MS = 24 * 60 * 60 * 1000; + return remaining.totalMs < TWENTY_FOUR_HOURS_MS; +} + +/** + * Check if campaign is filling fast (> 85% capacity) + * @param {number} participantCount + * @param {number} maxCap + * @returns {boolean} + */ +export function isFillingFast(participantCount, maxCap) { + if (!maxCap || maxCap === 0) return false; + if (participantCount === undefined || participantCount === null) return false; + + const fillPercentage = participantCount / maxCap; + return fillPercentage > 0.85; +} + +/** + * Calculate fill percentage + * @param {number} participantCount + * @param {number} maxCap + * @returns {number | null} + */ +export function getFillPercentage(participantCount, maxCap) { + if (!maxCap || maxCap === 0) return null; + if (participantCount === undefined || participantCount === null) return null; + + return Math.min(Math.round((participantCount / maxCap) * 100), 100); +} + +/** + * Check if campaign just launched (< 48 hours since start) + * @param {string | Date} startDate + * @param {Date} [now=new Date()] + * @returns {boolean} + */ +export function isJustLaunched(startDate, now = new Date()) { + if (!startDate) return false; + + const start = new Date(startDate); + if (Number.isNaN(start.getTime())) return false; + + const elapsedMs = now.getTime() - start.getTime(); + if (elapsedMs < 0) return false; // Not started yet + + const FORTY_EIGHT_HOURS_MS = 48 * 60 * 60 * 1000; + return elapsedMs < FORTY_EIGHT_HOURS_MS; +} + +/** + * Badge types in priority order + */ +export const BADGE_TYPES = { + ENDING_SOON: 'ending_soon', + FILLING_FAST: 'filling_fast', + JUST_LAUNCHED: 'just_launched', +}; + +/** + * Determine which urgency badge to show (priority: Ending Soon > Filling Fast > Just Launched) + * @param {object} campaign + * @param {string} campaign.endDate + * @param {string} campaign.startDate + * @param {number} campaign.participantCount + * @param {number} campaign.maxParticipants + * @param {Date} [now=new Date()] + * @returns {{ type: string, data: object } | null} + */ +export function getUrgencyBadge(campaign, now = new Date()) { + if (!campaign) return null; + + const { endDate, startDate, participantCount = 0, maxParticipants = 0 } = campaign; + + // Priority 1: Ending Soon + if (isEndingSoon(endDate, now)) { + const remaining = getTimeRemaining(endDate, now); + return { + type: BADGE_TYPES.ENDING_SOON, + data: { + hours: remaining.hours, + minutes: remaining.minutes, + label: `Ends in ${formatCountdown(remaining.hours, remaining.minutes)}`, + }, + }; + } + + // Priority 2: Filling Fast + if (isFillingFast(participantCount, maxParticipants)) { + const percentage = getFillPercentage(participantCount, maxParticipants); + return { + type: BADGE_TYPES.FILLING_FAST, + data: { + percentage, + label: `${percentage}% full`, + }, + }; + } + + // Priority 3: Just Launched + if (isJustLaunched(startDate, now)) { + return { + type: BADGE_TYPES.JUST_LAUNCHED, + data: { + label: 'New', + }, + }; + } + + return null; +} + +/** + * Calculate urgency score for sorting + * Higher score = more urgent + * @param {object} campaign + * @param {Date} [now=new Date()] + * @returns {number} + */ +export function calculateUrgencyScore(campaign, now = new Date()) { + if (!campaign) return 0; + + let score = 0; + const { endDate, participantCount = 0, maxParticipants = 0 } = campaign; + + // Ending soon: highest priority, score based on time remaining + if (isEndingSoon(endDate, now)) { + const remaining = getTimeRemaining(endDate, now); + if (remaining) { + // Score: 1,000,000 - milliseconds remaining (so sooner = higher score) + score = 1_000_000 - Math.floor(remaining.totalMs / 1000); + } + } + // Filling fast: second priority, score based on fill percentage + else if (isFillingFast(participantCount, maxParticipants)) { + const percentage = getFillPercentage(participantCount, maxParticipants); + if (percentage !== null) { + // Score: 500,000 + percentage * 100 (so fuller = higher score) + score = 500_000 + percentage * 100; + } + } + + return score; +} diff --git a/frontend/src/utils/urgencyUtils.test.js b/frontend/src/utils/urgencyUtils.test.js new file mode 100644 index 00000000..a4e9c343 --- /dev/null +++ b/frontend/src/utils/urgencyUtils.test.js @@ -0,0 +1,377 @@ +import { describe, it, expect } from 'vitest'; +import { + getTimeRemaining, + formatCountdown, + isEndingSoon, + isFillingFast, + getFillPercentage, + isJustLaunched, + getUrgencyBadge, + calculateUrgencyScore, + BADGE_TYPES, +} from './urgencyUtils'; + +describe('urgencyUtils', () => { + describe('getTimeRemaining', () => { + it('calculates time remaining correctly', () => { + const now = new Date('2025-01-15T10:00:00Z'); + const endDate = new Date('2025-01-15T12:30:00Z'); + + const result = getTimeRemaining(endDate, now); + + expect(result).toEqual({ + hours: 2, + minutes: 30, + totalMs: 2.5 * 60 * 60 * 1000, + }); + }); + + it('returns null for past dates', () => { + const now = new Date('2025-01-15T10:00:00Z'); + const endDate = new Date('2025-01-15T09:00:00Z'); + + expect(getTimeRemaining(endDate, now)).toBeNull(); + }); + + it('returns null for invalid dates', () => { + const now = new Date('2025-01-15T10:00:00Z'); + expect(getTimeRemaining('invalid-date', now)).toBeNull(); + expect(getTimeRemaining(null, now)).toBeNull(); + expect(getTimeRemaining(undefined, now)).toBeNull(); + }); + + it('handles edge case of 0 time remaining', () => { + const now = new Date('2025-01-15T10:00:00Z'); + const endDate = new Date('2025-01-15T10:00:00Z'); + + const result = getTimeRemaining(endDate, now); + expect(result).toEqual({ hours: 0, minutes: 0, totalMs: 0 }); + }); + }); + + describe('formatCountdown', () => { + it('formats hours and minutes correctly', () => { + expect(formatCountdown(2, 30)).toBe('2h 30m'); + expect(formatCountdown(5, 15)).toBe('5h 15m'); + }); + + it('formats hours only', () => { + expect(formatCountdown(3, 0)).toBe('3h'); + }); + + it('formats minutes only', () => { + expect(formatCountdown(0, 45)).toBe('45m'); + }); + + it('handles zero time', () => { + expect(formatCountdown(0, 0)).toBe('Ending soon'); + }); + }); + + describe('isEndingSoon', () => { + it('returns true when less than 24 hours remaining', () => { + const now = new Date('2025-01-15T10:00:00Z'); + const endDate = new Date('2025-01-16T09:59:59Z'); // 23h 59m 59s + + expect(isEndingSoon(endDate, now)).toBe(true); + }); + + it('returns false when 24 hours or more remaining', () => { + const now = new Date('2025-01-15T10:00:00Z'); + const endDate = new Date('2025-01-16T10:00:01Z'); // 24h 0m 1s + + expect(isEndingSoon(endDate, now)).toBe(false); + }); + + it('returns false for invalid dates', () => { + const now = new Date('2025-01-15T10:00:00Z'); + expect(isEndingSoon(null, now)).toBe(false); + expect(isEndingSoon(undefined, now)).toBe(false); + }); + + it('returns false for past dates', () => { + const now = new Date('2025-01-15T10:00:00Z'); + const endDate = new Date('2025-01-14T10:00:00Z'); + + expect(isEndingSoon(endDate, now)).toBe(false); + }); + }); + + describe('isFillingFast', () => { + it('returns true when > 85% full', () => { + expect(isFillingFast(86, 100)).toBe(true); + expect(isFillingFast(90, 100)).toBe(true); + expect(isFillingFast(95, 100)).toBe(true); + }); + + it('returns false when <= 85% full', () => { + expect(isFillingFast(85, 100)).toBe(false); + expect(isFillingFast(50, 100)).toBe(false); + expect(isFillingFast(0, 100)).toBe(false); + }); + + it('returns false for unlimited capacity (maxCap = 0)', () => { + expect(isFillingFast(1000, 0)).toBe(false); + }); + + it('returns false for invalid inputs', () => { + expect(isFillingFast(null, 100)).toBe(false); + expect(isFillingFast(undefined, 100)).toBe(false); + expect(isFillingFast(50, null)).toBe(false); + }); + + it('handles edge case at exactly 85%', () => { + expect(isFillingFast(85, 100)).toBe(false); + }); + }); + + describe('getFillPercentage', () => { + it('calculates percentage correctly', () => { + expect(getFillPercentage(50, 100)).toBe(50); + expect(getFillPercentage(90, 100)).toBe(90); + expect(getFillPercentage(33, 100)).toBe(33); + }); + + it('rounds to nearest integer', () => { + expect(getFillPercentage(33, 100)).toBe(33); + expect(getFillPercentage(67, 100)).toBe(67); + }); + + it('caps at 100%', () => { + expect(getFillPercentage(150, 100)).toBe(100); + }); + + it('returns null for unlimited capacity', () => { + expect(getFillPercentage(50, 0)).toBeNull(); + }); + + it('returns null for invalid inputs', () => { + expect(getFillPercentage(null, 100)).toBeNull(); + expect(getFillPercentage(undefined, 100)).toBeNull(); + }); + }); + + describe('isJustLaunched', () => { + it('returns true when less than 48 hours since start', () => { + const now = new Date('2025-01-17T10:00:00Z'); + const startDate = new Date('2025-01-15T10:00:01Z'); // 47h 59m 59s ago + + expect(isJustLaunched(startDate, now)).toBe(true); + }); + + it('returns false when 48 hours or more since start', () => { + const now = new Date('2025-01-17T10:00:01Z'); + const startDate = new Date('2025-01-15T10:00:00Z'); // 48h 0m 1s ago + + expect(isJustLaunched(startDate, now)).toBe(false); + }); + + it('returns false for future start dates', () => { + const now = new Date('2025-01-15T10:00:00Z'); + const startDate = new Date('2025-01-16T10:00:00Z'); + + expect(isJustLaunched(startDate, now)).toBe(false); + }); + + it('returns false for invalid dates', () => { + const now = new Date('2025-01-15T10:00:00Z'); + expect(isJustLaunched(null, now)).toBe(false); + expect(isJustLaunched(undefined, now)).toBe(false); + }); + }); + + describe('getUrgencyBadge', () => { + const now = new Date('2025-01-15T10:00:00Z'); + + it('returns Ending Soon badge when < 24h remaining (highest priority)', () => { + const campaign = { + endDate: new Date('2025-01-16T05:30:00Z').toISOString(), // 19h 30m + startDate: new Date('2025-01-15T09:00:00Z').toISOString(), // 1h ago + participantCount: 90, + maxParticipants: 100, + }; + + const badge = getUrgencyBadge(campaign, now); + + expect(badge).toEqual({ + type: BADGE_TYPES.ENDING_SOON, + data: { + hours: 19, + minutes: 30, + label: 'Ends in 19h 30m', + }, + }); + }); + + it('returns Filling Fast badge when > 85% full (second priority)', () => { + const campaign = { + endDate: new Date('2025-01-20T10:00:00Z').toISOString(), // 5 days away + startDate: new Date('2025-01-10T10:00:00Z').toISOString(), // 5 days ago + participantCount: 90, + maxParticipants: 100, + }; + + const badge = getUrgencyBadge(campaign, now); + + expect(badge).toEqual({ + type: BADGE_TYPES.FILLING_FAST, + data: { + percentage: 90, + label: '90% full', + }, + }); + }); + + it('returns Just Launched badge when < 48h since start (lowest priority)', () => { + const campaign = { + endDate: new Date('2025-01-20T10:00:00Z').toISOString(), // 5 days away + startDate: new Date('2025-01-14T10:00:00Z').toISOString(), // 24h ago + participantCount: 10, + maxParticipants: 100, + }; + + const badge = getUrgencyBadge(campaign, now); + + expect(badge).toEqual({ + type: BADGE_TYPES.JUST_LAUNCHED, + data: { + label: 'New', + }, + }); + }); + + it('prioritizes Ending Soon over Filling Fast', () => { + const campaign = { + endDate: new Date('2025-01-16T05:00:00Z').toISOString(), // 19h away + startDate: new Date('2025-01-10T10:00:00Z').toISOString(), + participantCount: 95, + maxParticipants: 100, + }; + + const badge = getUrgencyBadge(campaign, now); + + expect(badge.type).toBe(BADGE_TYPES.ENDING_SOON); + }); + + it('prioritizes Filling Fast over Just Launched', () => { + const campaign = { + endDate: new Date('2025-01-20T10:00:00Z').toISOString(), + startDate: new Date('2025-01-14T10:00:00Z').toISOString(), // 24h ago + participantCount: 90, + maxParticipants: 100, + }; + + const badge = getUrgencyBadge(campaign, now); + + expect(badge.type).toBe(BADGE_TYPES.FILLING_FAST); + }); + + it('returns null when no urgency conditions met', () => { + const campaign = { + endDate: new Date('2025-01-20T10:00:00Z').toISOString(), + startDate: new Date('2025-01-10T10:00:00Z').toISOString(), + participantCount: 50, + maxParticipants: 100, + }; + + expect(getUrgencyBadge(campaign, now)).toBeNull(); + }); + + it('returns null for invalid campaign', () => { + expect(getUrgencyBadge(null, now)).toBeNull(); + expect(getUrgencyBadge(undefined, now)).toBeNull(); + }); + + it('handles unlimited capacity campaigns', () => { + const campaign = { + endDate: new Date('2025-01-20T10:00:00Z').toISOString(), + startDate: new Date('2025-01-14T10:00:00Z').toISOString(), // 24h ago + participantCount: 1000, + maxParticipants: 0, // unlimited + }; + + const badge = getUrgencyBadge(campaign, now); + + expect(badge.type).toBe(BADGE_TYPES.JUST_LAUNCHED); + }); + }); + + describe('calculateUrgencyScore', () => { + const now = new Date('2025-01-15T10:00:00Z'); + + it('assigns highest scores to campaigns ending soonest', () => { + const campaign1h = { + endDate: new Date('2025-01-15T11:00:00Z').toISOString(), // 1h + participantCount: 10, + maxParticipants: 100, + }; + + const campaign10h = { + endDate: new Date('2025-01-15T20:00:00Z').toISOString(), // 10h + participantCount: 10, + maxParticipants: 100, + }; + + const score1h = calculateUrgencyScore(campaign1h, now); + const score10h = calculateUrgencyScore(campaign10h, now); + + expect(score1h).toBeGreaterThan(score10h); + expect(score1h).toBeGreaterThan(500_000); // In "ending soon" range + }); + + it('assigns medium scores to filling fast campaigns', () => { + const campaign90 = { + endDate: new Date('2025-01-20T10:00:00Z').toISOString(), // 5 days + participantCount: 90, + maxParticipants: 100, + }; + + const campaign95 = { + endDate: new Date('2025-01-20T10:00:00Z').toISOString(), + participantCount: 95, + maxParticipants: 100, + }; + + const score90 = calculateUrgencyScore(campaign90, now); + const score95 = calculateUrgencyScore(campaign95, now); + + expect(score95).toBeGreaterThan(score90); + expect(score95).toBeLessThan(1_000_000); + expect(score90).toBeGreaterThan(500_000); + }); + + it('assigns zero score to campaigns without urgency', () => { + const campaign = { + endDate: new Date('2025-01-20T10:00:00Z').toISOString(), + participantCount: 50, + maxParticipants: 100, + }; + + expect(calculateUrgencyScore(campaign, now)).toBe(0); + }); + + it('returns 0 for invalid campaign', () => { + expect(calculateUrgencyScore(null, now)).toBe(0); + expect(calculateUrgencyScore(undefined, now)).toBe(0); + }); + + it('prioritizes ending soon over filling fast in scores', () => { + const endingSoon = { + endDate: new Date('2025-01-15T11:00:00Z').toISOString(), // 1h + participantCount: 50, + maxParticipants: 100, + }; + + const fillingFast = { + endDate: new Date('2025-01-20T10:00:00Z').toISOString(), + participantCount: 95, + maxParticipants: 100, + }; + + const scoreEndingSoon = calculateUrgencyScore(endingSoon, now); + const scoreFillingFast = calculateUrgencyScore(fillingFast, now); + + expect(scoreEndingSoon).toBeGreaterThan(scoreFillingFast); + }); + }); +});