diff --git a/BLOCKED.md b/BLOCKED.md index 2c5c3d53..fb93d15b 100644 --- a/BLOCKED.md +++ b/BLOCKED.md @@ -344,3 +344,11 @@ - 影响:`scripts/run_quality_gate.py --profile quick` 因这一条退出码为 1(`1 failed, 791 passed, 1 skipped`)。任何 Python 轮次在本机都会看到快速门红。 - 本单范围:只记录。AGENTS §7.7 不得顺手修不在任务书里的问题;改这条断言属于产品/前端归属,要单独立单确认「插入行里 user 归属现在由谁保证」再改,不得直接把断言删了变绿。 - 相关:本单进度记录 `docs/tasks/PROGRESS-consultation-residual-hotspots-20260916.md` 测试段。 + +## BUG-996 · 现场账户的单一数据库状态未取证(2026-09-22) + +- 状态:blocked(只限现场取证;代码分类与缓存失效已在本分支用合成 fixture 锁住) +- 缺什么:会议里「更新出生时间后星盘没有可显示内容」的那一户,没有脱敏后的 `profiles` 行。不能证明当时是查询失败、adopted offset 缺失、时区解析失败,还是旧快照。 +- 替代证据:`frontend/tests/chart-profile-update-consistency.test.ts` 用合成 reported / accepted / confirmed / 跨午夜 fixture。`guard_adopted_birth_date` 把 date、offset、provenance 一起清空,没有留下可排盘的半截 adopted 元组,因此没有新迁移,也没有跑 `npm run test:db`。 +- 未跑:全量 `npm test`、`next build`。交给父会话串行验收。 +- 解除:拿到脱敏行或 staging 上同一操作的分类日志后,把命中的 reason 回填到 BUG-996,再划掉本条。 diff --git a/CHANGELOG.md b/CHANGELOG.md index d49ed2ea..220666e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # 印度占星 Skill 更新日志 +## 2026-09-22 — 更新出生资料后,星盘跟的是同一份时间 + +保存出生资料后,星盘不再沿用刚才那张盘。读不到资料、已采用的时间缺时区、时区对不上、盘算不出来,页面上分开说明,不再只剩一句没有内容。Skill 版本不变。 + ## 2026-09-22 — 普通聊天按会话绑定的人物排盘 普通聊天不再在切换人物后仍用登录用户本人的资料。会话锁定的是本人或当前用户名下的另一份资料;对方资料缺失、被删或不完整时会拦住发送,不会退回本人。已有消息的会话不能改绑。报告、每日星语和生时校正仍只用本人资料。Skill 版本不变。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index bb279438..caea078c 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -13200,6 +13200,22 @@ - 复发自:无 - 修复版本:`2503c019` +## BUG-996 | 更新出生资料后星盘仍可能显示旧盘或无法区分失败原因 + +- 状态:resolved +- 首次发现:2026-09-22 +- 最近更新:2026-09-22 +- 影响面:`PATCH /api/account`、`GET /api/account`、`GET /api/chart-view`、`frontend/src/lib/secondary-page-data.ts` 的星盘快照缓存 +- 用户现象:已经更新出生时间,打开星盘仍像没有可显示内容,或点开后看不到刚保存的日期和时间。现场这一户的数据库行没有取到,不能把某一种残缺资料写成已经证实的唯一原因。 +- 触发条件:账户资料 PATCH 成功后星盘模块缓存仍留着上一张快照;或 `profiles` 读取失败、已采用日期缺 offset、时区解析失败、引擎 429 / 超时 / 坏结果被收成同一种「资料不完整」或同一句失败。 +- 根因:三处代码断点同时存在,不是一条已经证实的现场数据。第一,chart-view 忽略 profile 查询错误,空行会落到 `birth_profile_incomplete`;已采用日期缺 offset 或时区解析抛错时没有稳定分类。第二,`chartCache` 不按账户和资料指纹换键,PATCH 成功后也不丢弃旧快照。第三,账户响应和星盘排盘各自读日期、时间、offset,确认后的普通编辑虽不覆盖 active time,但调用方看不到同一份服务端真值。`guard_adopted_birth_date` 会把 adopted date、offset、provenance 一起清空,不会留下「有日期、没 offset」的半截元组,因此本轮不加迁移。 +- 修复:账户 PATCH / GET 与 chart-view 共用 `resolveServerOwnedChartBirth`。chart-view 把查询失败、资料不完整、adopted 计算不完整、时区失败、引擎忙、超时、坏结果和响应格式失败分开,查询失败不再写成资料不完整。PATCH 成功后按账户和资料指纹钉住星盘缓存,进行中的旧请求不能把旧盘写回去。确认状态的普通编辑仍不覆盖 active time。不把 `chart_profiles` 当作 `/chart` 真值,不放宽 accepted / confirmed。 +- 验证:`frontend/tests/chart-profile-update-consistency.test.ts` 覆盖 reported、accepted、confirmed、跨午夜、缺 active offset、查询失败、时区失败、引擎分档、schema 失败、旧快照失效和 D1。旧行为下查询错误没有独立 reason、缓存也没有账户指纹键,这些断言不会过。现场单户数据库状态仍未取证。 +- 防复发:数据库查询错误不得映射成 `birth_profile_incomplete`。已采用日期存在而 offset 缺失时不得回退到声明 offset 或清空 `active_birth_time`。星盘快照键必须包含账户和资料指纹;引擎缓存键仍包含日期、时间、offset、ayanamsa、node mode。失败文案不得写「过一会儿再打开」或没有上下文的「没有可显示内容」。 +- 相关记录:BUG-073、BUG-076、BUG-715、BUG-716、BUG-717、TASK-chart-profile-update-consistency-20260922 +- 复发自:无。BUG-073 仍要求服务端不信任客户端咨询模式;BUG-076 仍要求 `profiles` 而不是星盘库 JSON 为真值;BUG-715 至 717 的星盘打开、分档日志和开页只打 `/api/chart` 都还在,本条补的是资料真值和快照失效。 +- 修复版本:本分支,尚未推送、尚未部署 + ## BUG-997 | 切换人物后普通聊天仍用登录用户本人的出生资料 - 状态:resolved diff --git a/docs/tasks/PROGRESS-chart-profile-update-consistency-20260922.md b/docs/tasks/PROGRESS-chart-profile-update-consistency-20260922.md new file mode 100644 index 00000000..aa804f4d --- /dev/null +++ b/docs/tasks/PROGRESS-chart-profile-update-consistency-20260922.md @@ -0,0 +1,42 @@ +# 进度 · 更新出生资料后星盘真值一致性(2026-09-22) + +基线:`10baeb2fa865743c428806e115ac92ca9d92fb71`(`origin/staging`)。分支 `codex/chart-profile-update-consistency-20260922`。未推送,未部署。 + +## 做了什么 + +- `PATCH /api/account`、`GET /api/account`、`GET /api/chart-view` 共用 `resolveServerOwnedChartBirth`。reported 用声明日期、声明钟点和声明 offset;accepted / confirmed 的完整 adopted 元组用 active date、active time、active offset 和 provenance;跨午夜同此。 +- confirmed 的普通编辑仍返回空的 application patch,不覆盖 `active_birth_time`。声明字段变化时,与 `guard_adopted_birth_date` 相同:date、offset、provenance 一起清空,排盘落到既有的无 adopted 日期路径(声明日期 + 受保护的 active 分钟 + 声明 offset)。不是半截元组。 +- accepted 的普通声明编辑仍把状态收成 reported 并清空 active time;adopted 元组一并清空,不会停在「还能排、但 offset 缺了」。 +- adopted 日期在而 offset 缺失:`adopted_calculation_incomplete`,不回退声明 offset,也不去调时区解析。 +- chart-view 分开记录:`profile_query_error`、`profile_incomplete`、`adopted_calculation_incomplete`、`timezone_resolver_failure`、`engine_busy`、`engine_timeout`、`engine_bad_payload`、`engine_http_error`、`response_schema_failure`。查询失败的 HTTP 仍是 200 结构化状态,但 status 不是 `birth_profile_incomplete`。日志只有 reason、route、耗时、HTTP status。 +- 星盘模块缓存按账户 id + 资料指纹钉住。PATCH 成功立刻换键并丢掉不匹配的旧快照;与这次保存重叠的 `refreshAccount` 不会把旧指纹钉回去。进行中的旧 `/api/chart-view` 不能写回旧盘。引擎缓存键仍含日期、时间、offset、ayanamsa、node mode。 +- `/chart` 仍读账户 `profiles`,不读 `chart_profiles`。 + +## 没有新迁移 + +`20260920020000_adopted_birth_date.sql` 的 `guard_adopted_birth_date` 在同一分支把 `active_birth_date`、`active_birth_timezone_offset`、`active_birth_provenance` 置空,不会只清 offset。因此没有「可排盘且 adopted 元组不完整」的 trigger 产物,不加迁移,不跑 `npm run test:db`。 + +## 断言变更 + +无。既有 `chart-view-route`、`chart-view-engine`、`server-owned-birth-profile`、`adopted-birth-date`、`account-api`、`secondary-page-entry` 的测试名和断言原值都没改。 + +## 测试 + +| 命令 | 结果 | +| --- | --- | +| `.\node_modules\.bin\tsc --noEmit` | 通过 | +| `npm run lint` | exit 0,0 error,119 条既有 warning,本次改动的文件不在其中 | +| `npx tsx --test tests/chart-profile-update-consistency.test.ts` | 11 pass / 0 fail | +| 同命令加上 `chart-view-route`、`chart-view-engine`、`server-owned-birth-profile`、`adopted-birth-date`、`account-api`、`secondary-page-entry` | 57 pass / 0 fail | + +未跑全量 `npm test`,未跑 `next build`。 + +## 偏差 + +- 现场那一户的数据库行仍未知,BUG-996 不把某一种残缺写成唯一根因。见 `BLOCKED.md`。 +- 账户 GET 的 `chartBirth` 是库存真值。chart-view 只在声明 offset 缺失、且不是 adopted 半截元组时,才在内存里补时区;指纹仍用库存行,所以和 GET 的键一致。带齐 offset 的合成 fixture 上,日期、时间、offset、provenance 完全相同。 +- 没有改 `frontend/src/app/(app)/page.tsx`、没有改 `scripts/jyotish_api_server.py`、没有改迁移。 + +## 环境缺口 + +无受控登录态,不能复核会议里的那一户。无新迁移,故不用 Docker 跑 `test:db`。 diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index 424c08b6..6fca58bf 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -875,7 +875,7 @@ Three product rules for assistant markdown in `.message-markdown` (BUG-962 / 963 ### 等待态 -星盘页是独立文档。`SecondaryPageShell` 揭幕(标题、保留的 note 槽、撑满剩余视口的内容区)可以在主盘到达之前发生;揭幕之后填数据不得再出现 spinner / 骨架 / 「正在加载」,沿用 §9 星历页那一类静态句,不新造第五套加载动画。同一会话再次进入直接用模块缓存,不重放等待句。 +星盘页是独立文档。`SecondaryPageShell` 揭幕(标题、保留的 note 槽、撑满剩余视口的内容区)可以在主盘到达之前发生;揭幕之后填数据不得再出现 spinner / 骨架 / 「正在加载」,沿用 §9 星历页那一类静态句,不新造第五套加载动画。同一会话再次进入直接用模块缓存,不重放等待句。出生资料保存成功后,缓存按账户和资料指纹换键,旧盘不得留在页上冒充新的日期或时间。 | 时刻 | 文案 | | --- | --- | @@ -883,7 +883,11 @@ Three product rules for assistant markdown in `.message-markdown` (BUG-962 / 963 | 已点大运 / 西洋 / 七政,该路还没返回 | 这一栏还没拿到。 | | 已点非 D1 分盘,分盘包还没返回 | 这一分盘还没拿到。 | | 引擎 429 | 算盘的服务正忙,稍等几秒再打开就好。 | -| 其它主盘失败 | 这张盘算不出来,我们已经记录下来了。 | +| 其它主盘计算失败 | 这张盘算不出来,我们已经记录下来了。 | +| 资料读不出来 | 星盘资料这会儿读不出来,我们已经记录下来了。 | +| 已采用的时间缺时区 | 已采用的出生时间缺了排盘要用的时区,这张盘先不能算。我们已经记录下来了。 | +| 时区没有对上 | 出生地的时区没有对上,这张盘先不能算。我们已经记录下来了。 | +| 结果格式对不上 | 星盘结果的格式对不上,我们已经记录下来了。 | ## 16. 星历页 diff --git a/frontend/docs/VOICE.md b/frontend/docs/VOICE.md index 927ce38a..46251478 100644 --- a/frontend/docs/VOICE.md +++ b/frontend/docs/VOICE.md @@ -84,7 +84,8 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、 | 吻合率低时模型追问要不要把误差放到 ±2 小时 | 按你说的经历,出生时间可能比家人记的偏得更多。放宽后再比一次? | 放宽卡由服务端出,选项写「前后半小时 / 一小时 / 两小时」,不用偏移、误差、置信度、概率。 | | 只知道傍晚时直接给分钟或采用卡 | 先切成三段(写起止钟点),比完再按分钟收。分不开就说「时段分不开,直接按分钟比」。 | 超过两小时的窗口先走子段,不在 4~6 小时上铺分钟卡。 | | 记下精确到日的事后不问日子从哪来 | 刚才那个日子是查过记录,还是凭记忆? | 只问日级、只问一次;不答就跳过。 | -| 打开即有 / 直接计算 · 打开即有 · 不消耗点数 / 主盘直接算 · 分盘按需 · 不消耗点数 | (不写) | 星盘页不写成本、速度、计费说明。失败页也不写。忙(429)说「算盘的服务正忙,稍等几秒再打开就好。」;其它失败说「这张盘算不出来,我们已经记录下来了。」不要说「过一会儿再打开」。 | +| 打开即有 / 直接计算 · 打开即有 · 不消耗点数 / 主盘直接算 · 分盘按需 · 不消耗点数 | (不写) | 星盘页不写成本、速度、计费说明。失败页也不写。忙(429)说「算盘的服务正忙,稍等几秒再打开就好。」;其它计算失败说「这张盘算不出来,我们已经记录下来了。」不要说「过一会儿再打开」。 | +| 没有可显示内容 / 资料还在,过一会儿再打开 | 读不到资料:「星盘资料这会儿读不出来,我们已经记录下来了。」已采用的时间缺时区:「已采用的出生时间缺了排盘要用的时区,这张盘先不能算。我们已经记录下来了。」时区对不上:「出生地的时区没有对上,这张盘先不能算。我们已经记录下来了。」结果格式不对:「星盘结果的格式对不上,我们已经记录下来了。」 | 失败要说出是哪一种。不写「过一会儿再打开」,也不用一句没头没尾的「没有可显示内容」。 | | 下面是词条式释义,不是对你个人的判断。 | (不写) | 基础信息 Tab 只放行星卡。词条本身不得写成运势,但不另印这句边界说明。 | | Agent 未完成必要的方法与计算步骤,本次不会扣点。(模型其实写过一段) | 把模型写过的那段留下,文末由服务端加一句:「这次没跑完星盘计算,上面是模型直接写的,先看着。」 | 合同没绿但写过字时不得整轮空白。这句不是模型写的。没写过字仍报未完成、不扣点。 | | 这一页是天象本身,不是对你的判断。 | (不写) | 星历页只放日期、五要素、行运、九十天事件。「带这天去提问」在顶栏。不另印定性句。 | diff --git a/frontend/src/app/api/account/route.ts b/frontend/src/app/api/account/route.ts index 18e8b59b..461d64f8 100644 --- a/frontend/src/app/api/account/route.ts +++ b/frontend/src/app/api/account/route.ts @@ -8,6 +8,7 @@ import { resolveAccountBirthTimeApplicationPatch, resolveAppliedAccountBirthTime, } from "@/lib/account-profile-patch"; +import { chartBirthAfterAccountWrite, resolveServerOwnedChartBirth } from "@/lib/chart-birth-truth"; import { optionalBeamAvatarFromProfile } from "@/lib/beam-avatar"; import { createAdminSupabaseClient, isAdminUser } from "@/lib/supabase/admin"; import { @@ -160,6 +161,7 @@ export async function GET() { latitude: profile.latitude ?? null, longitude: profile.longitude ?? null, }, + chartBirth: resolveServerOwnedChartBirth(profile), }); } catch (error) { if (isSupabaseConfigurationError(error)) { @@ -292,14 +294,14 @@ export async function PATCH(request: Request) { return admin .from("profiles") .upsert(values, { onConflict: "id" }) - .select("id,birth_time_status,active_birth_time,active_birth_date,active_birth_timezone_offset") + .select("id,birth_time_status,active_birth_time,active_birth_date,active_birth_timezone_offset,active_birth_provenance,birth_date,reported_birth_time,birth_time,timezone_offset,timezone_id,latitude,longitude,ayanamsa") .maybeSingle(); } let query = admin.from("profiles").update(values).eq("id", userId); if (invalidatesUnconfirmedApplication) { query = applyAccountProfileConcurrencyGuards(query, currentProfile); } - return query.select("id,birth_time_status,active_birth_time,active_birth_date,active_birth_timezone_offset").maybeSingle(); + return query.select("id,birth_time_status,active_birth_time,active_birth_date,active_birth_timezone_offset,active_birth_provenance,birth_date,reported_birth_time,birth_time,timezone_offset,timezone_id,latitude,longitude,ayanamsa").maybeSingle(); } let { data, error } = await writeProfile(withCoordinates); @@ -352,6 +354,11 @@ export async function PATCH(request: Request) { activeDate: data.active_birth_date ?? null, activeTimezoneOffset: data.active_birth_timezone_offset ?? null, }, + chartBirth: chartBirthAfterAccountWrite({ + current: currentProfile, + written: withCoordinates, + returned: data, + }), }); } catch (error) { if (isSupabaseConfigurationError(error)) { diff --git a/frontend/src/hooks/use-chart-page.ts b/frontend/src/hooks/use-chart-page.ts index abc57ee3..797f6b79 100644 --- a/frontend/src/hooks/use-chart-page.ts +++ b/frontend/src/hooks/use-chart-page.ts @@ -76,9 +76,13 @@ export function useChartPage() { void fetchChartView({ layers: [layer] }) .then((result) => { if (result.body?.status === "ok") { + const stored = writeChartPage({ kind: "view", view: result.body }); + if (stored.kind !== "view" || stored.view.status !== "ok") { + setView(stored.kind === "view" ? stored.view : result.body); + return; + } loadedLayers.current.add(layer); - writeChartPage({ kind: "view", view: result.body }); - setView(result.body); + setView(stored.view); } }) .catch((error: unknown) => { diff --git a/frontend/src/hooks/use-profile-onboarding.ts b/frontend/src/hooks/use-profile-onboarding.ts index 64670343..c31e2a2f 100644 --- a/frontend/src/hooks/use-profile-onboarding.ts +++ b/frontend/src/hooks/use-profile-onboarding.ts @@ -17,6 +17,7 @@ import { type BirthTimeConsultationConsentState, type LatestAccountRequestGuard, } from "@/lib/birth-time-consultation-consent"; +import { readChartBirthFingerprint } from "@/lib/chart-birth-truth"; import { composerDraftSnapshot } from "@/lib/composer-draft"; import { activeChartStorageKey, @@ -25,6 +26,11 @@ import { LoginRedirectError, saveCloudChartProfile, } from "@/lib/home-cloud-sync"; +import { + accountChartPinStillCurrent, + beginChartCacheAccountRead, + pinChartSnapshotIdentity, +} from "@/lib/secondary-page-data"; import { birthProfileDeclarationChanged, buildSelfChartRecord, @@ -124,9 +130,14 @@ export function useProfileOnboarding(params: ProfileOnboardingParams) { async function refreshAccount() { const requestIdentity = accountRefreshGuard.current.begin(); + const chartRead = beginChartCacheAccountRead(); try { const latest = await fetchAccount(); if (!accountRefreshGuard.current.isCurrent(requestIdentity)) return; + const fingerprint = readChartBirthFingerprint(latest); + if (fingerprint && accountChartPinStillCurrent(chartRead)) { + pinChartSnapshotIdentity({ accountId: latest.user.id, fingerprint }); + } const nextProfile = readProfile(latest.profile); const activeOther = activeChartId !== "self" && chartLibrary.find((record) => record.id === activeChartId && record.role === "other"); @@ -231,10 +242,13 @@ export function useProfileOnboarding(params: ProfileOnboardingParams) { const payload = await response.json().catch(() => null) as { error?: string; birthTime?: unknown; + chartBirth?: { fingerprint?: unknown }; } | null; if (!response.ok) { throw new Error(payload?.error || "账户资料暂时无法保存。"); } + const fingerprint = readChartBirthFingerprint(payload); + if (fingerprint) pinChartSnapshotIdentity({ accountId: account.user.id, fingerprint }); const savedProfile = applyPersistedBirthTime(nextProfile, payload?.birthTime); setAccount((current) => current ? { ...current, profile: savedProfile } : current); setActiveChartId("self"); diff --git a/frontend/src/lib/chart-birth-truth.ts b/frontend/src/lib/chart-birth-truth.ts new file mode 100644 index 00000000..73f7b62e --- /dev/null +++ b/frontend/src/lib/chart-birth-truth.ts @@ -0,0 +1,276 @@ +import { resolveAyanamsa } from "./ayanamsa.ts"; +import { birthCalendarDate } from "./effective-birth-date.ts"; +import { globalBirthProfileFromAccountRow } from "./server-owned-birth-profile.ts"; + +export const CHART_BIRTH_FAILURE_REASONS = [ + "profile_query_error", + "profile_incomplete", + "adopted_calculation_incomplete", + "timezone_resolver_failure", + "engine_busy", + "engine_timeout", + "engine_bad_payload", + "engine_http_error", + "response_schema_failure", +] as const; + +export type ChartBirthFailureReason = (typeof CHART_BIRTH_FAILURE_REASONS)[number]; + +export type ChartBirthAdoption = "adopted" | "legacy" | "declared" | "incomplete"; + +export type ServerOwnedChartBirth = { + status: string | null; + date: string | null; + time: string | null; + timezoneOffset: number | null; + timezoneId: string | null; + provenance: unknown; + reportedTime: string | null; + activeTime: string | null; + activeDate: string | null; + declaredDate: string | null; + fingerprint: string; + chartable: boolean; + failure: ChartBirthFailureReason | null; + adoption: ChartBirthAdoption; +}; + +const ADOPTION_CLEAR_KEYS = [ + "birth_date", + "reported_birth_time", + "birth_time_source", + "birth_time_period", + "declared_window_start", + "declared_window_end", + "uncertainty_before_minutes", + "uncertainty_after_minutes", + "latitude", + "longitude", + "timezone_id", + "timezone_offset", +] as const; + +function record(value: unknown): Record { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? value as Record + : {}; +} + +function text(value: unknown): string | null { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +function clock(value: unknown): string | null { + const raw = text(value); + if (!raw) return null; + return raw.slice(0, 5); +} + +function finite(value: unknown): number | null { + if (typeof value === "number" && Number.isFinite(value)) return Object.is(value, -0) ? 0 : value; + if (typeof value === "string" && value.trim()) { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; + } + return null; +} + +function canonical(value: unknown, depth = 0): string { + if (depth > 8 || value == null) return ""; + if (typeof value === "number") return Number.isFinite(value) ? String(Object.is(value, -0) ? 0 : value) : ""; + if (typeof value === "string") return value; + if (typeof value === "boolean") return value ? "1" : "0"; + if (Array.isArray(value)) return `[${value.map((item) => canonical(item, depth + 1)).join(",")}]`; + if (typeof value === "object") { + const entries = Object.keys(value as Record).sort(); + return `{${entries.map((key) => `${key}:${canonical((value as Record)[key], depth + 1)}`).join(",")}}`; + } + return ""; +} + +function fingerprintOf(parts: Record): string { + const textValue = canonical(parts); + let out = ""; + for (const seed of [0x811c9dc5, 0x01000193, 0x9e3779b9, 0x85ebca6b]) { + let hash = seed >>> 0; + for (let index = 0; index < textValue.length; index += 1) { + hash ^= textValue.charCodeAt(index); + hash = Math.imul(hash, 0x01000193); + } + out += (hash >>> 0).toString(16).padStart(8, "0"); + } + return out; +} + +function comparable(key: string, value: unknown): unknown { + if (key === "birth_date" || key === "active_birth_date") return birthCalendarDate(value) ?? null; + if (key.endsWith("_time") || key.endsWith("_start") || key.endsWith("_end")) return clock(value); + if (key === "latitude" || key === "longitude" || key === "timezone_offset" || key.endsWith("_minutes")) { + return finite(value); + } + if (key === "active_birth_provenance") return canonical(value); + return value ?? null; +} + +function isDistinct(key: string, left: unknown, right: unknown): boolean { + return comparable(key, left) !== comparable(key, right); +} + +function emptyBirth(failure: ChartBirthFailureReason): ServerOwnedChartBirth { + return { + status: null, + date: null, + time: null, + timezoneOffset: null, + timezoneId: null, + provenance: null, + reportedTime: null, + activeTime: null, + activeDate: null, + declaredDate: null, + fingerprint: fingerprintOf({ failure }), + chartable: false, + failure, + adoption: "incomplete", + }; +} + +export function unresolvedChartBirth(failure: ChartBirthFailureReason): ServerOwnedChartBirth { + return emptyBirth(failure); +} + +/** + * Mirrors `public.guard_adopted_birth_date`. Date, offset, and provenance + * clear together. This never clears `active_birth_time`. + */ +export function applyAdoptedBirthDateGuard>( + previous: T | null, + next: T, +): T { + const status = next.birth_time_status; + const statusUnusable = status == null || (status !== "accepted" && status !== "confirmed"); + const activeTimeMissing = next.active_birth_time == null || next.active_birth_time === ""; + const declarationChanged = previous != null && ADOPTION_CLEAR_KEYS.some((key) => ( + isDistinct(key, next[key], previous[key]) + )); + const activeTimeChangedWithoutProvenance = previous != null + && isDistinct("active_birth_time", next.active_birth_time, previous.active_birth_time) + && !isDistinct("active_birth_provenance", next.active_birth_provenance, previous.active_birth_provenance); + if (statusUnusable || activeTimeMissing || declarationChanged || activeTimeChangedWithoutProvenance) { + return { + ...next, + active_birth_date: null, + active_birth_timezone_offset: null, + active_birth_provenance: null, + }; + } + return next; +} + +export function resolveServerOwnedChartBirth(row: unknown): ServerOwnedChartBirth { + const source = record(row); + const status = text(source.birth_time_status); + const declaredDate = birthCalendarDate(source.birth_date) ?? null; + const activeDate = birthCalendarDate(source.active_birth_date) ?? null; + const reportedTime = clock(source.reported_birth_time); + const activeTime = clock(source.active_birth_time) ?? clock(source.birth_time); + const timezoneId = text(source.timezone_id); + const provenance = source.active_birth_provenance ?? null; + const name = text(source.name); + const latitude = finite(source.latitude); + const longitude = finite(source.longitude); + const ayanamsa = resolveAyanamsa(source); + let profile: ReturnType | null = null; + let failure: ChartBirthFailureReason | null = null; + try { + profile = globalBirthProfileFromAccountRow(row); + } catch (error) { + if (error instanceof Error && error.message === "adopted_birth_calculation_incomplete") { + failure = "adopted_calculation_incomplete"; + } else { + throw error; + } + } + const usable = status === "accepted" || status === "confirmed"; + const date = failure ? null : profile?.date ?? null; + const time = failure ? null : profile?.time ?? null; + // An incomplete adopted tuple must not fall back to the declared offset. + const timezoneOffset = failure + ? null + : typeof profile?.timezoneOffset === "number" && Number.isFinite(profile.timezoneOffset) + ? profile.timezoneOffset + : null; + let adoption: ChartBirthAdoption = "declared"; + if (failure === "adopted_calculation_incomplete") adoption = "incomplete"; + else if (usable && activeDate) adoption = "adopted"; + else if (usable) adoption = "legacy"; + if (!failure && (date == null || time == null || timezoneOffset == null || latitude == null || longitude == null)) { + failure = "profile_incomplete"; + } + return { + status, + date, + time, + timezoneOffset, + timezoneId, + provenance, + reportedTime, + activeTime, + activeDate, + declaredDate, + fingerprint: fingerprintOf({ + name, + status, + date, + time, + timezoneOffset, + timezoneId, + provenance, + reportedTime, + activeTime, + activeDate, + declaredDate, + latitude, + longitude, + ayanamsa, + failure, + }), + chartable: failure == null, + failure, + adoption, + }; +} + +export function chartBirthAfterAccountWrite(input: { + current: unknown; + written: unknown; + returned: unknown; +}): ServerOwnedChartBirth { + const current = input.current == null ? null : record(input.current); + const written = record(input.written); + const returned = input.returned == null ? null : record(input.returned); + const merged = { + ...(current ?? {}), + ...written, + ...(returned ?? {}), + }; + const databaseAlreadyJudgedAdoption = returned != null && ( + "active_birth_date" in returned + || "active_birth_timezone_offset" in returned + || "active_birth_provenance" in returned + ); + const row = databaseAlreadyJudgedAdoption + ? merged + : applyAdoptedBirthDateGuard(current, merged); + return resolveServerOwnedChartBirth(row); +} + +export function readChartBirthFingerprint(value: unknown): string | null { + if (!value || typeof value !== "object" || !("chartBirth" in value)) return null; + const chartBirth = (value as { chartBirth?: { fingerprint?: unknown } }).chartBirth; + return chartBirth && typeof chartBirth.fingerprint === "string" && chartBirth.fingerprint + ? chartBirth.fingerprint + : null; +} diff --git a/frontend/src/lib/chart-view-client.ts b/frontend/src/lib/chart-view-client.ts index bbfa7c81..8c17b58f 100644 --- a/frontend/src/lib/chart-view-client.ts +++ b/frontend/src/lib/chart-view-client.ts @@ -1,5 +1,6 @@ import { chartViewResponseSchema, type ChartViewResponse } from "./chart-view-contract.ts"; import type { ChartViewLayer } from "./chart-view-engine.ts"; +import { chartViewFailureResponse } from "./chart-view-failure.ts"; export async function fetchChartView(input: { layers?: readonly ChartViewLayer[]; @@ -18,8 +19,12 @@ export async function fetchChartView(input: { } catch (error) { const errorName = error instanceof Error ? error.name : "Error"; console.warn("chart_view_client_bad_payload", { errorName, httpStatus: response.status }); - return { httpStatus: response.status, body: null }; + return { httpStatus: response.status, body: chartViewFailureResponse("response_schema_failure") }; } const parsed = chartViewResponseSchema.safeParse(json); - return { httpStatus: response.status, body: parsed.success ? parsed.data : null }; + if (!parsed.success) { + console.warn("chart_view_client_schema_failed", { httpStatus: response.status }); + return { httpStatus: response.status, body: chartViewFailureResponse("response_schema_failure") }; + } + return { httpStatus: response.status, body: parsed.data }; } diff --git a/frontend/src/lib/chart-view-contract.ts b/frontend/src/lib/chart-view-contract.ts index 80d6ec5c..4cbf4ddb 100644 --- a/frontend/src/lib/chart-view-contract.ts +++ b/frontend/src/lib/chart-view-contract.ts @@ -1,5 +1,6 @@ import { z } from "zod"; +import { CHART_BIRTH_FAILURE_REASONS } from "./chart-birth-truth.ts"; import { PLANET_GLYPH_KEYS } from "./planet-glyphs.ts"; const isoDate = z.string().regex(/^\d{4}-\d{2}-\d{2}$/); @@ -169,6 +170,7 @@ export const chartViewProfileSchema = z.object({ placeLabel: z.string().min(1), latitude: z.number(), longitude: z.number(), + timezoneOffset: z.number().finite().optional(), timezoneLabel: z.string().min(1), ayanamsa: z.string().min(1), ayanamsaDisplay: z.string().min(1), @@ -202,12 +204,21 @@ export const chartViewOkSchema = z.object({ }), western: z.union([chartViewWesternOkSchema, chartViewUnavailableLayerSchema]), qizheng: z.union([chartViewQizhengOkSchema, chartViewUnavailableLayerSchema]), + accountId: z.string().min(1).optional(), + profileFingerprint: z.string().min(1).optional(), }); +const chartViewIdentitySchema = { + accountId: z.string().min(1).optional(), + profileFingerprint: z.string().min(1).optional(), +}; + export const chartViewMessageSchema = z.object({ status: z.enum(["unauthenticated", "birth_profile_incomplete", "chart_unavailable", "rate_limited"]), billed: z.literal(false), message: z.string().min(1), + reason: z.enum(CHART_BIRTH_FAILURE_REASONS).optional(), + ...chartViewIdentitySchema, }); export const chartViewResponseSchema = z.union([chartViewOkSchema, chartViewMessageSchema]); diff --git a/frontend/src/lib/chart-view-failure.ts b/frontend/src/lib/chart-view-failure.ts new file mode 100644 index 00000000..7d5e5a91 --- /dev/null +++ b/frontend/src/lib/chart-view-failure.ts @@ -0,0 +1,53 @@ +import { type ChartBirthFailureReason } from "./chart-birth-truth.ts"; +import type { ChartViewMessage } from "./chart-view-contract.ts"; +import { CHART_VIEW_COPY } from "./chart-view-labels.ts"; + +const FAILURE_COPY: Record = { + profile_query_error: CHART_VIEW_COPY.profileQueryError, + profile_incomplete: CHART_VIEW_COPY.incomplete, + adopted_calculation_incomplete: CHART_VIEW_COPY.adoptedIncomplete, + timezone_resolver_failure: CHART_VIEW_COPY.timezoneFailure, + engine_busy: CHART_VIEW_COPY.busy, + engine_timeout: CHART_VIEW_COPY.unavailable, + engine_bad_payload: CHART_VIEW_COPY.unavailable, + engine_http_error: CHART_VIEW_COPY.unavailable, + response_schema_failure: CHART_VIEW_COPY.schemaFailure, +}; + +const FAILURE_STATUS: Record = { + profile_query_error: "chart_unavailable", + profile_incomplete: "birth_profile_incomplete", + adopted_calculation_incomplete: "chart_unavailable", + timezone_resolver_failure: "chart_unavailable", + engine_busy: "chart_unavailable", + engine_timeout: "chart_unavailable", + engine_bad_payload: "chart_unavailable", + engine_http_error: "chart_unavailable", + response_schema_failure: "chart_unavailable", +}; + +export function chartViewFailureResponse(reason: ChartBirthFailureReason): ChartViewMessage { + return { + status: FAILURE_STATUS[reason], + billed: false, + message: FAILURE_COPY[reason], + reason, + }; +} + +export function chartViewFailureStatus(reason: ChartBirthFailureReason): ChartViewMessage["status"] { + return FAILURE_STATUS[reason]; +} + +export function logChartViewFailure(input: { + reason: ChartBirthFailureReason; + elapsedMs?: number; + httpStatus?: number | null; +}): void { + console.warn("chart_view_failure", { + reason: input.reason, + route: "/api/chart-view", + elapsedMs: input.elapsedMs ?? null, + httpStatus: input.httpStatus ?? null, + }); +} diff --git a/frontend/src/lib/chart-view-labels.ts b/frontend/src/lib/chart-view-labels.ts index cf5ba032..5bc9ea18 100644 --- a/frontend/src/lib/chart-view-labels.ts +++ b/frontend/src/lib/chart-view-labels.ts @@ -158,6 +158,10 @@ export const CHART_VIEW_COPY = { waitingVarga: "这一分盘还没拿到。", unauthenticated: "请先登录后再看星盘。", incomplete: "还没有可用来排盘的出生资料。把日期、时间和地点填进星盘资料后,打开就能看见盘。", + profileQueryError: "星盘资料这会儿读不出来,我们已经记录下来了。", + adoptedIncomplete: "已采用的出生时间缺了排盘要用的时区,这张盘先不能算。我们已经记录下来了。", + timezoneFailure: "出生地的时区没有对上,这张盘先不能算。我们已经记录下来了。", + schemaFailure: "星盘结果的格式对不上,我们已经记录下来了。", rateLimited: "刚才打开得太勤,稍等再试。", } as const; diff --git a/frontend/src/lib/chart-view-load.ts b/frontend/src/lib/chart-view-load.ts index c6af321e..f154ca68 100644 --- a/frontend/src/lib/chart-view-load.ts +++ b/frontend/src/lib/chart-view-load.ts @@ -1,4 +1,7 @@ +import { ZodError } from "zod"; + import { resolveAyanamsa } from "./ayanamsa.ts"; +import type { ChartBirthFailureReason } from "./chart-birth-truth.ts"; import { chartViewMessageSchema, type ChartViewResponse, @@ -9,6 +12,7 @@ import { type ChartViewLayer, type EngineCallResult, } from "./chart-view-engine.ts"; +import { chartViewFailureResponse } from "./chart-view-failure.ts"; import { CHART_VIEW_COPY, VARGA_CHIP_ORDER } from "./chart-view-labels.ts"; import { buildChartView, @@ -28,13 +32,31 @@ export type ChartViewLoadInput = { asOf: string; rateLimited?: boolean; layers?: readonly ChartViewLayer[]; + accountId?: string | null; + profileFingerprint?: string | null; }; function message( status: "unauthenticated" | "birth_profile_incomplete" | "chart_unavailable" | "rate_limited", copy: string, + reason?: ChartBirthFailureReason, ): ChartViewResponse { - return chartViewMessageSchema.parse({ status, billed: false, message: copy }); + return chartViewMessageSchema.parse({ status, billed: false, message: copy, reason }); +} + +function stamp(body: ChartViewResponse, input: ChartViewLoadInput): ChartViewResponse { + return { + ...body, + ...(input.accountId ? { accountId: input.accountId } : {}), + ...(input.profileFingerprint ? { profileFingerprint: input.profileFingerprint } : {}), + }; +} + +function engineFailureReason(status: Exclude): ChartBirthFailureReason { + if (status === "busy") return "engine_busy"; + if (status === "timeout") return "engine_timeout"; + if (status === "bad_payload") return "engine_bad_payload"; + return "engine_http_error"; } function birthPayload(profile: ChartViewProfileInput): Record { @@ -87,14 +109,14 @@ export async function assembleChartView(input: ChartViewLoadInput): Promise<{ if (input.rateLimited) { return { httpStatus: 429, - body: message("rate_limited", CHART_VIEW_COPY.rateLimited), + body: stamp(message("rate_limited", CHART_VIEW_COPY.rateLimited), input), }; } const profile = input.profile; if (!profile) { return { httpStatus: 200, - body: message("birth_profile_incomplete", CHART_VIEW_COPY.incomplete), + body: stamp(chartViewFailureResponse("profile_incomplete"), input), }; } @@ -103,10 +125,7 @@ export async function assembleChartView(input: ChartViewLoadInput): Promise<{ if (chartResult.status !== "ok") { return { httpStatus: 200, - body: message( - "chart_unavailable", - chartResult.status === "busy" ? CHART_VIEW_COPY.busy : CHART_VIEW_COPY.unavailable, - ), + body: stamp(chartViewFailureResponse(engineFailureReason(chartResult.status)), input), }; } const chart = chartResult.payload; @@ -119,7 +138,7 @@ export async function assembleChartView(input: ChartViewLoadInput): Promise<{ }); return { httpStatus: 200, - body: message("chart_unavailable", CHART_VIEW_COPY.unavailable), + body: stamp(chartViewFailureResponse("engine_bad_payload"), input), }; } @@ -165,14 +184,15 @@ export async function assembleChartView(input: ChartViewLoadInput): Promise<{ profile, asOf: input.asOf, }); - return { httpStatus: 200, body }; + return { httpStatus: 200, body: stamp(body, input) }; } catch (error) { const errorName = error instanceof Error ? error.name : "Error"; const errorMessage = error instanceof Error ? error.message : "unknown"; console.warn("chart_view_assemble_failed", { errorName, message: errorMessage }); + const reason = error instanceof ZodError ? "response_schema_failure" : "engine_bad_payload"; return { httpStatus: 200, - body: message("chart_unavailable", CHART_VIEW_COPY.unavailable), + body: stamp(chartViewFailureResponse(reason), input), }; } } diff --git a/frontend/src/lib/chart-view-mapper.ts b/frontend/src/lib/chart-view-mapper.ts index 707a1196..8c828fe7 100644 --- a/frontend/src/lib/chart-view-mapper.ts +++ b/frontend/src/lib/chart-view-mapper.ts @@ -659,6 +659,7 @@ export function buildChartView(bundle: ChartViewEngineBundle): ChartViewOk { placeLabel, latitude: profile.latitude, longitude: profile.longitude, + timezoneOffset: profile.timezoneOffset, timezoneLabel: profile.timezoneId || `UTC${profile.timezoneOffset >= 0 ? "+" : ""}${profile.timezoneOffset}`, ayanamsa, ayanamsaDisplay, diff --git a/frontend/src/lib/chart-view-profile-read.ts b/frontend/src/lib/chart-view-profile-read.ts new file mode 100644 index 00000000..d75cb0b4 --- /dev/null +++ b/frontend/src/lib/chart-view-profile-read.ts @@ -0,0 +1,96 @@ +import { BirthProfileTimezoneError, resolveMissingBirthTimezoneOffset } from "./birth-profile-timezone.ts"; +import { + resolveServerOwnedChartBirth, + unresolvedChartBirth, + type ChartBirthFailureReason, + type ServerOwnedChartBirth, +} from "./chart-birth-truth.ts"; +import type { ChartViewProfileInput } from "./chart-view-mapper.ts"; + +export type ChartViewProfileRead = + | { ok: true; profile: ChartViewProfileInput; birth: ServerOwnedChartBirth } + | { ok: false; failure: ChartBirthFailureReason; birth: ServerOwnedChartBirth }; + +function text(value: unknown): string | undefined { + if (typeof value !== "string") return undefined; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +function isTimezoneFailure(error: unknown): boolean { + return error instanceof BirthProfileTimezoneError + || (error instanceof Error && error.name === "BirthProfileTimezoneError"); +} + +function chartInput(row: unknown, birth: ServerOwnedChartBirth): ChartViewProfileInput | null { + if (!birth.chartable || birth.date == null || birth.time == null || birth.timezoneOffset == null) return null; + const source = row !== null && typeof row === "object" && !Array.isArray(row) + ? row as Record + : {}; + const latitude = typeof source.latitude === "number" ? source.latitude : Number(source.latitude); + const longitude = typeof source.longitude === "number" ? source.longitude : Number(source.longitude); + if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) return null; + return { + name: text(source.name), + date: birth.date, + time: birth.time, + placeLabel: text(source.birth_place_label), + latitude, + longitude, + timezoneOffset: birth.timezoneOffset, + timezoneId: birth.timezoneId ?? undefined, + ayanamsa: typeof source.ayanamsa === "string" ? source.ayanamsa : undefined, + birthTimeStatus: birth.status ?? undefined, + }; +} + +export async function prepareChartViewProfile(input: { + row: unknown; + queryError?: unknown; + resolveTimezone?: typeof resolveMissingBirthTimezoneOffset; +}): Promise { + if (input.queryError) { + return { + ok: false, + failure: "profile_query_error", + birth: unresolvedChartBirth("profile_query_error"), + }; + } + const stored = resolveServerOwnedChartBirth(input.row); + if (stored.failure === "adopted_calculation_incomplete") { + return { ok: false, failure: stored.failure, birth: stored }; + } + const resolveTimezone = input.resolveTimezone ?? resolveMissingBirthTimezoneOffset; + let resolved: unknown; + try { + resolved = await resolveTimezone(input.row, { useActiveDate: true }); + } catch (error) { + if (!isTimezoneFailure(error)) throw error; + return { + ok: false, + failure: "timezone_resolver_failure", + birth: { + ...stored, + failure: "timezone_resolver_failure", + chartable: false, + timezoneOffset: null, + }, + }; + } + const filled = resolveServerOwnedChartBirth(resolved); + // Keep the stored-row fingerprint so account GET and chart-view name the + // same profile even when a missing declared offset was filled in memory. + const birth = { ...filled, fingerprint: stored.fingerprint }; + if (birth.failure === "adopted_calculation_incomplete") { + return { ok: false, failure: birth.failure, birth }; + } + const profile = chartInput(resolved, birth); + if (!profile || birth.failure) { + return { + ok: false, + failure: birth.failure ?? "profile_incomplete", + birth: { ...birth, failure: birth.failure ?? "profile_incomplete", chartable: false }, + }; + } + return { ok: true, profile, birth }; +} diff --git a/frontend/src/lib/chart-view-service.ts b/frontend/src/lib/chart-view-service.ts index c3449711..dde9ae16 100644 --- a/frontend/src/lib/chart-view-service.ts +++ b/frontend/src/lib/chart-view-service.ts @@ -1,16 +1,16 @@ import "server-only"; -import { resolveMissingBirthTimezoneOffset } from "./birth-profile-timezone.ts"; +import { BirthProfileTimezoneError } from "./birth-profile-timezone.ts"; import { consumeRequestRateLimit } from "./request-rate-limit.ts"; -import { - ACCOUNT_BIRTH_SELECT, - globalBirthProfileFromAccountRow, -} from "./server-owned-birth-profile.ts"; +import { ACCOUNT_BIRTH_SELECT } from "./server-owned-birth-profile.ts"; import { createServerSupabaseClient } from "./supabase/server.ts"; import { resolveAyanamsa } from "./ayanamsa.ts"; import { assembleChartView, type ChartViewEnginePost } from "./chart-view-load.ts"; import type { ChartViewProfileInput } from "./chart-view-mapper.ts"; import type { ChartViewResponse } from "./chart-view-contract.ts"; +import { prepareChartViewProfile } from "./chart-view-profile-read.ts"; +import { chartViewFailureResponse, logChartViewFailure } from "./chart-view-failure.ts"; +import type { ChartBirthFailureReason } from "./chart-birth-truth.ts"; import { CHART_VIEW_ENGINE_TIMEOUT_MS, chartViewEngineCacheKey, @@ -24,21 +24,6 @@ import { const jyotishApiBase = process.env.JYOTISH_API_BASE ?? "http://127.0.0.1:5200"; -function text(value: unknown): string | undefined { - if (typeof value !== "string") return undefined; - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : undefined; -} - -function finite(value: unknown): number | undefined { - if (typeof value === "number" && Number.isFinite(value)) return value; - if (typeof value === "string" && value.trim()) { - const parsed = Number(value); - return Number.isFinite(parsed) ? parsed : undefined; - } - return undefined; -} - async function postEngine(path: string, body: Record): Promise { const started = Date.now(); try { @@ -104,30 +89,24 @@ function cachedPostEngine(userId: string, profile: ChartViewProfileInput): Chart }; } -function profileFromRow(row: unknown): ChartViewProfileInput | null { - const profile = globalBirthProfileFromAccountRow(row); - const record = row && typeof row === "object" && !Array.isArray(row) - ? row as Record - : {}; - if (!profile.date || !profile.time) return null; - const latitude = finite(profile.latitude); - const longitude = finite(profile.longitude); - const timezoneOffset = finite(profile.timezoneOffset); - if (latitude === undefined || longitude === undefined || timezoneOffset === undefined) return null; +function failureBody(reason: ChartBirthFailureReason, accountId: string, fingerprint?: string | null): ChartViewResponse { return { - name: profile.name, - date: profile.date, - time: profile.time, - placeLabel: text(record.birth_place_label), - latitude, - longitude, - timezoneOffset, - timezoneId: profile.timezoneId, - ayanamsa: typeof profile.ayanamsa === "string" ? profile.ayanamsa : undefined, - birthTimeStatus: profile.birthTimeStatus, + ...chartViewFailureResponse(reason), + accountId, + ...(fingerprint ? { profileFingerprint: fingerprint } : {}), }; } +function classifyThrown(error: unknown): ChartBirthFailureReason { + if (error instanceof BirthProfileTimezoneError || (error instanceof Error && error.name === "BirthProfileTimezoneError")) { + return "timezone_resolver_failure"; + } + if (error instanceof Error && error.message === "adopted_birth_calculation_incomplete") { + return "adopted_calculation_incomplete"; + } + return "response_schema_failure"; +} + export async function loadChartView(input: { now?: Date; layers?: readonly ChartViewLayer[]; @@ -148,25 +127,67 @@ export async function loadChartView(input: { }); } - const limited = consumeRequestRateLimit({ - key: `chartView:${user.id}`, - limit: 20, - windowMs: 60_000, - }); + const started = Date.now(); + try { + const limited = consumeRequestRateLimit({ + key: `chartView:${user.id}`, + limit: 20, + windowMs: 60_000, + }); + if (!limited.ok) { + return assembleChartView({ + userId: user.id, + accountId: user.id, + profile: null, + postEngine, + asOf: now.toISOString().slice(0, 10), + rateLimited: true, + layers: input.layers, + }); + } - const { data: row } = await supabase - .from("profiles") - .select(ACCOUNT_BIRTH_SELECT) - .eq("id", user.id) - .maybeSingle(); + const { data: row, error } = await supabase + .from("profiles") + .select(ACCOUNT_BIRTH_SELECT) + .eq("id", user.id) + .maybeSingle(); + if (error) { + logChartViewFailure({ + reason: "profile_query_error", + elapsedMs: Date.now() - started, + httpStatus: null, + }); + return { httpStatus: 200, body: failureBody("profile_query_error", user.id) }; + } - const profile = profileFromRow(await resolveMissingBirthTimezoneOffset(row, { useActiveDate: true })); - return assembleChartView({ - userId: user.id, - profile, - postEngine: profile ? cachedPostEngine(user.id, profile) : postEngine, - asOf: now.toISOString().slice(0, 10), - rateLimited: !limited.ok, - layers: input.layers, - }); + const prepared = await prepareChartViewProfile({ row }); + if (!prepared.ok) { + logChartViewFailure({ + reason: prepared.failure, + elapsedMs: Date.now() - started, + httpStatus: 200, + }); + return { + httpStatus: 200, + body: failureBody(prepared.failure, user.id, prepared.birth.fingerprint), + }; + } + return assembleChartView({ + userId: user.id, + accountId: user.id, + profileFingerprint: prepared.birth.fingerprint, + profile: prepared.profile, + postEngine: cachedPostEngine(user.id, prepared.profile), + asOf: now.toISOString().slice(0, 10), + layers: input.layers, + }); + } catch (error) { + const reason = classifyThrown(error); + logChartViewFailure({ + reason, + elapsedMs: Date.now() - started, + httpStatus: 200, + }); + return { httpStatus: 200, body: failureBody(reason, user.id) }; + } } diff --git a/frontend/src/lib/home-types.ts b/frontend/src/lib/home-types.ts index 2928a828..aa3be4ea 100644 --- a/frontend/src/lib/home-types.ts +++ b/frontend/src/lib/home-types.ts @@ -135,6 +135,7 @@ export type Account = { hasConfirmedBirthTime: boolean; hasUsableBirthTime: boolean; profile: unknown; + chartBirth?: { fingerprint?: string | null } | null; }; export type OnboardingStep = "name" | "birth" | "place" | "rectification"; export type AccountDialog = "profile" | "chart-library" | "billing" | "general" | "logout"; diff --git a/frontend/src/lib/secondary-page-data.ts b/frontend/src/lib/secondary-page-data.ts index f2c3f0f6..bc827cd6 100644 --- a/frontend/src/lib/secondary-page-data.ts +++ b/frontend/src/lib/secondary-page-data.ts @@ -1,6 +1,6 @@ import { fetchChartView } from "@/lib/chart-view-client"; import type { ChartViewResponse } from "@/lib/chart-view-contract"; -import { CHART_VIEW_COPY } from "@/lib/chart-view-labels"; +import { chartViewFailureResponse } from "@/lib/chart-view-failure"; import { parseEphemerisOkResponse, type EphemerisOkResponse } from "@/lib/ephemeris-contract"; export const REPORTS_WAITING_COPY = "报告列表还没拿到。"; @@ -76,15 +76,122 @@ function createMemoryCache(load: () => Promise): MemoryCache { }; } -function unavailableChart(): ChartViewResponse { - return { status: "chart_unavailable", billed: false, message: CHART_VIEW_COPY.unavailable }; +export type ChartSnapshotIdentity = { + accountId: string; + fingerprint: string; +}; + +type ChartCacheSlot = { + identity: ChartSnapshotIdentity | null; + snapshot: ChartPageSnapshot; +}; + +let chartEpoch = 0; +let chartAccountReadGeneration = 0; +let pinnedChartIdentity: ChartSnapshotIdentity | null = null; +let chartSlot: ChartCacheSlot | null = null; +let chartInflight: { epoch: number; promise: Promise } | null = null; + +function sameChartIdentity(left: ChartSnapshotIdentity | null, right: ChartSnapshotIdentity | null): boolean { + return Boolean(left && right && left.accountId === right.accountId && left.fingerprint === right.fingerprint); } -const chartCache = createMemoryCache(async () => { - const result = await fetchChartView({}); +export function readChartSnapshotIdentity(snapshot: ChartPageSnapshot): ChartSnapshotIdentity | null { + if (snapshot.kind !== "view") return null; + const accountId = typeof snapshot.view.accountId === "string" ? snapshot.view.accountId : ""; + const fingerprint = typeof snapshot.view.profileFingerprint === "string" ? snapshot.view.profileFingerprint : ""; + if (!accountId || !fingerprint) return null; + return { accountId, fingerprint }; +} + +function snapshotFromChartResult(result: { httpStatus: number; body: ChartViewResponse | null }): ChartPageSnapshot { if (result.httpStatus === 401) return { kind: "unauthenticated" }; - return { kind: "view", view: result.body ?? unavailableChart() }; -}); + return { + kind: "view", + view: result.body ?? chartViewFailureResponse("response_schema_failure"), + }; +} + +function pinnedChartFailure(): ChartPageSnapshot { + const pin = pinnedChartIdentity; + return { + kind: "view", + view: { + ...chartViewFailureResponse("response_schema_failure"), + ...(pin ? { accountId: pin.accountId, profileFingerprint: pin.fingerprint } : {}), + }, + }; +} + +function rememberChartSnapshot(snapshot: ChartPageSnapshot): ChartPageSnapshot { + const identity = readChartSnapshotIdentity(snapshot); + chartSlot = { + snapshot, + identity: identity ?? (pinnedChartIdentity && snapshot.kind === "view" ? { ...pinnedChartIdentity } : null), + }; + return snapshot; +} + +function chartSnapshotVisible(): ChartPageSnapshot | null { + if (!chartSlot) return null; + if (chartSlot.snapshot.kind === "unauthenticated") return chartSlot.snapshot; + if (!pinnedChartIdentity) return chartSlot.snapshot; + if (!chartSlot.identity) { + return chartSlot.snapshot.kind === "view" && chartSlot.snapshot.view.status !== "ok" + ? chartSlot.snapshot + : null; + } + return sameChartIdentity(chartSlot.identity, pinnedChartIdentity) ? chartSlot.snapshot : null; +} + +function chartSnapshotMatchesPin(snapshot: ChartPageSnapshot): boolean { + if (!pinnedChartIdentity) return true; + if (snapshot.kind === "unauthenticated") return true; + const identity = readChartSnapshotIdentity(snapshot); + if (!identity) return snapshot.kind === "view" && snapshot.view.status !== "ok"; + return sameChartIdentity(identity, pinnedChartIdentity); +} + +async function loadChartSnapshot(started: number): Promise { + const first = snapshotFromChartResult(await fetchChartView({})); + if (started !== chartEpoch) return refreshChartPage(); + let snapshot = first; + if (pinnedChartIdentity && !chartSnapshotMatchesPin(snapshot)) { + snapshot = snapshotFromChartResult(await fetchChartView({})); + if (started !== chartEpoch) return refreshChartPage(); + } + if (!chartSnapshotMatchesPin(snapshot)) return rememberChartSnapshot(pinnedChartFailure()); + return rememberChartSnapshot(snapshot); +} + +function refreshChartSnapshot(): Promise { + if (chartInflight && chartInflight.epoch === chartEpoch) return chartInflight.promise; + const started = chartEpoch; + const promise = loadChartSnapshot(started).finally(() => { + if (chartInflight?.promise === promise) chartInflight = null; + }); + chartInflight = { epoch: started, promise }; + return promise; +} + +export function beginChartCacheAccountRead(): number { + return chartAccountReadGeneration; +} + +export function accountChartPinStillCurrent(started: number): boolean { + return started === chartAccountReadGeneration; +} + +export function pinChartSnapshotIdentity(identity: ChartSnapshotIdentity): void { + if (!identity.accountId || !identity.fingerprint) return; + if (sameChartIdentity(pinnedChartIdentity, identity)) return; + pinnedChartIdentity = { accountId: identity.accountId, fingerprint: identity.fingerprint }; + chartEpoch += 1; + chartAccountReadGeneration += 1; + if (!sameChartIdentity(chartSlot?.identity ?? null, pinnedChartIdentity)) { + chartSlot = null; + } +} const ephemerisCache = createMemoryCache(async () => { const response = await fetch("/api/ephemeris", { @@ -144,15 +251,20 @@ const reportsCache = createMemoryCache(async () => { }); export function peekChartPage(): ChartPageSnapshot | null { - return chartCache.peek(); + return chartSnapshotVisible(); } export function writeChartPage(snapshot: ChartPageSnapshot): ChartPageSnapshot { - return chartCache.write(snapshot); + if (pinnedChartIdentity && !chartSnapshotMatchesPin(snapshot)) { + const current = chartSnapshotVisible(); + if (current) return current; + return rememberChartSnapshot(pinnedChartFailure()); + } + return rememberChartSnapshot(snapshot); } export function refreshChartPage(): Promise { - return chartCache.refresh(); + return refreshChartSnapshot(); } export function peekEphemerisPage(): EphemerisPageSnapshot | null { @@ -180,13 +292,17 @@ export function refreshReportsPage(): Promise { } export function prefetchSecondaryPage(href: string): void { - if (href === "/chart" || href.startsWith("/chart?")) chartCache.prefetch(); + if (href === "/chart" || href.startsWith("/chart?")) void refreshChartPage(); else if (href === "/ephemeris" || href.startsWith("/ephemeris?")) ephemerisCache.prefetch(); else if (href === "/reports" || href.startsWith("/reports?")) reportsCache.prefetch(); } export function resetSecondaryPageDataForTests(): void { - chartCache.reset(); + chartEpoch += 1; + chartAccountReadGeneration += 1; + pinnedChartIdentity = null; + chartSlot = null; + chartInflight = null; ephemerisCache.reset(); reportsCache.reset(); } diff --git a/frontend/tests/chart-profile-update-consistency.test.ts b/frontend/tests/chart-profile-update-consistency.test.ts new file mode 100644 index 00000000..578328cc --- /dev/null +++ b/frontend/tests/chart-profile-update-consistency.test.ts @@ -0,0 +1,449 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +import { + applyAdoptedBirthDateGuard, + chartBirthAfterAccountWrite, + CHART_BIRTH_FAILURE_REASONS, + resolveServerOwnedChartBirth, +} from "../src/lib/chart-birth-truth.ts"; +import { resolveAccountBirthTimeApplicationPatch } from "../src/lib/account-profile-patch.ts"; +import { BirthProfileTimezoneError } from "../src/lib/birth-profile-timezone.ts"; +import { assembleChartView, type ChartViewEnginePost } from "../src/lib/chart-view-load.ts"; +import { chartViewMessageSchema, chartViewOkSchema } from "../src/lib/chart-view-contract.ts"; +import { chartViewEngineCacheKey } from "../src/lib/chart-view-engine.ts"; +import { + chartViewFailureResponse, + chartViewFailureStatus, + logChartViewFailure, +} from "../src/lib/chart-view-failure.ts"; +import { CHART_VIEW_COPY } from "../src/lib/chart-view-labels.ts"; +import { prepareChartViewProfile } from "../src/lib/chart-view-profile-read.ts"; +import { fetchChartView } from "../src/lib/chart-view-client.ts"; +import { + beginChartCacheAccountRead, + accountChartPinStillCurrent, + peekChartPage, + pinChartSnapshotIdentity, + refreshChartPage, + resetSecondaryPageDataForTests, + writeChartPage, +} from "../src/lib/secondary-page-data.ts"; +import type { ChartViewResponse } from "../src/lib/chart-view-contract.ts"; + +const golden = JSON.parse( + readFileSync(new URL("./fixtures/chart-view-golden.json", import.meta.url), "utf8"), +) as { chart: Record }; + +const crossMidnight = { + name: "Synthetic", + birth_date: "2000-06-15", + reported_birth_time: "00:10", + active_birth_date: "2000-06-14", + active_birth_time: "23:55", + active_birth_timezone_offset: -5, + active_birth_provenance: { contract: "dated-v1", candidate_id: "synthetic-candidate" }, + birth_time_status: "accepted", + birth_time_source: "family_exact", + birth_time_period: null, + declared_window_start: null, + declared_window_end: null, + birth_time_clue: null, + uncertainty_before_minutes: 15, + uncertainty_after_minutes: 15, + latitude: 40, + longitude: -74, + timezone_id: "America/New_York", + timezone_offset: -4, + ayanamsa: "raman", + birth_place_label: "Synthetic place", + rectification_case_id: null, + birth_time: null, +}; + +function identityResolver(row: unknown): Promise { + return Promise.resolve(row); +} + +test("account and chart-view share server-owned date, time, offset, and provenance", async () => { + const reported = { ...crossMidnight, birth_time_status: "reported" }; + const accepted = crossMidnight; + const confirmed = { ...crossMidnight, birth_time_status: "confirmed" }; + for (const row of [reported, accepted, confirmed]) { + const fromAccount = resolveServerOwnedChartBirth(row); + const prepared = await prepareChartViewProfile({ row, resolveTimezone: identityResolver }); + assert.equal(prepared.ok, fromAccount.chartable); + assert.equal(prepared.birth.date, fromAccount.date); + assert.equal(prepared.birth.time, fromAccount.time); + assert.equal(prepared.birth.timezoneOffset, fromAccount.timezoneOffset); + assert.equal(prepared.birth.fingerprint, fromAccount.fingerprint); + assert.deepEqual(prepared.birth.provenance, fromAccount.provenance); + assert.equal(prepared.birth.failure, fromAccount.failure); + if (prepared.ok) { + assert.equal(prepared.profile.date, fromAccount.date); + assert.equal(prepared.profile.time, fromAccount.time); + assert.equal(prepared.profile.timezoneOffset, fromAccount.timezoneOffset); + } + } + + const reportedBirth = resolveServerOwnedChartBirth(reported); + assert.equal(reportedBirth.date, "2000-06-15"); + assert.equal(reportedBirth.time, "00:10"); + assert.equal(reportedBirth.timezoneOffset, -4); + assert.equal(reportedBirth.adoption, "declared"); + + const adoptedBirth = resolveServerOwnedChartBirth(accepted); + assert.equal(adoptedBirth.date, "2000-06-14"); + assert.equal(adoptedBirth.time, "23:55"); + assert.equal(adoptedBirth.timezoneOffset, -5); + assert.equal(adoptedBirth.adoption, "adopted"); + assert.equal(adoptedBirth.failure, null); +}); + +test("confirmed ordinary edits keep the active minute and clear a complete adopted tuple together", () => { + const confirmed = { ...crossMidnight, birth_time_status: "confirmed" as const }; + const patch = { + birth_date: "2000-06-16", + reported_birth_time: "00:20", + birth_time_source: "approximate" as const, + birth_time_period: null, + birth_time_clue: null, + uncertainty_before_minutes: 30, + uncertainty_after_minutes: 30, + }; + assert.deepEqual(resolveAccountBirthTimeApplicationPatch(confirmed, patch), {}); + const stored = applyAdoptedBirthDateGuard(confirmed, { ...confirmed, ...patch }); + assert.equal(stored.active_birth_time, "23:55"); + assert.equal(stored.active_birth_date, null); + assert.equal(stored.active_birth_timezone_offset, null); + assert.equal(stored.active_birth_provenance, null); + const birth = resolveServerOwnedChartBirth(stored); + assert.equal(birth.status, "confirmed"); + assert.equal(birth.time, "23:55"); + assert.equal(birth.date, "2000-06-16"); + assert.equal(birth.timezoneOffset, -4); + assert.equal(birth.adoption, "legacy"); + assert.equal(birth.failure, null); + const echoed = chartBirthAfterAccountWrite({ + current: confirmed, + written: { ...confirmed, ...patch }, + returned: stored, + }); + assert.equal(echoed.fingerprint, birth.fingerprint); + assert.equal(echoed.date, birth.date); + assert.equal(echoed.time, birth.time); + assert.equal(echoed.timezoneOffset, birth.timezoneOffset); +}); + +test("an accepted declaration edit does not stay chartable with a partial adopted tuple", () => { + const patch = { + birth_date: "2000-06-15", + reported_birth_time: "01:10", + birth_time_source: "approximate" as const, + birth_time_period: null, + birth_time_clue: null, + uncertainty_before_minutes: 30, + uncertainty_after_minutes: 30, + }; + const application = resolveAccountBirthTimeApplicationPatch(crossMidnight, patch); + assert.deepEqual(application, { + active_birth_time: null, + birth_time_status: "reported", + rectification_case_id: null, + }); + const current = { ...crossMidnight } as Record; + const stored = applyAdoptedBirthDateGuard(current, { ...current, ...patch, ...application }); + assert.equal(stored.active_birth_time, null); + assert.equal(stored.active_birth_date, null); + assert.equal(stored.active_birth_timezone_offset, null); + const birth = resolveServerOwnedChartBirth(stored); + assert.equal(birth.status, "reported"); + assert.equal(birth.date, "2000-06-15"); + assert.equal(birth.time, "01:10"); + assert.equal(birth.timezoneOffset, -4); + assert.notEqual(birth.adoption, "incomplete"); + assert.equal(birth.failure, null); +}); + +test("a missing adopted offset is not charted and is not called a profile query or incomplete profile", async () => { + let resolverCalls = 0; + const partial = { ...crossMidnight, active_birth_timezone_offset: null }; + const birth = resolveServerOwnedChartBirth(partial); + assert.equal(birth.failure, "adopted_calculation_incomplete"); + assert.equal(birth.chartable, false); + assert.equal(birth.date, null); + assert.equal(birth.timezoneOffset, null); + assert.equal(birth.activeTime, "23:55"); + const prepared = await prepareChartViewProfile({ + row: partial, + resolveTimezone: async () => { + resolverCalls += 1; + throw new Error("must not resolve an incomplete adopted tuple"); + }, + }); + assert.equal(prepared.ok, false); + if (prepared.ok) return; + assert.equal(prepared.failure, "adopted_calculation_incomplete"); + assert.equal(resolverCalls, 0); + assert.equal(chartViewFailureStatus("adopted_calculation_incomplete"), "chart_unavailable"); + assert.notEqual(chartViewFailureResponse("adopted_calculation_incomplete").status, "birth_profile_incomplete"); +}); + +test("chart-view classifies query, timezone, engine, and schema failures separately", async () => { + const query = await prepareChartViewProfile({ + row: crossMidnight, + queryError: { message: "connection reset" }, + resolveTimezone: async () => { + throw new Error("must not read a failed profile query"); + }, + }); + assert.equal(query.ok, false); + if (!query.ok) { + assert.equal(query.failure, "profile_query_error"); + assert.notEqual(query.failure, "profile_incomplete"); + assert.equal(chartViewFailureResponse("profile_query_error").status, "chart_unavailable"); + assert.match(chartViewFailureResponse("profile_query_error").message, /读不出来/); + } + + const missingOffset = { + ...crossMidnight, + birth_time_status: "reported", + active_birth_date: null, + active_birth_timezone_offset: null, + timezone_offset: null, + }; + const timezone = await prepareChartViewProfile({ + row: missingOffset, + resolveTimezone: async () => { + throw new BirthProfileTimezoneError(); + }, + }); + assert.equal(timezone.ok, false); + if (!timezone.ok) assert.equal(timezone.failure, "timezone_resolver_failure"); + + const messages = [ + "profile_query_error", + "profile_incomplete", + "adopted_calculation_incomplete", + "timezone_resolver_failure", + "response_schema_failure", + ].map((reason) => chartViewFailureResponse(reason as typeof CHART_BIRTH_FAILURE_REASONS[number]).message); + assert.equal(new Set(messages).size, messages.length); + for (const reason of CHART_BIRTH_FAILURE_REASONS) { + const body = chartViewMessageSchema.parse(chartViewFailureResponse(reason)); + assert.equal(body.reason, reason); + assert.doesNotMatch(body.message, /没有可显示内容|过一会儿再打开/); + } + assert.equal(chartViewFailureResponse("engine_busy").message, CHART_VIEW_COPY.busy); + assert.equal(chartViewFailureResponse("engine_timeout").message, CHART_VIEW_COPY.unavailable); + assert.equal(chartViewFailureResponse("engine_bad_payload").message, CHART_VIEW_COPY.unavailable); + + const warnings: unknown[] = []; + const original = console.warn; + console.warn = (...args: unknown[]) => { + warnings.push(args); + }; + try { + logChartViewFailure({ reason: "profile_query_error", elapsedMs: 4, httpStatus: null }); + } finally { + console.warn = original; + } + const line = JSON.stringify(warnings); + assert.match(line, /profile_query_error/); + assert.match(line, /\/api\/chart-view/); + assert.doesNotMatch(line, /2000-06-15|23:55|Synthetic|-74/); +}); + +test("cross-midnight chart-view posts the adopted date, minute, and offset and still returns D1", async () => { + const prepared = await prepareChartViewProfile({ row: crossMidnight, resolveTimezone: identityResolver }); + assert.equal(prepared.ok, true); + if (!prepared.ok) return; + const bodies: Array<{ path: string; body: Record }> = []; + const postEngine: ChartViewEnginePost = async (path, body) => { + bodies.push({ path, body }); + if (path === "/api/chart") return { status: "ok", payload: golden.chart }; + return { status: "http_error", path, elapsedMs: 1, httpStatus: 500 }; + }; + const result = await assembleChartView({ + userId: "synthetic-user", + accountId: "synthetic-user", + profileFingerprint: prepared.birth.fingerprint, + profile: prepared.profile, + postEngine, + asOf: "2026-09-22", + }); + const view = chartViewOkSchema.parse(result.body); + assert.equal(view.profile.date, "2000-06-14"); + assert.equal(view.profile.time.slice(0, 5), "23:55"); + assert.equal(view.profile.timezoneOffset, -5); + assert.equal(view.profileFingerprint, prepared.birth.fingerprint); + assert.equal(view.accountId, "synthetic-user"); + const d1 = view.vedic.vargas.find((item) => item.id === "D1"); + assert.ok(d1); + assert.equal(d1?.chart.houses.length, 12); + assert.ok(view.vedic.planets.length >= 9); + const natal = bodies.find((item) => item.path === "/api/chart"); + assert.equal(natal?.body.day, 14); + assert.equal(natal?.body.hour, 23); + assert.equal(natal?.body.minute, 55); + assert.equal(natal?.body.tz, -5); + assert.deepEqual(bodies.map((item) => item.path), ["/api/chart"]); +}); + +test("engine cache keys still include date, time, offset, ayanamsa, and node mode", () => { + const base = { + userId: "synthetic-user", + date: "2000-06-14", + time: "23:55", + latitude: 40, + longitude: -74, + timezoneOffset: -5, + ayanamsa: "raman", + nodeMode: "mean", + path: "/api/chart", + }; + assert.notEqual(chartViewEngineCacheKey(base), chartViewEngineCacheKey({ ...base, date: "2000-06-15" })); + assert.notEqual(chartViewEngineCacheKey(base), chartViewEngineCacheKey({ ...base, time: "23:56" })); + assert.notEqual(chartViewEngineCacheKey(base), chartViewEngineCacheKey({ ...base, timezoneOffset: -4 })); + assert.notEqual(chartViewEngineCacheKey(base), chartViewEngineCacheKey({ ...base, ayanamsa: "lahiri" })); + assert.notEqual(chartViewEngineCacheKey(base), chartViewEngineCacheKey({ ...base, nodeMode: "true" })); +}); + +test("a saved profile pin drops the previous chart snapshot and ignores an in-flight old response", async () => { + resetSecondaryPageDataForTests(); + const oldView = { + status: "ok", + billed: false, + accountId: "acct-a", + profileFingerprint: "fingerprint-old", + profile: { date: "1990-01-01", time: "01:00", timezoneOffset: 8 }, + } as ChartViewResponse; + writeChartPage({ kind: "view", view: oldView }); + assert.equal(peekChartPage()?.kind, "view"); + pinChartSnapshotIdentity({ accountId: "acct-a", fingerprint: "fingerprint-new" }); + assert.equal(peekChartPage(), null); + pinChartSnapshotIdentity({ accountId: "acct-b", fingerprint: "fingerprint-new" }); + writeChartPage({ + kind: "view", + view: { ...oldView, accountId: "acct-a", profileFingerprint: "fingerprint-new" }, + }); + const foreign = peekChartPage(); + assert.notEqual(foreign?.kind === "view" && foreign.view.status === "ok", true); + if (foreign?.kind === "view") { + assert.equal(foreign.view.accountId, "acct-b"); + assert.doesNotMatch(JSON.stringify(foreign.view), /1990-01-01/); + } + + resetSecondaryPageDataForTests(); + let releaseFirst: (response: Response) => void = () => {}; + let markStarted: () => void = () => {}; + const started = new Promise((resolve) => { + markStarted = resolve; + }); + let calls = 0; + const originalFetch = globalThis.fetch; + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = String(input); + if (!url.includes("/api/chart-view")) throw new Error(`unexpected fetch ${url}`); + calls += 1; + if (calls === 1) { + markStarted(); + return new Promise((resolve) => { + releaseFirst = resolve; + }); + } + return new Response(JSON.stringify({ + status: "chart_unavailable", + billed: false, + message: "新资料还不能排。", + reason: "profile_incomplete", + accountId: "acct-a", + profileFingerprint: "fingerprint-new", + }), { status: 200, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + try { + const pending = refreshChartPage(); + await started; + pinChartSnapshotIdentity({ accountId: "acct-a", fingerprint: "fingerprint-new" }); + releaseFirst(new Response(JSON.stringify(oldView), { + status: 200, + headers: { "content-type": "application/json" }, + })); + const snapshot = await pending; + assert.equal(calls, 2); + assert.equal(snapshot.kind, "view"); + if (snapshot.kind !== "view") return; + assert.equal(snapshot.view.profileFingerprint, "fingerprint-new"); + assert.notEqual(snapshot.view.profileFingerprint, "fingerprint-old"); + const visible = peekChartPage(); + assert.equal(visible?.kind, "view"); + if (visible?.kind === "view") { + assert.equal(visible.view.profileFingerprint, "fingerprint-new"); + assert.doesNotMatch(JSON.stringify(visible.view), /1990-01-01/); + } + } finally { + globalThis.fetch = originalFetch; + resetSecondaryPageDataForTests(); + } +}); + +test("an account read that overlaps a profile save does not pin the stale fingerprint", () => { + resetSecondaryPageDataForTests(); + const started = beginChartCacheAccountRead(); + pinChartSnapshotIdentity({ accountId: "acct-a", fingerprint: "fingerprint-new" }); + assert.equal(accountChartPinStillCurrent(started), false); + const reread = beginChartCacheAccountRead(); + assert.equal(accountChartPinStillCurrent(reread), true); + resetSecondaryPageDataForTests(); +}); + +test("chart-view schema failures stay on the page as a named format error", async () => { + resetSecondaryPageDataForTests(); + const originalFetch = globalThis.fetch; + globalThis.fetch = (async () => new Response("not-json", { + status: 200, + headers: { "content-type": "application/json" }, + })) as typeof fetch; + try { + const result = await fetchChartView({}); + const body = result.body; + if (!body || body.status === "ok") { + assert.fail("schema failure should stay a chart-view message"); + } + assert.equal(body.reason, "response_schema_failure"); + assert.match(body.message, /格式对不上/); + assert.doesNotMatch(body.message, /没有可显示内容|过一会儿再打开/); + } finally { + globalThis.fetch = originalFetch; + resetSecondaryPageDataForTests(); + } +}); + +test("the adopted-date trigger clears the whole tuple and chart-view does not read chart_profiles", () => { + const migration = readFileSync( + new URL("../supabase/migrations/20260920020000_adopted_birth_date.sql", import.meta.url), + "utf8", + ); + const service = readFileSync(new URL("../src/lib/chart-view-service.ts", import.meta.url), "utf8"); + const account = readFileSync(new URL("../src/app/api/account/route.ts", import.meta.url), "utf8"); + const onboarding = readFileSync(new URL("../src/hooks/use-profile-onboarding.ts", import.meta.url), "utf8"); + const guard = migration.slice( + migration.indexOf("function public.guard_adopted_birth_date"), + migration.indexOf("drop trigger if exists zz_guard_adopted_birth_date"), + ); + assert.match(guard, /new\.active_birth_date := null/); + assert.match(guard, /new\.active_birth_timezone_offset := null/); + assert.match(guard, /new\.active_birth_provenance := null/); + assert.doesNotMatch(guard, /new\.active_birth_time := null/); + assert.match(service, /const \{ data: row, error \}/); + assert.match(service, /profile_query_error/); + assert.doesNotMatch(service, /chart_profiles/); + assert.match(account, /chartBirth: resolveServerOwnedChartBirth\(profile\)/); + assert.match(account, /chartBirthAfterAccountWrite/); + assert.match(onboarding, /pinChartSnapshotIdentity/); + assert.doesNotMatch( + readFileSync(new URL("../src/app/(app)/page.tsx", import.meta.url), "utf8"), + /pinChartSnapshotIdentity|chartBirth/, + ); +});