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.
| Field | On success | On failure |
|---|---|---|
data | Resolved value | null |
error | null | Caught 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')ifcodestarts with'1','2', or'3'— callers must pass a 4xx or 5xx code. - Returns
NextResponse.json({ time, code, message, error }, { status: code }). errorparameter defaults tonullwhen omitted.- Unknown codes fall through to the
defaultbranch: message becomes'Unknown error'and HTTP status is overridden to500.
Response envelope:
{
"time": "<number>",
"code": "<number>",
"message": "<string>",
"error": "<any | null>"
}
Supported codes:
| Code | Message |
|---|---|
| 400 | Bad Request |
| 401 | Unauthorized |
| 403 | Forbidden |
| 404 | Not found |
| 405 | Method not allowed |
| 418 | I'm a teapot |
| 429 | Too many requests |
| 451 | Unavailable for legal reasons |
| 500 | Internal server error |
| 501 | Not implemented |
| 502 | Bad gateway |
| 503 | Service 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')ifcodedoes not start with'2'. - Returns
NextResponse.json({ time, code, message, data }, { status: code }). - Unknown 2xx codes fall through to the
defaultbranch: message becomes'Unknown'and HTTP status is overridden to200.
Response envelope:
{
"time": "<number>",
"code": "<number>",
"message": "<string>",
"data": "<any>"
}
Supported codes:
| Code | Message |
|---|---|
| 200 | OK |
| 201 | Created |
| 202 | Accepted |
| 203 | Non-authoritative information |
| 204 | No content |
| (other 2xx) | Unknown — HTTP status forced to 200 |
3. Time Utilities
src/shared/utils/time.mjs
Performance measurement (server-side)
| Function | Signature | Returns | Description |
|---|---|---|---|
performanceTime | (start: number) → number | Elapsed ms | performance.now() - start. Used directly inside responses.mjs for the time field on every API response. |
roundedPerformanceTime | (start: number) → number | Rounded ms | Rounds 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)
| Function | Signature | Returns | Description |
|---|---|---|---|
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
| Function | Signature | Returns | Description |
|---|---|---|---|
elapsedSeconds | (past: Date) → number | Integer seconds | Math.floor((now - past) / 1000). |
elapsedDateTime | (past: Date) → number | Milliseconds | Raw 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:
| Field | Included |
|---|---|
campaign_status[].season | Yes |
defend_event.season | Yes (single object, not array) |
statistics[].season | Yes |
attack_events[].season | No — 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:
- Collect seasons from included sources into a flat array.
- Deduplicate with
new Set. - If zero unique values → throw
Error('No seasons found in status data'). - If more than one unique value →
console.warn(does not throw; uses the first). - Validate the first value with
isValidNumber.safeParse→ throwError('Invalid Current Season')on failure. - Return
Number(uniqueSeasons[0]).
Throws on:
datais falsy →Error('status is missing')- No seasons found →
Error('No seasons found in status data') - Season value fails
isValidNumber→Error('Invalid Current Season')
getSeasonFromSnapshot(data)
getSeasonFromSnapshot(data: object): number
Sources consulted:
| Field | Included |
|---|---|
snapshots[].season | Yes |
defend_events[].season | Yes |
attack_events[].season | Yes |
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:
null/undefined→'—'NaN/Infinity→'—'>= 1,000,000,000→(n / 1B).toFixed(1) + 'B'>= 1,000,000→(n / 1M).toFixed(1) + 'M'>= 1,000→n.toLocaleString()(with commas)- 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:
null/undefined→null- Invalid date / future date →
'Updated just now' < 60 seconds→'Updated Xs ago'< 60 minutes→'Updated Xm ago'>= 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.
| Condition | Suffix |
|---|---|
% 100 in 11-13 | th |
% 10 === 1 | st |
% 10 === 2 | nd |
% 10 === 3 | rd |
| otherwise | th |
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:
- Normalizes email:
email.trim().toLowerCase(). - MD5-hashes the normalized string using Node's built-in
crypto.createHash('md5'). - 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:
data-umami-eventHTML attributes — for click tracking on static elements. The Umami tracker script (loaded inlayout.jsxvia/stats.jsproxy) captures these automatically. Preferred for simple interactions.useTrack()hook /window.umami?.track()— for dynamic client-side interactions where event names or data depend on runtime state.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 callswindow.umami.track(eventName, data). - Guards against
window.umamibeing undefined (ad blockers, dev mode, SSR). - Silently no-ops when the tracker is unavailable — analytics never affects user experience.
- For tracking inside
useEffectcallbacks (where hooks can't be called), usewindow.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(fromUMAMI_SITE_ID),hostname(fromgetHostname()),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) anddata(optional custom properties object, defaults to{}) to the payload. - Event names use
category-actionformat: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_ENV | Returns |
|---|---|
'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:
- No data (all arrays empty) →
null - All 3 live factions
status === 'defeated'→{ outcome: 'victory' }(early return) - Victory signal: any snapshot shows all 3 defeated, OR all 3 homeworlds captured via attack events
- Defeat signal: last region-0 defend event has
status === 'fail' - 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'bugPaths—Array<{ id, sector, d }>(11 items, sectors 1-11)cyborgPaths— same structure (11 items)illuminatePaths— same structure (11 items)superEarthCircle—{ id, cx, cy, r }factionIcons—Array<{ 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:
- Calculate expected points at current time assuming linear progress (
expectedRate × elapsedTime). - Compare actual
pointsagainst expected with a 10% buffer. - Return status:
"Ahead by N points"— actual > expected + 10% buffer"Behind by N points"— actual < expected"On track by N points"— within buffernull— eventstatusis 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:
| Field | Type |
|---|---|
time | number |
error_code | number |
campaign_status | campaignStatusSchema[] |
defend_event | defendEventSchema (single object, not array) |
attack_events | attackEventSchema[] |
statistics | statisticsSchema[] |
campaignStatusSchema:
| Field | Type |
|---|---|
season | number |
points | number |
points_taken | number |
points_max | number |
status | enum: 'active' | 'defeated' | 'hidden' |
introduction_order | number |
defendEventSchema:
| Field | Type |
|---|---|
season | number |
event_id | number |
start_time | number |
end_time | number |
region | number |
enemy | number |
points_max | number |
points | number |
status | enum: 'active' | 'success' | 'fail' |
attackEventSchema: Same as defend event but without region, and with two additional fields:
| Additional field | Type |
|---|---|
players_at_start | number |
max_event_id | number |
statisticsSchema:
| Field | Type |
|---|---|
season | number |
season_duration | number |
enemy | number |
players | number |
total_unique_players | number |
missions | number |
successful_missions | number |
total_mission_difficulty | number |
completed_planets | number |
defend_events | number |
successful_defend_events | number |
attack_events | number |
successful_attack_events | number |
deaths | number |
kills | number |
accidentals | number |
shots | number |
hits | number |
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:
| Field | Type |
|---|---|
time | number |
error_code | number |
introduction_order | number[] |
points_max | number[] |
snapshots | snapshotSchema[] |
defend_events | eventSchema[] (refined: must have region) |
attack_events | eventSchema[] (refined: must not have region) |
snapshotSchema:
| Field | Type | Notes |
|---|---|---|
season | number | |
time | number | |
data | string | Stringified JSON. Validated by parsing and checking each item against snapshotDataItemSchema. |
snapshotDataItemSchema (not exported):
| Field | Type |
|---|---|
points | number |
points_taken | number |
status | enum: 'hidden' | 'active' | 'defeated' |
eventSchema (shared base for defend and attack events):
| Field | Required | Type |
|---|---|---|
season | Yes | number |
event_id | Yes | number |
start_time | Yes | number |
end_time | Yes | number |
enemy | Yes | number |
points_max | Yes | number |
points | Yes | number |
status | Yes | enum: 'fail' | 'success' |
players_at_start | Yes | number |
region | No | number (optional) |
The distinction between defend and attack events is enforced via .refine():
defend_eventsentries:regionmust be present (notundefined).attack_eventsentries:regionmust be absent (undefined).
Note: Unlike
isValidStatus'sattackEventSchema,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 value | Required fields | Optional fields | Extra keys |
|---|---|---|---|
get_campaign_status | (none beyond action) | — | Forbidden (strict) |
get_snapshots | season (via schemaNumber) | — | Allowed |
get_available_entitlements | (none beyond action) | — | Forbidden (strict) |
get_leaderboards | network (steam|psn), season | count, users (string[]) | Allowed |
get_usernames | network (steam|psn), count | — | Allowed |
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:
isValidNumberandschemaNumberare functionally identical schemas defined in separate files.isValidNumberis insrc/validators/,schemaNumberis co-located inisValidFormData.jsand 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
| Constant | Value | Meaning |
|---|---|---|
LIVE_SNAPSHOT_INTERVAL | 900 | Minimum seconds between live snapshots (15 min) |
EVENT_SNAPSHOT_INTERVAL | 600 | Minimum seconds between event snapshots (10 min) |
In-memory state
| Variable | Type | Description |
|---|---|---|
currentSeason | number | null | Tracks the current season; triggers reset on change |
lastLiveSnapshotTime | number | null | Epoch-seconds of the most recent live snapshot |
lastEventSnapshotTimes | Map<string, number> | Key: ${type}:${event_id}, value: epoch-seconds |
shouldTakeLiveSnapshot(season, apiTime)
shouldTakeLiveSnapshot(season: number, apiTime: number): Promise<boolean>
Behavior:
- Calls
resetIfSeasonChanged(season)— if the season has changed since the last call, clears all in-memory state. - If
lastLiveSnapshotTimeisnull(cold start), queriesh1_live_snapshotfor the most recent snapshot time for the given season. Seeds the in-memory timer with the result (or0if no rows exist). - Returns
trueifapiTime - 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:
- Builds a lookup key as
${type}:${eventId}. - If the key is not in
lastEventSnapshotTimes(cold start for this event), queriesh1_event_snapshotfor the most recent snapshot time matchingtypeandevent_id. Seeds the map entry with the result (or0if no rows exist). - Returns
trueifapiTime - 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 sectorssectorsInProgress = 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'— setsevent: 'active'on the region (visual overlay)status === 'fail'— reverts the region and all regions beyond it (to region 10) toloststatus === 'success'— setsevent: 'idle'(preserves score-based status)- Region 0 defend events affect Super Earth (
map[3][0])
Used by
| Caller | Events 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.jsx | Time-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
- Calls
fetchSeason(season)fromsrc/update/fetch.mjs(official APIget_snapshotsendpoint) - Returns early (no-op) if the API returns no meaningful data (no snapshots, no events)
- Upserts into:
h1_season,h1_introduction_order,h1_points_max,h1_event(defend + attack),h1_snapshot - All operations use Prisma upserts — idempotent, safe to call multiple times for the same season
- 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
isValidStatusandisValidSeasonfit into the update pipeline.
