diff --git a/docs/desk-n1-read-projection-contract.md b/docs/desk-n1-read-projection-contract.md index 42a09faf..67dfc9b7 100644 --- a/docs/desk-n1-read-projection-contract.md +++ b/docs/desk-n1-read-projection-contract.md @@ -103,7 +103,33 @@ readbacks, credentials and alert-delivery routes intact. - `alert_badge`: fixed unknown/not_supported (no alerts store); the UI shows `?`, never 0, and hides it behind the lock. - `sessions`: US/HK/CN slots, every state unknown with null times. -- `next_run`: unknown/source_unavailable (no verified schedule source is read). +- `next_run`: the next scheduled observation from stored runtime-daily + schedules, through an explicit opt-in on the existing reader: + `todaySummary(..., { schedule: true })` adds a `schedule` field + (`state`, `business_date`, `timezone`, `next_due_at`) to each row. Without + the option the legacy summary output is unchanged. Only the read for the + current New York business day opts in; no additional storage read or write + is made and no schedule is collected. + - A candidate is a fully read, complete record (fresh or stale) whose + schedule belongs to that business day in `America/New_York`, whose state is + not `unevaluable`, that carries no `schedule_unverified` or + `target_disabled` diagnostic, and whose `next_due_at` is still after the + request time. A timestamp already past at request time is not an upcoming + run. + - Every configured account has a candidate: `known` with the earliest + `{target_id, kind: "scheduled_run", due_at}` (or `stale` with its age when + any candidate record is stale). `observed_at` is the oldest candidate + record observation. + - Some accounts have one: `partial` with the earliest observed candidate. + It is not the global next run, and the UI labels it as such. + - None: unknown (`read_failed`, `coverage_incomplete` or + `source_unavailable`). Missing, conflicting, disabled, unevaluable and + schedule-unverified records stay unknown; a null `next_due_at` never proves + that no run is due. A past `?date=` does not read today's record, so its + `next_run` is unknown. + - Overview `runs_today.expected` and `pending` stay null: neighbouring + schedule instants are not a full-day occurrence count, and recorded runs + may include retries or manual runs. - `freshness`: from the oldest displayed stored observation and the source stale threshold, never from `generated_at`. - Tape mapping per stored run: a `finished_at` instant gives `state: done`, @@ -115,7 +141,10 @@ readbacks, credentials and alert-delivery routes intact. most 100 entries (truncation makes it partial). An empty tape with no fully read account names `coverage_incomplete` when a record was read (it carries its own observation instant) but is incomplete, `read_failed` on a failed - read, and `source_unavailable` only when no record was read. + read, and `source_unavailable` only when no record was read. A missing record + is never coverage, on a weekend too. Tape `observed_at` is the oldest stored + observation of every record that was read, stale incomplete ones included; + the request time is never substituted. ### C. `GET /api/desk/accounts` @@ -168,8 +197,15 @@ readbacks, credentials and alert-delivery routes intact. - A stored daily record counts as covered only when its source coverage is complete (fresh or stale alike); a stale, incomplete record is never a verified empty tape or a counted run. +- Runtime-daily completeness comes from the reader: a row is complete only + when the reader's `read_summary.status` is `complete` (a legacy body without + `read_summary` falls back to the record's own kind/status/completeness and + diagnostics). An accepted record whose outer read is incomplete is never a + known-null `last_run` or a covered zero. - `runs_today`: partial at most (`expected`/`pending` null without a verified - schedule source); `config_drift`: unknown/not_supported; a known count read + schedule source). With no covered record it is unknown `read_failed` when any + read failed, `coverage_incomplete` when a record was read but is incomplete, + and `source_unavailable` otherwise; `config_drift`: unknown/not_supported; a known count read may not hide a `null` member. - `book`: per-target masked rows; strategy/mode/runtime are not inferred from configuration; NAV only from a bound stored snapshot. `last_run` is an N1 @@ -188,6 +224,9 @@ readbacks, credentials and alert-delivery routes intact. inside the drawn window. Never interpolated and never a return. An unread, identity-unverified, currency-mismatched or non-cohort (paper) history, or fewer than two points, gives `points: []`. +- `freshness`/`data_age_seconds`: from the oldest displayed bound stored + observation; when every displayed fact is stale, it is `stale` with that age, + never unavailable. A stale NAV is rendered with its stale label and age. - `needs_you`: credential-maintenance items only; pending decisions are not read, so the list is partial and never "nothing needs you". - `drawdown`, exposures: `null` (no risk thresholds or three-tier runtime). @@ -213,6 +252,19 @@ readbacks, credentials and alert-delivery routes intact. response time never drives data age. - Dock badge: unknown shows `?`; TopBar shows a "mode unknown" chip; both are hidden behind the lock. Old callers keep their behaviour. +- TopBar next-run chip from the ribbon `next_run`: known shows the time and + target; stale adds its age; partial says it is the earliest observed run and + not the overall next run; unknown, null or a time already past shows + `Next run ?`. The chip is hidden behind the lock. Logs prints any + non-unknown next run without its read status, so its copy reads "earliest + observed scheduled run" and never claims the overall next run. +- Overview Recent Signals shows the newest five tape entries, newest first; a + loading ribbon is not shown as empty, and a populated partial or stale tape + keeps its coverage or age note. +- Performance renders the requested window, first stored date, return basis per + target and any truncation note whether or not a curve exists. The live path + never calls `api.benchmarks()` (`/api/market-benchmark/performance` is not an + approved N1 source); only fixture pages show a benchmark comparison. - Overview/Accounts/Strategies/Performance unwrap validated reads: unknown and partial are never success-empty; stale is greyed. Logs only adapts the shared ribbon tape; it keeps its fixture read. diff --git a/tests/console_desk_n1_validation.mjs b/tests/console_desk_n1_validation.mjs index 604c687a..95f6678f 100644 --- a/tests/console_desk_n1_validation.mjs +++ b/tests/console_desk_n1_validation.mjs @@ -18,6 +18,7 @@ import * as v1Accounts from "../web/strategy-switch-console/frontend/src/desk/ap import * as v1Performance from "../web/strategy-switch-console/frontend/src/desk/api/fixtures/performance.ts"; import { forTarget } from "../web/strategy-switch-console/frontend/src/desk/api/fixtures/switchStrategies.ts"; import { DESK_MESSAGES, deskTranslate } from "../web/strategy-switch-console/frontend/src/desk/i18n/index.ts"; +import { ribbonTape } from "../web/strategy-switch-console/application/desk/ribbon.js"; const workerRequire = createRequire(new URL("../web/strategy-switch-console/package.json", import.meta.url)); const ajv = new (workerRequire("ajv/dist/2020").default)({ strict: false, allErrors: true }); @@ -159,7 +160,7 @@ const compiled = require("esbuild").buildSync({ contents: ` export { OverviewView } from "./desk/pages/OverviewPage"; export { BrokersView } from "./desk/pages/AccountsPage"; - export { portfolioStatsFor } from "./desk/pages/PerformancePage"; + export { portfolioStatsFor, PerformanceView, loadBenchmarks, benchmarkReadAllowed } from "./desk/pages/PerformancePage"; export { Dock } from "./desk/shell/Dock"; export { TopBar } from "./desk/shell/TopBar"; export { DeskShell } from "./desk/shell/DeskShell"; @@ -173,7 +174,8 @@ const compiled = require("esbuild").buildSync({ }).outputFiles[0].text; const desk = { exports: {} }; new Function("require", "module", "exports", compiled)(require, desk, desk.exports); -const { OverviewView, BrokersView, portfolioStatsFor, Dock, TopBar, DeskShell, catalogEntries, entryCounts, filterEntries, catalogRevisionText } = desk.exports; +const { OverviewView, BrokersView, portfolioStatsFor, PerformanceView, loadBenchmarks, benchmarkReadAllowed, + Dock, TopBar, DeskShell, catalogEntries, entryCounts, filterEntries, catalogRevisionText } = desk.exports; const text = html => html.replace(/<[^>]*>/g, " ").replace(/\s+/g, " "); for (const language of ["zh", "en"]) { @@ -287,6 +289,150 @@ for (const language of ["zh", "en"]) { assert.equal(locked.includes(t("mode.unknown_chip")), false, "no mode chip behind the lock"); } +// S2 corrections: presentation of existing reads (#4/F2/F3), Performance metadata (F4) and the +// next scheduled run chip (#6 option 1a). Both languages; light and dark shell. +const tapeEntry = (index, at) => ({ id: `firstrade/target0#run-${index}`, at, target_id: "firstrade/target0", label: `Run${index}`, kind: "runtime_run", + outcome: "no_op", outcome_detail: null, state: "done", source_business_date: "2030-01-08", source_timezone: "America/New_York" }); +const sixRuns = ["13:01", "13:12", "13:23", "13:34", "13:45", "13:56"].map((hm, index) => tapeEntry(index, `2030-01-08T${hm}:00Z`)); +// Six stored runs: the oldest and newest whole seconds each have a later fractional instant. +const fractionRun = (id, started_at, extra = {}) => ({ id, started_at, finished_at: "2030-01-08T13:59:00Z", activity: "no_signal", errors_present: false, ...extra }); +const fractionRuns = [fractionRun("old-fraction", "2030-01-08T13:01:00.500Z"), fractionRun("old-whole", "2030-01-08T13:01:00Z", { errors_present: true }), + fractionRun("mid-1", "2030-01-08T13:23:00Z"), fractionRun("mid-2", "2030-01-08T13:34:00Z"), + fractionRun("new-fraction", "2030-01-08T13:56:00.500Z", { activity: "filled" }), fractionRun("new-whole", "2030-01-08T13:56:00Z")]; +const ribbonWith = tape => { const data = golden("ribbon", "ok"); data.tape = tape; return { state: "ready", data }; }; +const nextRead = (status, due_at = "2030-01-08T19:30:00Z", extra = {}) => ({ status, value: { target_id: "firstrade/target1", kind: "scheduled_run", due_at }, + observed_at: at, coverage: status === "partial" ? cover("partial", 1, 2, ["coverage_incomplete"]) : cover("complete", 2, 2), ...extra }); +for (const language of ["zh", "en"]) { + const t = (key, params) => deskTranslate(language, key, params); + const view = (data, ribbon) => render(h(OverviewView, { data, source: "live", ribbon, t, language, narrow: false })); + const ok = golden("overview", "ok"); + // F2: newest five, newest first; the oldest of six is not shown. + const sixTape = { status: "known", value: sixRuns, observed_at: at, coverage: cover("complete", 1, 1) }; + assert.ok(isN1Ribbon(ribbonWith(sixTape).data)); + const six = view(ok, ribbonWith(sixTape)); + assert.equal(six.includes("Run0"), false, "the oldest of six runs is not a recent signal"); + for (let index = 1; index < 6; index += 1) assert.ok(six.includes(`Run${index}`), `run ${index} shown`); + assert.ok(six.indexOf("13:56") < six.indexOf("13:12"), "newest first"); + // F2 across a fractional-second boundary, from the projected tape: chronological, never lexical. + const fractionTape = ribbonTape({ days: [{ date: "2030-01-07", summary: { accounts: [{ platform: "firstrade", account_alias: "fake-0", status: "ok", + completeness: "complete", as_of: "2030-01-08T14:00:00Z", runs: fractionRuns }] } }], + accounts: [{ platform: "firstrade", key: "fake-0", target_id: "firstrade/target0" }], date: "2030-01-08", readAt: Date.parse(at) }).tape; + const fractionHtml = view(ok, ribbonWith(fractionTape)); + assert.equal(fractionHtml.includes('data-outcome="error"'), false, "the whole-second oldest run is the one left out"); + assert.ok(fractionHtml.indexOf('data-outcome="filled"') < fractionHtml.indexOf('data-outcome="no_op"'), "the later fractional run leads"); + // F2: a loading ribbon is not an empty signal list. + const loading = view(ok, { state: "loading" }); + assert.ok(loading.includes(t("overview.signals.loading")), "loading is named"); + assert.equal(loading.includes(t("overview.signals.empty")), false, "loading is never 'no signals'"); + assert.equal(view(ok, { state: "failed" }).includes(t("overview.signals.empty")), false, "a failed ribbon is never 'no signals'"); + // #4: a populated partial or stale list keeps its qualification. + const partialTape = { status: "partial", value: sixRuns.slice(0, 2), observed_at: at, coverage: cover("partial", 1, 2, ["coverage_incomplete"]) }; + const partialHtml = view(ok, ribbonWith(partialTape)); + assert.ok(partialHtml.includes("Run1") && partialHtml.includes(t("n1.partial", { coverage: "1/2" })), "partial list keeps its coverage"); + const staleTape = { status: "stale", value: sixRuns.slice(0, 2), observed_at: at, age_seconds: 7200, coverage: cover("complete", 1, 1) }; + const staleHtml = view(ok, ribbonWith(staleTape)); + assert.ok(staleHtml.includes("Run1") && staleHtml.includes('data-read="stale"'), "stale list keeps its staleness"); + assert.ok(staleHtml.includes(t("read.stale", { age: "@" }).split("@")[0]), "stale list names its age"); + const knownHtml = view(ok, ribbonWith(sixTape)); + assert.equal(knownHtml.includes(t("n1.partial", { coverage: "1/1" })), false, "a complete list carries no partial note"); + // F3: a stale NAV is displayed with its stale label and age, never as a current figure. + const staleNav = golden("overview", "ok"); + staleNav.book.value[0].nav = { status: "stale", currency: "USD", amount: "1000", usd: "1000", observed_at: "2030-01-06T15:00:00Z", age_seconds: 172800 }; + staleNav.freshness = { data_status: "stale", age_seconds: 172800 }; + assert.ok(isN1Overview(staleNav) && schemaOk("overview", staleNav)); + const staleNavHtml = view(staleNav, ribbonWith(sixTape)); + assert.ok(staleNavHtml.includes(t("overview.book.nav_stale", { age: "@" }).split("@")[0]), "stale NAV is labelled"); + assert.ok(staleNavHtml.includes('class="mrd-ov-book-nav" data-read="stale"'), "the figure itself is marked stale"); + assert.ok(staleNavHtml.includes(t("states.stale", { age: "@" }).split("@")[0]) && !staleNavHtml.includes(t("states.stale", { age: "—" })), "all-stale age is shown, not dropped"); + assert.equal(view(ok, ribbonWith(sixTape)).includes(t("overview.book.nav_stale", { age: "@" }).split("@")[0]), false, "a known NAV has no stale label"); + + // F4: Performance minimum-set metadata renders even when the curve is unavailable. + const perfView = (data, source = "live", benchmarks = { phase: "failed" }) => render(h(PerformanceView, { + perf: { state: "ready", result: { data, source, scenario: null } }, benchmarks, t, language, narrow: false })); + const unknownPerf = golden("performance", "ok"); + const perfHtml = perfView(unknownPerf); + assert.ok(perfHtml.includes(t("perf.portfolio_unknown", { reason: t(n1ReasonKey(unknownPerf.equity.reason)) }))); + assert.ok(perfHtml.includes(t("perf.meta.window", { range: "6M", from: "2029-07-08" })), "requested window"); + assert.ok(perfHtml.includes(t("perf.meta.first_date", { date: "2030-01-02" })), "first stored date"); + assert.ok(perfHtml.includes(t("perf.history.truncated", { range: "6M", date: "2030-01-02" })), "truncation note"); + assert.ok(perfHtml.includes(t("perf.meta.basis", { basis: t("basis.nav_change_unadjusted") })), "portfolio return basis"); + for (const target of ["firstrade/target0", "firstrade/target1"]) { + assert.ok(perfHtml.includes(t("perf.meta.basis_target", { target, basis: t("basis.nav_change_unadjusted") })), `per-target basis ${target}`); + } + const fullHistory = golden("performance", "ok"); fullHistory.history.truncated_by = null; + assert.ok(isN1Performance(fullHistory)); + const fullHtml = perfView(fullHistory); + assert.equal(fullHtml.includes("data-truncated"), false, "no truncation note for a complete window"); + assert.ok(fullHtml.includes(t("perf.meta.first_date", { date: "2030-01-02" })), "first date still shown"); + const allHistory = golden("performance", "empty"); + assert.ok(perfView(allHistory).includes(t("perf.meta.window_all", { range: allHistory.range })), "an open window is named"); + if (language === "en") assert.doesNotMatch(text(perfHtml), /[一-鿿]/, "the English performance page shows no Chinese copy"); + + // #6/1a: the next-run chip in the shared bar, all states, both themes, hidden behind the lock. + for (const theme of ["light", "dark"]) { + const bar = { page: "overview", t, language, theme, gateway: { kind: "read", read: { status: "unknown", reason: "not_supported" } }, + nowIso: at, serverClock: true, mock: null, freshness: { kind: "read", data_status: "ready", age_seconds: 60 }, onLanguage: () => {}, onTheme: () => {} }; + const chip = read => render(h(TopBar, { ...bar, nextRun: read })); + const known = chip(nextRead("known")); + assert.ok(known.includes(t("next_run.known", { time: "19:30", target: "firstrade/target1" })) && known.includes('data-next-run="known"')); + const part = chip(nextRead("partial")); + assert.ok(part.includes(t("next_run.partial", { time: "19:30", target: "firstrade/target1", coverage: "1/2" })), "partial names itself"); + assert.equal(part.includes(t("next_run.known", { time: "19:30", target: "firstrade/target1" })), false, "partial is never the global next run"); + const old = chip(nextRead("stale", "2030-01-08T19:30:00Z", { age_seconds: 7200 })); + assert.ok(old.includes('data-next-run="stale"') && old.includes(t("next_run.stale", { time: "19:30", target: "firstrade/target1", age: "@" }).split("@")[0])); + const past = chip(nextRead("known", "2030-01-08T14:59:00Z")); + assert.ok(past.includes(t("next_run.unknown")) && past.includes('data-next-run="unknown"'), "a time already past is not upcoming"); + const unknownChip = chip({ status: "unknown", reason: "source_unavailable", observed_at: null, coverage: cover("unavailable", 0, null, ["source_unavailable"]) }); + assert.ok(unknownChip.includes(t("next_run.unknown")), "unknown is '?', never 'no run'"); + assert.ok(chip({ ...nextRead("known"), value: null }).includes(t("next_run.unknown")), "a null value never proves no future run"); + assert.equal(render(h(TopBar, bar)).includes("data-next-run"), false, "no chip without a ribbon read"); + const shell = (lock) => render(h(DeskShell, { ...bar, alertCount: null, alertUnknown: true, nextRun: nextRead("known"), lock, onNavigate: () => {}, children: null })); + assert.ok(shell(null).includes('data-next-run="known"'), "the shell passes the read through"); + assert.equal(shell({ crumb: "access.title", chip: "access.chip.login" }).includes("data-next-run"), false, "no next run behind the lock"); + } + for (const key of ["overview.signals.loading", "overview.book.nav_stale", "perf.meta.window", "perf.meta.window_all", "perf.meta.first_date", + "perf.meta.basis", "perf.meta.basis_target", "perf.bench.contained", "next_run.known", "next_run.stale", "next_run.partial", "next_run.unknown", "next_run.title"]) { + assert.ok(DESK_MESSAGES[language][key], `missing ${language} ${key}`); + } +} + +// F5: the live N1 path never reads the non-approved benchmark source, even with a qualified curve. +{ + let benchmarkCalls = 0; + const outbound = []; + const spy = { benchmarks: async () => { benchmarkCalls += 1; throw Error("benchmark read attempted"); } }; + const qualified = golden("performance", "ok"); + qualified.equity = { status: "known", value: { dates: ["2030-01-02", "2030-01-03", "2030-01-06"], portfolio_indexed: ["100", "101", "102"], + drawdown_ratio: ["0", "0", "0"], gap_dates: [], gap_semantics: "break_before_observation" }, observed_at: at, coverage: cover("complete", 2, 2) }; + assert.ok(isN1Performance(qualified) && schemaOk("performance", qualified), "a synthetic qualified curve"); + const window = { start: "2030-01-02", end: "2030-01-06" }; + assert.equal(benchmarkReadAllowed("live"), false); + assert.deepEqual(await loadBenchmarks(spy, { data: qualified, source: "live", scenario: null }, window), { phase: "failed" }); + assert.equal(benchmarkCalls, 0, "no api.benchmarks() call on the live path"); + // Through the real API object: a live benchmark read would hit /api/market-benchmark/performance. + const liveApi = createDeskApi({ forcedScenario: null, delayMs: 0, sources: liveSources, fetchJson: async url => { outbound.push(url); throw Error("no network"); } }); + await loadBenchmarks(liveApi, { data: qualified, source: "live", scenario: null }, window); + assert.deepEqual(outbound, [], "no request to the benchmark route"); + // Fixture pages keep their synthetic comparison. + assert.equal(benchmarkReadAllowed("mock"), true); + await loadBenchmarks(spy, { data: qualified, source: "mock", scenario: "ok" }, window); + assert.equal(benchmarkCalls, 1, "mock keeps the fixture benchmark read"); + const tEn = (key, params) => deskTranslate("en", key, params); + const liveCurve = render(h(PerformanceView, { perf: { state: "ready", result: { data: qualified, source: "live", scenario: null } }, + benchmarks: { phase: "failed" }, t: tEn, language: "en", narrow: false })); + assert.ok(liveCurve.includes(tEn("perf.bench.contained")), "the live page says the comparison is not read"); + assert.ok(liveCurve.includes(tEn("perf.meta.first_date", { date: qualified.history.first_date })), "metadata with a curve too"); + const perfSrc = readFileSync(new URL("../web/strategy-switch-console/frontend/src/desk/pages/PerformancePage.tsx", import.meta.url), "utf8"); + assert.equal(perfSrc.match(/api\.benchmarks\(/g)?.length, 1, "the only benchmark call sits behind benchmarkReadAllowed"); + + // Logs (outside this binding) prints any non-unknown next run without its read status, so its + // copy must stay true for a partial earliest candidate: never "the next run". + for (const language of ["zh", "en"]) { + const copy = DESK_MESSAGES[language]["logs.runs.next"]; + assert.match(copy, language === "en" ? /^Earliest observed scheduled run / : /^已观测到的最早计划运行 /, `${language}: Logs next-run copy is qualified`); + } +} + // Performance KPIs: backend reads only, no computed fallback; CAGR has no N1 source. assert.deepEqual(portfolioStatsFor(golden("performance", "ok")), { ret: null, cagr: null, maxDrawdown: null }); const knownPerf = golden("performance", "ok"); diff --git a/tests/desk_n1_projection_validation.mjs b/tests/desk_n1_projection_validation.mjs index 53f84a2b..57d53437 100644 --- a/tests/desk_n1_projection_validation.mjs +++ b/tests/desk_n1_projection_validation.mjs @@ -16,7 +16,7 @@ import { bookSparkline, deskOverviewResponse, lastRun, overviewTotals, runsToday import { deskPerformanceResponse, performanceProjection, performanceQuery, performanceWindow, decimal, } from "../web/strategy-switch-console/application/desk/performance.js"; -import { deskRibbonResponse, ribbonBusinessDates, ribbonDate, ribbonTape, tapeOutcome } from "../web/strategy-switch-console/application/desk/ribbon.js"; +import { deskRibbonResponse, nextScheduledRun, ribbonBusinessDates, ribbonDate, ribbonTape, tapeOutcome } from "../web/strategy-switch-console/application/desk/ribbon.js"; import { catalogFingerprint, deskSwitchStrategiesResponse, switchStrategiesTargetId, } from "../web/strategy-switch-console/application/desk/switch_strategies.js"; @@ -119,7 +119,7 @@ function world({ accounts = [account(0), account(1)], config = true, facts = tru } throw Error("unexpected"); }, - runtimeDay: async (request, env, list, date) => { calls.push(`runtime:${date}`); if (summaries[date] === "fail") throw Error("down"); return summaries[date] ?? summaryFor(accounts, date); }, + runtimeDay: async (request, env, list, date, options) => { calls.push(`runtime:${date}`); if (options !== undefined) calls.push(`options:${date}:${JSON.stringify(options)}`); if (summaries[date] === "fail") throw Error("down"); return summaries[date] ?? summaryFor(accounts, date); }, }; return { deps, calls, env: { ACCOUNT_FACTS_READ_MODEL_ENABLED: "true" } }; } @@ -558,6 +558,243 @@ assert.equal(switchStrategiesTargetId("/api/desk/switch/targets"), null); assert.deepEqual([mixedBody.totals.runs_today.value.done, mixedBody.totals.runs_today.coverage.included], [1, 1], "runs today still counts complete records only"); } +// ---------------------------------------------------------------- S2 corrections: coverage, failures, observation time +{ + const tapeAccounts = [{ platform: "firstrade", key: "fake-0", target_id: "firstrade/target0" }]; + const day = (accounts, date = "2030-01-07") => [{ date, summary: { accounts } }]; + // F1: a missing record on a weekend is not a verified-empty tape. + const weekendMissing = { platform: "firstrade", account_alias: "fake-0", status: "no_data", unavailable_reason: "non_trading_weekend", + completeness: null, as_of: null, runs: [] }; + const weekendTape = ribbonTape({ days: day([weekendMissing], "2030-01-05"), accounts: tapeAccounts, date: "2030-01-05", readAt: NOW }); + assert.notEqual(weekendTape.tape.status, "known", "a weekend without a record is not a known tape"); + assert.notEqual(weekendTape.tape.coverage.state, "verified_empty", "a weekend does not prove that nothing ran"); + assert.deepEqual([weekendTape.tape.status, weekendTape.tape.reason, weekendTape.tape.coverage.included], ["unknown", "source_unavailable", 0]); + const weekendRuns = runsToday({ targets: [{ platform: "firstrade", key: "fake-0" }], summary: { accounts: [weekendMissing] }, businessDate: "2030-01-05", now: NOW }); + assert.equal(weekendRuns.status, "unknown", "no weekend zero runs"); + + // #3: a summary of only failed rows stays read_failed, never source_unavailable or empty. + const failedRow = (key) => ({ platform: "firstrade", account_alias: key, status: "read_failed", unavailable_reason: "stored_records_unreadable", + completeness: null, as_of: null, runs: [] }); + const targets2 = [{ platform: "firstrade", key: "fake-0" }, { platform: "firstrade", key: "fake-1" }]; + const failedOnly = runsToday({ targets: targets2, summary: { accounts: [failedRow("fake-0"), failedRow("fake-1")] }, businessDate: "2030-01-08", now: NOW }); + assert.deepEqual([failedOnly.status, failedOnly.reason, failedOnly.coverage.reasons], ["unknown", "read_failed", ["read_failed"]]); + assert.notEqual(failedOnly.coverage.state, "verified_empty"); + const covered = summaryFor([account(1)], "2030-01-08").accounts[0]; + const mixedFail = runsToday({ targets: targets2, summary: { accounts: [failedRow("fake-0"), covered] }, businessDate: "2030-01-08", now: NOW }); + assert.equal(mixedFail.status, "partial"); + assert.deepEqual([mixedFail.value.done, mixedFail.value.expected, mixedFail.value.pending], [1, null, null], "no invented totals"); + assert.ok(mixedFail.coverage.reasons.includes("read_failed") && mixedFail.coverage.reasons.includes("coverage_incomplete")); + const failedTape = ribbonTape({ days: day([failedRow("fake-0")]), accounts: tapeAccounts, date: "2030-01-08", readAt: NOW }); + assert.deepEqual([failedTape.tape.status, failedTape.tape.reason], ["unknown", "read_failed"]); + + // #23: a stale incomplete record keeps its stored observation; the request time is never substituted. + const staleIncomplete = { platform: "firstrade", account_alias: "fake-0", status: "stale", completeness: "incomplete", + unavailable_reason: "runtime_daily_stale", as_of: "2030-01-08T03:00:00Z", + runs: [{ id: "early", started_at: "2030-01-08T01:00:00Z", finished_at: "2030-01-08T01:05:00Z", activity: "no_signal", errors_present: false }] }; + const historical = ribbonTape({ days: day([staleIncomplete]), accounts: tapeAccounts, date: "2030-01-08", readAt: NOW }); + assert.equal(historical.tape.status, "partial"); + assert.equal(historical.tape.observed_at, "2030-01-08T03:00:00Z", "the stored observation, not the request time"); + assert.notEqual(historical.tape.observed_at, utcInstant(NOW)); + assert.deepEqual(historical.observed, ["2030-01-08T03:00:00Z"], "freshness keeps the stale incomplete record's observation"); + assert.deepEqual(freshness(historical.observed, { now: NOW, ttlSeconds: 36 * 3600, staleKnown: historical.stale }), { data_status: "stale", age_seconds: 43200 }); + // Mixed coverage: one complete, one stale incomplete: oldest stored observation of both. + const twoAccounts = [...tapeAccounts, { platform: "firstrade", key: "fake-1", target_id: "firstrade/target1" }]; + const mixedTape = ribbonTape({ days: day([staleIncomplete, { ...covered, as_of: "2030-01-08T14:30:00Z" }]), accounts: twoAccounts, date: "2030-01-08", readAt: NOW }); + assert.deepEqual([mixedTape.tape.status, mixedTape.tape.coverage.included, mixedTape.tape.coverage.total], ["partial", 1, 2]); + assert.equal(mixedTape.tape.observed_at, "2030-01-08T03:00:00Z"); + // A covered record without a usable observation instant has no time basis, never "now". + const noInstant = ribbonTape({ days: day([{ ...covered, account_alias: "fake-0", as_of: "not-an-instant", runs: [] }]), accounts: tapeAccounts, date: "2030-01-08", readAt: NOW }); + assert.deepEqual([noInstant.tape.status, noInstant.tape.reason], ["unknown", "time_basis_unavailable"]); + + // Tape order is chronological, never lexical: "14:31:00.500Z" sorts before "14:31:00Z" as a + // string but is the later instant. Within one second, then an exact tie broken by entry id. + const tapeRun = (id, started_at, extra = {}) => ({ id, started_at, finished_at: "2030-01-08T14:59:00Z", activity: "no_signal", errors_present: false, ...extra }); + const withinSecond = ribbonTape({ days: day([{ ...covered, account_alias: "fake-0", as_of: "2030-01-08T14:59:30Z", + runs: [tapeRun("c", "2030-01-08T14:31:00.500Z"), tapeRun("b", "2030-01-08T14:31:00Z"), tapeRun("a", "2030-01-08T14:31:00.250Z"), + tapeRun("tie-2", "2030-01-08T09:31:00.75-05:00"), tapeRun("tie-1", "2030-01-08T14:31:00.750Z")] }]), accounts: tapeAccounts, date: "2030-01-08", readAt: NOW }); + assert.equal(withinSecond.tape.status, "known"); + assert.deepEqual(withinSecond.tape.value.map(entry => [entry.id, entry.at]), [ + ["firstrade/target0#b", "2030-01-08T14:31:00Z"], ["firstrade/target0#a", "2030-01-08T14:31:00.250Z"], ["firstrade/target0#c", "2030-01-08T14:31:00.500Z"], + ["firstrade/target0#tie-1", "2030-01-08T14:31:00.750Z"], ["firstrade/target0#tie-2", "2030-01-08T14:31:00.750Z"]], "epoch order; the id breaks an exact tie"); + // Truncation keeps the newest TAPE_LIMIT (100) chronologically; the newest five come from the end. + const minuteRuns = Array.from({ length: 99 }, (_, index) => { + const minute = 12 * 60 + index; + return tapeRun(`m${String(index).padStart(2, "0")}`, `2030-01-08T${String(Math.floor(minute / 60)).padStart(2, "0")}:${String(minute % 60).padStart(2, "0")}:00Z`); + }); + const truncatedTape = ribbonTape({ days: day([{ ...covered, account_alias: "fake-0", as_of: "2030-01-08T14:59:30Z", + runs: [...minuteRuns, tapeRun("first-fraction", "2030-01-08T12:00:00.500Z"), tapeRun("last-fraction", "2030-01-08T13:38:00.500Z")] }]), + accounts: tapeAccounts, date: "2030-01-08", readAt: NOW }); + assert.deepEqual([truncatedTape.tape.status, truncatedTape.tape.value.length, truncatedTape.tape.coverage.reasons], ["partial", 100, ["coverage_incomplete"]]); + assert.equal(truncatedTape.tape.value.some(entry => entry.id === "firstrade/target0#m00"), false, "the whole-second oldest entry is the one truncated"); + assert.equal(truncatedTape.tape.value[0].id, "firstrade/target0#first-fraction", "the later fractional instant survives truncation"); + assert.deepEqual(truncatedTape.tape.value.slice(-5).reverse().map(entry => entry.id), + ["firstrade/target0#last-fraction", "firstrade/target0#m98", "firstrade/target0#m97", "firstrade/target0#m96", "firstrade/target0#m95"], + "newest five, newest first: the fractional instant after its whole second leads"); + // The oldest stored observation is chosen chronologically, with mixed whole and fractional instants. + const fractionObserved = ribbonTape({ days: day([{ ...covered, account_alias: "fake-0", as_of: "2030-01-08T14:00:00Z", runs: [] }, + { ...covered, account_alias: "fake-1", as_of: "2030-01-08T13:59:59.900Z", runs: [] }]), accounts: twoAccounts, date: "2030-01-08", readAt: NOW }); + assert.equal(fractionObserved.tape.observed_at, "2030-01-08T13:59:59.900Z", "a fractional instant earlier than a whole second is the oldest"); + const wholeObserved = ribbonTape({ days: day([{ ...covered, account_alias: "fake-0", as_of: "2030-01-08T14:00:00.500Z", runs: [] }, + { ...covered, account_alias: "fake-1", as_of: "2030-01-08T14:00:00Z", runs: [] }]), accounts: twoAccounts, date: "2030-01-08", readAt: NOW }); + assert.equal(wholeObserved.tape.observed_at, "2030-01-08T14:00:00Z", "the whole second precedes its own fractional instants"); + + // Overview instants are chronological too: "14:32:00.500Z" is after "14:32:00Z" though it sorts first as a string. + const run = (id, finished_at, activity = "no_signal") => ({ id, started_at: "2030-01-08T14:31:00Z", finished_at, activity, errors_present: false }); + const lastOf = (runs, extra = {}) => { const row = { ...covered, account_alias: "fake-0", runs, ...extra }; return lastRun({ accounts: [row] }, row, NOW); }; + const fractionLast = lastOf([run("later", "2030-01-08T14:32:00.500Z", "filled"), run("whole", "2030-01-08T14:32:00Z")]); + assert.deepEqual([fractionLast.status, fractionLast.value], ["known", { at: "2030-01-08T14:32:00.500Z", outcome: "filled" }], + "the last run is the later fractional finish, not the whole second"); + const partialLast = lastOf([run("later", "2030-01-08T14:32:00.500Z", "filled"), run("whole", "2030-01-08T14:32:00Z")], + { status: "partial", completeness: "incomplete", unavailable_reason: "runtime_daily_incomplete" }); + assert.deepEqual([partialLast.status, partialLast.value], ["partial", { at: "2030-01-08T14:32:00.500Z", outcome: "filled" }], "partial reads pick chronologically too"); + const tieLast = lastOf([run("z-first", "2030-01-08T14:32:00.500Z", "filled"), run("tie", "2030-01-08T09:32:00.5-05:00")]); + assert.deepEqual(tieLast.value, { at: "2030-01-08T14:32:00.500Z", outcome: "no_op" }, "an exact tie keeps record order: the later record wins"); + const runsObserved = runsToday({ targets: targets2, businessDate: "2030-01-08", now: NOW, summary: { accounts: [ + { ...covered, account_alias: "fake-0", as_of: "2030-01-08T14:30:00.500Z" }, { ...covered, account_alias: "fake-1", as_of: "2030-01-08T14:30:00Z" }] } }); + assert.equal(runsObserved.observed_at, "2030-01-08T14:30:00Z", "runs today: the oldest observation, the whole second before its fraction"); + const fractionFacts = [account(0), account(1)]; + fractionFacts[0].current = { ...fractionFacts[0].current, observed_finished_at: "2030-01-08T14:58:00.500Z" }; + assert.equal(fractionFacts[1].current.observed_finished_at, "2030-01-08T14:58:00.000Z"); + const fractionWorld = world({ accounts: fractionFacts }); + const fractionBody = await body(await deskOverviewResponse(get("/api/desk/overview"), fractionWorld.env, fractionWorld.deps)); + conforms("overview", fractionBody); + assert.equal(fractionBody.snapshot_at, "2030-01-08T14:58:00Z", "snapshot: the oldest fact observation, the whole second before its fraction"); + + // F3: when every displayed fact is stale, Overview freshness keeps their age; totals are not upgraded. + const allStale = world({ accounts: [account(0, { staleHours: 40 }), account(1, { staleHours: 50 })] }); + const allStaleBody = await body(await deskOverviewResponse(get("/api/desk/overview"), allStale.env, allStale.deps)); + conforms("overview", allStaleBody); + assert.equal(allStaleBody.freshness.data_status, "stale"); + assert.equal(allStaleBody.freshness.age_seconds, 50 * 3600 + 120, "the oldest stored observation's age"); + assert.equal(allStaleBody.totals.data_age_seconds, allStaleBody.freshness.age_seconds); + assert.deepEqual([allStaleBody.totals.status, allStaleBody.totals.nav], ["unavailable", null], "stale facts never make a total"); + assert.ok(allStaleBody.book.value.every(row => row.nav.status === "stale" && Number.isInteger(row.nav.age_seconds)), "stale NAV keeps its age"); + const mixedAge = world({ accounts: [account(0, { staleHours: 40 }), account(1)] }); + const mixedAgeBody = await body(await deskOverviewResponse(get("/api/desk/overview"), mixedAge.env, mixedAge.deps)); + conforms("overview", mixedAgeBody); + assert.deepEqual([mixedAgeBody.freshness.data_status, mixedAgeBody.freshness.age_seconds], ["stale", 40 * 3600 + 120]); + assert.deepEqual([mixedAgeBody.totals.status, mixedAgeBody.totals.nav], ["partial", null], "partial facts are never a total"); +} + +// ---------------------------------------------------------------- S2 #6: next scheduled observation (option 1/1a) +{ + const today = "2030-01-08"; + const accts = [{ platform: "firstrade", key: "fake-0", target_id: "firstrade/target0" }, { platform: "firstrade", key: "fake-1", target_id: "firstrade/target1" }]; + const schedule = (next_due_at, extra = {}) => ({ state: "not_due", business_date: today, timezone: "America/New_York", next_due_at, ...extra }); + const row = (key, next, extra = {}) => ({ platform: "firstrade", account_alias: key, status: "ok", completeness: "complete", unavailable_reason: null, + as_of: "2030-01-08T14:30:00Z", read_error_codes: [], runs: [], schedule: typeof next === "object" && next !== null ? next : schedule(next), ...extra }); + const next = (rows) => nextScheduledRun({ summary: { accounts: rows }, accounts: accts, businessDate: today, now: NOW }); + // Every account has a future candidate: known earliest, with the oldest stored observation. + const both = next([row("fake-0", "2030-01-08T20:55:00Z"), row("fake-1", "2030-01-08T19:30:00Z", { as_of: "2030-01-08T14:00:00Z" })]); + assert.deepEqual([both.status, both.value, both.observed_at, both.coverage.state], ["known", + { target_id: "firstrade/target1", kind: "scheduled_run", due_at: "2030-01-08T19:30:00Z" }, "2030-01-08T14:00:00Z", "complete"]); + // An offset is honoured: 14:20-05:00 is 19:20Z, earlier than 19:30Z. + const offset = next([row("fake-0", "2030-01-08T14:20:00-05:00"), row("fake-1", "2030-01-08T19:30:00Z")]); + assert.deepEqual([offset.value.target_id, offset.value.due_at], ["firstrade/target0", "2030-01-08T19:20:00Z"]); + // A time already past at request time is not an upcoming run (valid for its observation, expired now). + const expired = next([row("fake-0", "2030-01-08T14:45:00Z"), row("fake-1", "2030-01-08T19:30:00Z")]); + assert.equal(expired.status, "partial", "an expired candidate leaves coverage incomplete"); + assert.deepEqual([expired.value.target_id, expired.coverage.included, expired.coverage.total], ["firstrade/target1", 1, 2]); + const exactlyNow = next([row("fake-0", utcInstant(NOW)), row("fake-1", utcInstant(NOW))]); + assert.deepEqual([exactlyNow.status, exactlyNow.reason], ["unknown", "source_unavailable"], "due exactly now is not upcoming"); + // Partial account coverage: earliest observed candidate, partial, never known. + const half = next([row("fake-0", "2030-01-08T20:55:00Z")]); + assert.deepEqual([half.status, half.coverage.state, half.coverage.reasons], ["partial", "partial", ["coverage_incomplete"]]); + // Stale complete records: stale with their age. + const staleBoth = next([row("fake-0", "2030-01-08T20:55:00Z", { status: "stale", as_of: "2030-01-07T00:00:00Z" }), row("fake-1", "2030-01-08T19:30:00Z")]); + assert.deepEqual([staleBoth.status, staleBoth.age_seconds, staleBoth.observed_at], ["stale", 54000 + 86400, "2030-01-07T00:00:00Z"]); + // Missing, null, conflicting, disabled, unevaluable, schedule-unverified, wrong day: unknown, never "no run". + const cases = { + "null next due": [row("fake-0", null), row("fake-1", null)], + "no schedule field (legacy summary)": [row("fake-0", null, { schedule: undefined }), row("fake-1", null, { schedule: null })], + "unevaluable": [row("fake-0", schedule("2030-01-08T20:55:00Z", { state: "unevaluable" })), row("fake-1", schedule("2030-01-08T20:55:00Z", { state: "unevaluable" }))], + "schedule unverified": [row("fake-0", "2030-01-08T20:55:00Z", { read_error_codes: ["schedule_unverified"] }), row("fake-1", "2030-01-08T20:55:00Z", { read_error_codes: ["target_disabled"] })], + "binding conflict": [row("fake-0", "2030-01-08T20:55:00Z", { status: "partial", completeness: null, unavailable_reason: "runtime_daily_binding_conflict", as_of: null }), + row("fake-1", "2030-01-08T20:55:00Z", { status: "not_connected", completeness: null, as_of: null })], + "duplicate rows": [row("fake-0", "2030-01-08T20:55:00Z"), row("fake-0", "2030-01-08T21:55:00Z"), row("fake-1", "2030-01-08T20:55:00Z", { status: "no_data", completeness: null, as_of: null })], + "previous NY business day": [row("fake-0", schedule("2030-01-08T20:55:00Z", { business_date: "2030-01-07" })), row("fake-1", schedule("2030-01-08T20:55:00Z", { timezone: "UTC" }))], + "unparseable instant": [row("fake-0", "2030-01-08T20:55:00"), row("fake-1", "tomorrow")], + }; + // Review B1: instants keep nonzero milliseconds, so ordering is chronological, never lexical. + // "19:30:00.500Z" sorts before "19:30:00Z" as a string but is the later instant. + for (const rows of [[row("fake-0", "2030-01-08T19:30:00Z"), row("fake-1", "2030-01-08T19:30:00.500Z")], + [row("fake-1", "2030-01-08T19:30:00.500Z"), row("fake-0", "2030-01-08T19:30:00Z")]]) { + const whole = next(rows); + assert.deepEqual([whole.status, whole.value], ["known", { target_id: "firstrade/target0", kind: "scheduled_run", due_at: "2030-01-08T19:30:00Z" }], + "known: the whole second beats the later fractional instant regardless of row order"); + } + const fractionalFirst = next([row("fake-0", "2030-01-08T19:30:00.500Z"), row("fake-1", "2030-01-08T19:30:00.250Z")]); + assert.deepEqual([fractionalFirst.value.target_id, fractionalFirst.value.due_at], ["firstrade/target1", "2030-01-08T19:30:00.250Z"]); + const accts3 = [...accts, { platform: "firstrade", key: "fake-2", target_id: "firstrade/target2" }]; + const partialFraction = nextScheduledRun({ summary: { accounts: [row("fake-0", "2030-01-08T19:30:00Z"), row("fake-1", "2030-01-08T19:30:00.500Z")] }, + accounts: accts3, businessDate: today, now: NOW }); + assert.deepEqual([partialFraction.status, partialFraction.value.target_id, partialFraction.value.due_at, partialFraction.coverage.included, partialFraction.coverage.total], + ["partial", "firstrade/target0", "2030-01-08T19:30:00Z", 2, 3], "partial: the same chronological earliest candidate"); + // Equal instants, however written, fall back to the target id only then. + const tie = next([row("fake-1", "2030-01-08T19:30:00.000Z"), row("fake-0", "2030-01-08T14:30:00-05:00")]); + assert.deepEqual([tie.status, tie.value.target_id, tie.value.due_at], ["known", "firstrade/target0", "2030-01-08T19:30:00Z"]); + const fractionTie = next([row("fake-1", "2030-01-08T19:30:00.500Z"), row("fake-0", "2030-01-08T14:30:00.5-05:00")]); + assert.deepEqual([fractionTie.value.target_id, fractionTie.value.due_at], ["firstrade/target0", "2030-01-08T19:30:00.500Z"]); + // A fractional offset instant is still normalized to UTC before ordering. + const fractionOffset = next([row("fake-0", "2030-01-08T14:29:59.750-05:00"), row("fake-1", "2030-01-08T19:30:00Z")]); + assert.deepEqual([fractionOffset.value.target_id, fractionOffset.value.due_at], ["firstrade/target0", "2030-01-08T19:29:59.750Z"]); + // Past-time rejection keeps millisecond precision at the request instant. + const subSecond = next([row("fake-0", "2030-01-08T14:59:59.999Z"), row("fake-1", "2030-01-08T15:00:00.001Z")]); + assert.deepEqual([subSecond.status, subSecond.value.target_id, subSecond.value.due_at, subSecond.coverage.included], ["partial", "firstrade/target1", "2030-01-08T15:00:00.001Z", 1]); + // The oldest stored observation is chosen chronologically too. + const oldest = next([row("fake-0", "2030-01-08T20:55:00Z", { as_of: "2030-01-08T14:00:00.500Z" }), row("fake-1", "2030-01-08T19:30:00Z", { as_of: "2030-01-08T14:00:00Z" })]); + assert.deepEqual([oldest.status, oldest.observed_at], ["known", "2030-01-08T14:00:00Z"]); + const oldestFraction = next([row("fake-0", "2030-01-08T20:55:00Z", { as_of: "2030-01-08T14:00:00Z" }), row("fake-1", "2030-01-08T19:30:00Z", { as_of: "2030-01-08T13:59:59.900Z" })]); + assert.equal(oldestFraction.observed_at, "2030-01-08T13:59:59.900Z"); + for (const [label, rows] of Object.entries(cases)) { + const read = next(rows); + assert.equal(read.status, "unknown", `${label}: unknown`); + assert.equal("value" in read, false, `${label}: an unknown read carries no value, so no "no next run"`); + assert.notEqual(read.coverage.state, "verified_empty", `${label}: null never proves no future run`); + } + const incompleteRows = [row("fake-0", "2030-01-08T20:55:00Z", { status: "partial", completeness: "incomplete" }), row("fake-1", "2030-01-08T20:55:00Z", { status: "partial", completeness: "incomplete" })]; + assert.equal(next(incompleteRows).reason, "coverage_incomplete", "an incomplete record's schedule is not trusted"); + assert.equal(next([{ ...row("fake-0", null), status: "read_failed" }, row("fake-1", null)]).reason, "read_failed"); + assert.equal(nextScheduledRun({ summary: null, accounts: accts, businessDate: today, now: NOW }).reason, "read_failed"); + + // Route: only today's NY business-day read opts in; the same number of reads is made. + const scheduled = { date: today, accounts: [row("fake-0", "2030-01-08T20:55:00Z"), row("fake-1", "2030-01-08T19:30:00Z")] }; + const scheduledWorld = world({ summaries: { [today]: scheduled } }); + const ribbon = await body(await deskRibbonResponse(get("/api/desk/ribbon"), scheduledWorld.env, scheduledWorld.deps)); + conforms("ribbon", ribbon); + assert.deepEqual([ribbon.next_run.status, ribbon.next_run.value.due_at, ribbon.next_run.value.target_id], ["known", "2030-01-08T19:30:00Z", "firstrade/target1"]); + assert.deepEqual(scheduledWorld.calls.filter(call => /^(runtime|options):/.test(call)), + ["runtime:2030-01-07", "runtime:2030-01-08", 'options:2030-01-08:{"schedule":true}'], "one read per business day; only today opts in"); + const partialWorld = world({ summaries: { [today]: { date: today, accounts: [row("fake-0", "2030-01-08T20:55:00Z")] } } }); + const partialRibbon = await body(await deskRibbonResponse(get("/api/desk/ribbon"), partialWorld.env, partialWorld.deps)); + conforms("ribbon", partialRibbon); + assert.equal(partialRibbon.next_run.status, "partial"); + // Route, B1: the fractional-second case keeps one read per business day and the chronological earliest. + const fractionalWorld = world({ summaries: { [today]: { date: today, accounts: [row("fake-0", "2030-01-08T19:30:00Z"), row("fake-1", "2030-01-08T19:30:00.500Z")] } } }); + const fractionalRibbon = await body(await deskRibbonResponse(get("/api/desk/ribbon"), fractionalWorld.env, fractionalWorld.deps)); + conforms("ribbon", fractionalRibbon); + assert.deepEqual([fractionalRibbon.next_run.status, fractionalRibbon.next_run.value.due_at, fractionalRibbon.next_run.value.target_id], ["known", "2030-01-08T19:30:00Z", "firstrade/target0"]); + assert.deepEqual(fractionalWorld.calls.filter(call => /^(runtime|options):/.test(call)), + ["runtime:2030-01-07", "runtime:2030-01-08", 'options:2030-01-08:{"schedule":true}'], "fractional instants add no read"); + const pastWorld = world({ summaries: { [today]: scheduled } }); + const pastDay = await body(await deskRibbonResponse(get("/api/desk/ribbon?date=2030-01-06"), pastWorld.env, pastWorld.deps)); + conforms("ribbon", pastDay); + assert.equal(pastDay.next_run.status, "unknown", "a past UTC day does not read today's schedule"); + assert.equal(pastWorld.calls.some(call => call.startsWith("options:")), false, "and makes no opt-in read"); + // Overview counts stay unknown: neighbouring schedule instants are not a full-day count. + const overviewWorld = world({ summaries: { [today]: scheduled } }); + const overview = await body(await deskOverviewResponse(get("/api/desk/overview"), overviewWorld.env, overviewWorld.deps)); + assert.deepEqual([overview.totals.runs_today.value.expected, overview.totals.runs_today.value.pending], [null, null]); + assert.equal(overviewWorld.calls.some(call => call.startsWith("options:")), false, "overview does not opt in"); + // Worker deps pass the opt-in through to the existing reader; the default output is unchanged. + const workerDeps = __test.deskN1Deps(); + const req = get("/api/desk/ribbon"); + const optedIn = await workerDeps.runtimeDay(req, {}, [{ platform: "firstrade", key: "fake-0" }], today, { schedule: true }); + const legacy = await workerDeps.runtimeDay(req, {}, [{ platform: "firstrade", key: "fake-0" }], today); + assert.equal(optedIn.accounts[0].schedule, null, "no record read: schedule is null, not invented"); + assert.equal("schedule" in legacy.accounts[0], false, "legacy summary unchanged without the opt-in"); +} + // ---------------------------------------------------------------- worker-level: exact routes, read allowlist, zero writes { const accts = [account(0), account(1)]; diff --git a/tests/runtime_today_incomplete_validation.mjs b/tests/runtime_today_incomplete_validation.mjs index 16ef7503..87cd7c3b 100644 --- a/tests/runtime_today_incomplete_validation.mjs +++ b/tests/runtime_today_incomplete_validation.mjs @@ -1,6 +1,7 @@ import assert from 'node:assert/strict'; import { runStatus } from '../web/strategy-switch-console/application/console_api/run_status.js'; import { todaySummary } from '../web/strategy-switch-console/application/console_api/operations.js'; +import { lastRun, runsToday } from '../web/strategy-switch-console/application/desk/overview.js'; const now=Date.parse('2026-10-16T18:00:00Z'); const account={platform:'ibkr',key:'synthetic'}; const binding={platform:'ibkr',account_key:'synthetic',target_key:'synthetic-target',status:'bound'}; @@ -27,4 +28,88 @@ for(const extra of [{},{read_error_count:2,read_error_codes:['coverage_unconfirm for(const state of ['failed','blocked']) {row=await check({read_error_count:1,read_error_codes:['target_disabled'],record:{...base.record,status:state}},state);} assert.equal(runStatus({status:'partial',activity:'read_incomplete',runs:[],read_error_count:2, read_error_codes:['list_failed','unrecognized']}),'unknown','an unknown code makes mixed diagnostics unknown'); -console.log('runtime today incomplete: coverage, read failures, unknown and execution priority passed'); + +// The reader's completeness decision is preserved, never rebuilt into a weaker one. +const complete={...base,read_error_count:0,read_error_codes:[], + record:{...base.record,completeness:'complete',status:'no_rebalance',kind:'run',runs:[{run_id:'one',started_at:'2026-10-16T14:31:00Z',finished_at:'2026-10-16T14:32:00Z',activity:'no_rebalance',errors_present:false}], + schedule:{state:'not_due',business_date:date,timezone:'America/New_York',latest_due_at:null,next_due_at:'2026-10-16T19:55:00Z',grace_ends_at:null,publication_grace_ended:null,expected_window:'unspecified'}}}; +async function summarise(body,options){ + return todaySummary(new Request('https://synthetic.invalid/api/runtime/today'),{}, + {dailyBindings:async()=>[binding],daily:async()=>Response.json(body)},[account],now,options); +} +const summaryOf=(status,observed=1)=>({source:'stored_runtime_daily_projection',status,reason:status==='complete'?null:'runtime_daily_incomplete',observed_run_count:observed,total_run_count:status==='complete'?observed:null}); +const target=[{platform:'ibkr',key:'synthetic'}]; +// Accepted projection, outer read incomplete, record complete: never complete here. +let summary=await summarise({...complete,read_summary:summaryOf('partial')}); +assert.deepEqual([summary.accounts[0].status,summary.accounts[0].completeness,summary.counts.runs],['partial','incomplete',null]); +let last=lastRun(summary,summary.accounts[0],now); +assert.equal(last.status,'partial','an outer-incomplete record is a verified subset, never a complete read'); +assert.notDeepEqual([last.status,last.value],['known',null],'never a known-null last run'); +assert.notEqual(runsToday({targets:target,summary,businessDate:date,now}).coverage.included,1,'never a covered count'); +// Same, without a finished run: unknown coverage gap, never "no run" or a covered zero. +summary=await summarise({...complete,record:{...complete.record,runs:[]},read_summary:summaryOf('partial',0)}); +last=lastRun(summary,summary.accounts[0],now); +assert.deepEqual([last.status,last.reason],['unknown','coverage_incomplete']); +const zero=runsToday({targets:target,summary,businessDate:date,now}); +assert.deepEqual([zero.status,zero.reason],['unknown','coverage_incomplete'],'no covered zero runs'); +// Any non-complete or malformed reader summary stays incomplete. +for(const read_summary of [summaryOf('no_data'),summaryOf('read_failed'),null,{status:'COMPLETE'}]) { + summary=await summarise({...complete,read_summary}); + assert.equal(summary.accounts[0].completeness,'incomplete',`read_summary ${JSON.stringify(read_summary)}`); +} +// Complete reader decision and complete record: complete, a real (known) count. +summary=await summarise({...complete,read_summary:summaryOf('complete')}); +assert.deepEqual([summary.accounts[0].status,summary.accounts[0].completeness,summary.counts.runs],['ok','complete',1]); +assert.equal(lastRun(summary,summary.accounts[0],now).status,'known'); +summary=await summarise({...complete,record:{...complete.record,runs:[]},read_summary:summaryOf('complete',0)}); +last=lastRun(summary,summary.accounts[0],now); +assert.deepEqual([last.status,last.value],['known',null],'only a complete reader decision is a verified empty day'); +// Legacy bodies without read_summary keep working; the record's own incomplete kind/status still counts. +assert.equal((await summarise(complete)).accounts[0].completeness,'complete','legacy complete body unchanged'); +for(const record of [{...complete.record,kind:'incomplete'},{...complete.record,status:'read_incomplete'},{...complete.record,status:'missing_report'}]) + assert.equal((await summarise({...complete,record})).accounts[0].completeness,'incomplete'); +// Stale and fresh partials keep their status. +summary=await summarise({...complete,data_status:'stale',read_summary:summaryOf('partial')}); +assert.deepEqual([summary.accounts[0].status,summary.accounts[0].completeness],['stale','incomplete']); + +// Schedule projection: explicit opt-in only; the legacy output is unchanged by default. +summary=await summarise({...complete,read_summary:summaryOf('complete')}); +assert.equal('schedule' in summary.accounts[0],false,'no schedule field without the opt-in'); +summary=await summarise({...complete,read_summary:summaryOf('complete')},{schedule:true}); +assert.deepEqual(summary.accounts[0].schedule,{state:'not_due',business_date:date,timezone:'America/New_York',next_due_at:'2026-10-16T19:55:00Z'}); +assert.equal(JSON.stringify(summary.accounts[0].schedule).includes('latest_due_at'),false,'only the fields the projection needs'); +summary=await summarise({...complete,record:{...complete.record,schedule:null}},{schedule:true}); +assert.equal(summary.accounts[0].schedule,null,'a missing schedule is null, not invented'); +let reads=0; +const counted=await todaySummary(new Request('https://synthetic.invalid/api/runtime/today'),{}, + {dailyBindings:async()=>[binding],daily:async()=>{reads+=1;return Response.json({...complete,read_summary:summaryOf('complete')});}},[account],now,{schedule:true}); +assert.equal(reads,1,'the opt-in makes no additional storage read'); +assert.equal(counted.accounts[0].schedule.next_due_at,'2026-10-16T19:55:00Z'); +const missing=await todaySummary(new Request('https://synthetic.invalid/api/runtime/today'),{}, + {dailyBindings:async()=>[],daily:async()=>{throw Error('unreachable');}},[account],now,{schedule:true}); +assert.equal(missing.accounts[0].schedule,null,'an unbound account carries no schedule'); + +// Summary as_of is the chronologically oldest raw observed_at: "14:00:00.500Z" is after "14:00:00Z" though it sorts first as a string. +async function oldestOf(times){ + const keys=times.map((_,i)=>`synthetic-${i}`); + const bindings=keys.map(key=>({...binding,account_key:key})); + const result=await todaySummary(new Request('https://synthetic.invalid/api/runtime/today'),{}, + {dailyBindings:async()=>bindings,daily:async req=>{const key=new URL(req.url).searchParams.get('account_key'); + return Response.json({...complete,account_key:key,read_summary:summaryOf('complete'),record:{...complete.record,observed_at:times[keys.indexOf(key)]}});}}, + keys.map(key=>({platform:'ibkr',key})),now); + assert.deepEqual(result.accounts.map(r=>r.as_of),times,'rows keep their own raw observed_at'); + return result.as_of; +} +assert.equal(await oldestOf(['2026-10-16T14:00:00.500Z','2026-10-16T14:00:00Z']),'2026-10-16T14:00:00Z','the whole second precedes its own fraction'); +assert.equal(await oldestOf(['2026-10-16T14:00:00Z','2026-10-16T14:00:00.500Z']),'2026-10-16T14:00:00Z','independent of row order'); +assert.equal(await oldestOf(['2026-10-16T14:00:00.001000000Z','2026-10-16T14:00:00Z']),'2026-10-16T14:00:00Z','nine fractional digits order by time'); +// Epochs resolve to milliseconds, as the reader's own check does: a sub-millisecond difference is a tie. +assert.equal(await oldestOf(['2026-10-16T14:00:00Z','2026-10-16T14:00:00.000000001Z']),'2026-10-16T14:00:00.000000001Z','a sub-millisecond tie keeps the legacy string pick'); +assert.equal(await oldestOf(['2026-10-16T10:00:00.250-04:00','2026-10-16T13:30:00Z']),'2026-10-16T13:30:00Z','an offset instant orders by its UTC time'); +for(const times of [['2026-10-16T14:00:00Z','2026-10-16T14:00:00.000Z'],['2026-10-16T14:00:00.000Z','2026-10-16T10:00:00-04:00']]) + assert.equal(await oldestOf(times),[...times].sort()[0],'an exact instant tie keeps the legacy string pick'); +assert.equal(await oldestOf(['2026-10-16T14:00:00.500Z']),'2026-10-16T14:00:00.500Z','a single row is its own oldest'); +assert.equal((await summarise({...complete,record:null})).as_of,null,'no observation is null, never invented'); +assert.equal(await oldestOf(['2026-10-16T14:00:00.500Z','2026-10-16T14:00:00Z','not-a-time']),'2026-10-16T14:00:00.500Z','an unparseable value keeps the legacy string pick'); + +console.log('runtime today incomplete: coverage, read failures, unknown, execution priority, reader completeness, schedule opt-in and chronological as_of passed'); diff --git a/web/strategy-switch-console/application/console_api/operations.js b/web/strategy-switch-console/application/console_api/operations.js index 8106ddf1..c339b2ab 100644 --- a/web/strategy-switch-console/application/console_api/operations.js +++ b/web/strategy-switch-console/application/console_api/operations.js @@ -14,17 +14,49 @@ function nonTradingWeekend(date) { const weekday = new Date(`${date}T00:00:00Z`).getUTCDay(); return weekday === 0 || weekday === 6; } -export async function todaySummary(request, env, deps, accounts, now = Date.now()) { +// The reader's own decision; mirrored locally only for a legacy body that carries none. +const INCOMPLETE_RECORD_STATUSES = new Set(["read_incomplete", "missing_report", "insufficient", "conflict"]); +function readerIncomplete(body, record) { + if (body.read_summary !== undefined) return body.read_summary?.status !== "complete"; + return record.kind === "incomplete" || INCOMPLETE_RECORD_STATUSES.has(record.status); +} +/** Public schedule fields for the opt-in N1 projection; anything malformed stays null. */ +function scheduleFields(schedule) { + if (!schedule || typeof schedule !== "object") return null; + const text = value => (typeof value === "string" ? value : null); + return { state: text(schedule.state), business_date: text(schedule.business_date), + timezone: text(schedule.timezone), next_due_at: text(schedule.next_due_at) }; +} +// The daily reader validates observed_at but returns it raw, so fractional digits and +// offsets vary: the oldest observation is the earliest epoch, not the first string. +const DAILY_INSTANT = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?(?:Z|[+-]\d{2}:\d{2})$/; +function oldestObservation(values) { + const present = values.filter(Boolean); + const epochs = present.map(value => (typeof value === "string" && DAILY_INSTANT.test(value) ? Date.parse(value) : NaN)); + // A value this grammar cannot place keeps the legacy string pick; no time is invented. + if (epochs.some(epoch => !Number.isFinite(epoch))) return present.sort()[0] || null; + // An exact instant tie keeps the string order the legacy pick used. + const order = present.map((value, i) => ({ value, epoch: epochs[i] })) + .sort((a, b) => a.epoch - b.epoch || (a.value < b.value ? -1 : a.value > b.value ? 1 : 0)); + return order[0]?.value || null; +} +/** + * `options.schedule` is an explicit N1 opt-in: rows then carry `schedule` (null when no + * record was read). Without it the legacy output is unchanged. + */ +export async function todaySummary(request, env, deps, accounts, now = Date.now(), options = {}) { // Daily records, their reader, and this summary must share one date domain. const date = runtimeDailyBusinessDate(now); const weekend = nonTradingWeekend(date); + const withSchedule = options?.schedule === true; let bindings; try { bindings = await deps.dailyBindings(env); } catch { bindings = null; } const rows = await Promise.all(accounts.map(async (account) => { const base = { account_alias: account.key, platform: account.platform, status: "not_connected", unavailable_reason: "runtime_daily_not_connected", - as_of: null, activity: null, completeness: null, run_count: null, runs: [] }; + as_of: null, activity: null, completeness: null, run_count: null, runs: [], + ...(withSchedule ? { schedule: null } : {}) }; if (!bindings || bindings.some(b => b.platform === account.platform && b.status === "read_failed")) return { ...base, status: "read_failed", unavailable_reason: "stored_records_unreadable" }; if (bindings.some(b => b.platform === account.platform && b.account_key === account.key && b.status === "conflict")) @@ -60,7 +92,9 @@ export async function todaySummary(request, env, deps, accounts, now = Date.now( && body.read_error_codes.length === readErrorCount && body.read_error_codes.every(code => typeof code === "string" && safeReadCodes.has(code)) ? body.read_error_codes : null; - const incomplete = record.completeness !== "complete" || body.read_error_count > 0 || body.unmatched_count > 0 || record.conflict_count > 0; + // An accepted projection whose outer read is incomplete stays incomplete here. + const incomplete = readerIncomplete(body, record) || record.completeness !== "complete" + || body.read_error_count > 0 || body.unmatched_count > 0 || record.conflict_count > 0; return { ...base, status: body.data_status !== "fresh" ? "stale" : incomplete ? "partial" : "ok", unavailable_reason: body.data_status !== "fresh" ? "runtime_daily_stale" : incomplete ? "runtime_daily_incomplete" : null, as_of: record.observed_at, activity: record.status, @@ -71,6 +105,7 @@ export async function todaySummary(request, env, deps, accounts, now = Date.now( runs: record.runs.map(run => ({ id: run.run_id, started_at: run.started_at, finished_at: run.finished_at, activity: run.activity, execution_lane: run.execution_lane, errors_present: run.errors_present, ...(run.issue ? { issue: run.issue } : {}) })), + ...(withSchedule ? { schedule: scheduleFields(record.schedule) } : {}), }; } catch { return { ...base, status: "read_failed", unavailable_reason: "stored_records_unreadable" }; @@ -84,7 +119,7 @@ export async function todaySummary(request, env, deps, accounts, now = Date.now( return { status: rows.some(r => r.status === "read_failed") ? "read_failed" : rows.some(r => r.status !== "ok") ? "partial" : "ok", date, timezone: RUNTIME_DAILY_TIMEZONE, market_session: weekend ? "weekend" : "trading_day", - as_of: rows.map(r => r.as_of).filter(Boolean).sort()[0] || null, + as_of: oldestObservation(rows.map(r => r.as_of)), accounts: rows, counts: { configured: rows.length, by_state: runStateCounts(rows), observed: rows.filter(r => r.run_count !== null).length, runs: rows.some(r => r.run_count === null || r.completeness !== "complete") ? null : rows.reduce((n, r) => n + r.run_count, 0) }, diff --git a/web/strategy-switch-console/application/desk/overview.js b/web/strategy-switch-console/application/desk/overview.js index ea592cf3..39d5242c 100644 --- a/web/strategy-switch-console/application/desk/overview.js +++ b/web/strategy-switch-console/application/desk/overview.js @@ -77,7 +77,9 @@ export function bookNav(row, facts, now) { function latestFinishedRun(row) { const finished = (row.runs || []).map(run => ({ run, at: utcInstant(run.finished_at) })).filter(item => item.at !== null); - finished.sort((a, b) => a.at.localeCompare(b.at)); + // Normalized instants keep nonzero milliseconds, so order by epoch time, not string; + // the stable sort keeps record order for an exact tie, so the last such run is picked. + finished.sort((a, b) => Date.parse(a.at) - Date.parse(b.at)); return finished.at(-1) ?? null; } @@ -126,14 +128,16 @@ export function runsToday({ targets, summary, businessDate, now }) { if (!summary) return unknown("read_failed", coverage("unavailable", 0, targets.length, ["read_failed"])); const rows = targets.map(target => summaryRowFor(summary, target)); const covered = rows.filter(runCovered); + const failed = rows.some(row => row?.status === "read_failed"); if (!covered.length) { - // Records that were read but are incomplete are a coverage gap, not a missing source. - const reason = rows.some(runRowReadIncomplete) ? "coverage_incomplete" : "source_unavailable"; + // A failed read stays a failure; records that were read but are incomplete are a + // coverage gap; neither is a missing source. + const reason = failed ? "read_failed" : rows.some(runRowReadIncomplete) ? "coverage_incomplete" : "source_unavailable"; return unknown(reason, coverage("unavailable", 0, targets.length, [reason])); } const done = covered.reduce((count, row) => count + (row.runs || []).filter(run => utcInstant(run.finished_at) !== null).length, 0); - const times = covered.map(row => utcInstant(row.as_of)).filter(Boolean).sort(); - const reasons = ["not_supported", ...(covered.length < targets.length ? ["coverage_incomplete"] : [])]; + const times = covered.map(row => utcInstant(row.as_of)).filter(Boolean).sort((a, b) => Date.parse(a) - Date.parse(b)); + const reasons = ["not_supported", ...(covered.length < targets.length ? ["coverage_incomplete"] : []), ...(failed ? ["read_failed"] : [])]; return partial({ done, expected: null, pending: null, ...source }, times[0] ?? null, coverage("partial", covered.length, targets.length, reasons)); } @@ -228,10 +232,15 @@ export function overviewProjection({ config, facts, credential, summary, busines } const targets = readbackTargets(config.value); const { reporting, totals } = overviewTotals({ targets, facts, now }); - const includedTimes = facts.ok ? targets.map(target => factsRowFor(facts.model, target)) - .filter(row => row?.data_status === "fresh" || row?.data_status === "stale").map(factsObservedAt).filter(Boolean) : []; - const fresh = totals.coverage.included > 0 && includedTimes.length - ? freshness(includedTimes, { now, ttlSeconds: FACTS_STALE_SECONDS }) : { data_status: "unavailable", age_seconds: null }; + const shownRows = facts.ok ? targets.map(target => factsRowFor(facts.model, target)) + .filter(row => (row?.data_status === "fresh" || row?.data_status === "stale") && row.binding_status === "bound" && !row.identity_mismatch) : []; + const includedTimes = shownRows.map(factsObservedAt).filter(Boolean); + // When every displayed fact is stale, their age is kept rather than suppressed. This + // qualifies the displayed rows only; it never upgrades partial facts into totals. + const allStale = shownRows.length > 0 && shownRows.every(row => row.data_status === "stale"); + const fresh = (totals.coverage.included > 0 || allStale) && includedTimes.length + ? freshness(includedTimes, { now, ttlSeconds: FACTS_STALE_SECONDS, staleKnown: shownRows.some(row => row.data_status === "stale") }) + : { data_status: "unavailable", age_seconds: null }; const rows = targets.map(target => { const label = platformLabel(target.platform); const row = facts.ok ? factsRowFor(facts.model, target) : null; @@ -257,7 +266,7 @@ export function overviewProjection({ config, facts, credential, summary, busines }); const book = rows.length ? known(rows, now, coverage("complete", rows.length, rows.length)) : known([], now, coverage("verified_empty", 0, 0)); return { - snapshot_at: includedTimes.sort()[0] ?? null, + snapshot_at: includedTimes.sort((a, b) => Date.parse(a) - Date.parse(b))[0] ?? null, freshness: fresh, reporting_currency: reporting, totals: { diff --git a/web/strategy-switch-console/application/desk/ribbon.js b/web/strategy-switch-console/application/desk/ribbon.js index 9337c517..eb86b704 100644 --- a/web/strategy-switch-console/application/desk/ribbon.js +++ b/web/strategy-switch-console/application/desk/ribbon.js @@ -49,10 +49,12 @@ export function tapeOutcome(run) { return NO_OP.has(run.activity) ? "no_op" : null; } -/** Fully read stored record: fresh or stale, but always complete; or a verified weekend. */ +/** + * Fully read stored record: fresh or stale, but always complete. A missing record is + * never coverage, on a weekend too: a weekend does not prove no stored or manual run. + */ export function runRowCovered(row) { - return (["ok", "stale"].includes(row?.status) && row.completeness === "complete") - || (row?.status === "no_data" && row.unavailable_reason === "non_trading_weekend"); + return ["ok", "stale"].includes(row?.status) && row.completeness === "complete"; } /** @@ -97,7 +99,8 @@ export function ribbonTape({ days, accounts, date, readAt }) { if (runRowReadIncomplete(row)) anyIncomplete = true; if (!runRowCovered(row)) coveredAccounts.delete(account.target_id); if (row.status === "stale") anyStale = true; - if (row.as_of && (runRowCovered(row) || row.status === "partial")) observed.push(row.as_of); + // Every record that was read keeps its own stored observation, stale incomplete ones too. + if (row.as_of && (runRowCovered(row) || runRowReadIncomplete(row) || row.status === "partial")) observed.push(row.as_of); for (const run of Array.isArray(row.runs) ? row.runs : []) { const at = utcInstant(run.started_at); if (at === null) { undated = true; continue; } @@ -118,7 +121,9 @@ export function ribbonTape({ days, accounts, date, readAt }) { } } } - entries.sort((a, b) => a.at.localeCompare(b.at) || a.id.localeCompare(b.id)); + // Normalized instants keep nonzero milliseconds, so order by epoch time, not string; + // the entry id breaks only an exact tie. + entries.sort((a, b) => Date.parse(a.at) - Date.parse(b.at) || a.id.localeCompare(b.id)); const truncated = entries.length > TAPE_LIMIT; const value = truncated ? entries.slice(-TAPE_LIMIT) : entries; const included = coveredAccounts.size; @@ -126,7 +131,8 @@ export function ribbonTape({ days, accounts, date, readAt }) { if (included < total) reasons.push(anyFailure ? "read_failed" : "coverage_incomplete"); if (undated) reasons.push("time_basis_unavailable"); if (truncated && !reasons.includes("coverage_incomplete")) reasons.push("coverage_incomplete"); - const observedAt = observed.map(utcInstant).filter(Boolean).sort()[0] ?? readAt; + // The request time is never an observation: without a stored instant the read has no time basis. + const observedAt = observed.map(utcInstant).filter(Boolean).sort((a, b) => Date.parse(a) - Date.parse(b))[0] ?? null; let tape; if (included === total && !undated && !truncated) { const cover = coverage(value.length ? "complete" : "verified_empty", included, total); @@ -139,6 +145,54 @@ export function ribbonTape({ days, accounts, date, readAt }) { return { tape, observed, stale: anyStale }; } +const NEXT_RUN_UNVERIFIED = new Set(["schedule_unverified", "target_disabled"]); + +/** + * Next scheduled observation from the stored runtime-daily schedule of the current New + * York business day (opt-in `schedule` rows). Only a complete, schedule-verified record + * whose `next_due_at` is still in the future at request time is a candidate. Known (or + * stale) only when every configured account has one; otherwise the earliest candidate is + * a partial read, never the global next run. A null or past `next_due_at` proves no + * absence of a future run, so no candidates stays unknown. Expected/pending counts are + * not derived from these instants. + */ +export function nextScheduledRun({ summary, accounts, businessDate, now }) { + const total = accounts.length; + // Unknown reads keep a null total, as before this projection existed. + if (!summary) return unknown("read_failed", coverage("unavailable", 0, null, ["read_failed"])); + if (total === 0) return unknown("source_unavailable", coverage("unavailable", 0, null, ["source_unavailable"])); + const candidates = []; + let anyFailure = false; + let anyIncomplete = false; + for (const account of accounts) { + const rows = (summary.accounts || []).filter(item => item.platform === account.platform && item.account_alias === account.key); + const row = rows.length === 1 ? rows[0] : null; + if (row?.status === "read_failed") anyFailure = true; + if (runRowReadIncomplete(row)) anyIncomplete = true; + const schedule = row?.schedule; + if (!row || !runRowCovered(row) || !schedule || utcInstant(row.as_of) === null) continue; + if (schedule.business_date !== businessDate || schedule.timezone !== RUNTIME_DAILY_TIMEZONE || schedule.state === "unevaluable") continue; + if ((row.read_error_codes || []).some(code => NEXT_RUN_UNVERIFIED.has(code))) continue; + const due = utcInstant(schedule.next_due_at); + if (due === null || Date.parse(due) <= Number(now)) continue; + candidates.push({ target_id: account.target_id, due_at: due, observed_at: utcInstant(row.as_of), stale: row.status === "stale" }); + } + if (!candidates.length) { + const reason = anyFailure ? "read_failed" : anyIncomplete ? "coverage_incomplete" : "source_unavailable"; + return unknown(reason, coverage("unavailable", 0, null, [reason])); + } + // Normalized instants keep nonzero milliseconds, so order by epoch time, not string; + // the target id breaks only an exact tie. + candidates.sort((a, b) => Date.parse(a.due_at) - Date.parse(b.due_at) || a.target_id.localeCompare(b.target_id)); + const value = { target_id: candidates[0].target_id, kind: "scheduled_run", due_at: candidates[0].due_at }; + const observedAt = candidates.map(item => item.observed_at).sort((a, b) => Date.parse(a) - Date.parse(b))[0]; + if (candidates.length < total) { + return partial(value, observedAt, coverage("partial", candidates.length, total, [anyFailure ? "read_failed" : "coverage_incomplete"])); + } + const cover = coverage("complete", total, total); + return candidates.some(item => item.stale) ? stale(value, observedAt, now, cover) : known(value, observedAt, cover); +} + export function ribbonDate(url, now) { const values = url.searchParams.getAll("date"); if ([...url.searchParams.keys()].some(key => key !== "date") || values.length > 1) throw new DeskError("invalid_query", 400); @@ -150,12 +204,21 @@ export function ribbonDate(url, now) { export async function ribbonProjection(request, env, deps, { date, now, config }) { const accounts = configuredAccounts(config); const dates = ribbonBusinessDates(date, now); + // Only the current NY business day's read opts in to its schedule fields; no extra read. + const today = zonedDate(now, RUNTIME_DAILY_TIMEZONE); const days = []; for (const businessDate of dates) { - const read = await readOnce(() => deps.runtimeDay(request, env, accounts, businessDate)); + const read = await readOnce(() => (businessDate === today + ? deps.runtimeDay(request, env, accounts, businessDate, { schedule: true }) + : deps.runtimeDay(request, env, accounts, businessDate))); days.push({ date: businessDate, summary: read.ok && read.value && Array.isArray(read.value.accounts) ? read.value : null }); } const result = ribbonTape({ days, accounts, date, readAt: now }); + const todayRead = days.find(day => day.date === today); + // A past UTC day does not read today's record, so its next run stays unknown. + const nextRun = todayRead + ? nextScheduledRun({ summary: todayRead.summary, accounts, businessDate: today, now }) + : unknown("source_unavailable", coverage("unavailable", 0, null, ["source_unavailable"])); return { date, date_basis: "utc_day", @@ -168,8 +231,8 @@ export async function ribbonProjection(request, env, deps, { date, now, config } // No alerts store exists: unknown, never zero. alert_badge: notSupported(), sessions: ["US", "HK", "CN"].map(market => ({ market, state: "unknown", open_utc: null, close_utc: null, next_change_at: null, holiday: null })), - // No verified schedule source is read here; "no next run" is never inferred. - next_run: unknown("source_unavailable", coverage("unavailable", 0, null, ["source_unavailable"])), + // Observed schedule instants only; "no next run" is never inferred. + next_run: nextRun, tape: result.tape, }; } diff --git a/web/strategy-switch-console/frontend/src/desk/DeskApp.tsx b/web/strategy-switch-console/frontend/src/desk/DeskApp.tsx index 10877855..bb36c9ad 100644 --- a/web/strategy-switch-console/frontend/src/desk/DeskApp.tsx +++ b/web/strategy-switch-console/frontend/src/desk/DeskApp.tsx @@ -152,6 +152,7 @@ export default function DeskApp() { const alertCount = badge && badge.status === "known" ? badge.value.open_count : null; const alertUnknown = badge !== null && badge.status !== "known"; const modeUnknown = ribbon.state === "ready" && ribbon.result.data.console_state.mode.status !== "known"; + const nextRun = ribbon.state === "ready" ? ribbon.result.data.next_run : null; // A page may mix live reads and fixtures. Derive the chip from every endpoint // that page renders, so a newly live endpoint cannot make fixtures look real. const exposure = fixtureExposure(page, scenario); @@ -207,6 +208,7 @@ export default function DeskApp() { alertCount={alertCount} alertUnknown={alertUnknown} modeUnknown={modeUnknown} + nextRun={lock ? null : nextRun} nowIso={nowIso} serverClock={serverClock} mock={mock} diff --git a/web/strategy-switch-console/frontend/src/desk/i18n/en.ts b/web/strategy-switch-console/frontend/src/desk/i18n/en.ts index 92a2e885..32fdeb99 100644 --- a/web/strategy-switch-console/frontend/src/desk/i18n/en.ts +++ b/web/strategy-switch-console/frontend/src/desk/i18n/en.ts @@ -479,7 +479,7 @@ export const en: Record = { "logs.meta": "Kept {days} days · redacted", "logs.sessions.title": "Today's sessions · UTC", "logs.runs.title": "Run record", - "logs.runs.next": "Next {time} · {target}", + "logs.runs.next": "Earliest observed scheduled run {time} · {target}", "logs.runs.empty": "No runs today.", "logs.logs.title": "Logs", "logs.filter.label": "Log level", @@ -591,4 +591,18 @@ export const en: Record = { "logs.runs.utc_day": "UTC day {date}", "logs.runs.unknown": "Run record could not be read: {reason}", "logs.runs.partial": "Run record covers {coverage} targets; the rest is unknown.", + "overview.signals.loading": "Loading recent signals…", + "overview.book.nav_stale": "Stale NAV · {age} ago", + "perf.meta.title": "History and return basis", + "perf.meta.window": "Requested window: {range} from {from}", + "perf.meta.window_all": "Requested window: {range}, all stored history", + "perf.meta.first_date": "Stored history starts {date}", + "perf.meta.basis": "Return basis: {basis}", + "perf.meta.basis_target": "{target}: {basis}", + "perf.bench.contained": "Benchmark comparison is not read: it is outside the approved sources for this page.", + "next_run.known": "Next run {time} UTC · {target}", + "next_run.stale": "Next run {time} UTC · {target} · stale {age}", + "next_run.partial": "Earliest observed run {time} UTC · {target} · {coverage} accounts; not the overall next run", + "next_run.unknown": "Next run ?", + "next_run.title": "From stored runtime-daily schedules only. A missing time does not mean no run is due.", }; diff --git a/web/strategy-switch-console/frontend/src/desk/i18n/zh-CN.ts b/web/strategy-switch-console/frontend/src/desk/i18n/zh-CN.ts index 5783c226..07d7bf4a 100644 --- a/web/strategy-switch-console/frontend/src/desk/i18n/zh-CN.ts +++ b/web/strategy-switch-console/frontend/src/desk/i18n/zh-CN.ts @@ -486,7 +486,7 @@ export const zh = { "logs.meta": "保留 {days} 天 · 已脱敏", "logs.sessions.title": "今日时段 · UTC", "logs.runs.title": "运行记录", - "logs.runs.next": "下次 {time} · {target}", + "logs.runs.next": "已观测到的最早计划运行 {time} · {target}", "logs.runs.empty": "今天没有运行记录。", "logs.logs.title": "日志", "logs.filter.label": "日志级别", @@ -599,6 +599,20 @@ export const zh = { "logs.runs.utc_day": "UTC 日 {date}", "logs.runs.unknown": "运行记录读取失败:{reason}", "logs.runs.partial": "运行记录只覆盖 {coverage} 个目标;缺失部分未知。", + "overview.signals.loading": "正在加载近期信号…", + "overview.book.nav_stale": "净资产已过期 · {age}前", + "perf.meta.title": "历史与收益口径", + "perf.meta.window": "请求窗口:{range},自 {from}", + "perf.meta.window_all": "请求窗口:{range},全部已存历史", + "perf.meta.first_date": "已存历史从 {date} 开始", + "perf.meta.basis": "收益口径:{basis}", + "perf.meta.basis_target": "{target} 的收益口径:{basis}", + "perf.bench.contained": "未读取基准对比:它不在本页已批准的数据来源内。", + "next_run.known": "下次运行 {time} UTC · {target}", + "next_run.stale": "下次运行 {time} UTC · {target} · 已过期 {age}", + "next_run.partial": "已观测到的最早运行 {time} UTC · {target} · 覆盖 {coverage} 个账户;不是全局下次运行", + "next_run.unknown": "下次运行 ?", + "next_run.title": "仅来自已存的每日运行计划。没有时间不代表没有待运行任务。", } as const; export type DeskKey = keyof typeof zh; diff --git a/web/strategy-switch-console/frontend/src/desk/pages/OverviewPage.tsx b/web/strategy-switch-console/frontend/src/desk/pages/OverviewPage.tsx index 185a9213..a3cafe4b 100644 --- a/web/strategy-switch-console/frontend/src/desk/pages/OverviewPage.tsx +++ b/web/strategy-switch-console/frontend/src/desk/pages/OverviewPage.tsx @@ -59,6 +59,9 @@ function BookCard({ row, t, language }: { row: N1BookRow; t: DeskTranslate; lang const nav = row.nav.status === "unknown" ? null : formatMoney(row.nav.usd ?? row.nav.amount, row.nav.usd ? "USD" : row.nav.currency, language); + // A stale NAV keeps its qualification and age; it is never shown as a current figure. + const navStale = row.nav.status === "stale" + ? t("overview.book.nav_stale", { age: formatAge(row.nav.age_seconds, language) ?? "\u2014" }) : null; const day = row.day_change ? formatSignedPercent(row.day_change.ratio) : null; const drawdown = formatSignedPercent(row.drawdown.ratio); const limit = formatSignedPercent(row.drawdown.limit_ratio, 0); @@ -95,11 +98,12 @@ function BookCard({ row, t, language }: { row: N1BookRow; t: DeskTranslate; lang

{strategy ?? t("read.unknown")}

{row.target_id}

- {nav ?? UNKNOWN} + {nav ?? UNKNOWN} {day ?? UNKNOWN}
+ {navStale && nav ?

{navStale}

: null} {paths.length > 0 ? ( @@ -171,7 +175,8 @@ export function OverviewView({ data, source, ribbon, t, language, narrow }: { : unknownText(runs, t); const drift = totals.config_drift; const tape = ribbon.state === "ready" ? ribbon.data.tape : null; - const signals = tape ? (readValue(tape) ?? []).slice(0, 5) : []; + // The tape is oldest first: Recent Signals shows the newest five, newest first. + const signals = tape ? (readValue(tape) ?? []).slice(-5).reverse() : []; const coverageTotal = totals.coverage.total ?? t("read.unknown_mark"); // An unread book proves nothing about what is running. @@ -333,20 +338,27 @@ export function OverviewView({ data, source, ribbon, t, language, narrow }: {
{t("overview.signals.title")} - {ribbon.state === "failed" ?

{t("states.error")}

+ {ribbon.state === "loading" ?

{t("overview.signals.loading")}

+ : ribbon.state === "failed" ?

{t("states.error")}

: tape?.status === "unknown" ?

{unknownText(tape, t)}

: signals.length === 0 && tape?.status === "partial" ?

{t("n1.partial", { coverage: coverageText(tape.coverage) })}

: signals.length === 0 ?

{t("overview.signals.empty")}

: ( -
    - {signals.map(entry => ( -
  • - {entry.at.slice(11, 16)} - {entry.label} - {t(tapeStateKey(entry) as DeskKey)} -
  • - ))} -
+ <> +
    + {signals.map(entry => ( +
  • + {entry.at.slice(11, 16)} + {entry.label} + {t(tapeStateKey(entry) as DeskKey)} +
  • + ))} +
+ {/* A populated list keeps its qualification: a subset or old read is not the whole day. */} + {tape?.status === "partial" ?

{t("n1.partial", { coverage: coverageText(tape.coverage) })}

+ : tape?.status === "stale" ?

{t("read.stale", { age: formatAge(tape.age_seconds, language) ?? "\u2014" })}

+ : null} + )}
diff --git a/web/strategy-switch-console/frontend/src/desk/pages/PerformancePage.tsx b/web/strategy-switch-console/frontend/src/desk/pages/PerformancePage.tsx index d9668ead..905e6b34 100644 --- a/web/strategy-switch-console/frontend/src/desk/pages/PerformancePage.tsx +++ b/web/strategy-switch-console/frontend/src/desk/pages/PerformancePage.tsx @@ -3,14 +3,14 @@ import type { Language } from "../../lib/prefs"; import type { DeskApi, DeskResult } from "../api/endpoints.ts"; import type { N1Performance, N1Read } from "../contract/n1"; import { n1ReasonKey, readValue } from "../api/n1.ts"; -import type { DeskTranslate } from "../i18n/index.ts"; +import type { DeskKey, DeskTranslate } from "../i18n/index.ts"; import { BenchmarkPanel, useNarrow } from "../performance/BenchmarkPanel"; import type { BenchmarkState } from "../performance/BenchmarkPanel"; import { S } from "../performance/perfStyles.ts"; import { parseBenchmarks, portfolioCurve } from "../performance/benchmarks.ts"; import type { Stats } from "../performance/benchmarks.ts"; -type Loaded = { state: "loading" } | { state: "failed" } | { state: "ready"; result: DeskResult }; +export type Loaded = { state: "loading" } | { state: "failed" } | { state: "ready"; result: DeskResult }; /** A metric is shown only from a known (or explicitly stale) backend read of the requested window. */ const knownRatio = (read: N1Read): number | null => @@ -28,6 +28,63 @@ export function portfolioStatsFor(performance: N1Performance): Stats { }; } +/** + * The live N1 Performance path reads no benchmark: `/api/market-benchmark/performance` is not + * an approved source under the current N1 contract, so a live curve never activates it. + * Fixture (mock) pages keep their synthetic comparison. + */ +export function benchmarkReadAllowed(source: DeskResult["source"]): boolean { + return source === "mock"; +} + +/** Benchmark state for a loaded page: the API is called only when the source allows it. */ +export async function loadBenchmarks( + api: Pick, + loaded: DeskResult, + window: { start: string; end: string }, +): Promise { + if (!benchmarkReadAllowed(loaded.source)) return { phase: "failed" }; + try { + const model = parseBenchmarks(await api.benchmarks(window, loaded.source, loaded.scenario), window); + return model ? { phase: "ready", model } : { phase: "failed" }; + } catch { + // Any HTTP 400/503 or network error: every benchmark is unavailable, the portfolio stays. + return { phase: "failed" }; + } +} + +const basisKey = (basis: string) => `basis.${basis}` as DeskKey; + +/** + * The minimum-set metadata the projection carries, shown whether or not a curve exists: + * requested window, first stored date, the per-target return basis and any truncation. + */ +function HistoryMeta({ data, t }: { data: N1Performance; t: DeskTranslate }) { + const history = data.history; + const targets = Object.entries(data.return_basis.per_target).sort(([a], [b]) => a.localeCompare(b)); + return ( +
+

+ {history.requested_from + ? t("perf.meta.window", { range: data.range, from: history.requested_from }) + : t("perf.meta.window_all", { range: data.range })} +

+

{t("perf.meta.first_date", { date: history.first_date ?? t("read.unknown_mark") })}

+ {history.truncated_by ? ( +

+ {t("perf.history.truncated", { range: data.range, date: history.first_date ?? t("read.unknown_mark") })} +

+ ) : null} +

{t("perf.meta.basis", { basis: t(basisKey(data.return_basis.kind)) })}

+ {targets.length ? ( +
    + {targets.map(([target, basis]) =>
  • {t("perf.meta.basis_target", { target, basis: t(basisKey(basis)) })}
  • )} +
+ ) : null} +
+ ); +} + export function PerformancePage({ api, t, language }: { api: DeskApi; t: DeskTranslate; language: Language }) { const narrow = useNarrow(); const [perf, setPerf] = useState({ state: "loading" }); @@ -44,31 +101,41 @@ export function PerformancePage({ api, t, language }: { api: DeskApi; t: DeskTra }, [api]); const loaded = perf.state === "ready" ? perf.result : null; - const equity = loaded ? readValue(loaded.data.equity) : null; - const curve = useMemo( - () => (equity ? portfolioCurve(equity.dates, equity.portfolio_indexed, equity.gap_dates) : null), - [equity], - ); - const window = curve ? { start: curve.points[0].date, end: curve.points[curve.points.length - 1].date } : null; + const curve = useMemo(() => performanceCurve(loaded), [loaded]); + const window = curveWindow(curve); useEffect(() => { if (!loaded || !window) return; let alive = true; setBenchmarks({ phase: "loading" }); - api.benchmarks(window, loaded.source, loaded.scenario).then( - raw => { - if (!alive) return; - const model = parseBenchmarks(raw, window); - setBenchmarks(model ? { phase: "ready", model } : { phase: "failed" }); - }, - // Any HTTP 400/503 or network error: every benchmark is unavailable, the portfolio stays. - () => alive && setBenchmarks({ phase: "failed" }), - ); + loadBenchmarks(api, loaded, window).then(state => alive && setBenchmarks(state)); return () => { alive = false; }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [api, loaded, window?.start, window?.end]); - const history = loaded?.data.history ?? null; + return ; +} + +function performanceCurve(loaded: DeskResult | null) { + const equity = loaded ? readValue(loaded.data.equity) : null; + return equity ? portfolioCurve(equity.dates, equity.portfolio_indexed, equity.gap_dates) : null; +} + +function curveWindow(curve: ReturnType) { + return curve ? { start: curve.points[0].date, end: curve.points[curve.points.length - 1].date } : null; +} + +/** Pure render of a performance load, so the unknown-curve and containment rules are testable. */ +export function PerformanceView({ perf, benchmarks, t, language, narrow }: { + perf: Loaded; + benchmarks: BenchmarkState; + t: DeskTranslate; + language: Language; + narrow: boolean; +}) { + const loaded = perf.state === "ready" ? perf.result : null; + const curve = performanceCurve(loaded); + const window = curveWindow(curve); const unknownCurve = loaded && loaded.data.equity.status === "unknown" ? loaded.data.equity : null; const body = perf.state === "loading" ?

{t("perf.loading")}

: unknownCurve ? ( @@ -78,25 +145,24 @@ export function PerformancePage({ api, t, language }: { api: DeskApi; t: DeskTra ) : perf.state === "failed" || !loaded || !curve || !window ?

{t("perf.portfolio_unavailable")}

: ( - + <> + + {benchmarkReadAllowed(loaded.source) ? null :

{t("perf.bench.contained")}

} + ); return (

{t("page.performance.headline")}

{loaded?.source === "mock" ?

{t("perf.mock_note")}

: null} - {history?.truncated_by ? ( -

- {t("perf.history.truncated", { range: loaded?.data.range ?? "", date: history.first_date ?? t("read.unknown_mark") })} -

- ) : null} + {loaded ? : null}
{body}
diff --git a/web/strategy-switch-console/frontend/src/desk/shell/DeskShell.tsx b/web/strategy-switch-console/frontend/src/desk/shell/DeskShell.tsx index d946c2c7..19440c46 100644 --- a/web/strategy-switch-console/frontend/src/desk/shell/DeskShell.tsx +++ b/web/strategy-switch-console/frontend/src/desk/shell/DeskShell.tsx @@ -6,7 +6,7 @@ import type { DeskTranslate } from "../i18n/index.ts"; import type { DeskPage } from "../router.ts"; import { AuthPage } from "../pages/AuthPage"; import { Dock } from "./Dock"; -import { TopBar, type GatewayChip, type ShellLock } from "./TopBar"; +import { TopBar, type GatewayChip, type NextRunRead, type ShellLock } from "./TopBar"; export interface DeskShellProps { page: DeskPage; @@ -19,6 +19,8 @@ export interface DeskShellProps { alertUnknown?: boolean; /** N1: the global runtime mode is unknown; the bar says so instead of guessing. */ modeUnknown?: boolean; + /** N1 ribbon `next_run`; never shown behind the lock. */ + nextRun?: NextRunRead | null; nowIso: string; serverClock: boolean; mock: MockScenario | null; @@ -35,12 +37,12 @@ export interface DeskShellProps { /** Meridian frame: dock + top bar + page. Pure; state lives in DeskApp. */ export function DeskShell(props: DeskShellProps) { - const { page, t, alertCount, alertUnknown = false, lock, onNavigate, children, ...bar } = props; + const { page, t, alertCount, alertUnknown = false, lock, nextRun = null, onNavigate, children, ...bar } = props; return (
- + {children}
diff --git a/web/strategy-switch-console/frontend/src/desk/shell/TopBar.tsx b/web/strategy-switch-console/frontend/src/desk/shell/TopBar.tsx index 49ae1687..3e2b5324 100644 --- a/web/strategy-switch-console/frontend/src/desk/shell/TopBar.tsx +++ b/web/strategy-switch-console/frontend/src/desk/shell/TopBar.tsx @@ -1,5 +1,7 @@ import type { Language } from "../../lib/prefs"; import type { GatewayState, Read } from "../contract/types"; +import type { N1Ribbon } from "../contract/n1"; +import { coverageText } from "../api/n1.ts"; import type { MockScenario } from "../api/mock.ts"; import { FreshnessChip } from "../components/StateBlocks.tsx"; import type { FreshnessChipState } from "../components/stateModel.ts"; @@ -12,6 +14,9 @@ import { P } from "../components/deskStyles.ts"; /** Gateway chip state: a read, still loading, or a failed ribbon request. Unknown is never "down". */ export type GatewayChip = { kind: "read"; read: Read } | { kind: "loading" } | { kind: "failed" }; +/** The shared ribbon's `next_run` read; null while the ribbon is not ready. */ +export type NextRunRead = N1Ribbon["next_run"]; + /** Locked shell: the crumb and the single chip that replaces every data read. */ export interface ShellLock { crumb: DeskKey; @@ -37,6 +42,8 @@ interface TopBarProps { lock?: ShellLock | null; /** N1: the global runtime mode read is unknown. */ modeUnknown?: boolean; + /** N1 ribbon `next_run`; omitted or null shows no chip. */ + nextRun?: NextRunRead | null; onLanguage: (language: Language) => void; onTheme: () => void; } @@ -51,8 +58,23 @@ function gatewayView(chip: GatewayChip): { key: DeskKey; tone: string; ageSecond return { key: `gateway.${read.value}` as DeskKey, tone }; } -export function TopBar({ page, t, language, theme, gateway, nowIso, serverClock, mock, mockPartial = false, freshness, lock, modeUnknown = false, onLanguage, onTheme }: TopBarProps) { +/** + * Next scheduled run chip. A partial read is only the earliest observed candidate and says + * so; a stale read keeps its age; a time already past (the ribbon polls) or a null value + * is unknown, never "no run". + */ +export function nextRunView(read: NextRunRead, nowIso: string, t: DeskTranslate, language: Language): { text: string; tone: string; state: string } { + const value = read.status === "unknown" ? null : read.value; + if (!value || !(Date.parse(value.due_at) > Date.parse(nowIso))) return { text: t("next_run.unknown"), tone: "unknown", state: "unknown" }; + const params = { time: formatUtcHm(value.due_at) ?? "--:--", target: value.target_id }; + if (read.status === "partial") return { text: t("next_run.partial", { ...params, coverage: coverageText(read.coverage) }), tone: "unknown", state: "partial" }; + if (read.status === "stale") return { text: t("next_run.stale", { ...params, age: formatAge(read.age_seconds, language) ?? "—" }), tone: "unknown", state: "stale" }; + return { text: t("next_run.known", params), tone: "muted", state: "known" }; +} + +export function TopBar({ page, t, language, theme, gateway, nowIso, serverClock, mock, mockPartial = false, freshness, lock, modeUnknown = false, nextRun = null, onLanguage, onTheme }: TopBarProps) { const chip = gatewayView(gateway); + const next = nextRun ? nextRunView(nextRun, nowIso, t, language) : null; const time = formatUtcHm(nowIso) ?? "--:--"; const day = formatUtcDayLabel(nowIso, language); return ( @@ -79,6 +101,9 @@ export function TopBar({ page, t, language, theme, gateway, nowIso, serverClock, {t("mode.unknown_chip")} ) : null} + {next ? ( + {next.text} + ) : null}