diff --git a/CHANGELOG.md b/CHANGELOG.md
index b667875b..d5d46fa4 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,13 @@
# 印度占星 Skill 更新日志
+## 2026-11-02 — 改出生资料时,时区没查到不再清掉出生地
+
+- 修改已存人物的出生日期、时间或地点后,如果时区这会儿查不到,原来的出生地会留着,地点下面写「时区这会儿没查到,原来的出生地还在,查到后才能保存。」,旁边有「重试」。查到之前「保存」不能点,所以不会把旧日期的时区存进新日期;改回原来的日期和地点也可以直接保存。
+- 新建人物时查不到时区,仍是「时区服务暂时不可用,稍后重试即可。」加「重试」,「确定」不能点。
+- 顺带修了同一个原因的两处:编辑时改省份、城市或国家,选到一半不会再跳回空白要重选;打开海外人物的编辑,不会再被清空并跳回中国。
+- 改日期查到新时区后又改回原日期,会重新查一次,不再沿用刚才那个日期的时区。
+- 记为 BUG-1506。Skill 版本不变。
+
## 2026-11-01 — 年运报告:从「我的报告」直接打开,写好一节出一节
- 「我的报告」多了一张「年运」卡片,点进去就是年运报告页,没有单独的入口页。个人报告卡片位置不变。
diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md
index b7104882..552c1b94 100644
--- a/docs/BUG_HISTORY.md
+++ b/docs/BUG_HISTORY.md
@@ -20605,19 +20605,24 @@
## BUG-1506 | 修改已存人物的出生资料时,时区服务失败会默默清空出生地
-- 状态:investigating
+- 状态:resolved
- 首次发现:2026-11-01(Gitea #119 第 42 项,外部依赖故障审计)
-- 最近更新:2026-11-01
-- 影响面:用 `BirthPlacePicker` 编辑已存人物的地方(本人资料、人物列表)。新建人物不受影响:新建时会显示错误和「重试」。
+- 最近更新:2026-11-02
+- 影响面:所有用 `BirthPlacePicker` 的表单:星盘档案和星盘页的「编辑人物 / 编辑我的资料 / 添加一个人」(共用 `ChartProfileForm`),以及建档的出生地点卡。修复时又确认了同一根因的两条触发路径:编辑时改省份、改市或改国家(还没选完那一刻就清空并跳回空选择器,要再选一遍),以及打开海外人物的编辑(地区列表还在加载时就被清空、跳回中国)。
- 用户现象:修改出生日期、时间或地点后,如果 `/api/locations/timezone` 返回 503、连不上或报错,原来显示的「省 · 市 · 区县 … UTC+8」变回「按省、市、区县逐级选择即可。」。没有错误提示,也没有「重试」按钮,用户要重新选地点,而且可能以为已经存好了。
-- 触发条件:编辑已有地点的人物时,时区解析失败。
-- 根因(读码 + 本机渲染确认):失败时 `frontend/src/components/birth-place-picker.tsx` 调 `onChange(null)`(约 274 行)。`frontend/src/components/birth-location-fields.tsx:21-35` 把地点代码全部清空,选择器的 `externalKey` 因此变化,草稿按空值重置(`birth-place-picker.tsx:129-132`),刚设置的错误状态也一起被丢掉。
-- 修复:未修。本轮只审计,待产品决定排期。
-- 验证:本机用真实 React 渲染 `BirthPlacePicker`,把 fetch 换成返回 503 的 stub,复现上述现象。脚本是临时的,没有入库。
-- 防复发:待修复单补「编辑时时区失败保留原地点并显示重试」的行为测试;现有 `frontend/tests/birth-place-picker.test.ts` 只断言源码文本。
-- 相关记录:`docs/operations/outage-behaviour-2026-11-01.md` 第二节。
+- 触发条件:编辑已有地点的人物时,时区解析失败;或改到一半的省 / 市 / 国家;或打开海外人物。
+- 根因(读码 + 真实渲染确认):选择器在两处向上报 `null`:时区查询失败(`frontend/src/components/birth-place-picker.tsx` 原 274 行)和选项没选完(原 228-233 行)。`frontend/src/components/birth-location-fields.tsx` 收到 `null` 就把地点代码、坐标和时区全部清空,选择器的 `externalKey` 因此变化,草稿按空值重置(原 129-132 行),刚设置的错误状态也一起被丢掉。另外改日期查到新时区后再改回原日期,旧代码按「挂载时的出生时刻」判断已存时区有效,会把新日期的时区当原日期的存下去。
+- 修复:
+ - 选择器不再上报 `null`,`onChange` 只在查到新时区时调用;失败和没选完都保留资料里原来的地点和时区。
+ - 新增 `onPendingChange`:已选地点或出生时刻需要新时区、但资料里还没有(没选完、正在查、查失败)时为 true。`frontend/src/components/birth-place-pending.tsx` 的 `BirthPlacePendingScope` 收这个状态,`BirthPlaceSubmitButton` 据此禁用保存。`ChartProfileForm` 和建档出生地点卡都包了这一层,所以「保存」/「确定」在时区查到或用户改回原值之前不能点。
+ - 查失败时,有原地点的写「时区这会儿没查到,原来的出生地还在,查到后才能保存。」,后面是「重试」;新建(没有原地点)仍是「时区服务暂时不可用,稍后重试即可。」加「重试」。不加别的提示行,状态行位置和高度不变。
+ - 资料里时区对应的出生时刻单独记(`valueMoment`),查到新时区时更新;改回原日期时会重新查,不再把别的日期的时区存下去。查询被取消后迟到的成功结果不再写回。
+- 验证:新增 `frontend/tests/birth-place-timezone-failure-20261102.test.tsx`,用真实 React 渲染 `ChartProfileForm` + fetch stub:改日期后时区 503 → 地点、坐标、时区原样保留,显示上面的错误句和「重试」,保存按钮禁用;重试成功 → 错误消失、时区改为新值、保存可点;改回原时间 → 不再请求,恢复原时区;改省份没选完 → 草稿留在新省份、资料不变、保存禁用;新建人物查失败 → 新建文案 + 重试,资料里没有可用地点;海外人物打开时(地区列表成功或失败)资料都不被清空。把 `frontend/src` 换回修复前版本,这 6 条行为测试全部失败。`birth-place-picker.test.ts` 的源码断言按新写法改,带原值 / 新值 / 原因注释。
+- 防复发:上面的行为测试。表单里放出生地选择器时要包 `BirthPlacePendingScope` 并用 `BirthPlaceSubmitButton`,同一测试文件检查两处表单的接线。选择器的 `onChange` 类型不再接受 `null`,以后谁再想用「上报空值」来表示失败会过不了类型检查。
+- 遗留:海外人物地区列表加载失败时,再改日期会保存不了,但状态行只说「该国家的地区列表暂时不可用」,没有重试(`docs/operations/outage-behaviour-2026-11-01.md` 第二节另一行,未改)。时区接口卡住时仍没有超时(同节另一行,未改)。
+- 相关记录:`docs/operations/outage-behaviour-2026-11-01.md` 第二节;BUG-1507。
- 复发自:无
-- 修复版本:未修。
+- 修复版本:分支 `codex/profile-birthplace-1506`(基于 `origin/staging` ba1b4be7)。
## BUG-1507 | 早期中国出生资料没有时区 ID,被默默按 +8 计算(1986–1991 夏令时出生差一小时)
diff --git a/docs/operations/outage-behaviour-2026-11-01.md b/docs/operations/outage-behaviour-2026-11-01.md
index 3ef80ae3..feabebf5 100644
--- a/docs/operations/outage-behaviour-2026-11-01.md
+++ b/docs/operations/outage-behaviour-2026-11-01.md
@@ -57,7 +57,7 @@ Gitea #119 第 42 项:「VedAstro、地点服务、模型供应商挂掉时网
| --- | --- | --- | --- | --- |
| 新建人物,时区接口 503 或连不上 | 「时区服务暂时不可用,稍后重试即可。」加「重试」按钮,草稿保留,「确定」按钮不可点。不能手填经纬度或时区 | 不涉及 | 路由和服务都不写日志 | `birth-place-picker.tsx:272-276`、`:490-493`;`home-onboarding-shell.tsx:137` [读码];[实测] stub 503 / 连接拒绝 / 坏 JSON 都返回 `unavailable` |
| 新建人物,时区接口卡住 | 「正在确认该地点的时区…」一直转,我们的代码没有超时,要等 Node 默认约 300 秒 | 不涉及 | 无 | 选择器到 Next 再到 Python 三段都没有 AbortSignal 或超时 [读码];[实测] stub 卡住时转圈不结束 |
-| **修改已存人物的出生日期、时间或地点,时区接口失败** | **已存的出生地被清空,没有报错,也没有「重试」按钮**,提示回到「按省、市、区县逐级选择即可。」用户可能以为已经存好 | 不涉及 | 无 | 失败时 `onChange(null)`(`birth-place-picker.tsx:274-275`),父组件把地点代码清空(`birth-location-fields.tsx:21-35`),选择器按新键重置草稿(`:129-132`)。[实测] 真实渲染 + fetch stub。记为 BUG-1506 |
+| **修改已存人物的出生日期、时间或地点,时区接口失败** | **已存的出生地被清空,没有报错,也没有「重试」按钮**,提示回到「按省、市、区县逐级选择即可。」用户可能以为已经存好 | 不涉及 | 无 | 失败时 `onChange(null)`(`birth-place-picker.tsx:274-275`),父组件把地点代码清空(`birth-location-fields.tsx:21-35`),选择器按新键重置草稿(`:129-132`)。[实测] 真实渲染 + fetch stub。记为 BUG-1506。**2026-11-02 已修**:保留原地点,显示「时区这会儿没查到,原来的出生地还在,查到后才能保存。」加「重试」,保存按钮在查到前不能点 |
| 海外国家地区列表加载失败 | 「该国家的地区列表暂时不可用,请稍后重试。」没有重试按钮,要换国家再换回来 | 不涉及 | 无 | `birth-place-picker.tsx:149-171`、`:336` [读码] |
| 星盘页:本人资料没存时区,查询时时区失败 | 「出生地的时区没有对上,这张盘先不能算。我们已经记录下来了。」引导去改资料。这是临时故障,文案却让用户以为资料有错;「已经记录下来」实际只是一行 `console.warn`。卡住时 25 秒后显示「这张盘等了太久没有回来…」 | `billed:false` | `console.warn("chart_view_failure", {reason:"timezone_resolver_failure"})`,不告警 | `frontend/src/lib/chart-view-service.ts:190-199`、`chart-view-labels.ts:184`、`:205` [读码];测试 `chart-profile-update-consistency.test.ts`「classifies query, timezone…」 |
| 对话:本人资料没存时区,时区失败 | 503「暂时无法核对完整出生资料…请重新保存后再试,本次不会扣点。」同样把临时故障说成资料问题 | **不扣点**:时区在 `reserve()` 之前解析 | 不写日志 | `consultation-route-service.ts:567-574` 对比 `:622` [读码];测试 `consultation-route-service.test.ts`「historical timezone failure remains pre-billing」[实测通过] |
@@ -116,7 +116,7 @@ Gitea #119 第 42 项:「VedAstro、地点服务、模型供应商挂掉时网
1. **报告任务租约过期不退点(BUG-1505)。** 进程在最后一次尝试时重启就会触发,发版时尤其容易遇到。点数扣掉了、报告失败,用户要么删报告,要么重新付一次。退点 RPC 失败也被忽略。
2. **报告生成同步等 VedAstro,没有总时限。** VedAstro 卡住时约 128 秒,超过前端 90 秒,报告失败;同时占着两个重计算名额中的一个,并发两份报告会让对话和校正拿到 429。这条路径的失败也不写失败缓存,下一份报告会重来一遍。可选做法:报告也走 `defer_optional_external_evidence`,或者跳过主入口概览,或者复用对话的前台预算和失败缓存。
3. **VedAstro 连接被截断会变成 500(BUG-1504)。** 报告失败(会退点),不带 skip 的 `/api/chart` 也会 500。
-4. **出生资料编辑时,时区服务失败会默默清掉出生地(BUG-1506)。**
+4. **出生资料编辑时,时区服务失败会默默清掉出生地(BUG-1506)。**2026-11-02 已修。
5. **早期中国资料默默按 +8 计算(BUG-1507)。** 这是正确性问题,与故障无关:1986–1991 年夏季出生的差一小时。新疆 +6 要产品决定。
6. **模型调用不重试、没有备用供应商。** 一次 429 或 503 就让付费用户这一轮失败。会退点,但体验差;目录里配了多个模型,失败时也不切换。
7. **外部依赖挂了没有任何告警或日志。** 健康检查不探测,VedAstro 模块不写日志,报告侧模型失败不写 `errorCode`。凌晨低流量时,对话也达不到 5 分钟 10 次的阈值。
diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md
index 3a723c41..3e861204 100644
--- a/frontend/DESIGN.md
+++ b/frontend/DESIGN.md
@@ -386,6 +386,7 @@ The birth-time rectification session is the consultation transcript plus a house
- **Value:** administrative codes, the node centre coordinate, and the IANA zone are all persisted. Coordinates come from the dataset; the zone is resolved once per chosen place through the chart engine rather than per keystroke.
- **States:** nothing chosen, partially chosen, resolving the zone, resolved, and zone service unavailable with an explicit retry. An unresolved zone is never reported upward as a usable birth place.
- **Saved profiles:** mounting an already-saved place neither refetches nor reports a change, so opening the profile dialog cannot look like a location edit. Changing the birth date does invalidate the stored offset and resolves the zone again.
+- **Never clears (2026-11-02, BUG-1506):** the picker reports a place only when a fresh zone comes back. A half-chosen level or a failed lookup keeps the profile's previous place and offset; the picker reports *pending* instead, and the form's submit (`BirthPlaceSubmitButton` inside `BirthPlacePendingScope`: the edit dialogs' 保存, onboarding's 确定) stays disabled until the lookup succeeds or the reader goes back to the saved place and moment. A failed lookup on a person who already has a place reads 「时区这会儿没查到,原来的出生地还在,查到后才能保存。」 with 重试; with no place yet it stays 「时区服务暂时不可用,稍后重试即可。」 with 重试. The error replaces the status line in place: no extra hint row, no height change. An offset resolved for an edited date is not reused when the date goes back.
- **Accessibility:** every level keeps a visible label, an explicit trigger name, and the 44px target from the select recipe; status text uses a polite live region.
### Birth date picker
diff --git a/frontend/src/components/birth-location-fields.tsx b/frontend/src/components/birth-location-fields.tsx
index b8c936c6..7ebc5e45 100644
--- a/frontend/src/components/birth-location-fields.tsx
+++ b/frontend/src/components/birth-location-fields.tsx
@@ -1,9 +1,12 @@
"use client";
+import { useBirthPlacePendingReporter } from "@/components/birth-place-pending";
import { BirthPlacePicker } from "@/components/birth-place-picker";
import type { Profile } from "@/lib/home-types";
export function BirthLocationFields({ value, onChange }: { value: Profile; onChange: (profile: Profile) => void }) {
+ // The enclosing form holds its submit while the picker still needs a fresh timezone (BUG-1506).
+ const reportPending = useBirthPlacePendingReporter();
return (
diff --git a/frontend/src/components/birth-place-pending.tsx b/frontend/src/components/birth-place-pending.tsx
new file mode 100644
index 00000000..0fb7ccaf
--- /dev/null
+++ b/frontend/src/components/birth-place-pending.tsx
@@ -0,0 +1,30 @@
+"use client";
+
+import { createContext, useContext, useMemo, useState, type ComponentProps, type ReactNode } from "react";
+import { Button } from "@/components/ui/button";
+
+type BirthPlacePending = { readonly pending: boolean; readonly report: (pending: boolean) => void };
+
+const BirthPlacePendingContext = createContext(null);
+
+/**
+ * Wraps a form that holds a birth place picker. While the picker still needs a fresh
+ * timezone (unfinished levels, a lookup in flight or a failed lookup), the profile keeps
+ * the previous place and offset, so the form's submit button stays disabled (BUG-1506).
+ */
+export function BirthPlacePendingScope({ children }: { readonly children: ReactNode }) {
+ const [pending, report] = useState(false);
+ const value = useMemo(() => ({ pending, report }), [pending]);
+ return {children};
+}
+
+/** The picker inside a scope reports here; outside a scope there is nothing to hold. */
+export function useBirthPlacePendingReporter() {
+ return useContext(BirthPlacePendingContext)?.report;
+}
+
+/** The form's submit button, disabled while the scope's picker is pending. */
+export function BirthPlaceSubmitButton({ disabled, ...props }: Omit, "type">) {
+ const pending = useContext(BirthPlacePendingContext)?.pending ?? false;
+ return ;
+}
diff --git a/frontend/src/components/birth-place-picker.tsx b/frontend/src/components/birth-place-picker.tsx
index 73c0e26d..30b7aed1 100644
--- a/frontend/src/components/birth-place-picker.tsx
+++ b/frontend/src/components/birth-place-picker.tsx
@@ -50,7 +50,14 @@ type BirthPlacePickerProps = {
birthDate?: string;
birthTime?: string;
disabled?: boolean;
- onChange: (selection: BirthPlaceSelection | null) => void;
+ /** Reports a freshly resolved place only. A failed or unfinished choice never clears the value. */
+ onChange: (selection: BirthPlaceSelection) => void;
+ /**
+ * True while the shown choice or birth moment needs a timezone the value does not carry yet
+ * (unfinished levels, a lookup in flight, or a failed lookup). The form must not save then:
+ * the value still holds the previous place and offset (BUG-1506).
+ */
+ onPendingChange?: (pending: boolean) => void;
};
type TimezoneState =
@@ -97,6 +104,10 @@ function draftFromValue(value: SavedBirthPlace): BirthPlaceDraft {
};
}
+function draftKey(draft: BirthPlaceDraft) {
+ return `${draft.countryCode}/${draft.provinceCode}/${draft.cityCode}/${draft.districtCode}/${draft.cityCentre ? "centre" : ""}`;
+}
+
function countryLabel(countryCode: string) {
return worldCountries.find((item) => item.code === countryCode)?.name ?? countryCode;
}
@@ -107,14 +118,15 @@ export function BirthPlacePicker({
birthTime = "",
disabled = false,
onChange,
+ onPendingChange,
}: BirthPlacePickerProps) {
const countryCode = value.countryCode?.trim().toUpperCase() || "CN";
const fieldPrefix = useId();
const onChangeRef = useRef(onChange);
- const savedRef = useRef(value);
+ const onPendingChangeRef = useRef(onPendingChange);
useEffect(() => {
onChangeRef.current = onChange;
- savedRef.current = value;
+ onPendingChangeRef.current = onPendingChange;
});
const externalKey = `${countryCode}/${value.provinceCode}/${value.cityCode}/${value.districtCode}`;
@@ -124,11 +136,15 @@ export function BirthPlacePicker({
const [globalLoadErrorCountry, setGlobalLoadErrorCountry] = useState("");
const [outcome, setOutcome] = useState(null);
const [retryToken, setRetryToken] = useState(0);
+ // The birth moment the value's offset was resolved for. A historical offset depends on it.
+ const birthMoment = `${birthDate}/${birthTime}`;
+ const [valueMoment, setValueMoment] = useState(birthMoment);
- // Adopt a profile that was loaded or replaced outside this picker.
+ // Adopt a profile that was loaded or replaced outside this picker, or the place this picker just resolved.
if (syncedKey !== externalKey) {
setSyncedKey(externalKey);
setDraft(draftFromValue(value));
+ setValueMoment(birthMoment);
}
const isChina = draft.countryCode === "CN";
@@ -208,12 +224,15 @@ export function BirthPlacePicker({
}
: globalResolved;
- // The offset the profile already carries is only authoritative while the birth moment is unchanged.
- const birthMoment = `${birthDate}/${birthTime}`;
- const [mountedMoment] = useState(birthMoment);
+ // The offset the profile already carries is only authoritative for the birth moment it was resolved for.
+ const valueHasZone = value.timezoneId.trim() !== "";
+ const valueHasPlace = valueHasZone || countryCode !== "CN" || Boolean(value.provinceCode);
+ const valueIsCurrent = valueHasZone
+ && valueMoment === birthMoment
+ && draftKey(draft) === draftKey(draftFromValue(value));
const savedIsCurrent = resolved !== null
- && mountedMoment === birthMoment
- && value.timezoneId.trim() !== ""
+ && valueMoment === birthMoment
+ && valueHasZone
&& countryCode === resolved.countryCode
&& value.provinceCode === resolved.provinceCode
&& value.cityCode === resolved.cityCode
@@ -226,14 +245,11 @@ export function BirthPlacePicker({
: "empty";
useEffect(() => {
- if (syncKey === "saved") return;
- if (syncKey === "empty") {
- const saved = savedRef.current;
- if (saved.countryCode !== "CN" || saved.provinceCode || saved.timezoneId) onChangeRef.current(null);
- return;
- }
+ // An unfinished choice keeps the previous value; the pending flag holds the save instead.
+ if (syncKey === "saved" || syncKey === "empty") return;
const target = resolved as NonNullable;
+ const lookupMoment = birthMoment;
const controller = new AbortController();
(async () => {
try {
@@ -254,7 +270,9 @@ export function BirthPlacePicker({
const timezoneId = typeof payload.timezoneId === "string" ? payload.timezoneId.trim() : "";
if (!timezoneId) throw new Error("timezone_unavailable");
const timezoneOffset = typeof payload.timezoneOffset === "number" ? payload.timezoneOffset : null;
+ if (controller.signal.aborted) return;
setOutcome({ key: syncKey, state: { status: "ready", timezoneId, timezoneOffset } });
+ setValueMoment(lookupMoment);
onChangeRef.current({
countryCode: target.countryCode,
provinceCode: target.provinceCode,
@@ -271,8 +289,8 @@ export function BirthPlacePicker({
});
} catch {
if (controller.signal.aborted) return;
+ // Keep the value: a failed lookup must not erase the saved place (BUG-1506).
setOutcome({ key: syncKey, state: { status: "error" } });
- onChangeRef.current(null);
}
})();
return () => controller.abort();
@@ -320,6 +338,14 @@ export function BirthPlacePicker({
: outcome?.key === syncKey
? outcome.state
: { status: "loading" };
+ const pending = syncKey === "saved"
+ ? false
+ : syncKey === "empty"
+ ? valueHasPlace && !valueIsCurrent
+ : settled.status !== "ready";
+ useEffect(() => { onPendingChangeRef.current?.(pending); }, [pending]);
+ useEffect(() => () => onPendingChangeRef.current?.(false), []);
+
const globalIsLoading = !isChina
&& globalData?.code !== draft.countryCode
&& globalLoadErrorCountry !== draft.countryCode;
@@ -329,7 +355,9 @@ export function BirthPlacePicker({
: settled.status === "ready" && resolved
? `${resolved.label} · ${formatBirthPlaceCoordinate(resolved.latitude, resolved.longitude)} · ${formatBirthPlaceTimezone(settled.timezoneId, settled.timezoneOffset)}`
: settled.status === "error"
- ? "时区服务暂时不可用,稍后重试即可。"
+ ? valueHasPlace
+ ? "时区这会儿没查到,原来的出生地还在,查到后才能保存。"
+ : "时区服务暂时不可用,稍后重试即可。"
: globalIsLoading
? "正在加载该国家的地区列表…"
: globalHasError
diff --git a/frontend/src/components/chart-profile-form.tsx b/frontend/src/components/chart-profile-form.tsx
index 69a0c61b..a5f9c154 100644
--- a/frontend/src/components/chart-profile-form.tsx
+++ b/frontend/src/components/chart-profile-form.tsx
@@ -2,6 +2,7 @@
import type { Dispatch, FormEvent, ReactNode, SetStateAction } from "react";
import { AyanamsaPreferenceField } from "@/components/ayanamsa-preference-field";
+import { BirthPlacePendingScope, BirthPlaceSubmitButton } from "@/components/birth-place-pending";
import { Button } from "@/components/ui/button";
import { ProfileFields } from "@/components/profile-fields";
import { PersonRelationField } from "@/components/person-relation-field";
@@ -48,39 +49,41 @@ export function ChartProfileForm({
notice,
}: ChartProfileFormProps) {
return (
-
+
);
}
diff --git a/frontend/src/components/home-onboarding-shell.tsx b/frontend/src/components/home-onboarding-shell.tsx
index b3767d59..c04b7b73 100644
--- a/frontend/src/components/home-onboarding-shell.tsx
+++ b/frontend/src/components/home-onboarding-shell.tsx
@@ -3,6 +3,7 @@
import type { FormEvent } from "react";
import { BirthTimeAssessmentOverlay, type BirthTimeAssessmentPhase } from "@/components/birth-time-assessment-overlay";
import { BirthLocationFields } from "@/components/birth-location-fields";
+import { BirthPlacePendingScope, BirthPlaceSubmitButton } from "@/components/birth-place-pending";
import { BirthTimeIntakeFields } from "@/components/birth-time-intake";
import { rememberSkyOrigin } from "@/components/birth-sky/sky-origin";
import { OnboardingChatMessage } from "@/components/onboarding-chat-message";
@@ -130,14 +131,16 @@ export function HomeOnboardingShell({
{!profileComplete && onboardingStep === "place" && onboardingCardReady && (
-
+
+
+
)}
diff --git a/frontend/tests/birth-place-picker.test.ts b/frontend/tests/birth-place-picker.test.ts
index a9bec3b3..4d099a58 100644
--- a/frontend/tests/birth-place-picker.test.ts
+++ b/frontend/tests/birth-place-picker.test.ts
@@ -111,10 +111,18 @@ test("timezone is resolved once for the chosen place rather than during browsing
assert.equal(existsSync(new URL("../src/app/api/locations/timezone/route.ts", import.meta.url)), true);
assert.match(pickerSource, /fetch\("\/api\/locations\/timezone"/);
// Browsing the levels cannot spend a lookup: only a resolved place gets a sync key.
- assert.match(pickerSource, /if \(syncKey === "empty"\)/);
+ // 原值:`if (syncKey === "empty")`(单独一个分支,里面上报 null)。
+ // 新值:`if (syncKey === "saved" || syncKey === "empty") return;`,没选完不发请求也不上报。
+ // 原因:BUG-1506,见下一条测试。
+ assert.match(pickerSource, /syncKey === "empty"\) return;/);
assert.match(pickerSource, /: \{ status: "loading" \};/);
// An unresolved zone must not be reported upward as a usable birth place.
- assert.match(pickerSource, /setOutcome\(\{ key: syncKey, state: \{ status: "error" \} \}\);\s*onChangeRef\.current\(null\);/);
+ // 原值:断言失败分支 `setOutcome(error)` 之后紧跟 `onChangeRef.current(null)`。
+ // 新值:失败分支只记错误,不再上报 null;选择器的 onChange 也不再接受 null。
+ // 原因:BUG-1506,上报 null 会把已存的出生地清空。现在靠 onPendingChange 拦住保存,行为测试在 birth-place-timezone-failure-20261102.test.tsx。
+ assert.match(pickerSource, /setOutcome\(\{ key: syncKey, state: \{ status: "error" \} \}\);\s*\}/);
+ assert.doesNotMatch(pickerSource, /onChangeRef\.current\(null\)/);
+ assert.match(pickerSource, /onChange: \(selection: BirthPlaceSelection\) => void;/);
const service = readFileSync(new URL("../src/lib/birth-location-timezone-service.ts", import.meta.url), "utf8");
assert.match(service, /\/api\/location\/timezone/);
@@ -125,19 +133,27 @@ test("mounting a saved birth place neither refetches nor clears the stored locat
// Reporting null on mount would look like a location edit and would drop a
// rectified birth-time candidate just because the profile dialog was opened.
assert.match(pickerSource, /const savedIsCurrent = resolved !== null/);
- assert.match(pickerSource, /if \(syncKey === "saved"\) return;/);
- assert.match(pickerSource, /if \(saved\.countryCode !== "CN" \|\| saved\.provinceCode \|\| saved\.timezoneId\) onChangeRef\.current\(null\);/);
+ // 原值:断言 `if (syncKey === "saved") return;`,以及选空时 `if (saved.countryCode !== "CN" || saved.provinceCode || saved.timezoneId) onChangeRef.current(null);`。
+ // 新值:已存和没选完都直接返回,不上报。
+ // 原因:BUG-1506,没选完时上报 null 会清空已存地点,草稿也跟着按空值重置(改省份、改国家、打开海外人物都会中招)。
+ assert.match(pickerSource, /if \(syncKey === "saved" \|\| syncKey === "empty"\) return;/);
// A changed birth date invalidates the stored offset, so the zone is resolved again.
- assert.match(pickerSource, /mountedMoment === birthMoment/);
+ // 原值:`mountedMoment === birthMoment`。
+ // 新值:`valueMoment === birthMoment`,valueMoment 是资料里这份时区对应的出生时刻。
+ // 原因:BUG-1506,改日期查到新时区后再改回原日期,旧写法会把新日期的时区当成原日期的存下去。
+ assert.match(pickerSource, /valueMoment === birthMoment/);
});
test("the profile stores administrative codes alongside coordinates and the IANA zone", () => {
assert.match(pageSource, / {
+ const url = String(input);
+ if (url.includes("/data/world-locations/")) {
+ return options.regions === "fail"
+ ? new Response("", { status: 503 })
+ : new Response(JSON.stringify(usRegions), { status: 200 });
+ }
+ lookups.push(JSON.parse(String(init?.body ?? "{}")));
+ if (reply === "fail") return new Response("{}", { status: 503 });
+ return new Response(JSON.stringify(reply), { status: 200 });
+ }) as typeof fetch;
+
+ const state: { profile: Profile; set: (next: Profile) => void } = { profile: initial, set: () => {} };
+ function Host() {
+ const [profile, setProfile] = useState(initial);
+ state.profile = profile;
+ state.set = setProfile;
+ return (
+ event.preventDefault()}
+ submitLabel="保存"
+ submitDisabled={false}
+ />
+ );
+ }
+
+ await harness.render();
+ await harness.idle();
+
+ const byClass = (name: string) => harness.elements().find((node) => (node.getAttribute("class") ?? "").split(/\s+/).includes(name));
+ return {
+ lookups,
+ state,
+ reply(next: Reply) { reply = next; },
+ status: () => byClass("birth-place-status")?.text ?? "",
+ retry: () => byClass("birth-place-retry"),
+ saveDisabled: () => Boolean(byClass("save-profile")?.hasAttribute("disabled")),
+ async edit(patch: Partial) {
+ await harness.update(() => state.set({ ...state.profile, ...patch }));
+ await harness.idle();
+ },
+ async choose(levelLabel: string, code: string) {
+ const nodes = harness.elements();
+ const trigger = nodes.findIndex((node) => node.getAttribute("aria-label") === levelLabel);
+ assert.notEqual(trigger, -1, `${levelLabel} is rendered`);
+ const input = nodes.slice(trigger).find((node) => node.tagName === "INPUT");
+ assert.ok(input, `${levelLabel} has its hidden input`);
+ await harness.event(input, "onChange", { target: Object.assign(input, { value: code }) });
+ await harness.idle();
+ },
+ async clickRetry() {
+ await harness.event(byClass("birth-place-retry")!);
+ await harness.idle();
+ },
+ async close() {
+ try { await harness.close(); } finally { globalThis.fetch = originalFetch; }
+ },
+ };
+}
+
+function assertStillHandan(profile: Profile) {
+ assert.equal(profile.provinceCode, "130000");
+ assert.equal(profile.cityCode, "130400");
+ assert.equal(profile.districtCode, "130402");
+ assert.equal(profile.birthPlaceLabel, "中国 · 河北省 · 邯郸市 · 邯山区");
+ assert.equal(profile.latitude, 36.603196);
+ assert.equal(profile.timezoneId, "Asia/Shanghai");
+ assert.ok(selectedBirthPlace(profile), "the saved place is still a usable birth place");
+}
+
+test("a failed lookup after a date edit keeps the saved place, shows the error with a retry and holds the save", async () => {
+ const form = await mountForm(savedHandan);
+ try {
+ // Opening the saved person neither looks the zone up nor holds the save.
+ assert.equal(form.lookups.length, 0);
+ assert.match(form.status(), /邯山区.*Asia\/Shanghai · UTC\+8/);
+ assert.equal(form.saveDisabled(), false);
+
+ await form.edit({ date: "1990-05-02" });
+ assert.equal(form.lookups.length, 1);
+ assert.deepEqual(form.lookups[0], { latitude: 36.603196, longitude: 114.484989, birthDate: "1990-05-02", birthTime: "10:00" });
+ assert.ok(form.status().includes(EDIT_FAILURE), form.status());
+ assert.doesNotMatch(form.status(), /按省、市、区县逐级选择即可/);
+ assert.ok(form.retry(), "the error carries a retry");
+ assertStillHandan(form.state.profile);
+ // The stored offset belongs to the old date: it must not be saved for the new one.
+ assert.equal(form.state.profile.timezoneOffset, 8);
+ assert.equal(form.saveDisabled(), true);
+
+ form.reply({ timezoneId: "Asia/Shanghai", timezoneOffset: 9 });
+ await form.clickRetry();
+ assert.equal(form.lookups.length, 2);
+ assert.ok(!form.status().includes(EDIT_FAILURE));
+ assert.match(form.status(), /Asia\/Shanghai · UTC\+9/);
+ assert.equal(form.retry(), undefined);
+ assert.equal(form.state.profile.timezoneOffset, 9);
+ assert.equal(form.state.profile.date, "1990-05-02");
+ assert.equal(form.saveDisabled(), false);
+ } finally {
+ await form.close();
+ }
+});
+
+test("reverting the edit after a failed lookup restores the saved offset without another lookup", async () => {
+ const form = await mountForm(savedHandan);
+ try {
+ await form.edit({ reportedTime: "11:00", time: "11:00" });
+ assert.equal(form.lookups.length, 1);
+ assert.equal(form.saveDisabled(), true);
+
+ await form.edit({ reportedTime: "10:00", time: "10:00" });
+ assert.equal(form.lookups.length, 1);
+ assert.match(form.status(), /Asia\/Shanghai · UTC\+8/);
+ assert.equal(form.saveDisabled(), false);
+ } finally {
+ await form.close();
+ }
+});
+
+test("an offset resolved for an edited date is looked up again when the date goes back", async () => {
+ const form = await mountForm(savedHandan);
+ try {
+ form.reply({ timezoneId: "Asia/Shanghai", timezoneOffset: 9 });
+ await form.edit({ date: "1990-05-02" });
+ assert.equal(form.state.profile.timezoneOffset, 9);
+
+ form.reply("fail");
+ await form.edit({ date: "1990-05-01" });
+ // 9 was resolved for 05-02; saving it silently for 05-01 would be the stale offset.
+ assert.equal(form.lookups.length, 2);
+ assert.ok(form.status().includes(EDIT_FAILURE));
+ assert.equal(form.saveDisabled(), true);
+ } finally {
+ await form.close();
+ }
+});
+
+test("changing the place keeps the saved place while choosing and after a failed lookup", async () => {
+ const form = await mountForm(savedHandan);
+ try {
+ await form.choose("出生省份", "440000");
+ // The half-chosen province stays on screen instead of snapping back to an empty picker.
+ assert.match(form.status(), /按省、市、区县逐级选择即可/);
+ assert.ok(form.state.profile.provinceCode === "130000");
+ assertStillHandan(form.state.profile);
+ assert.equal(form.saveDisabled(), true);
+ assert.equal(form.lookups.length, 0);
+
+ await form.choose("出生城市", "441900");
+ assert.equal(form.lookups.length, 1);
+ assert.ok(form.status().includes(EDIT_FAILURE));
+ assertStillHandan(form.state.profile);
+ assert.equal(form.saveDisabled(), true);
+
+ form.reply({ timezoneId: "Asia/Shanghai", timezoneOffset: 8 });
+ await form.clickRetry();
+ assert.equal(form.state.profile.provinceCode, "440000");
+ assert.equal(form.state.profile.cityCode, "441900");
+ assert.match(form.state.profile.birthPlaceLabel, /东莞市/);
+ assert.match(form.status(), /东莞市/);
+ assert.equal(form.saveDisabled(), false);
+ } finally {
+ await form.close();
+ }
+});
+
+test("a new person with no saved place gets the retry and no usable place on failure", async () => {
+ const form = await mountForm({ ...emptyProfile, name: "乙", date: "1992-03-04", reportedTime: "08:00", time: "08:00" });
+ try {
+ assert.equal(form.lookups.length, 0);
+ assert.match(form.status(), /按省、市、区县逐级选择即可/);
+ assert.equal(form.saveDisabled(), false, "the picker alone holds nothing while nothing is chosen");
+
+ await form.choose("出生省份", "440000");
+ await form.choose("出生城市", "441900");
+ assert.equal(form.lookups.length, 1);
+ assert.ok(form.status().includes(NEW_FAILURE), form.status());
+ assert.ok(form.retry());
+ assert.equal(form.state.profile.provinceCode, "");
+ assert.equal(selectedBirthPlace(form.state.profile), null);
+ assert.equal(form.saveDisabled(), true);
+
+ form.reply({ timezoneId: "Asia/Shanghai", timezoneOffset: 8 });
+ await form.clickRetry();
+ assert.equal(form.state.profile.cityCode, "441900");
+ assert.equal(form.state.profile.timezoneOffset, 8);
+ assert.ok(selectedBirthPlace(form.state.profile));
+ assert.equal(form.saveDisabled(), false);
+ } finally {
+ await form.close();
+ }
+});
+
+test("opening a saved overseas person keeps the place while the region list loads or fails", async () => {
+ const savedAbbeville: Profile = {
+ ...savedHandan,
+ countryCode: "US",
+ provinceCode: "US-AL",
+ cityCode: "110968",
+ districtCode: "",
+ birthPlaceLabel: "美国 · Alabama · Abbeville",
+ birthPlaceType: "city",
+ birthPlaceProvider: "world_location_table",
+ birthPlaceProviderId: "world:US:US-AL:110968",
+ timezoneId: "America/Chicago",
+ latitude: 31.57184,
+ longitude: -85.25049,
+ timezoneOffset: -5,
+ };
+ for (const regions of ["ok", "fail"] as const) {
+ const form = await mountForm(savedAbbeville, { regions });
+ try {
+ // Before the fix the picker reported null while the list loaded: the place was cleared and the picker fell back to China.
+ assert.equal(form.lookups.length, 0, regions);
+ assert.equal(form.state.profile.countryCode, "US", regions);
+ assert.equal(form.state.profile.cityCode, "110968", regions);
+ assert.equal(form.state.profile.timezoneId, "America/Chicago", regions);
+ assert.equal(form.saveDisabled(), false, regions);
+ if (regions === "ok") assert.match(form.status(), /Abbeville.*America\/Chicago · UTC[−-]5/);
+ } finally {
+ await form.close();
+ }
+ }
+});
+
+test("every form that holds the picker holds its submit through the pending scope", () => {
+ const read = (path: string) => readFileSync(new URL(path, import.meta.url), "utf8");
+ const fields = read("../src/components/birth-location-fields.tsx");
+ assert.match(fields, /onPendingChange=\{reportPending\}/);
+ // The edit dialogs (people page, chart page) share ChartProfileForm; the onboarding place card has its own form.
+ for (const path of ["../src/components/chart-profile-form.tsx", "../src/components/home-onboarding-shell.tsx"]) {
+ const source = read(path);
+ const scope = source.slice(source.indexOf(""), source.indexOf(""));
+ assert.ok(scope.includes("