Skip to content

Utilities Reference

Lookup reference for all shared utility functions and validation schemas — signatures, behavior, edge cases, and source locations.

graph TD
    subgraph ErrorHandling["ERROR HANDLING"]
        TC["tryCatch()"]
        ER["errorResponse()"]
        SR["successResponse()"]
    end

    subgraph Formatting["FORMATTING"]
        FN["formatNumber()"]
        FT["formatTimeAgo()"]
        FU["formatUptime()"]
        AO["addOrdinalSuffix()"]
    end

    subgraph GameLogic["GAME LOGIC"]
        CMS["computeMapState()"]
        DC["detectChanges()"]
        EP["evaluateProgress()"]
        GWO["getWarOutcome()"]
    end

    subgraph Validation["VALIDATION"]
        VS["isValidStatus"]
        VSN["isValidSeason"]
        VFD["isValidFormData"]
        VCT["isValidContentType"]
        VN["isValidNumber"]
    end

    TC --> ER
    TC --> SR
    VS --> DC
    CMS --> DC

    style ErrorHandling fill:#1c1917,stroke:#f59e0b,color:#fbbf24
    style Formatting fill:#1a1a2e,stroke:#a855f7,color:#c084fc
    style GameLogic fill:#0f1a0f,stroke:#22c55e,color:#4ade80
    style Validation fill:#1e293b,stroke:#3b82f6,color:#60a5fa

1. Error Handling — tryCatch

src/shared/utils/tryCatch.mjs

export async function tryCatch(promise) {
    try {
        const data = await promise;
        return { data, error: null };
    } catch (error) {
        return { data: null, error };
    }
}

Behavior

Wraps any Promise and returns a two-field result object instead of throwing. This is the project-wide substitute for try/catch blocks; every async operation in the codebase goes through this wrapper.

FieldOn successOn failure
dataResolved valuenull
errornullCaught Error object

Usage pattern

const { data, error } = await tryCatch(someAsyncOp());
if (error) {
    // handle error
}
// use data

Called from

src/update/status.mjs, src/update/season.mjs, src/instrumentation.js, API route handlers, page-level server components.


2. Response Helpers

src/shared/utils/api/responses.mjs

Both helpers return a NextResponse.json() with a consistent envelope shape and include elapsed time via performanceTime(start).

errorResponse(code, start, error?)

errorResponse(code: number, start: number, error?: any): NextResponse

Behavior:

  • Throws Error('Invalid error code') if code starts with '1', '2', or '3' — callers must pass a 4xx or 5xx code.
  • Returns NextResponse.json({ time, code, message, error }, { status: code }).
  • error parameter defaults to null when omitted.
  • Unknown codes fall through to the default branch: message becomes 'Unknown error' and HTTP status is overridden to 500.

Response envelope:

{
    "time": "<number>",
    "code": "<number>",
    "message": "<string>",
    "error": "<any | null>"
}

Supported codes:

CodeMessage
400Bad Request
401Unauthorized
403Forbidden
404Not found
405Method not allowed
418I'm a teapot
429Too many requests
451Unavailable for legal reasons
500Internal server error
501Not implemented
502Bad gateway
503Service unavailable
(other)Unknown error — HTTP status forced to 500

Note: Code 401 means "I don't know who you are." Code 403 means "I know who you are but you're still not allowed." Code 502 is used specifically when the upstream official Helldivers API is unreachable.


successResponse(code, start, data)

successResponse(code: number, start: number, data: any): NextResponse

Behavior:

  • Throws Error('Invalid success code') if code does not start with '2'.
  • Returns NextResponse.json({ time, code, message, data }, { status: code }).
  • Unknown 2xx codes fall through to the default branch: message becomes 'Unknown' and HTTP status is overridden to 200.

Response envelope:

{
    "time": "<number>",
    "code": "<number>",
    "message": "<string>",
    "data": "<any>"
}

Supported codes:

CodeMessage
200OK
201Created
202Accepted
203Non-authoritative information
204No content
(other 2xx)Unknown — HTTP status forced to 200

