fix: integrate people archive, report reader and western chart corrections
Validate on Linux Node 22 and PostgreSQL 17: 3894 frontend tests and 64 database tests pass, with no removed test names or new failures. Preserve static Home, bounded gzip, assertion-change records and manual acceptance gaps. Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -13,7 +13,9 @@ export const WESTERN_R_HOUSE = 112;
|
||||
export const WESTERN_R_NUMBER = 84;
|
||||
export const WESTERN_R_ASPECT = 62;
|
||||
export const WESTERN_R_ANGLE = 162;
|
||||
export const WESTERN_FONT_SIZE = 17;
|
||||
// The 375px page leaves 343px for the wheel after its two 16px gutters.
|
||||
export const WESTERN_FONT_SIZE = 19;
|
||||
export const WESTERN_HIT_RADIUS = 34;
|
||||
export const WESTERN_MIN_SEPARATION_DEG = 11;
|
||||
|
||||
const TEXT_PRESENTATION = "\uFE0E";
|
||||
@@ -103,7 +105,8 @@ export function westernLabelBox(
|
||||
fontSize = WESTERN_FONT_SIZE,
|
||||
): WesternLabelBox {
|
||||
const width = westernTextWidth(text, fontSize);
|
||||
const height = fontSize;
|
||||
// Vendored Noto Symbols has a taller em box than Latin/CJK fallback text.
|
||||
const height = fontSize * (/[☉☽☿♀♂♃♄♅♆♇☊℞]/u.test(text) ? 2.2 : 1.4);
|
||||
return {
|
||||
left: x - width / 2,
|
||||
top: y - height / 2,
|
||||
@@ -183,14 +186,48 @@ function planetPoint(displayLongitude: number, ascendantLongitude: number, tier:
|
||||
return westernPolar(radius, westernScreenAngle(displayLongitude, ascendantLongitude));
|
||||
}
|
||||
|
||||
/**
|
||||
* Push planet captions apart along the ecliptic until adjacent display angles
|
||||
* are at least 11°. A second radius is used only when the primary radius would
|
||||
* still overlap; the font size stays at 17.
|
||||
export function layoutWesternHouseLabels(
|
||||
houses: readonly { house: number; longitude: number }[],
|
||||
ascendantLongitude: number,
|
||||
) {
|
||||
const occupied: WesternLabelBox[] = [];
|
||||
return houses.map((house, index) => {
|
||||
const next = houses[(index + 1) % houses.length] ?? house;
|
||||
const longitude = houseMidLongitude(house.longitude, next.longitude);
|
||||
const angle = westernScreenAngle(longitude, ascendantLongitude);
|
||||
let point = westernPolar(WESTERN_R_NUMBER, angle);
|
||||
// Keep the true cusp midpoint angle; only crowded numbers move radially.
|
||||
for (const radius of [WESTERN_R_NUMBER, 108, 60, 132, 36]) {
|
||||
const candidate = westernPolar(radius, angle);
|
||||
const box = westernLabelBox(candidate.x, candidate.y, String(house.house));
|
||||
if (occupied.some((other) => boxesOverlap(box, other))) continue;
|
||||
point = candidate;
|
||||
break;
|
||||
}
|
||||
occupied.push(westernLabelBox(point.x, point.y, String(house.house)));
|
||||
return { house: house.house, longitude, ...point };
|
||||
});
|
||||
}
|
||||
|
||||
export function westernAngleLabels(ascendantLongitude: number, mcLongitude?: number) {
|
||||
return [
|
||||
{ caption: "ASC", longitude: ascendantLongitude },
|
||||
...(mcLongitude === undefined ? [] : [{ caption: "MC", longitude: mcLongitude }]),
|
||||
].map((angle) => ({
|
||||
...angle,
|
||||
...westernPolar(WESTERN_R_ANGLE, westernScreenAngle(angle.longitude, ascendantLongitude) + 9),
|
||||
}));
|
||||
}
|
||||
|
||||
/** Keep the axes fixed and place each caption in the nearest free two-ring slot.
|
||||
* The search checks text boxes as well as hit-circle centres, rather than
|
||||
* assuming alternating radii resolves every collision after angular relaxation.
|
||||
*/
|
||||
export function layoutWesternLabels(
|
||||
planets: readonly WesternLayoutBody[],
|
||||
ascendantLongitude = 0,
|
||||
mcLongitude?: number,
|
||||
fixedBoxes: readonly WesternLabelBox[] = [],
|
||||
): WesternLayoutResult[] {
|
||||
if (planets.length === 0) return [];
|
||||
const indexed = planets.map((planet, index) => ({
|
||||
@@ -200,44 +237,47 @@ export function layoutWesternLabels(
|
||||
}));
|
||||
indexed.sort((a, b) => a.longitude - b.longitude || a.planet.id.localeCompare(b.planet.id));
|
||||
const display = separateSorted(indexed.map((item) => item.longitude));
|
||||
const byDisplay = indexed
|
||||
.map((_, index) => index)
|
||||
.sort((a, b) => display[a]! - display[b]! || indexed[a]!.planet.id.localeCompare(indexed[b]!.planet.id));
|
||||
const overlapCount = (tiers: readonly (0 | 1)[]) => {
|
||||
const boxes = indexed.map((item, index) => {
|
||||
const point = planetPoint(display[index]!, ascendantLongitude, tiers[index]!);
|
||||
return westernLabelBox(point.x, point.y, item.planet.caption);
|
||||
});
|
||||
let count = 0;
|
||||
for (let left = 0; left < boxes.length; left += 1) {
|
||||
for (let right = left + 1; right < boxes.length; right += 1) {
|
||||
if (boxesOverlap(boxes[left]!, boxes[right]!)) count += 1;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
};
|
||||
const alternate = (phase: 0 | 1) => {
|
||||
const tiers = new Array<0 | 1>(indexed.length).fill(0);
|
||||
byDisplay.forEach((sortedIndex, order) => {
|
||||
tiers[sortedIndex] = ((order + phase) % 2) as 0 | 1;
|
||||
});
|
||||
return tiers;
|
||||
};
|
||||
const flat = new Array<0 | 1>(indexed.length).fill(0);
|
||||
let tiers = flat;
|
||||
if (overlapCount(flat) > 0) {
|
||||
const primary = alternate(0);
|
||||
const secondary = alternate(1);
|
||||
tiers = overlapCount(secondary) < overlapCount(primary) ? secondary : primary;
|
||||
}
|
||||
const occupied = [
|
||||
...fixedBoxes,
|
||||
...westernAngleLabels(ascendantLongitude, mcLongitude).map((angle) =>
|
||||
westernLabelBox(angle.x, angle.y, angle.caption)),
|
||||
];
|
||||
const centres: Array<{ x: number; y: number; longitude: number }> = [];
|
||||
const results: WesternLayoutResult[] = new Array(planets.length);
|
||||
const candidates = [0, 1].flatMap((tier) =>
|
||||
Array.from({ length: 361 }, (_, step) => {
|
||||
const offset = Math.ceil(step / 2) * (step % 2 === 0 ? -1 : 1);
|
||||
return { tier: tier as 0 | 1, offset, cost: Math.abs(offset) + tier * 8 };
|
||||
}),
|
||||
).sort((a, b) => a.cost - b.cost || a.tier - b.tier || a.offset - b.offset);
|
||||
indexed.forEach((item, index) => {
|
||||
// Preserve every body even if a future caller exceeds the eleven-body wheel.
|
||||
results[item.index] = {
|
||||
id: item.planet.id,
|
||||
longitude: item.longitude,
|
||||
displayLongitude: display[index]!,
|
||||
tier: tiers[index]!,
|
||||
tier: 0,
|
||||
};
|
||||
for (const candidate of candidates) {
|
||||
const longitude = normalizeDegrees(display[index]! + candidate.offset);
|
||||
const point = planetPoint(longitude, ascendantLongitude, candidate.tier);
|
||||
const box = westernLabelBox(point.x, point.y, item.planet.caption);
|
||||
if (!westernLabelInsideViewBox(box) || occupied.some((other) => boxesOverlap(box, other))) continue;
|
||||
if (centres.some((other) => {
|
||||
const delta = Math.abs(longitude - other.longitude);
|
||||
return Math.min(delta, 360 - delta) < WESTERN_MIN_SEPARATION_DEG - 1e-6
|
||||
|| Math.hypot(point.x - other.x, point.y - other.y) <= WESTERN_HIT_RADIUS + 1;
|
||||
})) continue;
|
||||
results[item.index] = {
|
||||
id: item.planet.id,
|
||||
longitude: item.longitude,
|
||||
displayLongitude: longitude,
|
||||
tier: candidate.tier,
|
||||
};
|
||||
occupied.push(box);
|
||||
centres.push({ ...point, longitude });
|
||||
break;
|
||||
}
|
||||
});
|
||||
return results;
|
||||
}
|
||||
|
||||
@@ -3,10 +3,10 @@ import { SIGN_ORDER, signZh, type SignName } from "@/lib/chart-view-labels";
|
||||
import {
|
||||
WESTERN_CX,
|
||||
WESTERN_CY,
|
||||
WESTERN_R_ANGLE,
|
||||
WESTERN_FONT_SIZE,
|
||||
WESTERN_HIT_RADIUS,
|
||||
WESTERN_R_ASPECT,
|
||||
WESTERN_R_HOUSE,
|
||||
WESTERN_R_NUMBER,
|
||||
WESTERN_R_OUTER,
|
||||
WESTERN_R_PLANET,
|
||||
WESTERN_R_PLANET_INNER,
|
||||
@@ -14,6 +14,9 @@ import {
|
||||
WESTERN_SIZE,
|
||||
houseMidLongitude,
|
||||
layoutWesternLabels,
|
||||
layoutWesternHouseLabels,
|
||||
westernAngleLabels,
|
||||
westernLabelBox,
|
||||
westernPlanetCaption,
|
||||
westernPolar,
|
||||
westernRingSlice,
|
||||
@@ -82,6 +85,16 @@ export function WesternWheelSvg({
|
||||
}) {
|
||||
const western = skeleton ? EMPTY_WESTERN_WHEEL : suppliedWestern;
|
||||
const asc = western.ascendantLongitude;
|
||||
const tenth = western.houses.find((house) => house.house === 10);
|
||||
const angles = westernAngleLabels(asc, tenth?.longitude);
|
||||
const houseLabels = layoutWesternHouseLabels(western.houses, asc);
|
||||
const fixedBoxes = [
|
||||
...SIGN_ORDER.map((sign, index) => {
|
||||
const point = westernPolar((WESTERN_R_ZODIAC + WESTERN_R_OUTER) / 2, westernScreenAngle(index * 30 + 15, asc));
|
||||
return westernLabelBox(point.x, point.y, signZh(sign));
|
||||
}),
|
||||
...houseLabels.map((label) => westernLabelBox(label.x, label.y, String(label.house))),
|
||||
];
|
||||
const placements = layoutWesternLabels(
|
||||
western.planets.map((planet) => ({
|
||||
id: planet.id,
|
||||
@@ -89,11 +102,12 @@ export function WesternWheelSvg({
|
||||
caption: westernPlanetCaption(planet),
|
||||
})),
|
||||
asc,
|
||||
tenth?.longitude,
|
||||
fixedBoxes,
|
||||
);
|
||||
const placementById = new Map(placements.map((item) => [item.id, item]));
|
||||
const longitudeByLabel = new Map(western.planets.map((planet) => [planet.label, planet.longitude]));
|
||||
longitudeByLabel.set("上升", asc);
|
||||
const tenth = western.houses.find((house) => house.house === 10);
|
||||
if (tenth) longitudeByLabel.set("中天", tenth.longitude);
|
||||
|
||||
return (
|
||||
@@ -131,7 +145,7 @@ export function WesternWheelSvg({
|
||||
<g key={`${sign}-spoke`}>
|
||||
<line x1={inner.x} y1={inner.y} x2={outer.x} y2={outer.y} className="chart-page-western-spoke" />
|
||||
{!skeleton && (
|
||||
<text x={label.x} y={label.y} textAnchor="middle" dominantBaseline="middle" className="chart-page-western-sign-label">
|
||||
<text x={label.x} y={label.y} textAnchor="middle" dominantBaseline="central" style={{ fontSize: WESTERN_FONT_SIZE }} className="chart-page-western-sign-label">
|
||||
{signZh(sign)}
|
||||
</text>
|
||||
)}
|
||||
@@ -144,7 +158,7 @@ export function WesternWheelSvg({
|
||||
const inner = westernPolar(WESTERN_R_ASPECT, angle);
|
||||
const outer = westernPolar(WESTERN_R_ZODIAC, angle);
|
||||
const mid = houseMidLongitude(house.longitude, next.longitude);
|
||||
const label = westernPolar(WESTERN_R_NUMBER, westernScreenAngle(mid, asc));
|
||||
const label = houseLabels[index]!;
|
||||
const axis = house.house === 1 || house.house === 4 || house.house === 7 || house.house === 10;
|
||||
return (
|
||||
<g key={house.house}>
|
||||
@@ -159,7 +173,7 @@ export function WesternWheelSvg({
|
||||
x={label.x}
|
||||
y={label.y}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
dominantBaseline="central" style={{ fontSize: WESTERN_FONT_SIZE }}
|
||||
className="chart-page-western-house-label"
|
||||
data-house={house.house}
|
||||
data-longitude={fmt(mid)}
|
||||
@@ -197,7 +211,6 @@ export function WesternWheelSvg({
|
||||
const truth = westernPolar(WESTERN_R_ZODIAC - 8, westernScreenAngle(placement.longitude, asc));
|
||||
const leaderEnd = pointToward(truth, display, 14);
|
||||
const selected = selectedLabel === planet.label;
|
||||
const caption = westernPlanetCaption(planet);
|
||||
return (
|
||||
<g
|
||||
key={planet.id}
|
||||
@@ -220,6 +233,7 @@ export function WesternWheelSvg({
|
||||
x2={leaderEnd.x}
|
||||
y2={leaderEnd.y}
|
||||
className="chart-page-western-leader"
|
||||
pointerEvents="none"
|
||||
data-active={selected ? "true" : undefined}
|
||||
/>
|
||||
<line
|
||||
@@ -228,36 +242,52 @@ export function WesternWheelSvg({
|
||||
x2={truth.x}
|
||||
y2={truth.y}
|
||||
className="chart-page-western-tick"
|
||||
pointerEvents="none"
|
||||
/>
|
||||
<circle cx={display.x} cy={display.y} r={22} className="chart-page-western-hit" />
|
||||
<text x={display.x} y={display.y} textAnchor="middle" dominantBaseline="middle">
|
||||
{caption}
|
||||
<circle cx={display.x} cy={display.y} r={WESTERN_HIT_RADIUS} className="chart-page-western-hit" />
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
{!skeleton && western.planets.map((planet) => {
|
||||
const placement = placementById.get(planet.id);
|
||||
if (!placement) return null;
|
||||
const radius = placement.tier === 0 ? WESTERN_R_PLANET : WESTERN_R_PLANET_INNER;
|
||||
const display = westernPolar(radius, westernScreenAngle(placement.displayLongitude, asc));
|
||||
const selected = selectedLabel === planet.label;
|
||||
return (
|
||||
<g
|
||||
key={`${planet.id}-caption`}
|
||||
className={selected ? "chart-page-western-planet is-selected" : "chart-page-western-planet"}
|
||||
data-planet-caption={planet.id}
|
||||
aria-hidden="true"
|
||||
onClick={() => onSelectPlanet?.(planet.id)}
|
||||
>
|
||||
<text
|
||||
x={display.x}
|
||||
y={display.y}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="central"
|
||||
pointerEvents="bounding-box"
|
||||
style={{ fontSize: WESTERN_FONT_SIZE }}
|
||||
>
|
||||
{westernPlanetCaption(planet)}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
{!skeleton && (
|
||||
{!skeleton && angles.map((angle) => (
|
||||
<text
|
||||
x={westernPolar(WESTERN_R_ANGLE, westernScreenAngle(asc, asc) + 9).x}
|
||||
y={westernPolar(WESTERN_R_ANGLE, westernScreenAngle(asc, asc) + 9).y}
|
||||
key={angle.caption}
|
||||
x={angle.x}
|
||||
y={angle.y}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
dominantBaseline="central"
|
||||
style={{ fontSize: WESTERN_FONT_SIZE }}
|
||||
className="chart-page-western-angle"
|
||||
>
|
||||
ASC
|
||||
{angle.caption}
|
||||
</text>
|
||||
)}
|
||||
{!skeleton && tenth && (
|
||||
<text
|
||||
x={westernPolar(WESTERN_R_ANGLE, westernScreenAngle(tenth.longitude, asc) + 9).x}
|
||||
y={westernPolar(WESTERN_R_ANGLE, westernScreenAngle(tenth.longitude, asc) + 9).y}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
className="chart-page-western-angle"
|
||||
>
|
||||
MC
|
||||
</text>
|
||||
)}
|
||||
))}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { SubjectSwitcher } from "@/components/subject-switcher";
|
||||
import { loadSubjectCatalog, setCurrentSubject, useCurrentSubject } from "@/lib/current-subject";
|
||||
import { loadSubjectCatalog, setCurrentSubject, subscribeSubjectCatalog, useCurrentSubject } from "@/lib/current-subject";
|
||||
import type { ChatProfileCatalogStatus } from "@/lib/chat-profile-picker-model";
|
||||
import type { ChartLibraryRecord } from "@/lib/home-types";
|
||||
|
||||
@@ -24,17 +24,22 @@ export function CurrentSubjectSwitcher() {
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void loadSubjectCatalog()
|
||||
.then((next) => {
|
||||
if (cancelled) return;
|
||||
setLibrary(next);
|
||||
setStatus("ready");
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setStatus("failed");
|
||||
});
|
||||
const refresh = () => {
|
||||
void loadSubjectCatalog()
|
||||
.then((next) => {
|
||||
if (cancelled) return;
|
||||
setLibrary(next);
|
||||
setStatus("ready");
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setStatus("failed");
|
||||
});
|
||||
};
|
||||
refresh();
|
||||
const unsubscribe = subscribeSubjectCatalog(refresh);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
unsubscribe();
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { useCurrentSubject } from "@/lib/current-subject";
|
||||
import { useCurrentSubject, useCurrentSubjectProfile, useCurrentSubjectReady } from "@/lib/current-subject";
|
||||
import { resolveBirthTimeConsultationRoute } from "@/lib/birth-time-consultation-consent";
|
||||
import { missingOtherProfileStep } from "@/lib/home-profile";
|
||||
import { calendarDateInTimeZone, dailyStarlanguageProfileKey } from "@/lib/daily-starlanguage";
|
||||
import type { BootstrapPhase } from "@/lib/home-bootstrap";
|
||||
import {
|
||||
@@ -24,11 +26,20 @@ export function DailyStarlanguageBinder(props: {
|
||||
natalMinuteAvailable: boolean;
|
||||
setDailyStarlanguage: (next: DailyStarlanguageState) => void;
|
||||
}) {
|
||||
const { accountId, bootstrapPhase, profile, profileComplete, natalMinuteAvailable, setDailyStarlanguage } = props;
|
||||
const { accountId, bootstrapPhase, setDailyStarlanguage } = props;
|
||||
const subjectId = useCurrentSubject();
|
||||
const subjectReady = useCurrentSubjectReady();
|
||||
const profile = useCurrentSubjectProfile(props.profile);
|
||||
const profileComplete = Boolean(profile && missingOtherProfileStep(profile) === null);
|
||||
const route = profile ? resolveBirthTimeConsultationRoute(profile, {}, "") : null;
|
||||
const natalMinuteAvailable = route?.kind === "consult" && Boolean(route.time);
|
||||
|
||||
useEffect(() => {
|
||||
if (bootstrapPhase === "account" || !accountId || !profileComplete || !natalMinuteAvailable) return;
|
||||
if (bootstrapPhase === "account" || !accountId || !subjectReady || !profile || !profileComplete || !natalMinuteAvailable) {
|
||||
let cancelled = false;
|
||||
queueMicrotask(() => { if (!cancelled) setDailyStarlanguage({ kind: "unavailable" }); });
|
||||
return () => { cancelled = true; };
|
||||
}
|
||||
const today = calendarDateInTimeZone(new Date(), profile.timezoneId);
|
||||
const fingerprint = `${subjectId}:${dailyStarlanguageProfileKey(profile)}`;
|
||||
const stored = readStoredDailyStarlanguage(accountId);
|
||||
@@ -69,7 +80,7 @@ export function DailyStarlanguageBinder(props: {
|
||||
controller.abort();
|
||||
if (retryTimer !== undefined) clearTimeout(retryTimer);
|
||||
};
|
||||
}, [accountId, bootstrapPhase, natalMinuteAvailable, profile, profileComplete, setDailyStarlanguage, subjectId]);
|
||||
}, [accountId, bootstrapPhase, natalMinuteAvailable, profile, profileComplete, setDailyStarlanguage, subjectId, subjectReady]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import { Calendar } from "@/components/ui/calendar";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { SecondaryPageShell } from "@/components/secondary-page-shell";
|
||||
import { setComposerDraft } from "@/lib/composer-draft";
|
||||
import { readCurrentSubjectId, useCurrentSubject } from "@/lib/current-subject";
|
||||
import { readCurrentSubjectId, readCurrentSubjectScope, useCurrentSubjectScope, useCurrentSubject, useCurrentSubjectReady } from "@/lib/current-subject";
|
||||
import { parseEphemerisOkResponse, type EphemerisOkResponse } from "@/lib/ephemeris-contract";
|
||||
import {
|
||||
EPHEMERIS_WAITING_COPY,
|
||||
@@ -49,6 +49,10 @@ function snapshotToState(): PageState {
|
||||
|
||||
export function EphemerisPage() {
|
||||
const subjectId = useCurrentSubject();
|
||||
const scope = useCurrentSubjectScope();
|
||||
const subjectReady = useCurrentSubjectReady();
|
||||
const [initialAttempt, setInitialAttempt] = useState(0);
|
||||
const cacheSubject = useRef(scope);
|
||||
const cachedFirst = peekEphemerisPage();
|
||||
const initialDate = cachedFirst?.kind === "ready" ? cachedFirst.payload.date : null;
|
||||
const [date, setDate] = useState<string | null>(initialDate);
|
||||
@@ -56,10 +60,10 @@ export function EphemerisPage() {
|
||||
const [knownToday, setKnownToday] = useState<string | null>(
|
||||
cachedFirst?.kind === "ready" ? cachedFirst.payload.today : null,
|
||||
);
|
||||
const [seenSubject, setSeenSubject] = useState(subjectId);
|
||||
const [seenSubject, setSeenSubject] = useState(scope);
|
||||
const [state, setState] = useState<PageState>(snapshotToState);
|
||||
if (seenSubject !== subjectId) {
|
||||
setSeenSubject(subjectId);
|
||||
if (seenSubject !== scope) {
|
||||
setSeenSubject(scope);
|
||||
setDate(null);
|
||||
setKnownToday(null);
|
||||
setState({ phase: "pending" });
|
||||
@@ -70,19 +74,20 @@ export function EphemerisPage() {
|
||||
));
|
||||
|
||||
const load = useCallback(async (nextDate: string, signal: AbortSignal) => {
|
||||
const response = await fetch(`/api/ephemeris?date=${encodeURIComponent(nextDate)}&subject=${encodeURIComponent(readCurrentSubjectId())}`, {
|
||||
const requestedSubject = subjectId;
|
||||
const response = await fetch(`/api/ephemeris?date=${encodeURIComponent(nextDate)}&subject=${encodeURIComponent(requestedSubject)}`, {
|
||||
credentials: "same-origin",
|
||||
headers: { Accept: "application/json" },
|
||||
signal,
|
||||
});
|
||||
if (signal.aborted || selectedDateRef.current !== nextDate) return;
|
||||
if (signal.aborted || selectedDateRef.current !== nextDate || readCurrentSubjectScope() !== scope) 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;
|
||||
if (signal.aborted || selectedDateRef.current !== nextDate || readCurrentSubjectScope() !== scope) return;
|
||||
const payload = parseEphemerisOkResponse(json);
|
||||
if (payload?.subjectId && payload.subjectId !== readCurrentSubjectId()) return;
|
||||
if (!payload || payload.date !== nextDate) {
|
||||
@@ -95,15 +100,21 @@ export function EphemerisPage() {
|
||||
selectedDateRef.current = payload.date;
|
||||
setDate(payload.date);
|
||||
setState({ phase: "ready", payload });
|
||||
}, []);
|
||||
}, [subjectId, scope]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!subjectReady) return;
|
||||
let cancelled = false;
|
||||
cache.current.clear();
|
||||
invalidateEphemerisPage();
|
||||
void refreshEphemerisPage()
|
||||
if (cacheSubject.current !== scope) {
|
||||
cacheSubject.current = scope;
|
||||
cache.current.clear();
|
||||
selectedDateRef.current = null;
|
||||
invalidateEphemerisPage();
|
||||
}
|
||||
const warm = peekEphemerisPage();
|
||||
void (warm ? Promise.resolve(warm) : refreshEphemerisPage())
|
||||
.then((snapshot) => {
|
||||
if (cancelled) return;
|
||||
if (cancelled || readCurrentSubjectScope() !== scope) return;
|
||||
if (snapshot.kind === "unauthorized") {
|
||||
setState({ phase: "unauthorized" });
|
||||
return;
|
||||
@@ -117,25 +128,26 @@ export function EphemerisPage() {
|
||||
setState({ phase: "ready", payload: snapshot.payload });
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (cancelled) return;
|
||||
if (cancelled || scope !== readCurrentSubjectScope()) return;
|
||||
console.warn("ephemeris_page_fetch_failed", error);
|
||||
setState({ phase: "failed" });
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [subjectId]);
|
||||
}, [scope, subjectReady, initialAttempt]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!date || cache.current.has(date)) return;
|
||||
if (!subjectReady || !date || cache.current.has(date)) return;
|
||||
const controller = new AbortController();
|
||||
const requested = date;
|
||||
void load(requested, controller.signal).catch((error: unknown) => {
|
||||
if (controller.signal.aborted || selectedDateRef.current !== requested) return;
|
||||
if (controller.signal.aborted || readCurrentSubjectScope() !== scope || selectedDateRef.current !== requested) return;
|
||||
console.warn("ephemeris_page_fetch_failed", error);
|
||||
setState({ phase: "failed" });
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [date, attempt, load]);
|
||||
}, [date, attempt, load, subjectReady, scope]);
|
||||
|
||||
if (state.phase === "unauthorized") {
|
||||
return (
|
||||
@@ -152,7 +164,15 @@ export function EphemerisPage() {
|
||||
const readyPayload = state.phase === "ready" && date && state.payload.date === date
|
||||
? state.payload
|
||||
: null;
|
||||
if (!date) {
|
||||
if (!date || !subjectReady) {
|
||||
if (state.phase === "failed") return (
|
||||
<SecondaryPageShell title={EPHEMERIS_COPY.title}>
|
||||
<section className="ephemeris-message">
|
||||
<p>{EPHEMERIS_COPY.dateFailed}</p>
|
||||
<Button type="button" onClick={() => setInitialAttempt((current) => current + 1)}>重试</Button>
|
||||
</section>
|
||||
</SecondaryPageShell>
|
||||
);
|
||||
return <SecondaryPageShell title={EPHEMERIS_COPY.title} waiting={EPHEMERIS_WAITING_COPY} />;
|
||||
}
|
||||
const activeDate = date;
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import { useCurrentSubjectReady } from "@/lib/current-subject";
|
||||
import type { ChartLibraryRecord, Profile, SynastryRelationshipType } from "@/lib/home-types";
|
||||
|
||||
function stripQuery(key: string) {
|
||||
@@ -17,13 +18,14 @@ export function NewChatDeepLink(props: {
|
||||
startNewChat: () => Promise<unknown>;
|
||||
}) {
|
||||
const started = useRef(false);
|
||||
const subjectReady = useCurrentSubjectReady();
|
||||
useEffect(() => {
|
||||
if (!props.ready || started.current) return;
|
||||
if (!props.ready || !subjectReady || started.current) return;
|
||||
if (!new URLSearchParams(window.location.search).has("newChat")) return;
|
||||
started.current = true;
|
||||
stripQuery("newChat");
|
||||
void props.startNewChat();
|
||||
}, [props.ready, props.startNewChat]);
|
||||
}, [props.ready, props.startNewChat, subjectReady]);
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
|
||||
import { useEffect, useState, type FormEvent } from "react";
|
||||
|
||||
import { effectiveBirthDate } from "@/lib/effective-birth-date";
|
||||
import { ChartProfileForm } from "@/components/chart-profile-form";
|
||||
import { SecondaryPageShell } from "@/components/secondary-page-shell";
|
||||
import { setCurrentSubject, useCurrentSubject } from "@/lib/current-subject";
|
||||
import { bindCurrentSubjectAccount, invalidateSubjectCatalog, setCurrentSubject, useCurrentSubject } from "@/lib/current-subject";
|
||||
import { fetchAccount } from "@/lib/home-cloud-sync";
|
||||
import {
|
||||
missingOtherProfileStep,
|
||||
@@ -22,7 +23,8 @@ type PersonRow = {
|
||||
readonly incomplete: boolean;
|
||||
};
|
||||
|
||||
type Usage = { sessions: number; reports: number };
|
||||
type Usage = { id: string; sessions: number; reports: number };
|
||||
const DELETE_USAGE_UNAVAILABLE = "暂时查不到这个人的对话数量,稍后再试";
|
||||
|
||||
const LIMIT_COPY = "最多保存 5 个人的星盘";
|
||||
|
||||
@@ -56,6 +58,7 @@ export function PeoplePage() {
|
||||
const profile = readProfile(row.profile);
|
||||
return [{ id: row.id, role: "other" as const, profile, incomplete: incompleteProfile(profile) }];
|
||||
});
|
||||
bindCurrentSubjectAccount(account.user.id, others.map((person) => person.id));
|
||||
setPeople([
|
||||
{ id: "self", role: "self", profile: self, incomplete: incompleteProfile(self) },
|
||||
...others,
|
||||
@@ -92,7 +95,7 @@ export function PeoplePage() {
|
||||
setSaving(true);
|
||||
setNotice("");
|
||||
try {
|
||||
const editingOther = selected?.role === "other" ? selected.id : "";
|
||||
const editingOther = selectedId && selected?.role === "other" ? selected.id : "";
|
||||
const response = await fetch(editingOther ? `/api/chart-profiles/${editingOther}` : "/api/chart-profiles", {
|
||||
method: editingOther ? "PUT" : "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
@@ -105,8 +108,11 @@ export function PeoplePage() {
|
||||
}
|
||||
setEditing(false);
|
||||
setNotice("");
|
||||
invalidateSubjectCatalog();
|
||||
await load();
|
||||
if (payload?.profile?.id) setSelectedId(payload.profile.id);
|
||||
} catch {
|
||||
setNotice("保存失败,请重试");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -114,13 +120,22 @@ export function PeoplePage() {
|
||||
|
||||
async function askDelete() {
|
||||
if (!selected || selected.role !== "other") return;
|
||||
const response = await fetch(`/api/chart-profiles/${selected.id}`, { cache: "no-store" });
|
||||
const payload = await response.json().catch(() => null) as { usage?: Usage } | null;
|
||||
setPendingDelete(response.ok && payload?.usage ? payload.usage : { sessions: 0, reports: 0 });
|
||||
setPendingDelete(null);
|
||||
setNotice("");
|
||||
try {
|
||||
const response = await fetch(`/api/chart-profiles/${selected.id}`, { cache: "no-store" });
|
||||
const payload = await response.json().catch(() => null) as { usage?: Omit<Usage, "id"> } | null;
|
||||
const usage = payload?.usage;
|
||||
if (!response.ok || !usage || !Number.isInteger(usage.sessions) || usage.sessions < 0
|
||||
|| !Number.isInteger(usage.reports) || usage.reports < 0) throw new Error("usage_unavailable");
|
||||
setPendingDelete({ id: selected.id, ...usage });
|
||||
} catch {
|
||||
setNotice(DELETE_USAGE_UNAVAILABLE);
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
if (!selected || selected.role !== "other") return;
|
||||
if (!selected || selected.role !== "other" || pendingDelete?.id !== selected.id) return;
|
||||
const response = await fetch(`/api/chart-profiles/${selected.id}`, { method: "DELETE" });
|
||||
if (!response.ok) {
|
||||
setNotice("删除失败,请重试");
|
||||
@@ -129,6 +144,7 @@ export function PeoplePage() {
|
||||
if (subjectId === selected.id) setCurrentSubject("self");
|
||||
setPendingDelete(null);
|
||||
setSelectedId("self");
|
||||
invalidateSubjectCatalog();
|
||||
await load();
|
||||
}
|
||||
|
||||
@@ -172,7 +188,7 @@ export function PeoplePage() {
|
||||
<section className="people-archive-detail" aria-label="档案详情">
|
||||
{editing ? (
|
||||
<ChartProfileForm
|
||||
title="添加一个人"
|
||||
title={selectedId ? "编辑人物" : "添加一个人"}
|
||||
description="称呼和出生资料。填报的出生时间保存后不能再改。"
|
||||
value={draft}
|
||||
onChange={setDraft}
|
||||
@@ -188,6 +204,7 @@ export function PeoplePage() {
|
||||
<>
|
||||
<h2>{selected.profile.name.trim() || (selected.role === "self" ? "我" : "未命名资料")}</h2>
|
||||
{selected.incomplete ? <p>这份资料还不完整,补全后才能排盘。</p> : null}
|
||||
<p>出生日期:{effectiveBirthDate(selected.profile) || "出生日期待补全"}</p>
|
||||
<p>填报时间:{selected.profile.reportedTime || "还没填"}</p>
|
||||
<p>排盘时间:{profileBirthTimeLabel(selected.profile)}</p>
|
||||
<p>地点:{profilePlaceLabel(selected.profile)}</p>
|
||||
@@ -197,7 +214,7 @@ export function PeoplePage() {
|
||||
<button type="button" className="button-secondary" onClick={() => openPerson(selected.id, "/chart")}>看星盘</button>
|
||||
<button type="button" className="button-secondary" onClick={() => openPerson(selected.id, "/reports")}>生成报告</button>
|
||||
{selected.role === "other" ? (
|
||||
<button type="button" className="button-secondary" onClick={() => openPerson(selected.id, `/?synastry=${selected.id}`)}>和我合盘</button>
|
||||
<button type="button" className="button-secondary" onClick={() => window.location.assign(`/?synastry=${selected.id}`)}>和我合盘</button>
|
||||
) : null}
|
||||
</div>
|
||||
{selected.role === "other" ? (
|
||||
@@ -209,7 +226,7 @@ export function PeoplePage() {
|
||||
{selected.role === "self" ? <p>本人不能删除。</p> : (
|
||||
<button type="button" className="button-primary danger-primary" onClick={() => void askDelete()}>删除这个人</button>
|
||||
)}
|
||||
{pendingDelete && selected.role === "other" ? (
|
||||
{pendingDelete?.id === selected.id && selected.role === "other" ? (
|
||||
<div role="alertdialog" aria-label="确认删除">
|
||||
<p>将同时删除 {pendingDelete.sessions} 个对话和 {pendingDelete.reports} 份报告,无法恢复。</p>
|
||||
<div className="dialog-actions">
|
||||
|
||||
@@ -6,7 +6,7 @@ import { InlineSpinner } from "@/components/inline-spinner";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { SecondaryPageShell } from "@/components/secondary-page-shell";
|
||||
import { useCurrentSubject } from "@/lib/current-subject";
|
||||
import { readCurrentSubjectScope, useCurrentSubjectScope, useCurrentSubjectReady } from "@/lib/current-subject";
|
||||
import { GeneratePersonalReportButton } from "./generate-personal-report-button";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useVisibilityAwarePoll } from "@/hooks/use-visibility-aware-poll";
|
||||
@@ -65,7 +65,8 @@ export function rowMeta(report: Pick<ReportListItem, "createdAt" | "themes"> & {
|
||||
}
|
||||
|
||||
export function PersonalReportCenter() {
|
||||
const subjectId = useCurrentSubject();
|
||||
const subjectId = useCurrentSubjectScope();
|
||||
const subjectReady = useCurrentSubjectReady();
|
||||
const [seenSubject, setSeenSubject] = useState(subjectId);
|
||||
const [state, setState] = useState<CenterState>(() => snapshotToState(peekReportsPage()));
|
||||
const deletedIds = useRef(new Set<string>());
|
||||
@@ -88,24 +89,27 @@ export function PersonalReportCenter() {
|
||||
}, []);
|
||||
|
||||
const load = useCallback(async (showLoading = false) => {
|
||||
if (!subjectReady) return;
|
||||
const requestedSubject = subjectId;
|
||||
if (showLoading) {
|
||||
setState((current) => ({ phase: "loading", reports: current.reports }));
|
||||
}
|
||||
try {
|
||||
const snapshot = await refreshReportsPage();
|
||||
if (cancelled.current) return;
|
||||
if (cancelled.current || requestedSubject !== readCurrentSubjectScope()) return;
|
||||
applySnapshot(snapshot);
|
||||
} catch {
|
||||
if (cancelled.current) return;
|
||||
if (cancelled.current || requestedSubject !== readCurrentSubjectScope()) return;
|
||||
const cached = peekReportsPage();
|
||||
const reports = cached && "reports" in cached ? cached.reports : [];
|
||||
const fallback: ReportsPageSnapshot = { kind: "error", reports };
|
||||
writeReportsPage(fallback);
|
||||
applySnapshot(fallback);
|
||||
}
|
||||
}, [applySnapshot]);
|
||||
}, [applySnapshot, subjectId, subjectReady]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!subjectReady) return;
|
||||
cancelled.current = false;
|
||||
invalidateReportsPage();
|
||||
const timer = window.setTimeout(() => void load(true), 0);
|
||||
@@ -113,7 +117,7 @@ export function PersonalReportCenter() {
|
||||
window.clearTimeout(timer);
|
||||
cancelled.current = true;
|
||||
};
|
||||
}, [load, subjectId]);
|
||||
}, [load, subjectId, subjectReady]);
|
||||
|
||||
const hasGenerating = state.reports.some((report) => report.status === "generating");
|
||||
const refresh = useCallback(() => void load(), [load]);
|
||||
@@ -147,7 +151,7 @@ export function PersonalReportCenter() {
|
||||
);
|
||||
}
|
||||
|
||||
if (state.phase === "loading" && state.reports.length === 0) {
|
||||
if (!subjectReady || (state.phase === "loading" && state.reports.length === 0)) {
|
||||
return <SecondaryPageShell title="我的报告" waiting={REPORTS_WAITING_COPY} />;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user