From 253e1e96b94169bae9ba9a03a7ba3765dec56206 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Sat, 14 Nov 2026 18:28:40 +0800 Subject: [PATCH 1/6] fix(console): preserve runtime completeness in desk projections todaySummary now keeps the runtime-daily reader's read_summary decision instead of rebuilding a weaker one, so an accepted projection with an incomplete outer read is never a known-null last run or a covered zero. Legacy bodies without read_summary keep their reconstruction. Desk runs-today keeps read_failed as its own outcome. A missing weekend record is no longer verified-empty tape coverage. Tape and ribbon freshness keep the stored observation of every record that was read and never substitute the request time. When every displayed account fact is stale, Overview freshness keeps their age. The ribbon's next_run is now an opt-in schedule projection through the existing reader: known only when every account has a future next_due_at, partial for the earliest observed candidate, unknown otherwise. Expected and pending counts stay null. No additional storage reads. Co-Authored-By: Claude Opus 5.5 --- docs/desk-n1-read-projection-contract.md | 58 ++++++- tests/desk_n1_projection_validation.mjs | 149 +++++++++++++++++- tests/runtime_today_incomplete_validation.mjs | 64 +++++++- .../application/console_api/operations.js | 28 +++- .../application/desk/overview.js | 21 ++- .../application/desk/ribbon.js | 75 ++++++++- web/strategy-switch-console/worker.js | 3 +- 7 files changed, 373 insertions(+), 25 deletions(-) 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/desk_n1_projection_validation.mjs b/tests/desk_n1_projection_validation.mjs index 53f84a2b..b766ebc4 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,151 @@ 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"]); + + // 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")], + }; + 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"); + 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..dd3d7efa 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,65 @@ 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'); + +console.log('runtime today incomplete: coverage, read failures, unknown, execution priority, reader completeness and schedule opt-in 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..dfed8c6c 100644 --- a/web/strategy-switch-console/application/console_api/operations.js +++ b/web/strategy-switch-console/application/console_api/operations.js @@ -14,17 +14,36 @@ 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) }; +} +/** + * `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 +79,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 +92,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" }; diff --git a/web/strategy-switch-console/application/desk/overview.js b/web/strategy-switch-console/application/desk/overview.js index ea592cf3..80049bed 100644 --- a/web/strategy-switch-console/application/desk/overview.js +++ b/web/strategy-switch-console/application/desk/overview.js @@ -126,14 +126,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 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 +230,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; diff --git a/web/strategy-switch-console/application/desk/ribbon.js b/web/strategy-switch-console/application/desk/ribbon.js index 9337c517..d07f511f 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; } @@ -126,7 +129,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()[0] ?? null; let tape; if (included === total && !undated && !truncated) { const cover = coverage(value.length ? "complete" : "verified_empty", included, total); @@ -139,6 +143,52 @@ 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])); + } + candidates.sort((a, b) => a.due_at.localeCompare(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()[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 +200,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 +227,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/worker.js b/web/strategy-switch-console/worker.js index bbe906c5..d2c6690a 100644 --- a/web/strategy-switch-console/worker.js +++ b/web/strategy-switch-console/worker.js @@ -808,7 +808,8 @@ function deskN1Deps() { // The stored-facts loader issues only the allowlisted account_facts_list read. factsStored: (e, options, bindings) => loadStoredAccountFactsMap(e, options, bindings), // One stored runtime-daily summary for a New York business date (16:00Z is inside it). - runtimeDay: (req, e, accounts, date) => todaySummary(req, e, dailyDeps, accounts, Date.parse(`${date}T16:00:00Z`)), + // `options.schedule` is the ribbon's explicit opt-in; the same single read is made. + runtimeDay: (req, e, accounts, date, options) => todaySummary(req, e, dailyDeps, accounts, Date.parse(`${date}T16:00:00Z`), options), }; } From d27c484a9ab254f02c430696be26ea3d1c09231b Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Sat, 14 Nov 2026 18:28:40 +0800 Subject: [PATCH 2/6] fix(console): qualify desk signals, stale nav, performance and next run Recent Signals shows the newest five runs, distinguishes a loading ribbon from an empty one and keeps partial or stale qualification on a populated list. A stale NAV is shown with its stale label and age. Performance renders the requested window, first stored date, return basis per target and truncation note even without a curve. The live N1 path no longer calls the benchmark route, which is outside the approved sources; fixture pages keep their synthetic comparison. The shared top bar shows the ribbon next_run in its known, stale, partial and unknown states, treats a past time as unknown and hides it behind the access lock. The Logs next-run copy now reads "earliest observed scheduled run". v2_asset_map.js is regenerated by the build. Co-Authored-By: Claude Opus 5.5 --- tests/console_desk_n1_validation.mjs | 137 +++++++++++++++++- .../frontend/src/desk/DeskApp.tsx | 2 + .../frontend/src/desk/i18n/en.ts | 16 +- .../frontend/src/desk/i18n/zh-CN.ts | 16 +- .../frontend/src/desk/pages/OverviewPage.tsx | 36 +++-- .../src/desk/pages/PerformancePage.tsx | 128 ++++++++++++---- .../frontend/src/desk/shell/DeskShell.tsx | 8 +- .../frontend/src/desk/shell/TopBar.tsx | 27 +++- web/strategy-switch-console/v2_asset_map.js | 42 +++--- 9 files changed, 340 insertions(+), 72 deletions(-) diff --git a/tests/console_desk_n1_validation.mjs b/tests/console_desk_n1_validation.mjs index 604c687a..5039a083 100644 --- a/tests/console_desk_n1_validation.mjs +++ b/tests/console_desk_n1_validation.mjs @@ -159,7 +159,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 +173,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 +288,138 @@ 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`)); +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: 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/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}