3. Time Utilities

src/shared/utils/time.mjs

Performance measurement (server-side)

FunctionSignatureReturnsDescription
performanceTime(start: number) → numberElapsed msperformance.now() - start. Used directly inside responses.mjs for the time field on every API response.
roundedPerformanceTime(start: number) → numberRounded msRounds elapsed time up to the nearest 50ms using Math.ceil(elapsed / 50) * 50. Examples: 33ms → 50, 60ms → 100, 111ms → 150. Purpose: coarse bucketing for Umami analytics events so individual requests don't create unbounded cardinality.

Relative and formatted time (UI helpers)

FunctionSignatureReturnsDescription
timeSince(date: Date) → string"X minutes/hours/days ago"Converts a past date to a human-readable relative string. Threshold: < 60 min → minutes, < 24 h → hours, otherwise days. Pluralization handled (e.g., "1 minute ago" vs "2 minutes ago").
formatDate(date: Date) → string"YYYY-MM-DD HH:MM:SS"Zero-pads all components. Uses local time (not UTC). Used wherever consistent date display is needed.

Elapsed time computations

FunctionSignatureReturnsDescription
elapsedSeconds(past: Date) → numberInteger secondsMath.floor((now - past) / 1000).
elapsedDateTime(past: Date) → numberMillisecondsRaw now - past with no rounding.
elapsedSeasonTime(season_duration: number) → object{ days, hours, minutes, seconds }Breaks a total-seconds value into human-readable components. Input is the season_duration integer from the statistics table. All components use Math.floor with modulo arithmetic.

elapsedSeasonTime decomposition

days    = Math.floor(season_duration / 86400)
hours   = Math.floor((season_duration % 86400) / 3600)
minutes = Math.floor((season_duration % 3600) / 60)
seconds = season_duration % 60

4. Season Extraction

src/shared/utils/getSeason.mjs

Both functions extract the current season number from an already-validated API response object. They throw synchronously on error — callers must handle or wrap with tryCatch.


getSeasonFromStatus(data)

getSeasonFromStatus(data: object): number

Sources consulted:

FieldIncluded
campaign_status[].seasonYes
defend_event.seasonYes (single object, not array)
statistics[].seasonYes
attack_events[].seasonNo — intentionally excluded

Why attack_events is excluded: Attack events can reference an old season when the current season has no recorded attacks yet. Including them would produce a false season mismatch.

Algorithm:

  1. Collect seasons from included sources into a flat array.
  2. Deduplicate with new Set.
  3. If zero unique values → throw Error('No seasons found in status data').
  4. If more than one unique value → console.warn (does not throw; uses the first).
  5. Validate the first value with isValidNumber.safeParse → throw Error('Invalid Current Season') on failure.
  6. Return Number(uniqueSeasons[0]).

Throws on:

  • data is falsy → Error('status is missing')
  • No seasons found → Error('No seasons found in status data')
  • Season value fails isValidNumberError('Invalid Current Season')

getSeasonFromSnapshot(data)

getSeasonFromSnapshot(data: object): number

Sources consulted:

FieldIncluded
snapshots[].seasonYes
defend_events[].seasonYes
attack_events[].seasonYes

Snapshot data includes attack events because historical season data is already fully resolved — the old-season contamination risk present in live status does not apply here.

Algorithm: Identical to getSeasonFromStatus once sources are gathered. Same deduplication, warn-on-multiple, and isValidNumber validation.

Throws on: Same conditions as getSeasonFromStatus.


5. Formatting

formatNumber(n)

src/shared/utils/format/formatNumber.mjs

formatNumber(n: number | null | undefined): string

Behavior:

  1. null / undefined'—'
  2. NaN / Infinity'—'
  3. >= 1,000,000,000(n / 1B).toFixed(1) + 'B'
  4. >= 1,000,000(n / 1M).toFixed(1) + 'M'
  5. >= 1,000n.toLocaleString() (with commas)
  6. Otherwise → String(n)

