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
@@ -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">