Skip to content
4 changes: 4 additions & 0 deletions proto/worldmonitor/sanctions/v1/list_sanctions_pressure.proto
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ import "worldmonitor/sanctions/v1/sanctions_entry.proto";
// ListSanctionsPressureRequest retrieves recent OFAC sanctions pressure state.
message ListSanctionsPressureRequest {
int32 max_items = 1 [(sebuf.http.query) = { name: "max_items" }];
// Optional time range filter (e.g. "1h", "6h", "24h", "48h", "7d").
// When set, entries are filtered to those with effective_at within
// the window and counts are recomputed accordingly.
string time_range = 2 [(sebuf.http.query) = { name: "time_range" }];
}

// ListSanctionsPressureResponse contains normalized OFAC pressure summaries and recent entries.
Expand Down
80 changes: 80 additions & 0 deletions server/worldmonitor/sanctions/v1/list-sanctions-pressure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type {
ListSanctionsPressureRequest,
ListSanctionsPressureResponse,
SanctionsServiceHandler,
SanctionsEntry,
ServerContext,
} from '../../../../src/generated/server/worldmonitor/sanctions/v1/service_server';

Expand All @@ -11,6 +12,14 @@ const REDIS_CACHE_KEY = 'sanctions:pressure:v1';
const DEFAULT_MAX_ITEMS = 25;
const MAX_ITEMS_LIMIT = 60;

const TIME_RANGE_MS: Record<string, number> = {
'1h': 60 * 60 * 1000,
'6h': 6 * 60 * 60 * 1000,
'24h': 24 * 60 * 60 * 1000,
'48h': 48 * 60 * 60 * 1000,
'7d': 7 * 24 * 60 * 60 * 1000,
};

// All fetch/parse/scoring logic lives in the Railway seed script
// (scripts/seed-sanctions-pressure.mjs). This handler reads pre-built
// data from Redis only (gold standard: Vercel reads, Railway writes).
Expand All @@ -36,6 +45,72 @@ function emptyResponse(): ListSanctionsPressureResponse {
};
}

/**
* When a time_range is supplied (e.g. "7d"), recompute newEntryCount and
* per-country / per-program counts so they reflect only entries whose
* effectiveAt falls within the requested window.
*/
function applyTimeRangeFilter(
data: ListSanctionsPressureResponse,
timeRange: string,
maxItems: number,
): ListSanctionsPressureResponse {
const windowMs = TIME_RANGE_MS[timeRange];
if (!windowMs) {
// Unknown or 'all' — return unfiltered (existing behaviour)
return { ...data, entries: (data.entries ?? []).slice(0, maxItems) };
}

const cutoff = Date.now() - windowMs;
const allEntries = data.entries ?? [];

// Mark entries whose effectiveAt falls within the window as "new"
// and recompute the global / per-country / per-program counts.
const retagged: SanctionsEntry[] = allEntries.map((e) => {
const ts = typeof e.effectiveAt === 'string' ? Number(e.effectiveAt) : (e.effectiveAt as unknown as number);
const withinWindow = Number.isFinite(ts) && ts > 0 && ts >= cutoff;
return { ...e, isNew: withinWindow };
});

const newEntryCount = retagged.filter((e) => e.isNew).length;

// Start from the original countries/programs (preserving every entry),
// then patch newEntryCount with recomputed values from the time window.
const countryNewCounts = new Map<string, number>();
for (const entry of retagged) {
if (!entry.isNew) continue;
for (const code of (entry.countryCodes ?? [])) {
countryNewCounts.set(code, (countryNewCounts.get(code) ?? 0) + 1);
}
}
const countries = (data.countries ?? []).map((c) => ({
...c,
newEntryCount: countryNewCounts.get(c.countryCode) ?? 0,
}));

const programNewCounts = new Map<string, number>();
for (const entry of retagged) {
if (!entry.isNew) continue;
for (const prog of (entry.programs ?? [])) {
programNewCounts.set(prog, (programNewCounts.get(prog) ?? 0) + 1);
}
}
const programs = (data.programs ?? []).map((p) => ({
...p,
newEntryCount: programNewCounts.get(p.program) ?? 0,
}));

return {
...data,
entries: retagged.slice(0, maxItems),
countries,
programs,
newEntryCount,
vesselCount: data.vesselCount,
aircraftCount: data.aircraftCount,
};
}