Examples: 1_500_000_000"1.5B", 12_300_000"12.3M", 12345"12,345", 847"847".

Used by: StatGrid, EventCard.


formatTimeAgo(date, now?)

src/shared/utils/format/formatTimeAgo.mjs

formatTimeAgo(date: Date | null, now?: Date): string | null

Behavior:

  1. null / undefinednull
  2. Invalid date / future date → 'Updated just now'
  3. < 60 seconds'Updated Xs ago'
  4. < 60 minutes'Updated Xm ago'
  5. >= 60 minutes'Updated Xh ago'

Examples: 45 seconds elapsed → "Updated 45s ago", 3 minutes → "Updated 3m ago".

Used by: Galaxy component (timestamp below the map).


addOrdinalSuffix(num)

addOrdinalSuffix(num: number): string

Behavior: Appends the English ordinal suffix to an integer. Handles the teen exception (11th, 12th, 13th) by checking num % 100 before num % 10.

ConditionSuffix
% 100 in 11-13th
% 10 === 1st
% 10 === 2nd
% 10 === 3rd
otherwiseth

Examples: 1"1st", 11"11th", 21"21st", 112"112th".


6. Other Utilities

formDataToObject(formData)

src/shared/utils/formdata.mjs

formDataToObject(formData: FormData): Record<string, string>

Iterates formData.entries() and builds a plain object. The result is what gets passed to isValidFormData for Zod validation in the rebroadcast endpoint. No type coercion is performed here — that is the validator's job.


getGravatarUrl(email)

src/shared/utils/gravatar.mjs

getGravatarUrl(email: string): string

Behavior:

  1. Normalizes email: email.trim().toLowerCase().
  2. MD5-hashes the normalized string using Node's built-in crypto.createHash('md5').
  3. Returns https://www.gravatar.com/avatar/{hash}?s=64.

Size parameter is hardcoded to 64px. Used in dashboard UI to display user avatars.


Analytics Architecture

Umami v3 (self-hosted, cookieless) with three tracking layers:

  1. data-umami-event HTML attributes — for click tracking on static elements. The Umami tracker script (loaded in layout.jsx via /stats.js proxy) captures these automatically. Preferred for simple interactions.
  2. useTrack() hook / window.umami?.track() — for dynamic client-side interactions where event names or data depend on runtime state.
  3. sendUmamiEvent() server utility — for API route tracking. Sends server-to-server directly to Umami (not subject to ad blockers).

Ad-blocker bypass: The client-side tracker posts through a same-origin proxy chain: tracker → /api/send (Next.js rewrite) → /api/umami (proxy route) → umami.drunik.be/api/send. No external domain appears in CSP or network requests. The proxy forwards X-Forwarded-For so Umami receives the real client IP for its cookieless session hash.

User identification: Authenticated users are identified via umami.identify(userId, { provider }) in UserSection.jsx. Anonymous visitors remain fully anonymous.

Event naming: category-action format. Categories: nav, auth, footer, docs, diagram, faction, archive, notification, push, sw, toast, dashboard, api.

Production-only: Both server-side (NODE_ENV guard) and client-side (Script tag conditional) tracking only run in production.


useTrack() (hook)

src/shared/hooks/useTrack.mjs

useTrack(): (eventName: string, data?: object) => void

