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
+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 {