Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions __tests__/fixtures/factories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -517,3 +517,17 @@ export function createOrderWithIO(
started,
});
}

export function createTestAlert(overrides: Partial<PrunApi.Alert> = {}): PrunApi.Alert {
return {
id: nextId('alert'),
type: 'PRODUCTION_ORDER_FINISHED',
contextId: nextId('ctx'),
naturalId: 'XK-745b',
time: createDateTime(),
data: [],
seen: false,
read: false,
...overrides,
};
}
78 changes: 78 additions & 0 deletions components/status/AlertsPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { useMemo, useState, type ReactNode } from 'react';
import { Panel } from '../shared';
import { useAlertsStore } from '../../stores/entities';
import { formatAlert } from '../../lib/format-alert';
import { formatRelativeTime } from '../../lib/format-time';

// The NOTS list can run long; cap the panel and say so rather than scroll
// forever — the full history lives in APEX.
const MAX_ROWS = 50;

/**
* APEX notifications surfaced into APXM (the NOTS passthrough, #30): the
* ALERTS_* WebSocket data rendered as a collapsible Status panel, so alerts
* are visible without switching to APEX. Read state is server-driven and
* display-only — marking read would require sending a message, which APXM
* never does.
*/
export function AlertsPanel({ handle }: { handle?: ReactNode }) {
const [collapsed, setCollapsed] = useState(true);
const fetched = useAlertsStore((s) => s.fetched);
const entities = useAlertsStore((s) => s.entities);

const alerts = useMemo(
() =>
Array.from(entities.values()).sort((a, b) => b.time.timestamp - a.time.timestamp),
[entities]
);
const unread = useMemo(() => alerts.filter((a) => !a.read).length, [alerts]);
const rows = alerts.slice(0, MAX_ROWS);

return (
<Panel
title="Alerts"
code="NOTS"
collapsible
collapsed={collapsed}
onToggleCollapse={() => setCollapsed((c) => !c)}
summary={unread > 0 ? `${unread} unread` : `${alerts.length}`}
handle={handle}
>
{!fetched ? (
<p className="text-xs text-apxm-muted">Waiting for game data...</p>
) : alerts.length === 0 ? (
<p className="text-xs text-apxm-muted">No notifications</p>
) : (
<div className="space-y-1">
{rows.map((alert) => {
const { text, category } = formatAlert(alert);
return (
<div key={alert.id} className="flex items-baseline gap-2 text-xs">
<span className="font-mono text-[10px] uppercase text-apxm-text/50 w-16 shrink-0">
{category}
</span>
<span
className={`flex-1 min-w-0 ${
alert.read ? 'text-apxm-muted' : 'text-apxm-text'
}`}
>
{/* Unread also gets a marker — colour alone can't carry state */}
{!alert.read && <span className="text-prun-yellow mr-1">●</span>}
{text}
</span>
<span className="font-mono text-[10px] text-apxm-text/50 shrink-0">
{formatRelativeTime(alert.time.timestamp)}
</span>
</div>
);
})}
{alerts.length > MAX_ROWS && (
<p className="text-[10px] text-apxm-muted pt-1">
+{alerts.length - MAX_ROWS} older — see NOTS in APEX
</p>
)}
</div>
)}
</Panel>
);
}
14 changes: 9 additions & 5 deletions components/status/__tests__/reorder-utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@ import { reconcileOrder, applyReorder } from '../reorder-utils';

describe('reconcileOrder', () => {
it('returns the canonical order for an empty/default input', () => {
expect(reconcileOrder([])).toEqual(['bases', 'fleet', 'contracts', 'empire']);
expect(reconcileOrder([])).toEqual(['bases', 'fleet', 'contracts', 'empire', 'alerts']);
});

it('preserves a valid saved order', () => {
const saved = ['fleet', 'empire', 'contracts', 'bases'];
const saved = ['alerts', 'fleet', 'empire', 'contracts', 'bases'];
expect(reconcileOrder(saved)).toEqual(saved);
});

Expand All @@ -18,16 +18,19 @@ describe('reconcileOrder', () => {
// appended in canonical order
'contracts',
'empire',
'alerts',
]);
});

it('appends known panels missing from a stale saved order', () => {
// Saved order predates 'empire' being added.
expect(reconcileOrder(['fleet', 'bases', 'contracts'])).toEqual([
it('appends known panels missing from a stale saved order (no migration needed)', () => {
// A persisted order from before 'alerts' existed — the exact upgrade path
// every existing user hits when the NOTS panel ships.
expect(reconcileOrder(['fleet', 'bases', 'contracts', 'empire'])).toEqual([
'fleet',
'bases',
'contracts',
'empire',
'alerts',
]);
});

Expand All @@ -37,6 +40,7 @@ describe('reconcileOrder', () => {
'bases',
'contracts',
'empire',
'alerts',
]);
});
});
Expand Down
1 change: 1 addition & 0 deletions components/status/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,6 @@ export { ContractsMiniList } from './ContractsMiniList';
export { CashBalancePane } from './CashBalancePane';
export { AttentionPanel } from './AttentionPanel';
export { EmpireBurnPanel } from './EmpireBurnPanel';
export { AlertsPanel } from './AlertsPanel';
export { DragHandle } from './DragHandle';
export { useStatusReorder } from './useStatusReorder';
2 changes: 2 additions & 0 deletions components/views/StatusView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
CashBalancePane,
AttentionPanel,
EmpireBurnPanel,
AlertsPanel,
DragHandle,
useStatusReorder,
} from '../status';
Expand All @@ -18,6 +19,7 @@ const PANELS: Record<StatusPanelId, (handle: ReactNode) => ReactNode> = {
fleet: (handle) => <FleetMiniList handle={handle} />,
contracts: (handle) => <ContractsMiniList handle={handle} />,
empire: (handle) => <EmpireBurnPanel handle={handle} />,
alerts: (handle) => <AlertsPanel handle={handle} />,
};