Behavior:

  • Returns a stable useCallback-memoized function that calls window.umami.track(eventName, data).
  • Guards against window.umami being undefined (ad blockers, dev mode, SSR).
  • Silently no-ops when the tracker is unavailable — analytics never affects user experience.
  • For tracking inside useEffect callbacks (where hooks can't be called), use window.umami?.track() directly.

Used by: FactionTabs, SeasonSelector, NotificationToggle.


umamiTrackPage(title, url)

src/shared/utils/umami.mjs

umamiTrackPage(title: string, url: string): Promise<void>

Behavior:

  • Production-only. Returns immediately (no-op) if NODE_ENV !== 'production'.
  • POSTs a page-view event directly to https://{UMAMI_SITE_URL}/api/send (server-to-server).
  • Uses a hardcoded macOS Chrome User-Agent string (required by Umami's bot-detection).
  • Payload includes website (from UMAMI_SITE_ID), hostname (from getHostname()), screen: '1x1', language: 'en', title, url.
  • Errors are caught and logged to console.error; failures do not propagate.

umamiTrackEvent(title, url, name, data?)

src/shared/utils/umami.mjs

umamiTrackEvent(title: string, url: string, name: string, data?: object): Promise<void>

Behavior:

  • Production-only. Same early-return guard as umamiTrackPage.
  • Identical POST structure but adds name (event name) and data (optional custom properties object, defaults to {}) to the payload.
  • Event names use category-action format: api-campaign, api-rebroadcast, api-live-poll.
  • Called from API routes via Next.js after() hook so analytics does not block the response.

getHostname() (internal)

src/shared/utils/umami.mjs — not exported

function getHostname(): string;

Maps NODE_ENV to hostname:

NODE_ENVReturns
'development''localhost'
'staging''staging.helldivers.bot'
'production''helldivers.bot'
(anything else)throws Error('Unknown NODE_ENV')

/api/umami (proxy route)

src/app/api/umami/route.js

Same-origin proxy for the Umami tracker script. Receives POST from the client-side tracker (via /api/send rewrite in next.config.mjs) and forwards to https://{UMAMI_SITE_URL}/api/send.

Forwarded headers: Content-Type, User-Agent, X-Forwarded-For (critical for Umami's cookieless IP+UA session hash).

Error handling: Returns 502 Bad Gateway if the Umami instance is unreachable. Non-POST methods return 405.


7. War Outcome — getWarOutcome

src/features/archives/getWarOutcome.mjs

getWarOutcome(data: { snapshots?, events?, live? }): { outcome: 'victory'|'defeat', reason: string } | null

Determines whether a war ended in victory or defeat. Extracted from War.jsx for reuse.

Decision tree:

  1. No data (all arrays empty) → null
  2. All 3 live factions status === 'defeated'{ outcome: 'victory' } (early return)
  3. Victory signal: any snapshot shows all 3 defeated, OR all 3 homeworlds captured via attack events
  4. Defeat signal: last region-0 defend event has status === 'fail'
  5. Victory AND no defeat → victory. Defeat signal → defeat. No victory signal → defeat.

Consumers: War.jsx (UI banner), potentially future features. Note: the OG image route does NOT use this — it derives status directly from events.

Tests: src/__tests__/unit/utils/getWarOutcome.test.mjs (8 cases)


8. Shared Map Data — mapPaths

src/enums/mapPaths.mjs

Shared SVG path geometry for the Galaxy map. Single source of truth consumed by both Map.jsx (CSS class styling) and the OG image route (inline styling).

Exports:

  • viewBox'0 0 806.93 868.81'
  • bugPathsArray<{ id, sector, d }> (11 items, sectors 1-11)
  • cyborgPaths — same structure (11 items)
  • illuminatePaths — same structure (11 items)
  • superEarthCircle{ id, cx, cy, r }
  • factionIconsArray<{ id, href, x, y, width, height }> (4 items: bugs, cyborgs, illuminate, superearth)

The sector field is a number to avoid string parsing from id.


9. Event Progress — evaluateProgress

src/features/stats/evaluateProgress.mjs

Evaluates how a live event is performing relative to the expected linear progress rate. Returns a human-readable status string for active events, or null for completed/inactive events.

evaluateProgress(event: { start_time, end_time, points, points_max, status }) → string | null

Algorithm:

  1. Calculate expected points at current time assuming linear progress (expectedRate × elapsedTime).
  2. Compare actual points against expected with a 10% buffer.
  3. Return status:
    • "Ahead by N points" — actual > expected + 10% buffer
    • "Behind by N points" — actual < expected
    • "On track by N points" — within buffer
    • null — event status is not 'active'

Tests: src/__tests__/unit/utils/evaluateProgress.test.mjs


10. Validation Schemas

src/validators/

All schemas use Zod v4 ("zod": "^4.3.6" in package.json). Every validator imports from 'zod', which is the standard import path for Zod v4.


isValidStatus

src/validators/isValidStatus.mjs

Zod import: import { z } from 'zod' (Zod v4)

Export type: Function — (data: unknown) => SafeParseReturnType

Validates the official API get_campaign_status response.

Root schema fields:

FieldType
timenumber
error_codenumber
campaign_statuscampaignStatusSchema[]
defend_eventdefendEventSchema (single object, not array)
attack_eventsattackEventSchema[]
statisticsstatisticsSchema[]

campaignStatusSchema:

FieldType
seasonnumber
pointsnumber
points_takennumber
points_maxnumber
statusenum: 'active' | 'defeated' | 'hidden'
introduction_ordernumber

defendEventSchema:

FieldType
seasonnumber
event_idnumber
start_timenumber
end_timenumber
regionnumber
enemynumber
points_maxnumber
pointsnumber
statusenum: 'active' | 'success' | 'fail'

attackEventSchema: Same as defend event but without region, and with two additional fields:

Additional fieldType
players_at_startnumber
max_event_idnumber

statisticsSchema:

FieldType
seasonnumber
season_durationnumber
enemynumber
playersnumber
total_unique_playersnumber
missionsnumber
successful_missionsnumber
total_mission_difficultynumber
completed_planetsnumber
defend_eventsnumber
successful_defend_eventsnumber
attack_eventsnumber
successful_attack_eventsnumber
deathsnumber
killsnumber
accidentalsnumber
shotsnumber
hitsnumber

isValidSeason

src/validators/isValidSeason.mjs

Zod import: import { z } from 'zod' (Zod v4)

Export type: Function — (data: unknown) => SafeParseReturnType

Validates the official API get_snapshots response.

Root schema fields:

FieldType
timenumber
error_codenumber
introduction_ordernumber[]
points_maxnumber[]
snapshotssnapshotSchema[]
defend_eventseventSchema[] (refined: must have region)
attack_eventseventSchema[] (refined: must not have region)

snapshotSchema:

FieldTypeNotes
seasonnumber
timenumber
datastringStringified JSON. Validated by parsing and checking each item against snapshotDataItemSchema.

snapshotDataItemSchema (not exported):

FieldType
pointsnumber
points_takennumber
statusenum: 'hidden' | 'active' | 'defeated'

eventSchema (shared base for defend and attack events):

FieldRequiredType
seasonYesnumber
event_idYesnumber
start_timeYesnumber
end_timeYesnumber
enemyYesnumber
points_maxYesnumber
pointsYesnumber
statusYesenum: 'fail' | 'success'
players_at_startYesnumber
regionNonumber (optional)

The distinction between defend and attack events is enforced via .refine():

  • defend_events entries: region must be present (not undefined).
  • attack_events entries: region must be absent (undefined).

Note: Unlike isValidStatus's attackEventSchema, isValidSeason's event status enum only includes 'fail' | 'success' — there is no 'active' status in historical snapshot data.


isValidFormData

src/validators/isValidFormData.mjs

Zod import: import { z } from 'zod' (Zod v4)

Export type: Zod schema object (not a function) — call .safeParse(data) directly

Discriminated union on the action field. Used by the /api/h1/rebroadcast endpoint after formDataToObject converts the request body.

Actions and their required/optional fields:

action valueRequired fieldsOptional fieldsExtra keys
get_campaign_status(none beyond action)Forbidden (strict)
get_snapshotsseason (via schemaNumber)Allowed
get_available_entitlements(none beyond action)Forbidden (strict)
get_leaderboardsnetwork (steam|psn), seasoncount, users (string[])Allowed
get_usernamesnetwork (steam|psn), countAllowed

schemaNumber (also exported separately):

export const schemaNumber = z.preprocess(
    (val) => (typeof val === 'string' ? Number(val) : val),
    z.number().int().positive(),
);

Preprocesses a string to a number before validation. Used for form fields where numeric values arrive as strings. Validates: integer and positive (> 0).


isValidContentType

src/validators/isValidContentType.mjs

Zod import: import { z } from 'zod' (Zod v4)

Export type: Zod schema object — call .safeParse(value) directly

export const isValidContentType = z
    .string()
    .refine(
        (val) =>
            val.includes('multipart/form-data') ||
            val.includes('application/x-www-form-urlencoded'),
        { message: 'Invalid content type' },
    );

Validates the Content-Type request header. Uses .includes() (not exact match) so boundary parameters in multipart/form-data headers do not cause false failures. Used exclusively by the /api/h1/rebroadcast endpoint.


isValidNumber

src/validators/isValidNumber.mjs

Zod import: import { z } from 'zod' (Zod v4)

Export type: Zod schema object — call .safeParse(value) directly

export const isValidNumber = z.preprocess(
    (val) => (typeof val === 'string' ? Number(val) : val),
    z.number().int().positive(),
);

Identical preprocessing behavior to schemaNumber in isValidFormData.js — converts string to number then validates integer and positive. Used specifically for season number validation in getSeason.mjs and query parameter validation on the /api/h1/campaign endpoint.

Note: isValidNumber and schemaNumber are functionally identical schemas defined in separate files. isValidNumber is in src/validators/, schemaNumber is co-located in isValidFormData.js and exported from there.


11. Snapshot Throttle Timers

src/update/snapshotTimers.mjs

In-memory throttle layer that prevents the status pipeline from writing snapshots too frequently. Each function checks whether enough time has elapsed since the last snapshot before allowing a new write. On cold start (first call after process boot), the timers seed themselves from the database so restarts do not lose track of the last write time.

Called from src/update/status.mjs during the status update pipeline.

Constants

ConstantValueMeaning
LIVE_SNAPSHOT_INTERVAL900Minimum seconds between live snapshots (15 min)
EVENT_SNAPSHOT_INTERVAL600Minimum seconds between event snapshots (10 min)

In-memory state

VariableTypeDescription
currentSeasonnumber | nullTracks the current season; triggers reset on change
lastLiveSnapshotTimenumber | nullEpoch-seconds of the most recent live snapshot
lastEventSnapshotTimesMap<string, number>Key: ${type}:${event_id}, value: epoch-seconds

shouldTakeLiveSnapshot(season, apiTime)

shouldTakeLiveSnapshot(season: number, apiTime: number): Promise<boolean>

Behavior:

  1. Calls resetIfSeasonChanged(season) — if the season has changed since the last call, clears all in-memory state.
  2. If lastLiveSnapshotTime is null (cold start), queries h1_live_snapshot for the most recent snapshot time for the given season. Seeds the in-memory timer with the result (or 0 if no rows exist).
  3. Returns true if apiTime - lastLiveSnapshotTime >= 900.

Throws: Re-throws any Prisma error from the cold-start query.


recordLiveSnapshotTime(time)

recordLiveSnapshotTime(time: number): Promise<void>

Updates lastLiveSnapshotTime in memory. Called after a successful live snapshot database write to advance the throttle window.


shouldTakeEventSnapshot(type, eventId, apiTime)

shouldTakeEventSnapshot(type: string, eventId: number, apiTime: number): Promise<boolean>

Behavior:

  1. Builds a lookup key as ${type}:${eventId}.
  2. If the key is not in lastEventSnapshotTimes (cold start for this event), queries h1_event_snapshot for the most recent snapshot time matching type and event_id. Seeds the map entry with the result (or 0 if no rows exist).
  3. Returns true if apiTime - lastEventSnapshotTimes.get(key) >= 600.

Throws: Re-throws any Prisma error from the cold-start query.


recordEventSnapshotTime(type, eventId, time)

recordEventSnapshotTime(type: string, eventId: number, time: number): Promise<void>

Updates the lastEventSnapshotTimes map entry for the given ${type}:${eventId} key. Called after a successful event snapshot database write.


resetSnapshotTimers()

resetSnapshotTimers(): Promise<void>

Clears all in-memory state: sets lastLiveSnapshotTime to null and clears the lastEventSnapshotTimes map. Not called internally — resetIfSeasonChanged performs its own inline reset. Exported for external callers that need a full manual reset.


12. Map State — computeMapState

src/shared/utils/game/computeMapState.mjs

computeMapState(factionStates: Array, events?: Array): Object

Computes the visual state of the galaxy map from faction campaign data and events. Returns a deep clone of the map template with computed sector statuses — never mutates the template.

Sector calculation (regions 1-10)

Uses campaign.points (current influence score) divided by campaign.points_max / 10 (per-sector threshold) to determine how many sectors are captured:

  • sectorsEarned = Math.trunc(points / pointsPerSector) — fully captured sectors
  • sectorsInProgress = sectorsEarned + 1 — the sector currently being contested
  • Remaining sectors default to lost

Important: campaign.points is the correct field, not campaign.points_taken. The points field reflects current influence (can decrease due to counter-attacks), while points_taken is cumulative. The game's sector display corresponds to points.

Homeworld (region 11)

Region 11 is only affected by attack events. Campaign score has no effect. Attack events set region 11 to active (in progress), captured (success), or lost (fail).

Event filtering — critical for callers

For live views (homepage, OG image): only pass events with status === 'active'. Completed events are already reflected in the campaign score. Passing completed defend events will incorrectly overwrite score-based sector ownership.

// Correct (live view)
const activeEvents = (data.events ?? []).filter((e) => e.status === 'active');
const mapState = computeMapState(data.live, activeEvents);

For the timeline (WarTimeline): pass time-filtered events including completed ones, as the timeline reconstructs historical state from snapshots where the score at that moment matches the events.

Defend event behavior

Defend events are sorted by end_time (most recent wins per region):

  • status === 'active' — sets event: 'active' on the region (visual overlay)
  • status === 'fail' — reverts the region and all regions beyond it (to region 10) to lost
  • status === 'success' — sets event: 'idle' (preserves score-based status)
  • Region 0 defend events affect Super Earth (map[3][0])

Used by

CallerEvents passed
src/app/page.jsx (homepage)Active only
src/app/api/og/route.js (OG image)Active only
src/app/archives/page.jsx (history)None ([]) — timeline handles its own
src/components/h1/WarTimeline/WarTimeline.jsxTime-filtered (active + completed at that moment)

13. On-Demand Season Fetching — fetchAndSeedSeason

src/db/queries/fetchAndSeedSeason.mjs

fetchAndSeedSeason(season: number): Promise<void>

Fetches a single season from the official Helldivers API and seeds it into the normalized h1_* tables. Called on-demand by the /archives history page when a user requests a season not yet stored in the database.

Behavior

  1. Calls fetchSeason(season) from src/update/fetch.mjs (official API get_snapshots endpoint)
  2. Returns early (no-op) if the API returns no meaningful data (no snapshots, no events)
  3. Upserts into: h1_season, h1_introduction_order, h1_points_max, h1_event (defend + attack), h1_snapshot
  4. All operations use Prisma upserts — idempotent, safe to call multiple times for the same season
  5. Throws if the API fetch itself fails

Usage

// In /archives page — fetch missing season on first request
const { data, error } = await tryCatch(getCampaign(season));
if (!error && !data) {
    await tryCatch(fetchAndSeedSeason(season));
    // Re-query after seeding
    ({ data, error } = await tryCatch(getCampaign(season)));
}

Season selector derivation

The /archives page no longer queries the database for available seasons. Instead, it derives the list from the current active season number:

const seasons = Array.from({ length: activeSeason - 1 }, (_, i) => activeSeason - 1 - i);

This ensures all past seasons are always selectable, even if they haven't been fetched yet. First access triggers fetchAndSeedSeason(), after which the data is cached in the DB permanently.


Cross-References

  • See API Reference for where these utilities are used in route handlers.
  • See Data Flow for how isValidStatus and isValidSeason fit into the update pipeline.