From 7b1070f86d1911f7b693724ce580c944dd751e61 Mon Sep 17 00:00:00 2001 From: jesse-ux Date: Thu, 22 Oct 2026 03:55:47 +0800 Subject: [PATCH] =?UTF-8?q?feat(chart):=20=E5=A4=A7=E8=BF=90=E5=8F=B3?= =?UTF-8?q?=E6=A0=8F=E5=9C=A8=20Chara=20=E4=B8=8E=20Yogini=20=E4=B9=8B?= =?UTF-8?q?=E9=97=B4=E5=88=87=E6=8D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 点开大运只取 Chara。切到 Yogini 才去取那一轨。失败留在右栏,再点一次会再请求。 Co-Authored-By: Grok (agent loop) Co-authored-by: Cursor --- .../components/chart-page/chart-dasha-tab.tsx | 66 ++++++++++++++--- .../components/chart-page/chart-page-view.tsx | 19 ++++- frontend/tests/chart-page-view.test.tsx | 72 ++++++++++++++++++- 3 files changed, 144 insertions(+), 13 deletions(-) diff --git a/frontend/src/components/chart-page/chart-dasha-tab.tsx b/frontend/src/components/chart-page/chart-dasha-tab.tsx index 47f7a8c9..06b815f8 100644 --- a/frontend/src/components/chart-page/chart-dasha-tab.tsx +++ b/frontend/src/components/chart-page/chart-dasha-tab.tsx @@ -1,10 +1,21 @@ +"use client"; + +import { useState, type ReactNode } from "react"; + import { chartLayerFailureCopy } from "@/lib/chart-view-failure"; import { OrbitWaiting } from "@/components/orbit-waiting"; import type { ChartViewFailureStatus, ChartViewOk } from "@/lib/chart-view-contract"; +import type { ChartViewLayer } from "@/lib/chart-view-engine"; import { CHARA_ZERO_YEAR_LABEL, CHARA_ZERO_YEAR_NOTE, CHART_VIEW_COPY } from "@/lib/chart-view-labels"; type DashaTrack = ChartViewOk["dasha"]["vimshottari"]; type DashaPeriod = DashaTrack["periods"][number]; +type DashaSystemId = "chara" | "yogini"; + +const DASHA_SYSTEMS: ReadonlyArray<{ id: DashaSystemId; label: string }> = [ + { id: "chara", label: "Chara" }, + { id: "yogini", label: "Yogini" }, +]; function formatYearMonth(iso: string): string { return `${iso.slice(0, 4)}.${iso.slice(5, 7)}`; @@ -90,9 +101,11 @@ function Track({ antardashaNote, periods, asOf, -}: DashaTrack & { asOf: string }) { + leading, +}: DashaTrack & { asOf: string; leading?: ReactNode }) { return (
+ {leading}

{title}

{method && method !== title ?

{method}

: null} @@ -141,26 +154,61 @@ function Track({ export function ChartDashaTab({ view, - charaPending = false, - charaFailure, + pendingLayers, + layerFailures, + system: controlledSystem, + initialSystem = "chara", + onSelectSystem, }: { view: ChartViewOk; - charaPending?: boolean; - charaFailure?: ChartViewFailureStatus; + pendingLayers?: ReadonlySet; + layerFailures?: ReadonlyMap; + system?: DashaSystemId; + initialSystem?: DashaSystemId; + onSelectSystem?: (system: DashaSystemId) => void; }) { + const [uncontrolledSystem, setUncontrolledSystem] = useState(initialSystem); + const system = controlledSystem ?? uncontrolledSystem; const asOf = view.dasha.asOf; + const pending = pendingLayers?.has(system) ?? false; + const failure = layerFailures?.get(system); + const track = view.dasha[system]; + + function selectSystem(id: DashaSystemId) { + if (controlledSystem === undefined) setUncontrolledSystem(id); + onSelectSystem?.(id); + } + + const switcher = ( +
+ {DASHA_SYSTEMS.map((item) => ( + + ))} +
+ ); + return (
- {charaPending || charaFailure ? ( + {pending || failure ? (
- {charaPending + {switcher} + {pending ? - :

{chartLayerFailureCopy(charaFailure!)}

} + :

{chartLayerFailureCopy(failure!)}

}
) : ( - + )}

{view.dasha.dualTrackNote}

diff --git a/frontend/src/components/chart-page/chart-page-view.tsx b/frontend/src/components/chart-page/chart-page-view.tsx index e399da3f..81668b6d 100644 --- a/frontend/src/components/chart-page/chart-page-view.tsx +++ b/frontend/src/components/chart-page/chart-page-view.tsx @@ -95,6 +95,7 @@ export function ChartPageView({ const [tab, setTab] = useState(initialTab); const [vargaId, setVargaId] = useState("D1"); const [editingBirth, setEditingBirth] = useState(false); + const [dashaSystem, setDashaSystem] = useState<"chara" | "yogini">("chara"); function selectTab(id: ChartViewTabId) { setTab(id); @@ -102,6 +103,11 @@ export function ChartPageView({ if (id === "western") onNeedLayer?.("western"); } + function selectDashaSystem(id: "chara" | "yogini") { + setDashaSystem(id); + onNeedLayer?.(id); + } + function selectVarga(id: ChartTypeId) { setVargaId(id); const layer = chartTypeLayer(id); @@ -126,7 +132,10 @@ export function ChartPageView({ async function afterBirthSaved() { await onBirthSaved?.(); - if (tab === "dasha") onNeedLayer?.("chara"); + if (tab === "dasha") { + onNeedLayer?.("chara"); + if (dashaSystem === "yogini") onNeedLayer?.("yogini"); + } if (tab === "western") onNeedLayer?.("western"); if (tab === "vedic") { const layer = chartTypeLayer(vargaId); @@ -185,7 +194,13 @@ export function ChartPageView({ ) : null} {tab === "basics" ? : null} {tab === "dasha" ? ( - + ) : null} {tab === "western" ? ( pendingLayers?.has("western") ? ( diff --git a/frontend/tests/chart-page-view.test.tsx b/frontend/tests/chart-page-view.test.tsx index 54a9a758..09a32d9a 100644 --- a/frontend/tests/chart-page-view.test.tsx +++ b/frontend/tests/chart-page-view.test.tsx @@ -11,14 +11,15 @@ import { EMPTY_NORTH_INDIAN_CHART, VedicChartSvg } from "../src/components/perso import { ChartPageView } from "../src/components/chart-page/chart-page-view.tsx"; import { SidebarProvider } from "../src/components/ui/sidebar.tsx"; import { assembleChartView } from "../src/lib/chart-view-load.ts"; -import { CHART_VIEW_TABS, type ChartViewOk } from "../src/lib/chart-view-contract.ts"; -import { CHART_VIEW_LAYERS } from "../src/lib/chart-view-engine.ts"; +import { CHART_VIEW_TABS, type ChartViewFailureStatus, type ChartViewOk } from "../src/lib/chart-view-contract.ts"; +import { CHART_VIEW_LAYERS, type ChartViewLayer } from "../src/lib/chart-view-engine.ts"; import { chartViewFailureResponse } from "../src/lib/chart-view-failure.ts"; import { CHART_VIEW_COPY } from "../src/lib/chart-view-labels.ts"; import { CHART_SETTINGS_HREF } from "../src/lib/settings-url.ts"; import { westernLabelBox } from "../src/components/chart-page/western-wheel-layout.ts"; import { cssDeclarations } from "./css-contract-test-support.ts"; import { goldenSky } from "./birth-sky-test-support.ts"; +import { createClientLifecycleHarness } from "./react-client-lifecycle-test-support.ts"; Object.assign(globalThis, { React }); @@ -551,3 +552,70 @@ test("birth-sky prefetch follows the current subject and the dialog is a lazy ch assert.match(cover, /await import\("\.\/render\.ts"\)/); assert.doesNotMatch(chartView, /birth-sky-dialog|lib\/birth-sky\/(draw|render)/); }); + +function buttonByLabel(h: ReturnType, label: string) { + const node = h.elements().find((item) => item.tagName === "BUTTON" && item.text === label); + assert.ok(node, `missing button ${label}`); + return node; +} + +test("opening dasha requests only chara, and Yogini is a separate request", async () => { + const view = await okView(); + const calls: string[] = []; + const h = createClientLifecycleHarness(); + try { + await h.render(React.createElement(SidebarProvider, null, React.createElement(ChartPageView, { + view, + onNeedLayer: (layer: string) => { calls.push(layer); }, + }))); + await h.event(buttonByLabel(h, "大运")); + assert.deepEqual(calls, ["chara"]); + assert.match(h.container.text, /Chara Dasha(kn_rao 变体)/); + assert.match(h.container.text, /Vimshottari/); + await h.event(buttonByLabel(h, "Yogini")); + assert.deepEqual(calls, ["chara", "yogini"]); + } finally { + await h.close(); + } +}); + +test("a yogini failure stays in the right column, and clicking Yogini again asks again", async () => { + const view = await okView(); + const calls: string[] = []; + const h = createClientLifecycleHarness(); + try { + await h.render(React.createElement(SidebarProvider, null, React.createElement(ChartPageView, { + view, + layerFailures: new Map([["yogini", "engine_busy"]]), + onNeedLayer: (layer: string) => { calls.push(layer); }, + }))); + await h.event(buttonByLabel(h, "大运")); + await h.event(buttonByLabel(h, "Yogini")); + assert.match(h.container.text, /Vimshottari/); + assert.match(h.container.text, new RegExp(CHART_VIEW_COPY.busy)); + assert.doesNotMatch(h.container.text, /小运未经外部软件核对。/); + assert.equal(h.elements().some((node) => node.hasAttribute("data-skeleton")), false); + await h.event(buttonByLabel(h, "Yogini")); + assert.deepEqual(calls, ["chara", "yogini", "yogini"]); + } finally { + await h.close(); + } +}); + +test("yogini pending reuses the orbit sentence and does not draw a skeleton", async () => { + const view = await okView(); + const h = createClientLifecycleHarness(); + try { + await h.render(React.createElement(SidebarProvider, null, React.createElement(ChartPageView, { + view, + pendingLayers: new Set(["yogini"]), + }))); + await h.event(buttonByLabel(h, "大运")); + await h.event(buttonByLabel(h, "Yogini")); + assert.match(h.container.text, /这一栏还没拿到/); + assert.match(h.container.text, /Vimshottari/); + assert.equal(h.elements().some((node) => node.hasAttribute("data-skeleton")), false); + } finally { + await h.close(); + } +});