export const listSanctionsPressure: SanctionsServiceHandler['listSanctionsPressure'] = async (
_ctx: ServerContext,
req: ListSanctionsPressureRequest,
Expand All @@ -45,6 +120,11 @@ export const listSanctionsPressure: SanctionsServiceHandler['listSanctionsPressu
const data = await getCachedJson(REDIS_CACHE_KEY, true) as ListSanctionsPressureResponse & { _state?: unknown } | null;
if (!data?.totalCount) return emptyResponse();
const { _state: _discarded, ...rest } = data;

if (req.timeRange) {
return applyTimeRangeFilter(rest, req.timeRange, maxItems);
}

return {
...rest,
entries: (data.entries ?? []).slice(0, maxItems),
Expand Down
1 change: 1 addition & 0 deletions src/App.ts
Original file line number Diff line number Diff line change
Expand Up @@ -691,6 +691,7 @@ export class App {
loadAllData: () => this.dataLoader.loadAllData(),
updateMonitorResults: () => this.dataLoader.updateMonitorResults(),
loadSecurityAdvisories: () => this.dataLoader.loadSecurityAdvisories(),
onTimeRangeChanged: () => { void this.dataLoader.loadSanctionsPressure(); },
});

this.eventHandlers = new EventHandlerManager(this.state, {
Expand Down
2 changes: 1 addition & 1 deletion src/app/data-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3091,7 +3091,7 @@ export class DataLoaderManager implements AppModule {

async loadSanctionsPressure(): Promise<void> {
try {
const result = await fetchSanctionsPressure();
const result = await fetchSanctionsPressure(this.ctx.currentTimeRange);
this.callPanel('sanctions-pressure', 'setData', result);
this.ctx.intelligenceCache.sanctions = result;
signalAggregator.ingestSanctionsPressure(result.countries);
Expand Down
2 changes: 2 additions & 0 deletions src/app/panel-layout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ export interface PanelLayoutManagerCallbacks {
loadAllData: () => Promise<void>;
updateMonitorResults: () => void;
loadSecurityAdvisories?: () => Promise<void>;
onTimeRangeChanged?: (timeRange: string) => void;
}

export class PanelLayoutManager implements AppModule {
Expand All @@ -133,6 +134,7 @@ export class PanelLayoutManager implements AppModule {
this.callbacks = callbacks;
this.applyTimeRangeFilterDebounced = debounce(() => {
this.applyTimeRangeFilterToNewsPanels();
this.callbacks.onTimeRangeChanged?.(ctx.currentTimeRange);
}, 120);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

export interface ListSanctionsPressureRequest {
maxItems: number;
timeRange: string;
}

export interface ListSanctionsPressureResponse {
Expand Down Expand Up @@ -121,6 +122,7 @@ export class SanctionsServiceClient {
let path = "/api/sanctions/v1/list-sanctions-pressure";
const params = new URLSearchParams();
if (req.maxItems != null && req.maxItems !== 0) params.set("max_items", String(req.maxItems));
if (req.timeRange != null && req.timeRange !== "") params.set("time_range", String(req.timeRange));
const url = this.baseURL + path + (params.toString() ? "?" + params.toString() : "");

const headers: Record<string, string> = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

export interface ListSanctionsPressureRequest {
maxItems: number;
timeRange: string;
}

export interface ListSanctionsPressureResponse {
Expand Down Expand Up @@ -133,6 +134,7 @@ export function createSanctionsServiceRoutes(
const params = url.searchParams;
const body: ListSanctionsPressureRequest = {
maxItems: Number(params.get("max_items") ?? "0"),
timeRange: params.get("time_range") ?? "",
};
if (options?.validateRequest) {
const bodyViolations = options.validateRequest("listSanctionsPressure", body);
Expand Down
24 changes: 17 additions & 7 deletions src/services/sanctions-pressure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,17 +147,26 @@ function toResult(response: ListSanctionsPressureResponse): SanctionsPressureRes
};
}

export async function fetchSanctionsPressure(): Promise<SanctionsPressureResult> {
const hydrated = getHydratedData('sanctionsPressure') as ListSanctionsPressureResponse | undefined;
if (hydrated?.entries?.length || hydrated?.countries?.length || hydrated?.programs?.length) {
const result = toResult(hydrated);
latestSanctionsPressureResult = result;
return result;
export async function fetchSanctionsPressure(timeRange?: string): Promise<SanctionsPressureResult> {
// Only use the bootstrap hydration path when there is no timeRange filter:
// hydrated data carries the seed script's static isNew flags and cannot be
// re-filtered, so a non-default window would show incorrect counts.
if (!timeRange) {
const hydrated = getHydratedData('sanctionsPressure') as ListSanctionsPressureResponse | undefined;
if (hydrated?.entries?.length || hydrated?.countries?.length || hydrated?.programs?.length) {
const result = toResult(hydrated);
latestSanctionsPressureResult = result;
return result;
}
}

// Use the timeRange as the cache key so each window has its own cache slot.
const cacheKey = timeRange || 'all';

return breaker.execute(async () => {
const response = await client.listSanctionsPressure({
maxItems: 30,
timeRange: timeRange ?? '',
}, {
signal: AbortSignal.timeout(25_000),
});
Expand All @@ -167,10 +176,11 @@ export async function fetchSanctionsPressure(): Promise<SanctionsPressureResult>
// Seed is missing or the feed is down. Evict any stale cache so the
// panel surfaces "unavailable" instead of serving old designations
// indefinitely via stale-while-revalidate.
breaker.clearCache();
breaker.clearCache(cacheKey);
}
return result;
}, emptyResult, {
cacheKey,
shouldCache: (result) => result.totalCount > 0,
});
}
Expand Down