export function StatusView() {
Expand Down
108 changes: 108 additions & 0 deletions lib/__tests__/format-alert.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { describe, it, expect } from 'vitest';
import { formatAlert } from '../format-alert';
import type { PrunApi } from '../../types/prun-api';
import { createTestAlert, createAddress } from '../../__tests__/fixtures/factories';

function withData(
type: PrunApi.AlertType,
data: PrunApi.AlertData[] = []
): PrunApi.Alert {
return createTestAlert({ type, data });
}

describe('formatAlert — explicit templates', () => {
it('COMEX_ORDER_FILLED includes quantity and commodity when present', () => {
const result = formatAlert(
withData('COMEX_ORDER_FILLED', [
{ key: 'commodity', value: 'RAT' },
{ key: 'quantity', value: 500 },
])
);
expect(result.text).toBe('CX order filled: 500x RAT');
expect(result.category).toBe('trade');
});

it('COMEX_ORDER_FILLED degrades gracefully without data', () => {
expect(formatAlert(withData('COMEX_ORDER_FILLED')).text).toBe('CX order filled');
});

it('PRODUCTION_ORDER_FINISHED composes material and planet', () => {
const result = formatAlert(
withData('PRODUCTION_ORDER_FINISHED', [
{ key: 'material', value: 'PE' },
{ key: 'quantity', value: 200 },
{ key: 'planet', value: { address: createAddress({ planetName: 'Montem' }) } },
])
);
expect(result.text).toBe('Production finished: 200x PE @ Montem');
expect(result.category).toBe('production');
});

it('SHIP_FLIGHT_ENDED names the ship and destination', () => {
const result = formatAlert(
withData('SHIP_FLIGHT_ENDED', [
{ key: 'registration', value: 'AVI-04X21' },
{ key: 'destination', value: { address: createAddress({ planetName: 'Promitor' }) } },
])
);
expect(result.text).toBe('AVI-04X21 arrived at Promitor');
expect(result.category).toBe('fleet');
});

it('workforce alerts carry the planet and escalate wording', () => {
const planet = { key: 'planet' as const, value: { address: createAddress({ planetName: 'Vallis' }) } };
expect(formatAlert(withData('WORKFORCE_LOW_SUPPLIES', [planet])).text).toBe(
'Workforce low on supplies @ Vallis'
);
expect(formatAlert(withData('WORKFORCE_OUT_OF_SUPPLIES', [planet])).text).toBe(
'Workforce OUT of supplies @ Vallis'
);
expect(formatAlert(withData('WORKFORCE_UNSATISFIED', [planet])).category).toBe('workforce');
});

it('contract family shares the verb-phrase template with the partner name', () => {
const result = formatAlert(
withData('CONTRACT_CONTRACT_RECEIVED', [
{ key: 'partner', value: { name: 'Trade Partner Corp' } },
])
);
expect(result.text).toBe('Contract received — Trade Partner Corp');
expect(result.category).toBe('contract');
});

it('contract deadline variants both read as deadline exceeded', () => {
expect(formatAlert(withData('CONTRACT_DEADLINE_EXCEEDED_WITH_CONTROL')).text).toBe(
'Contract deadline exceeded'
);
expect(formatAlert(withData('CONTRACT_DEADLINE_EXCEEDED_WITHOUT_CONTROL')).text).toBe(
'Contract deadline exceeded'
);
});
});

describe('formatAlert — fallback for untemplated types', () => {
it('humanizes the enum name', () => {
const result = formatAlert(withData('GATEWAY_LINK_ESTABLISHED'));
expect(result.text).toBe('Gateway link established');
expect(result.category).toBe('fleet');
});

it('appends a salient data value when one is recognisable', () => {
const result = formatAlert(
withData('COGC_PROGRAM_CHANGED', [{ key: 'program', value: 'ADVERTISING_AGRICULTURE' }])
);
expect(result.text).toBe('Cogc program changed: ADVERTISING_AGRICULTURE');
expect(result.category).toBe('base');
});

it('never throws on unknown data shapes (boundary rule: hostile input)', () => {
const result = formatAlert(
withData('RELEASE_NOTES', [
{ key: 'weird', value: { nested: [1, 2, 3] } },
{ key: 'quantity', value: 'not-a-number' as unknown as number },
])
);
expect(result.text).toBe('Release notes');
expect(result.category).toBe('system');
});
});
Loading