fix(chart): keep zero-year chara and ephemeris dates apart

A zero-year Chara period is shown as 0 years instead of failing the whole chart. Switching ephemeris dates drops the previous day immediately and can be retried. Missing birth data links to chart settings.
This commit is contained in:
jesse-ux
2026-09-25 02:10:48 +08:00
parent 82c8aeb06a
commit 19ea0f06c1
25 changed files with 744 additions and 60 deletions
+7 -1
View File
@@ -918,6 +918,8 @@ Three product rules for assistant markdown in `.message-markdown` (BUG-962 / 963
「传统象征」是词条,不得写成运势。页面上不另写「词条式释义,不是对你个人的判断」——行星卡本身已经是度数、星宿、D9,没有判断句。大运 Tab 底部固定:「只有两条同时指向同一段时间才算证据,单轨命中要降一级来看。」Chara 的正式名称是 **Chara Dasha(kn_rao 变体)**,不要写成 Narayana。
0 年大运照实显示,不改引擎。某段 Chara 的 `duration_years` 为 0 时,这一段标注「0 年」,起止落在同一天,不占时间轴,也不展开子运;子运区写「本段时长为 0,不展开」。前一段的结束日就是下一段的开始日。
### 等待态
2026-09-24 产品决策有限推翻 09-18 方案一(BUG-716 / 966):`SecondaryPageShell` 标题、note 槽与撑满内容区不变,主盘未到时正常渲染 body 和禁用的 Tab 行;仅 D1、非 D1 分盘与西洋盘位置显示既有 SVG 空模型的骨架与原等待句。骨架无星体、度数、星座标签,几何不得复制;容器 `aria-busy=true`,装饰盘对读屏隐藏。中性 token、opacity 0.35 ↔ 0.6 / 2.4s ease-in-out,减弱动态效果时静止 0.5;不旋转、不扫光。大运、七政表格仍为静态句,首页、侧栏、星历、报告不适用此例外。首次请求(含预取)和按需层均有 25 秒客户端期限;失败移除骨架,再点该 Tab/chip 清失败并重试。401 先显式呈现登录句再跳转。同一会话再次进入直接用模块缓存,不重放等待句。出生资料保存成功后,缓存按账户和资料指纹换键,旧盘不得留在页上冒充新的日期或时间。
@@ -936,6 +938,10 @@ Three product rules for assistant markdown in `.message-markdown` (BUG-962 / 963
| 已采用的时间缺时区 | 已采用的出生时间缺了排盘要用的时区,这张盘先不能算。我们已经记录下来了。 |
| 时区没有对上 | 出生地的时区没有对上,这张盘先不能算。我们已经记录下来了。 |
| 结果格式对不上 | 星盘结果的格式对不上,我们已经记录下来了。 |
| 还没有出生资料 | 标题「还没有星盘资料」+ 原句 + 主按钮「去填写出生资料」 |
| 资料读不出来 / 已采用时间缺时区 / 时区没有对上 | 各自的原句 + 同一个「去填写出生资料」;不加「还没有星盘资料」 |
资料类这四句的按钮链到 `/?settings=chart`,打开设置里的星盘资料。标题用 `--type-title-md`,整块居中,按钮是主按钮。引擎忙、超时和坏结果不加这个按钮。解析 `?settings=` 只在 `settings-url.ts`;`?settings=billing` 仍只开账户与点数。
## 16. 星历页
@@ -944,7 +950,7 @@ Three product rules for assistant markdown in `.message-markdown` (BUG-962 / 963
- **结构:** 日期条;今日五要素;当日行运;未来九十天。顺序固定。页尾不另写定性句。
- **日期条:** 居中的 `‹ 日期 ›`——日期是主语,两枚 44px 箭头按钮是它的把手。箭头字形 `aria-hidden`,可访问名仍是「前一天」「后一天」,读屏不丢方向。**「今天」只在非今日时出现**,作为日期下方一枚文字按钮;在今日时它整个不渲染,而不是渲染成一枚禁用按钮——灰掉的按钮仍要被读、仍要被 Tab 跳过。原本三个并排等宽按钮把「前一天 / 今天 / 后一天」读成三个平级动作,日期本身反而被挤出了这一组。
- **三段同一套卡片:** 五要素 / 行运 / 未来九十天三个 `.ephemeris-section` 各是一张卡(1px 描边 + `--radius-lg` + `--color-canvas`),卡内是行。原本三段的**内容**各自画框而**段落**不画框,于是三段看起来是三种不同的东西。框归段落,段内只有行。
- 切日期立刻换五要素和行运;没有 spinner、骨架或「正在加载」。数据未到时写静态句「这一天的五要素还没拿到。」「这一天的行运还没拿到。」
- 切到未缓存的日期时,日期条和布局留下,五要素、行运立刻换成「这一天的五要素还没拿到。」「这一天的行运还没拿到。」事件区写「换座和停滞还在取。」,这时不用「还没接上」。没有 spinner、骨架或「正在加载」。请求失败时,三段都写「这一天的星历没取到,点日期再试一次。」再点这一天的日期,或在失败时点左右箭头,就重试这一天;日历仍可改去别的日期。旧日期的响应,包括进页时刷新的今天,不得盖住正在看的另一天。
- **五要素:** `dl` 网格保留:桌面五格一行(星期、月相日、月宿含第几足、瑜伽、迦那),格子是段落卡内的浅底块,不再各自描边。767px 以下收成五行,标签左、值右。Yoga / Karana 的吉凶只照搬引擎标签(吉 / 中 / 凶),不写成建议。卡片下固定一行:「这一段按 Lahiri 岁差算,和星盘页用的岁差不是同一套。」行运段不加这句。
- **当日行运:** 九曜的星座、度数、相对本命宫位、顺逆。桌面表格,767px 以下每行改成带标签的堆叠。没有本命上升时,相对本命列为空,并写「还没有本命上升,相对本命这一列空着。」
- **未来九十天:** 按日期升序只写事实(「某星 进入 某座」「某星 停滞转顺 · 度数」)。`/api/ephemeris_events` 未上线时整段静态说明:「换座和停滞还没接上。这一页其余部分不受影响。」不得让整页失败。
+10
View File
@@ -18,6 +18,16 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、
首页开场语只有一行,不带追问句,不用星星、月亮、陪伴一类比喻。
## 星盘空状态、0 年大运、星历切日期
这三句只描述眼前缺的数据,不解释运势,也不许写成「正在加载」:
- 星历事件还在取:「换座和停滞还在取。」
- 星历这一天没取到:「这一天的星历没取到,点日期再试一次。」
- 0 年大运不展开:「本段时长为 0,不展开。」时长本身写「0 年」。
没有出生资料时,标题是「还没有星盘资料」,按钮是「去填写出生资料」。资料读不出来、已采用时间缺时区、时区没有对上,正文仍用各自的原句,只共用这颗按钮。
## 报告分块导出与删除
- 列表已完成行只留「查看报告」。阅读页操作条只留「返回报告中心」与导出图标;图标读屏名「导出报告」,tooltip「导出」。不再显示「下载原始附录」或「打印」入口文案;浏览器 Ctrl+P 排版仍保留。
+2 -1
View File
@@ -499,7 +499,8 @@ export default function Home() {
const applySettingsFromSearch = (search: string) => {
const query = parseSettingsQuery(search);
if (!query) return;
openAccountDialog("billing", { source: query.source ?? undefined, plan: query.plan ?? undefined, tab: query.tab ?? undefined });
if (query.pane === "chart") openAccountDialog("chart-library");
else openAccountDialog("billing", { source: query.source ?? undefined, plan: query.plan ?? undefined, tab: query.tab ?? undefined });
const next = stripSettingsQuery(search, window.location.pathname);
if (`${window.location.pathname}${window.location.search}` !== next) window.history.replaceState(null, "", next);
};
+44 -9
View File
@@ -1,5 +1,6 @@
import { NextResponse } from "next/server";
import { chartViewEngineCacheKey, readChartViewEngineCache, writeChartViewEngineCache } from "@/lib/chart-view-engine";
import { calendarDateInTimeZone } from "@/lib/daily-starlanguage";
import { ephemerisIsoDateSchema, ephemerisOkResponseSchema, type EphemerisOkResponse } from "@/lib/ephemeris-contract";
import {
@@ -23,18 +24,54 @@ const engineTimeoutMs = 20_000;
type Profile = ReturnType<typeof globalBirthProfileFromAccountRow>;
type BirthPayload = NonNullable<Awaited<ReturnType<typeof dailyProfilePayload>>>;
async function fetchEngine(path: string, body: Record<string, unknown>) {
function engineSignal(request: Request): AbortSignal {
return AbortSignal.any([AbortSignal.timeout(engineTimeoutMs), request.signal]);
}
async function fetchEngine(path: string, body: Record<string, unknown>, signal: AbortSignal) {
const response = await fetch(`${jyotishApiBase}${path}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
cache: "no-store",
signal: AbortSignal.timeout(engineTimeoutMs),
signal,
});
if (!response.ok) throw new Error(`jyotish_api_${response.status}`);
return await response.json() as unknown;
}
function natalCacheKey(userId: string, birth: BirthPayload): string {
const month = String(birth.month).padStart(2, "0");
const day = String(birth.day).padStart(2, "0");
const hour = String(birth.hour).padStart(2, "0");
const minute = String(birth.minute).padStart(2, "0");
return chartViewEngineCacheKey({
userId,
date: `${birth.year}-${month}-${day}`,
time: `${hour}:${minute}`,
latitude: birth.lat,
longitude: birth.lon,
timezoneOffset: birth.tz,
ayanamsa: birth.ayanamsa,
nodeMode: birth.node_mode,
path: "/api/ephemeris/natal-chart",
});
}
async function fetchNatalChart(userId: string, birth: BirthPayload, signal: AbortSignal): Promise<unknown> {
const key = natalCacheKey(userId, birth);
const hit = readChartViewEngineCache(key);
if (hit) return hit;
const payload = await fetchEngine("/api/chart", {
...birth,
skip_vedastro_main_entry_overview: true,
}, signal);
if (!signal.aborted && payload && typeof payload === "object" && !Array.isArray(payload)) {
writeChartViewEngineCache(key, payload as Record<string, unknown>);
}
return payload;
}
function requestedDate(request: Request, timezoneId?: string | null): string {
const raw = new URL(request.url).searchParams.get("date")?.trim() ?? "";
if (raw && isEphemerisIsoDate(raw) && ephemerisIsoDateSchema.safeParse(raw).success) return raw;
@@ -75,6 +112,7 @@ export async function GET(request: Request) {
const birth = await dailyProfilePayload(profile, date).catch(() => null);
const location = locationBody(profile);
const signal = engineSignal(request);
const panchangaBody: Record<string, unknown> = { start_date: date, end_date: date };
if (location) {
panchangaBody.lat = location.lat;
@@ -82,23 +120,20 @@ export async function GET(request: Request) {
panchangaBody.tz = location.tz;
}
const panchangaPromise = fetchEngine("/api/panchanga_range", panchangaBody).catch((error: unknown) => {
const panchangaPromise = fetchEngine("/api/panchanga_range", panchangaBody, signal).catch((error: unknown) => {
console.warn("ephemeris_panchanga_unavailable", error);
return null;
});
const natalPromise = birth
? fetchEngine("/api/chart", {
...birth,
skip_vedastro_main_entry_overview: true,
}).catch((error: unknown) => {
? fetchNatalChart(user.id, birth, signal).catch((error: unknown) => {
console.warn("ephemeris_natal_chart_unavailable", error);
return null;
})
: Promise.resolve(null);
const transitPromise = birth
? fetchEngine("/api/chart", transitChartBody(birth, date)).catch((error: unknown) => {
? fetchEngine("/api/chart", transitChartBody(birth, date), signal).catch((error: unknown) => {
console.warn("ephemeris_transit_chart_unavailable", error);
return null;
})
@@ -109,7 +144,7 @@ export async function GET(request: Request) {
end_date: addDaysIso(date, 90),
ayanamsa: birth?.ayanamsa ?? profile.ayanamsa ?? "raman",
node_mode: "mean",
}).catch((error: unknown) => {
}, signal).catch((error: unknown) => {
console.warn("ephemeris_events_unavailable", error);
return null;
});
+3
View File
@@ -2193,6 +2193,9 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
font-size: var(--type-title-lg);
font-weight: 500;
}
.chart-page-message h2 {
font-size: var(--type-title-md);
}
@media (max-width: 767px) {
/* Replaces the three `*-shell { padding-inline }` rules the standalone pages
@@ -1,6 +1,6 @@
import { chartLayerFailureCopy } from "@/lib/chart-view-failure";
import type { ChartViewFailureStatus, ChartViewOk } from "@/lib/chart-view-contract";
import { CHART_VIEW_COPY } from "@/lib/chart-view-labels";
import { CHARA_ZERO_YEAR_LABEL, CHARA_ZERO_YEAR_NOTE, CHART_VIEW_COPY } from "@/lib/chart-view-labels";
function Track({
title,
@@ -28,9 +28,11 @@ function Track({
>
<p>
<strong>{period.label}</strong>
<span>{period.start} – {period.end}</span>
<span>{period.zeroYear ? CHARA_ZERO_YEAR_LABEL : `${period.start} – ${period.end}`}</span>
</p>
{period.antardashas.length > 0 ? (
{period.zeroYear ? (
<p className="chart-page-note">{CHARA_ZERO_YEAR_NOTE}</p>
) : period.antardashas.length > 0 ? (
<ul>
{period.antardashas.map((antar) => (
<li key={`${antar.lord}-${antar.start}`} className={antar.current ? "is-current" : undefined}>
@@ -12,6 +12,7 @@ import {
} from "@/lib/chart-view-contract";
import type { ChartViewLayer } from "@/lib/chart-view-engine";
import { CHART_VIEW_COPY } from "@/lib/chart-view-labels";
import { CHART_SETTINGS_HREF } from "@/lib/settings-url";
import { chartLayerFailureCopy } from "@/lib/chart-view-failure";
import { ChartPageSkeleton } from "./chart-page-skeleton";
import { ChartBasicsTab } from "./chart-basics-tab";
@@ -36,6 +37,19 @@ function WaitingNote({ copy }: { copy: string }) {
return <p className="chart-page-note">{copy}</p>;
}
const PROFILE_ENTRY_REASONS = new Set([
"profile_incomplete",
"profile_query_error",
"adopted_calculation_incomplete",
"timezone_resolver_failure",
]);
function showsProfileEntry(view: ChartViewResponse): boolean {
if (view.status === "ok") return false;
if (view.status === "birth_profile_incomplete") return true;
return view.reason != null && PROFILE_ENTRY_REASONS.has(view.reason);
}
export function ChartPageView({
view,
pendingLayers,
@@ -75,7 +89,11 @@ export function ChartPageView({
>
{view != null && view.status !== "ok" ? (
<section className="chart-page-message">
{view.status === "birth_profile_incomplete" ? <h2>{CHART_VIEW_COPY.profileEntryTitle}</h2> : null}
<p>{view.message}</p>
{showsProfileEntry(view) ? (
<a className="button-primary" href={CHART_SETTINGS_HREF}>{CHART_VIEW_COPY.profileEntryAction}</a>
) : null}
</section>
) : (
<div className="chart-page-body">
@@ -31,9 +31,13 @@ import {
type PageState =
| { phase: "ready"; payload: EphemerisOkResponse }
| { phase: "pending" }
| { phase: "failed" }
| { phase: "unauthorized" }
| { phase: "open" };
type EphemerisDataState = "ready" | "pending" | "failed";
function snapshotToState(): PageState {
const cached = peekEphemerisPage();
if (cached?.kind === "unauthorized") return { phase: "unauthorized" };
@@ -43,32 +47,41 @@ function snapshotToState(): PageState {
export function EphemerisPage() {
const cachedFirst = peekEphemerisPage();
const [date, setDate] = useState<string | null>(
cachedFirst?.kind === "ready" ? cachedFirst.payload.date : null,
const initialDate = cachedFirst?.kind === "ready" ? cachedFirst.payload.date : null;
const [date, setDate] = useState<string | null>(initialDate);
const [attempt, setAttempt] = useState(0);
const [knownToday, setKnownToday] = useState<string | null>(
cachedFirst?.kind === "ready" ? cachedFirst.payload.today : null,
);
const [state, setState] = useState<PageState>(snapshotToState);
const selectedDateRef = useRef<string | null>(initialDate);
const cache = useRef(new Map<string, EphemerisOkResponse>(
cachedFirst?.kind === "ready" ? [[cachedFirst.payload.date, cachedFirst.payload]] : [],
));
const load = useCallback(async (nextDate: string | null, signal: AbortSignal) => {
const query = nextDate ? `?date=${encodeURIComponent(nextDate)}` : "";
const response = await fetch(`/api/ephemeris${query}`, {
const load = useCallback(async (nextDate: string, signal: AbortSignal) => {
const response = await fetch(`/api/ephemeris?date=${encodeURIComponent(nextDate)}`, {
credentials: "same-origin",
headers: { Accept: "application/json" },
signal,
});
if (signal.aborted) return;
if (signal.aborted || selectedDateRef.current !== nextDate) return;
if (response.status === 401) {
writeEphemerisPage({ kind: "unauthorized" });
setState({ phase: "unauthorized" });
return;
}
const json: unknown = await response.json().catch(() => null);
if (signal.aborted || selectedDateRef.current !== nextDate) return;
const payload = parseEphemerisOkResponse(json);
if (!payload) return;
if (!payload || payload.date !== nextDate) {
setState({ phase: "failed" });
return;
}
cache.current.set(payload.date, payload);
setKnownToday(payload.today);
writeEphemerisPage({ kind: "ready", payload });
selectedDateRef.current = payload.date;
setDate(payload.date);
setState({ phase: "ready", payload });
}, []);
@@ -83,11 +96,12 @@ export function EphemerisPage() {
return;
}
cache.current.set(snapshot.payload.date, snapshot.payload);
setDate((current) => current ?? snapshot.payload.date);
setState((current) => {
if (current.phase === "ready" && current.payload.date !== snapshot.payload.date) return current;
return { phase: "ready", payload: snapshot.payload };
});
setKnownToday(snapshot.payload.today);
const viewing = selectedDateRef.current;
if (viewing && viewing !== snapshot.payload.date) return;
selectedDateRef.current = snapshot.payload.date;
setDate(snapshot.payload.date);
setState({ phase: "ready", payload: snapshot.payload });
})
.catch((error: unknown) => {
if (cancelled) return;
@@ -101,12 +115,14 @@ export function EphemerisPage() {
useEffect(() => {
if (!date || cache.current.has(date)) return;
const controller = new AbortController();
void load(date, controller.signal).catch((error: unknown) => {
if (controller.signal.aborted) return;
const requested = date;
void load(requested, controller.signal).catch((error: unknown) => {
if (controller.signal.aborted || selectedDateRef.current !== requested) return;
console.warn("ephemeris_page_fetch_failed", error);
setState({ phase: "failed" });
});
return () => controller.abort();
}, [date, load]);
}, [date, attempt, load]);
if (state.phase === "unauthorized") {
return (
@@ -120,17 +136,31 @@ export function EphemerisPage() {
);
}
const payload = state.phase === "ready" ? state.payload : null;
if (!payload) {
const readyPayload = state.phase === "ready" && date && state.payload.date === date
? state.payload
: null;
if (!date) {
return <SecondaryPageShell title={EPHEMERIS_COPY.title} waiting={EPHEMERIS_WAITING_COPY} />;
}
const activeDate = date ?? payload.date ?? payload.today ?? "";
const today = payload.today ?? activeDate;
const activeDate = date;
const today = readyPayload?.today ?? knownToday ?? activeDate;
const dataState: EphemerisDataState = state.phase === "failed"
? "failed"
: readyPayload
? "ready"
: "pending";
function changeDate(next: string) {
selectedDateRef.current = next;
setDate(next);
const cached = cache.current.get(next);
if (cached) setState({ phase: "ready", payload: cached });
if (cached) {
setKnownToday(cached.today);
setState({ phase: "ready", payload: cached });
return;
}
setState({ phase: "pending" });
setAttempt((current) => current + 1);
}
function askAboutDate() {
@@ -143,11 +173,13 @@ export function EphemerisPage() {
<EphemerisView
date={activeDate}
today={today}
payload={payload}
onPrevious={() => activeDate && changeDate(shiftEphemerisDate(activeDate, -1))}
onNext={() => activeDate && changeDate(shiftEphemerisDate(activeDate, 1))}
payload={readyPayload}
dataState={dataState}
onPrevious={() => state.phase === "failed" ? changeDate(activeDate) : changeDate(shiftEphemerisDate(activeDate, -1))}
onNext={() => state.phase === "failed" ? changeDate(activeDate) : changeDate(shiftEphemerisDate(activeDate, 1))}
onToday={() => today && changeDate(today)}
onSelectDate={(iso) => changeDate(iso)}
onRetry={() => changeDate(activeDate)}
onAsk={askAboutDate}
/>
);
@@ -157,17 +189,22 @@ export function EphemerisView(props: {
readonly date: string;
readonly today: string;
readonly payload: EphemerisOkResponse | null;
readonly dataState?: EphemerisDataState;
readonly onPrevious: () => void;
readonly onNext: () => void;
readonly onToday: () => void;
readonly onSelectDate: (iso: string) => void;
readonly onRetry?: () => void;
readonly onAsk: () => void;
}) {
const factors = props.payload?.panchanga.available ? props.payload.panchanga.factors : [];
const bodies = props.payload?.transits.available ? props.payload.transits.bodies : [];
const natalMissing = props.payload?.transits.natalRelativeNote === "missing_natal";
const eventsUnavailable = !props.payload || props.payload.events.status === "unavailable";
const events = props.payload?.events.status === "ok" ? props.payload.events.items : [];
const dataState = props.dataState ?? (props.payload ? "ready" : "pending");
const failed = dataState === "failed";
const pending = dataState === "pending";
const factors = !failed && !pending && props.payload?.panchanga.available ? props.payload.panchanga.factors : [];
const bodies = !failed && !pending && props.payload?.transits.available ? props.payload.transits.bodies : [];
const natalMissing = !failed && !pending && props.payload?.transits.natalRelativeNote === "missing_natal";
const eventsUnavailable = !pending && !failed && (!props.payload || props.payload.events.status === "unavailable");
const events = !pending && !failed && props.payload?.events.status === "ok" ? props.payload.events.items : [];
const isToday = Boolean(props.date) && props.date === props.today;
const [pickerOpen, setPickerOpen] = useState(false);
const selectedDay = props.date ? parseEphemerisDate(props.date) : undefined;
@@ -210,6 +247,9 @@ export function EphemerisView(props: {
variant="ghost"
className="ephemeris-date-label"
aria-label={`${props.date ? formatEphemerisDate(props.date) : ""},选择日期`}
onClick={() => {
if (failed) props.onRetry?.();
}}
/>}
>
<CalendarIcon aria-hidden="true" />
@@ -257,7 +297,9 @@ export function EphemerisView(props: {
<section className="ephemeris-section" aria-labelledby="ephemeris-panchanga-heading">
<h2 id="ephemeris-panchanga-heading">{EPHEMERIS_COPY.panchangaHeading}</h2>
{factors.length === 5 ? (
{failed ? (
<p className="ephemeris-static">{EPHEMERIS_COPY.dateFailed}</p>
) : factors.length === 5 ? (
<dl className="ephemeris-panchanga">
{factors.map((factor) => (
<div className="ephemeris-panchanga-item" key={factor.key}>
@@ -278,7 +320,9 @@ export function EphemerisView(props: {
<section className="ephemeris-section" aria-labelledby="ephemeris-transits-heading">
<h2 id="ephemeris-transits-heading">{EPHEMERIS_COPY.transitsHeading}</h2>
{natalMissing ? <p className="ephemeris-note">{EPHEMERIS_COPY.natalMissing}</p> : null}
{bodies.length > 0 ? (
{failed ? (
<p className="ephemeris-static">{EPHEMERIS_COPY.dateFailed}</p>
) : bodies.length > 0 ? (
<div className="ephemeris-transits">
<div className="ephemeris-transits-head" aria-hidden="true">
<span>{EPHEMERIS_COPY.planet}</span>
@@ -304,7 +348,11 @@ export function EphemerisView(props: {
<section className="ephemeris-section" aria-labelledby="ephemeris-events-heading">
<h2 id="ephemeris-events-heading">{EPHEMERIS_COPY.eventsHeading}</h2>
{eventsUnavailable ? (
{failed ? (
<p className="ephemeris-static">{EPHEMERIS_COPY.dateFailed}</p>
) : pending ? (
<p className="ephemeris-static">{EPHEMERIS_COPY.eventsPending}</p>
) : eventsUnavailable ? (
<p className="ephemeris-static">{EPHEMERIS_COPY.eventsUnavailable}</p>
) : (
<ol className="ephemeris-events">
+3
View File
@@ -86,6 +86,9 @@ export const chartViewPeriodSchema = z.object({
export const chartViewMahadashaSchema = chartViewPeriodSchema.extend({
sign: z.string().nullable(),
antardashas: z.array(chartViewPeriodSchema),
// Present only for a Chara mahadasha whose engine duration is exactly 0.
// It stays on the list, occupies no timeline, and has no antardashas.
zeroYear: z.literal(true).optional(),
});
export const chartViewDashaTrackSchema = z.object({
+6
View File
@@ -140,6 +140,10 @@ export const ANTARDASHA_DERIVED_NOTE = "分运按标准比例从大运边界推
export const CHARA_ANTARDASHA_DATE_NOTE = "分运时长来自引擎的十二等分;起止日期按大运边界推算,不是引擎单独返回的日历字段。";
export const CHARA_ZERO_YEAR_LABEL = "0 年";
export const CHARA_ZERO_YEAR_NOTE = "本段时长为 0,不展开";
export const WESTERN_UNAVAILABLE_COPY = "西洋盘这一栏要等回归黄道端点上线。现在还没有,先空着——不会拿印度盘的度数改写成回归黄道。";
export const QIZHENG_UNAVAILABLE_COPY = "七政四余这一栏要等宿度端点上线。现在还没有,先空着。";
@@ -161,6 +165,8 @@ export const CHART_VIEW_COPY = {
waitingVarga: "这一分盘还没拿到。",
unauthenticated: "请先登录后再看星盘。",
incomplete: "还没有可用来排盘的出生资料。把日期、时间和地点填进星盘资料后,打开就能看见盘。",
profileEntryTitle: "还没有星盘资料",
profileEntryAction: "去填写出生资料",
profileQueryError: "星盘资料这会儿读不出来,我们已经记录下来了。",
adoptedIncomplete: "已采用的出生时间缺了排盘要用的时区,这张盘先不能算。我们已经记录下来了。",
timezoneFailure: "出生地的时区没有对上,这张盘先不能算。我们已经记录下来了。",
+18 -5
View File
@@ -82,11 +82,19 @@ function isoDay(value: string): string {
function addWeightedSpan(startIso: string, endIso: string, index: number, weights: readonly number[]): { start: string; end: string } {
const start = Date.parse(`${startIso}T00:00:00Z`);
const end = Date.parse(`${endIso}T00:00:00Z`);
const total = weights.reduce((sum, weight) => sum + weight, 0);
const before = weights.slice(0, index).reduce((sum, weight) => sum + weight, 0);
const total = weights.reduce((sum, weight) => sum + (Number.isFinite(weight) ? weight : 0), 0);
// A zero-year Chara mahadasha gives every antardasha weight 0. `??` does not
// replace 0, so total is 0 and `before / total` is NaN. Never call
// `Date#toISOString` on that — it throws RangeError and fails the whole chart.
if (!Number.isFinite(start) || !Number.isFinite(end) || !(total > 0)) {
return { start: startIso, end: startIso };
}
const before = weights.slice(0, index).reduce((sum, weight) => sum + (Number.isFinite(weight) ? weight : 0), 0);
const weight = Number.isFinite(weights[index]) ? weights[index]! : 0;
const span = end - start;
const from = start + span * (before / total);
const to = index === weights.length - 1 ? end : start + span * ((before + weights[index]!) / total);
const to = index === weights.length - 1 ? end : start + span * ((before + weight) / total);
if (!Number.isFinite(from) || !Number.isFinite(to)) return { start: startIso, end: startIso };
return { start: new Date(from).toISOString().slice(0, 10), end: new Date(to).toISOString().slice(0, 10) };
}
@@ -390,12 +398,16 @@ function charaTrack(charaPacket: Record<string, unknown> | null, birthDate: stri
if (!row) return [];
const sign = text(row.sign);
const lord = text(row.lord) ?? sign ?? "—";
const duration = finite(row.duration_years) ?? 1;
const rawDuration = finite(row.duration_years);
const duration = rawDuration === null ? 1 : rawDuration;
const zeroYear = duration === 0;
const start = cursor;
const end = addYears(start, duration);
cursor = end;
const rawAntar = Array.isArray(row.antardashas) ? row.antardashas : [];
const antardashas = rawAntar.length > 0
const antardashas = zeroYear
? []
: rawAntar.length > 0
? rawAntar.flatMap((antar, index) => {
const item = record(antar);
if (!item) return [];
@@ -430,6 +442,7 @@ function charaTrack(charaPacket: Record<string, unknown> | null, birthDate: stri
end,
current: containsDay(start, end, asOf),
derived: false,
...(zeroYear ? { zeroYear: true as const } : {}),
antardashas,
}];
});
+2
View File
@@ -19,7 +19,9 @@ export const EPHEMERIS_COPY = {
transitsUnavailable: "这一天的行运还没拿到。",
natalMissing: "还没有本命上升,相对本命这一列空着。",
eventsHeading: "未来九十天",
eventsPending: "换座和停滞还在取。",
eventsUnavailable: "换座和停滞还没接上。这一页其余部分不受影响。",
dateFailed: "这一天的星历没取到,点日期再试一次。",
ask: "带这天去提问",
loginTitle: "请先登录",
loginBody: "登录后即可查看星历。",
+6 -2
View File
@@ -3,12 +3,14 @@ import type { BillingPaneTab, MembershipPlanAlias } from "@/lib/membership";
export const SETTINGS_QUERY_KEY = "settings";
export type SettingsQuery = {
readonly pane: "billing";
readonly pane: "billing" | "chart";
readonly source: string | null;
readonly plan: MembershipPlanAlias | null;
readonly tab: BillingPaneTab | null;
};
export const CHART_SETTINGS_HREF = "/?settings=chart";
const PLAN_ALIASES: ReadonlySet<string> = new Set(["trial", "monthly", "yearly"]);
const BILLING_TABS: ReadonlySet<string> = new Set(["membership", "credits", "redeem", "orders"]);
@@ -18,7 +20,9 @@ function searchParams(search: string) {
export function parseSettingsQuery(search: string): SettingsQuery | null {
const params = searchParams(search);
if (params.get(SETTINGS_QUERY_KEY) !== "billing") return null;
const pane = params.get(SETTINGS_QUERY_KEY);
if (pane === "chart") return { pane: "chart", source: null, plan: null, tab: null };
if (pane !== "billing") return null;
const planRaw = params.get("plan");
const tabRaw = params.get("tab");
return {
+31
View File
@@ -11,7 +11,9 @@ 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 { 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 { cssDeclarations } from "./css-contract-test-support.ts";
Object.assign(globalThis, { React });
@@ -264,6 +266,35 @@ test("the chart page shell is visible before the natal chart arrives", () => {
assert.doesNotMatch(markup, /chart-page-eyebrow/);
});
test("profile data failures offer the chart settings entry and other failures do not", () => {
const reasons = [
"profile_incomplete",
"profile_query_error",
"adopted_calculation_incomplete",
"timezone_resolver_failure",
] as const;
for (const reason of reasons) {
const view = chartViewFailureResponse(reason);
const markup = withSidebarProvider(React.createElement(ChartPageView, { view }));
assert.match(markup, new RegExp(view.message));
assert.ok(markup.includes(`href="${CHART_SETTINGS_HREF}"`));
assert.match(markup, new RegExp(CHART_VIEW_COPY.profileEntryAction));
if (reason === "profile_incomplete") {
assert.match(markup, new RegExp(CHART_VIEW_COPY.profileEntryTitle));
assert.match(globalStyles, /\.chart-page-message h2 \{[^}]*font-size:\s*var\(--type-title-md\)/);
} else {
assert.doesNotMatch(markup, new RegExp(CHART_VIEW_COPY.profileEntryTitle));
}
assert.doesNotMatch(markup, /正在加载|skeleton|spinner/i);
}
const engine = withSidebarProvider(React.createElement(ChartPageView, {
view: chartViewFailureResponse("engine_busy"),
}));
assert.match(engine, new RegExp(CHART_VIEW_COPY.busy));
assert.equal(engine.includes(CHART_VIEW_COPY.profileEntryAction), false);
assert.equal(engine.includes(CHART_SETTINGS_HREF), false);
});
test("failure copy has no eyebrow and does not promise a later retry", () => {
const markup = withSidebarProvider(React.createElement(ChartPageView, {
view: { status: "chart_unavailable", billed: false, message: CHART_VIEW_COPY.unavailable },
+107
View File
@@ -0,0 +1,107 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import React from "react";
import test from "node:test";
import { renderToStaticMarkup } from "react-dom/server";
import { ChartDashaTab } from "../src/components/chart-page/chart-dasha-tab.tsx";
import type { ChartViewOk } from "../src/lib/chart-view-contract.ts";
import { assembleChartView } from "../src/lib/chart-view-load.ts";
import { CHARA_ZERO_YEAR_LABEL, CHARA_ZERO_YEAR_NOTE } from "../src/lib/chart-view-labels.ts";
import { buildChartView, type ChartViewProfileInput } from "../src/lib/chart-view-mapper.ts";
Object.assign(globalThis, { React });
/**
* Generated from the real engine, not a hand-built shape:
* C:\Users\74082\anaconda3\python.exe
* JyotishAPIHandler.__new__()._compute_chart + _compute_chara_dasha
* 1985-08-03 03:59, lat 39.9042, lon 116.4074, tz 8, ayanamsa raman, node_mode mean,
* skip_vedastro_main_entry_overview, antardasha true.
* Sagittarius duration_years is 0 (debilitated). scripts/jaimini.py was not modified.
*/
const golden = JSON.parse(readFileSync(new URL("./fixtures/chara-zero-year-1985-08-03-beijing.golden.json", import.meta.url), "utf8")) as {
chart: Record<string, unknown>;
chara: Record<string, unknown>;
};
const profile: ChartViewProfileInput = {
name: "虚构",
date: "1985-08-03",
time: "03:59",
placeLabel: "北京",
latitude: 39.9042,
longitude: 116.4074,
timezoneOffset: 8,
timezoneId: "Asia/Shanghai",
ayanamsa: "raman",
birthTimeStatus: "reported",
};
function viewFrom(chara: Record<string, unknown>): ChartViewOk {
return buildChartView({
chart: golden.chart,
varga: null,
chara,
western: null,
qizheng: null,
profile,
asOf: "2026-09-25",
});
}
test("a real zero-year Chara mahadasha keeps twelve continuous periods and does not throw", async () => {
const view = viewFrom(golden.chara);
const periods = view.dasha.chara.periods;
assert.equal(periods.length, 12);
const sagittarius = periods.find((period) => period.sign === "Sagittarius");
assert.ok(sagittarius);
assert.equal(sagittarius.zeroYear, true);
assert.equal(sagittarius.antardashas.length, 0);
assert.equal(sagittarius.start, sagittarius.end);
for (let index = 0; index < periods.length - 1; index += 1) {
assert.equal(periods[index]?.end, periods[index + 1]?.start);
}
const assembled = await assembleChartView({
userId: "synthetic",
accountId: "synthetic",
profileFingerprint: "fixture-zero-year",
profile,
asOf: "2026-09-25",
layers: ["chara"],
postEngine: async (path) => {
if (path === "/api/chart") return { status: "ok", payload: golden.chart };
if (path === "/api/dasha/chara") return { status: "ok", payload: golden.chara };
return { status: "http_error", path, elapsedMs: 0, httpStatus: 404 };
},
});
assert.equal(assembled.httpStatus, 200);
assert.equal(assembled.body.status, "ok");
});
test("zero antardasha weights on a positive mahadasha do not produce an invalid date", () => {
const chara = structuredClone(golden.chara) as {
result: { chara_dasha: { dasha_sequence: Array<{ duration_years: number; antardashas?: Array<{ duration_years: number }> }> } };
};
const positive = chara.result.chara_dasha.dasha_sequence.find((row) => row.duration_years > 0);
assert.ok(positive?.antardashas?.length);
for (const antar of positive.antardashas ?? []) antar.duration_years = 0;
const view = viewFrom(chara);
const mapped = view.dasha.chara.periods.find((period) => period.sign !== "Sagittarius" && period.antardashas.length > 0);
assert.ok(mapped);
for (const antar of mapped.antardashas) {
assert.match(antar.start, /^\d{4}-\d{2}-\d{2}$/);
assert.match(antar.end, /^\d{4}-\d{2}-\d{2}$/);
}
});
test("the zero-year Chara row shows 0 年 and does not render sub-periods", () => {
const view = viewFrom(golden.chara);
const markup = renderToStaticMarkup(React.createElement(ChartDashaTab, { view }));
const marker = markup.indexOf(CHARA_ZERO_YEAR_LABEL);
const note = markup.indexOf(CHARA_ZERO_YEAR_NOTE, marker);
assert.ok(marker >= 0 && note > marker);
const slice = markup.slice(Math.max(0, marker - 40), note);
assert.match(slice, /射手/);
assert.doesNotMatch(markup.slice(marker, note), /<ul/);
});
@@ -0,0 +1,167 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import React from "react";
import test from "node:test";
import { EphemerisPage } from "../src/components/ephemeris/ephemeris-page.tsx";
import { SidebarProvider } from "../src/components/ui/sidebar.tsx";
import { EPHEMERIS_COPY, panchangaFactorsFromEngine, transitsFromEngine } from "../src/lib/ephemeris-view.ts";
import { parseEphemerisOkResponse, type EphemerisOkResponse } from "../src/lib/ephemeris-contract.ts";
import { resetSecondaryPageDataForTests, writeEphemerisPage } from "../src/lib/secondary-page-data.ts";
import { createClientLifecycleHarness } from "./react-client-lifecycle-test-support.ts";
Object.assign(globalThis, { React });
const panchangaGolden = JSON.parse(readFileSync(new URL("./fixtures/ephemeris-panchanga-range.golden.json", import.meta.url), "utf8"));
const natalGolden = JSON.parse(readFileSync(new URL("./fixtures/ephemeris-chart-natal.golden.json", import.meta.url), "utf8"));
const transit15 = JSON.parse(readFileSync(new URL("./fixtures/ephemeris-chart-transit-2026-09-15.golden.json", import.meta.url), "utf8"));
const transit14 = JSON.parse(readFileSync(new URL("./fixtures/ephemeris-chart-transit-2026-09-14.golden.json", import.meta.url), "utf8"));
function payloadFor(date: "2026-09-14" | "2026-09-15"): EphemerisOkResponse {
const transits = transitsFromEngine({
transitChart: date === "2026-09-14" ? transit14 : transit15,
natalChart: natalGolden,
});
const parsed = parseEphemerisOkResponse({
status: "ok",
date,
today: "2026-09-15",
timezoneId: "Asia/Shanghai",
panchanga: {
available: true,
ayanamsaNote: "lahiri_not_account",
factors: panchangaFactorsFromEngine(panchangaGolden, date),
},
transits: {
available: true,
natalRelativeAvailable: true,
natalRelativeNote: "shown",
ayanamsa: "raman",
bodies: transits.bodies,
},
events: { status: "unavailable" },
});
assert.ok(parsed);
return parsed;
}
function asDate(payload: EphemerisOkResponse, date: string): EphemerisOkResponse {
return { ...payload, date };
}
function json(payload: EphemerisOkResponse) {
return new Response(JSON.stringify(payload), { status: 200, headers: { "content-type": "application/json" } });
}
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((next) => { resolve = next; });
return { promise, resolve };
}
async function withPage(run: (h: ReturnType<typeof createClientLifecycleHarness>) => Promise<void>) {
resetSecondaryPageDataForTests();
const originalFetch = globalThis.fetch;
const h = createClientLifecycleHarness();
Object.assign(window, { location: { assign() {}, pathname: "/ephemeris", search: "" } });
try {
await run(h);
assert.deepEqual(h.errors, []);
} finally {
await h.close();
globalThis.fetch = originalFetch;
resetSecondaryPageDataForTests();
}
}
function click(h: ReturnType<typeof createClientLifecycleHarness>, label: string) {
const node = h.elements().find((element) => element.getAttribute("aria-label") === label);
assert.ok(node, label);
return h.event(node);
}
test("switching to an uncached date keeps the date bar and ignores a late today refresh", async () => withPage(async (h) => {
const today = payloadFor("2026-09-15");
const sixteenth = asDate(payloadFor("2026-09-14"), "2026-09-16");
writeEphemerisPage({ kind: "ready", payload: today });
const refresh = deferred<Response>();
const requested = deferred<Response>();
globalThis.fetch = async (input) => {
const url = String(input);
if (url.includes("date=2026-09-16")) return requested.promise;
return refresh.promise;
};
await h.render(React.createElement(SidebarProvider, null, React.createElement(EphemerisPage)));
await h.idle();
assert.match(h.container.text, /2026年9月15日/);
assert.match(h.container.text, /Shukla Panchami/);
await click(h, EPHEMERIS_COPY.nextDay);
await h.idle();
assert.match(h.container.text, /2026年9月16日/);
assert.match(h.container.text, new RegExp(EPHEMERIS_COPY.panchangaUnavailable));
assert.match(h.container.text, new RegExp(EPHEMERIS_COPY.transitsUnavailable));
assert.match(h.container.text, new RegExp(EPHEMERIS_COPY.eventsPending));
assert.doesNotMatch(h.container.text, /Shukla Panchami|还没接上|正在加载|skeleton/i);
assert.ok(h.elements().some((element) => element.getAttribute("aria-label") === EPHEMERIS_COPY.previousDay));
await h.update(() => refresh.resolve(json(today)));
await h.idle();
assert.match(h.container.text, /2026年9月16日/);
assert.doesNotMatch(h.container.text, /Shukla Panchami/);
await h.update(() => requested.resolve(json(sixteenth)));
await h.idle();
assert.match(h.container.text, /2026年9月16日/);
assert.match(h.container.text, /Shukla Chaturthi/);
assert.doesNotMatch(h.container.text, /Shukla Panchami/);
}));
test("a late response for the previous date cannot overwrite the date now on screen", async () => withPage(async (h) => {
writeEphemerisPage({ kind: "ready", payload: payloadFor("2026-09-15") });
const first = deferred<Response>();
const second = deferred<Response>();
globalThis.fetch = async (input) => {
const url = String(input);
if (url.includes("date=2026-09-16")) return first.promise;
if (url.includes("date=2026-09-17")) return second.promise;
return json(payloadFor("2026-09-15"));
};
await h.render(React.createElement(SidebarProvider, null, React.createElement(EphemerisPage)));
await h.idle();
await click(h, EPHEMERIS_COPY.nextDay);
await h.idle();
await click(h, EPHEMERIS_COPY.nextDay);
await h.idle();
assert.match(h.container.text, /2026年9月17日/);
await h.update(() => first.resolve(json(asDate(payloadFor("2026-09-14"), "2026-09-16"))));
await h.idle();
assert.match(h.container.text, /2026年9月17日/);
assert.match(h.container.text, new RegExp(EPHEMERIS_COPY.panchangaUnavailable));
assert.doesNotMatch(h.container.text, /Shukla Chaturthi/);
}));
test("429 shows the failure sentence and the same date or an arrow retries successfully", async () => withPage(async (h) => {
writeEphemerisPage({ kind: "ready", payload: payloadFor("2026-09-15") });
const sixteenth = asDate(payloadFor("2026-09-14"), "2026-09-16");
let calls = 0;
globalThis.fetch = async (input) => {
const url = String(input);
if (!url.includes("date=2026-09-16")) return json(payloadFor("2026-09-15"));
calls += 1;
if (calls === 1) {
return new Response(JSON.stringify({ status: "unavailable", reason: "rate_limited" }), { status: 429 });
}
return json(sixteenth);
};
await h.render(React.createElement(SidebarProvider, null, React.createElement(EphemerisPage)));
await h.idle();
await click(h, EPHEMERIS_COPY.nextDay);
await h.idle();
assert.equal((h.container.text.match(new RegExp(EPHEMERIS_COPY.dateFailed, "g")) ?? []).length, 3);
assert.match(h.container.text, /2026年9月16日/);
assert.doesNotMatch(h.container.text, /Shukla Panchami|正在加载|skeleton/i);
await click(h, EPHEMERIS_COPY.nextDay);
await h.idle();
assert.equal(calls, 2);
assert.match(h.container.text, /2026年9月16日/);
assert.match(h.container.text, /Shukla Chaturthi/);
assert.doesNotMatch(h.container.text, new RegExp(EPHEMERIS_COPY.dateFailed));
}));
+37
View File
@@ -168,3 +168,40 @@ test("copy stays factual: no fortune-telling, no spinner, ask prefills the date"
assert.equal(ephemerisAskDraft("2026-09-15"), "请按 2026年9月15日 这一天的天象来看。");
assert.match(pageSource, /setComposerDraft\(ephemerisAskDraft/);
});
test("an uncached date keeps the date bar and replaces the three sections with static sentences", () => {
const markup = withSidebarProvider(React.createElement(EphemerisView, {
date: "2026-09-16",
today: "2026-09-15",
payload: null,
dataState: "pending",
onPrevious() {},
onNext() {},
onToday() {},
onSelectDate() {},
onAsk() {},
}));
assert.match(markup, /2026年9月16日/);
assert.match(markup, new RegExp(EPHEMERIS_COPY.previousDay));
assert.match(markup, new RegExp(EPHEMERIS_COPY.panchangaUnavailable));
assert.match(markup, new RegExp(EPHEMERIS_COPY.transitsUnavailable));
assert.match(markup, new RegExp(EPHEMERIS_COPY.eventsPending));
assert.doesNotMatch(markup, /还没接上|正在加载|skeleton|spinner/i);
});
test("a failed date shows the retry sentence in each section", () => {
const markup = withSidebarProvider(React.createElement(EphemerisView, {
date: "2026-09-16",
today: "2026-09-15",
payload: null,
dataState: "failed",
onPrevious() {},
onNext() {},
onToday() {},
onSelectDate() {},
onAsk() {},
}));
assert.equal((markup.match(new RegExp(EPHEMERIS_COPY.dateFailed, "g")) ?? []).length, 3);
assert.match(markup, /2026年9月16日/);
assert.doesNotMatch(markup, /正在加载|skeleton|spinner/i);
});
+47 -6
View File
@@ -37,7 +37,9 @@ function executeRoute(input: {
readonly user: boolean;
readonly row: Record<string, unknown> | null;
readonly date?: string;
readonly dates?: readonly string[];
readonly eventsStatus?: number;
readonly abortOnFetch?: boolean;
}) {
const script = String.raw`
import { mock } from "node:test";
@@ -48,8 +50,9 @@ function executeRoute(input: {
const moduleUrl = (path) => pathToFileURL(process.cwd() + "/" + path).href;
const user = ${input.user ? "true" : "false"};
const row = ${JSON.stringify(input.row)};
const date = ${JSON.stringify(input.date ?? "2026-09-15")};
const dates = ${JSON.stringify(input.dates ?? [input.date ?? "2026-09-15"])};
const eventsStatus = ${input.eventsStatus ?? 404};
const abortOnFetch = ${input.abortOnFetch ? "true" : "false"};
const fixture = (name) => JSON.parse(readFileSync(join(process.cwd(), "tests/fixtures", name), "utf8"));
const panchanga = fixture("ephemeris-panchanga-range.golden.json");
const natal = fixture("ephemeris-chart-natal.golden.json");
@@ -90,10 +93,16 @@ function executeRoute(input: {
});
const upstream = [];
let signalAborted = null;
const controller = new AbortController();
globalThis.fetch = async (inputUrl, init) => {
const url = String(inputUrl);
const body = init?.body ? JSON.parse(String(init.body)) : {};
upstream.push({ url, body });
if (abortOnFetch && signalAborted === null) {
controller.abort();
signalAborted = Boolean(init?.signal?.aborted);
}
upstream.push({ url, body, signalAborted: Boolean(init?.signal?.aborted) });
if (url.endsWith("/api/panchanga_range")) {
return new Response(JSON.stringify(panchanga), { status: 200, headers: { "content-type": "application/json" } });
}
@@ -108,12 +117,18 @@ function executeRoute(input: {
};
const { GET } = await import(moduleUrl("src/app/api/ephemeris/route.ts"));
const response = await GET(new Request("https://staging.jyotisha.chat/api/ephemeris?date=" + date));
const responses = [];
for (const date of dates) {
const response = await GET(new Request("https://staging.jyotisha.chat/api/ephemeris?date=" + date, abortOnFetch ? { signal: controller.signal } : undefined));
responses.push({ status: response.status, body: await response.json() });
}
console.log(JSON.stringify({
status: response.status,
body: await response.json(),
status: responses[0].status,
body: responses[0].body,
responses,
upstream,
billingImported,
signalAborted,
}));
`;
const result = spawnSync(process.execPath, [
@@ -129,8 +144,10 @@ function executeRoute(input: {
return JSON.parse(line) as {
status: number;
body: Record<string, unknown>;
upstream: Array<{ url: string; body: Record<string, unknown> }>;
responses: Array<{ status: number; body: Record<string, unknown> }>;
upstream: Array<{ url: string; body: Record<string, unknown>; signalAborted: boolean }>;
billingImported: boolean;
signalAborted: boolean | null;
};
}
@@ -226,3 +243,27 @@ test("golden engine packets pass the narrow response contract", () => {
assert.equal(eventsFromEngine(null).status, "unavailable");
assert.equal(eventsFromEngine({ error: "not_found" }).status, "unavailable");
});
test("the same account loads the natal chart once across two dates and forwards the request signal", () => {
assert.match(routeSource, /request\.signal/);
assert.match(routeSource, /readChartViewEngineCache/);
assert.match(routeSource, /writeChartViewEngineCache/);
const result = executeRoute({
user: true,
row: completeRow,
dates: ["2026-09-15", "2026-09-16"],
});
assert.equal(result.responses.length, 2);
assert.equal(result.responses[0]?.body.date, "2026-09-15");
assert.equal(result.responses[1]?.body.date, "2026-09-16");
const charts = result.upstream.filter((item) => item.url.endsWith("/api/chart"));
const natal = charts.filter((item) => item.body.year === 1990);
const transit = charts.filter((item) => item.body.year !== 1990);
assert.equal(natal.length, 1);
assert.equal(transit.length, 2);
assert.equal(result.billingImported, false);
const aborted = executeRoute({ user: true, row: completeRow, abortOnFetch: true });
assert.equal(aborted.signalAborted, true);
assert.equal(aborted.upstream[0]?.signalAborted, true);
});
File diff suppressed because one or more lines are too long
+17 -1
View File
@@ -1,7 +1,8 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { parseSettingsQuery, stripSettingsQuery } from "../src/lib/settings-url.ts";
import { CHART_SETTINGS_HREF, parseSettingsQuery, stripSettingsQuery } from "../src/lib/settings-url.ts";
test("parses a billing settings query with source, plan and tab", () => {
const query = parseSettingsQuery("?settings=billing&source=legacy&plan=monthly&tab=orders");
@@ -29,6 +30,21 @@ test("ignores unknown plan and tab aliases", () => {
});
});
test("parses the chart settings deep link without changing billing", () => {
assert.deepEqual(parseSettingsQuery("?settings=chart"), {
pane: "chart",
source: null,
plan: null,
tab: null,
});
assert.equal(parseSettingsQuery("?settings=profile"), null);
assert.equal(CHART_SETTINGS_HREF, "/?settings=chart");
const page = readFileSync(new URL("../src/app/(app)/page.tsx", import.meta.url), "utf8");
assert.match(page, /query\.pane === "chart"\) openAccountDialog\("chart-library"\)/);
assert.match(page, /openAccountDialog\("billing", \{ source: query\.source \?\? undefined, plan: query\.plan \?\? undefined, tab: query\.tab \?\? undefined \}\)/);
assert.equal(stripSettingsQuery("?settings=chart"), "/");
});
test("strips settings fields and keeps the session query", () => {
assert.equal(
stripSettingsQuery("?settings=billing&source=legacy&c=11111111-1111-4111-8111-111111111111"),