fix(profile): keep the saved birth place when the timezone lookup fails (BUG-1506)

The birth place picker reported null on a failed timezone lookup and on a
half-chosen level; BirthLocationFields then cleared the place and the picker
reset its draft. Editing a saved person's date, time or place during a
timezone outage silently erased the birthplace with no error or retry.
The same path reset the picker when changing province/city/country on edit
and when opening an overseas person while the region list loaded.

- The picker only reports a freshly resolved place; failures and unfinished
  choices keep the previous value and report "pending" instead.
- BirthPlacePendingScope + BirthPlaceSubmitButton disable 保存 (edit dialogs)
  and 确定 (onboarding) while pending.
- Failure copy for a person with a place: 「时区这会儿没查到,原来的出生地还在,
  查到后才能保存。」 with 重试; new profiles keep the existing copy.
- Track the moment the stored offset was resolved for, so going back to the
  original date after a successful lookup re-resolves instead of saving a
  stale offset; ignore late results from aborted lookups.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DbBXoyeZrYn6ryNcSe5Y2Y
This commit is contained in:
2026-11-02 00:58:09 +08:00
co-authored by Claude Opus 5.5
parent ba1b4be71d
commit 4ab81821cd
11 changed files with 482 additions and 93 deletions
+8
View File
@@ -1,5 +1,13 @@
# 印度占星 Skill 更新日志
## 2026-11-02 — 改出生资料时,时区没查到不再清掉出生地
- 修改已存人物的出生日期、时间或地点后,如果时区这会儿查不到,原来的出生地会留着,地点下面写「时区这会儿没查到,原来的出生地还在,查到后才能保存。」,旁边有「重试」。查到之前「保存」不能点,所以不会把旧日期的时区存进新日期;改回原来的日期和地点也可以直接保存。
- 新建人物时查不到时区,仍是「时区服务暂时不可用,稍后重试即可。」加「重试」,「确定」不能点。
- 顺带修了同一个原因的两处:编辑时改省份、城市或国家,选到一半不会再跳回空白要重选;打开海外人物的编辑,不会再被清空并跳回中国。
- 改日期查到新时区后又改回原日期,会重新查一次,不再沿用刚才那个日期的时区。
- 记为 BUG-1506。Skill 版本不变。
## 2026-11-01 — 年运报告:从「我的报告」直接打开,写好一节出一节
- 「我的报告」多了一张「年运」卡片,点进去就是年运报告页,没有单独的入口页。个人报告卡片位置不变。
+15 -10
View File
@@ -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 夏令时出生差一小时)
@@ -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 次的阈值。
+1
View File
@@ -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
@@ -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 (
<fieldset className="location-fieldset">
<legend>出生地点</legend>
@@ -18,21 +21,22 @@ export function BirthLocationFields({ value, onChange }: { value: Profile; onCha
}}
birthDate={value.date}
birthTime={value.reportedTime || value.time}
onPendingChange={reportPending}
onChange={(location) => onChange({
...value,
countryCode: location?.countryCode || "CN",
provinceCode: location?.provinceCode || "",
cityCode: location?.cityCode || "",
districtCode: location?.districtCode || "",
birthPlaceLabel: location?.label || "",
birthPlaceType: location?.placeType || "",
birthPlaceProvider: location?.provider || "",
birthPlaceProviderId: location?.providerPlaceId || "",
timezoneId: location?.timezoneId || "",
timezoneSource: location ? "iana_historical" : "",
latitude: location?.latitude ?? null,
longitude: location?.longitude ?? null,
timezoneOffset: location?.timezoneOffset ?? null,
countryCode: location.countryCode,
provinceCode: location.provinceCode,
cityCode: location.cityCode,
districtCode: location.districtCode,
birthPlaceLabel: location.label,
birthPlaceType: location.placeType,
birthPlaceProvider: location.provider,
birthPlaceProviderId: location.providerPlaceId,
timezoneId: location.timezoneId,
timezoneSource: "iana_historical",
latitude: location.latitude,
longitude: location.longitude,
timezoneOffset: location.timezoneOffset,
})}
/>
</fieldset>
@@ -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<BirthPlacePending | null>(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 <BirthPlacePendingContext.Provider value={value}>{children}</BirthPlacePendingContext.Provider>;
}
/** 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<ComponentProps<typeof Button>, "type">) {
const pending = useContext(BirthPlacePendingContext)?.pending ?? false;
return <Button {...props} type="submit" disabled={Boolean(disabled) || pending} />;
}
+45 -17
View File
@@ -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<TimezoneOutcome | null>(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<typeof resolved>;
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
+36 -33
View File
@@ -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 (
<form className="profile-form chart-library-form" onSubmit={onSubmit}>
<div className="section-heading"><b>{title}</b><small>{description}</small></div>
<ProfileFields
value={value}
onChange={onChange}
nameInputId={nameInputId}
lockReportedBirthTime={lockReportedBirthTime}
/>
{showRelation ? (
<PersonRelationField
idPrefix={nameInputId}
value={value.relation}
onChange={(relation) => onChange((current) => ({ ...current, relation }))}
<BirthPlacePendingScope>
<form className="profile-form chart-library-form" onSubmit={onSubmit}>
<div className="section-heading"><b>{title}</b><small>{description}</small></div>
<ProfileFields
value={value}
onChange={onChange}
nameInputId={nameInputId}
lockReportedBirthTime={lockReportedBirthTime}
/>
) : null}
{showGender ? (
<ProfileGenderField
idPrefix={nameInputId}
value={value.gender}
onChange={(gender) => onChange((current) => ({ ...current, gender }))}
/>
) : null}
{showAyanamsa ? (
<AyanamsaPreferenceField
value={resolveAyanamsa(value)}
onChange={(ayanamsa) => onChange((current) => ({ ...current, ayanamsa }))}
/>
) : null}
{notice ? <p className="chart-birth-edit-notice" role="status">{notice}</p> : null}
<div className="dialog-actions">
{onCancel ? <Button className="button-secondary" type="button" variant="outline" onClick={onCancel}>{cancelLabel}</Button> : null}
<Button className="button-primary save-profile" type="submit" disabled={submitDisabled}>{submitLabel}</Button>
</div>
</form>
{showRelation ? (
<PersonRelationField
idPrefix={nameInputId}
value={value.relation}
onChange={(relation) => onChange((current) => ({ ...current, relation }))}
/>
) : null}
{showGender ? (
<ProfileGenderField
idPrefix={nameInputId}
value={value.gender}
onChange={(gender) => onChange((current) => ({ ...current, gender }))}
/>
) : null}
{showAyanamsa ? (
<AyanamsaPreferenceField
value={resolveAyanamsa(value)}
onChange={(ayanamsa) => onChange((current) => ({ ...current, ayanamsa }))}
/>
) : null}
{notice ? <p className="chart-birth-edit-notice" role="status">{notice}</p> : null}
<div className="dialog-actions">
{onCancel ? <Button className="button-secondary" type="button" variant="outline" onClick={onCancel}>{cancelLabel}</Button> : null}
<BirthPlaceSubmitButton className="button-primary save-profile" disabled={submitDisabled}>{submitLabel}</BirthPlaceSubmitButton>
</div>
</form>
</BirthPlacePendingScope>
);
}
@@ -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 && (
<div className="onboarding-card-reveal" data-onboarding-enter="place-card" data-onboarding-index={4}>
<div className="onboarding-card-reveal-inner">
<form className="profile-form onboarding-card onboarding-step-card birth-time-transition-card" onSubmit={saveOnboardingPlace} aria-busy={birthTimeAssessmentPhase !== null}>
<div className="onboarding-card-heading"><b>出生地点</b><small>搜索全球城市或区县,系统会匹配出生当日的时区。</small></div>
<fieldset className="birth-time-transition-fields" disabled={birthTimeAssessmentPhase !== null}>
<BirthLocationFields value={profileDraft} onChange={setProfileDraft} />
<div className="onboarding-card-actions"><Button className="button-primary" type="submit" data-onboarding-press="" onClick={(event) => rememberSkyOrigin(event.clientX, event.clientY)} disabled={profileSaving || !selectedBirthPlace(profileDraft)}>{profileSaving ? "保存中" : "确定"}</Button></div>
</fieldset>
<BirthTimeAssessmentOverlay phase={birthTimeAssessmentPhase} />
</form>
<BirthPlacePendingScope>
<form className="profile-form onboarding-card onboarding-step-card birth-time-transition-card" onSubmit={saveOnboardingPlace} aria-busy={birthTimeAssessmentPhase !== null}>
<div className="onboarding-card-heading"><b>出生地点</b><small>搜索全球城市或区县,系统会匹配出生当日的时区。</small></div>
<fieldset className="birth-time-transition-fields" disabled={birthTimeAssessmentPhase !== null}>
<BirthLocationFields value={profileDraft} onChange={setProfileDraft} />
<div className="onboarding-card-actions"><BirthPlaceSubmitButton className="button-primary" data-onboarding-press="" onClick={(event) => rememberSkyOrigin(event.clientX, event.clientY)} disabled={profileSaving || !selectedBirthPlace(profileDraft)}>{profileSaving ? "保存中" : "确定"}</BirthPlaceSubmitButton></div>
</fieldset>
<BirthTimeAssessmentOverlay phase={birthTimeAssessmentPhase} />
</form>
</BirthPlacePendingScope>
</div>
</div>
)}
+26 -10
View File
@@ -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, /<BirthPlacePicker/);
assert.match(pageSource, /provinceCode: location\?\.provinceCode \|\| ""/);
assert.match(pageSource, /cityCode: location\?\.cityCode \|\| ""/);
assert.match(pageSource, /districtCode: location\?\.districtCode \|\| ""/);
assert.match(pageSource, /birthPlaceProvider: location\?\.provider \|\| ""/);
assert.match(pageSource, /timezoneSource: location \? "iana_historical" : ""/);
// 原值:`provinceCode: location?.provinceCode || ""` 等可空写法,以及 `timezoneSource: location ? "iana_historical" : ""`。
// 新值:`provinceCode: location.provinceCode` 等,`timezoneSource: "iana_historical"`。
// 原因:BUG-1506,选择器不再上报 null,父组件不再有把地点清空的分支。
assert.match(pageSource, /provinceCode: location\.provinceCode,/);
assert.match(pageSource, /cityCode: location\.cityCode,/);
assert.match(pageSource, /districtCode: location\.districtCode,/);
assert.match(pageSource, /birthPlaceProvider: location\.provider,/);
assert.match(pageSource, /timezoneSource: "iana_historical",/);
assert.match(pageSource, /birth_place_provider_id:\s*nextProfile\.birthPlaceProviderId/);
assert.match(pageSource, /timezone_id:\s*nextProfile\.timezoneId/);
});
@@ -0,0 +1,291 @@
import "./install-test-document";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import React, { useState } from "react";
import { createClientLifecycleHarness } from "./react-client-lifecycle-test-support";
import { ChartProfileForm } from "../src/components/chart-profile-form";
import { emptyProfile, type Profile } from "../src/lib/home-types";
import { selectedBirthPlace } from "../src/lib/home-profile";
import usRegions from "../public/data/world-locations/us.json";
// BUG-1506 (2026-11-02): editing the birth date, time or place of a saved person
// while /api/locations/timezone fails used to erase the saved place silently.
// These tests mount the real edit form with a fetch stub.
Object.assign(globalThis, { React });
const EDIT_FAILURE = "时区这会儿没查到,原来的出生地还在,查到后才能保存。";
const NEW_FAILURE = "时区服务暂时不可用,稍后重试即可。";
const savedHandan: Profile = {
...emptyProfile,
name: "甲",
date: "1990-05-01",
time: "10:00",
reportedTime: "10:00",
birthTimeSource: "hospital_record",
countryCode: "CN",
provinceCode: "130000",
cityCode: "130400",
districtCode: "130402",
birthPlaceLabel: "中国 · 河北省 · 邯郸市 · 邯山区",
birthPlaceType: "district",
birthPlaceProvider: "china_locations",
birthPlaceProviderId: "130402",
timezoneId: "Asia/Shanghai",
timezoneSource: "iana_historical",
latitude: 36.603196,
longitude: 114.484989,
timezoneOffset: 8,
};
type Reply = "fail" | { timezoneId: string; timezoneOffset: number };
async function mountForm(initial: Profile, options: { regions?: "ok" | "fail" } = {}) {
const harness = createClientLifecycleHarness();
const lookups: unknown[] = [];
let reply: Reply = "fail";
const originalFetch = globalThis.fetch;
globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => {
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 (
<ChartProfileForm
title="编辑人物"
description="称呼和出生资料。"
value={profile}
onChange={setProfile}
nameInputId="bug-1506"
onSubmit={(event) => event.preventDefault()}
submitLabel="保存"
submitDisabled={false}
/>
);
}
await harness.render(<Host />);
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<Profile>) {
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("<BirthPlacePendingScope>"), source.indexOf("</BirthPlacePendingScope>"));
assert.ok(scope.includes("<BirthPlaceSubmitButton"), path);
assert.ok(scope.includes(path.includes("chart-profile-form") ? "<ProfileFields" : "<BirthLocationFields"), path);
}
});