From 19ea0f06c16cc531ba9099fe81cb3db3c4e09a36 Mon Sep 17 00:00:00 2001 From: jesse-ux Date: Fri, 25 Sep 2026 02:06:35 +0800 Subject: [PATCH] fix(chart): keep zero-year chara and ephemeris dates apart A zero-year Chara period is shown as 0 years instead of failing the whole chart. Switching ephemeris dates drops the previous day immediately and can be retried. Missing birth data links to chart settings. --- CHANGELOG.md | 7 + docs/BUG_HISTORY.md | 48 +++++ ...PROGRESS-chart-ephemeris-fixes-20260924.md | 65 +++++++ docs/tasks/README.md | 2 +- .../testing/chart-ephemeris-fixes-20260924.md | 13 ++ frontend/DESIGN.md | 8 +- frontend/docs/VOICE.md | 10 ++ frontend/src/app/(app)/page.tsx | 3 +- frontend/src/app/api/ephemeris/route.ts | 53 +++++- frontend/src/app/globals.css | 3 + .../components/chart-page/chart-dasha-tab.tsx | 8 +- .../components/chart-page/chart-page-view.tsx | 18 ++ .../components/ephemeris/ephemeris-page.tsx | 110 ++++++++---- frontend/src/lib/chart-view-contract.ts | 3 + frontend/src/lib/chart-view-labels.ts | 6 + frontend/src/lib/chart-view-mapper.ts | 23 ++- frontend/src/lib/ephemeris-view.ts | 2 + frontend/src/lib/settings-url.ts | 8 +- frontend/tests/chart-page-view.test.tsx | 31 ++++ frontend/tests/chart-view-zero-year.test.ts | 107 +++++++++++ .../tests/ephemeris-page-lifecycle.test.tsx | 167 ++++++++++++++++++ frontend/tests/ephemeris-page.test.tsx | 37 ++++ frontend/tests/ephemeris-route.test.ts | 53 +++++- ...a-zero-year-1985-08-03-beijing.golden.json | 1 + frontend/tests/settings-url.test.ts | 18 +- 25 files changed, 744 insertions(+), 60 deletions(-) create mode 100644 docs/tasks/PROGRESS-chart-ephemeris-fixes-20260924.md create mode 100644 docs/testing/chart-ephemeris-fixes-20260924.md create mode 100644 frontend/tests/chart-view-zero-year.test.ts create mode 100644 frontend/tests/ephemeris-page-lifecycle.test.tsx create mode 100644 frontend/tests/fixtures/chara-zero-year-1985-08-03-beijing.golden.json diff --git a/CHANGELOG.md b/CHANGELOG.md index dd0588e9..6c60d6c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # 印度占星 Skill 更新日志 +## 2026-09-25 — 0 年大运、星历切日期、星盘无资料入口(待验收) + +- Chara 出现 0 年大运时照实显示「0 年」,不占时间轴、不展开子运,不再把整张盘打成「算不出来」。不改引擎口径。 +- 星历切到未缓存的日期时留下日期条,五要素和行运立刻换成静态句;失败可以再点日期或箭头重试。旧日期的响应不会盖住新日期。 +- 星盘缺少出生资料,或资料读失败、时区不完整时,可以「去填写出生资料」。 +- Skill 版本不 bump。浏览器和部署未验收。 + ## 2026-09-25 — 校正会话标题改为结果时间 - 历史校正会话的名字改成显示算出来的出生时间或当前范围,不再在标题里重复日期。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index b5821104..5ad4e026 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -13651,3 +13651,51 @@ - 相关记录:BUG-929、BUG-699、BUG-704、BUG-988、BUG-987、BUG-992、BUG-995 - 复发自:BUG-929(标题口径两次返工;「旧标题不批量改」被本单推翻) - 修复版本:本分支提交,尚未推送、尚未部署 + +## BUG-1023 | Chara 0 年大运把整张星盘收成算不出来 + +- 状态:resolved(定向回归已锁;浏览器与部署未验收,不把本分支写成已上线) +- 首次发现:2026-09-24 +- 最近更新:2026-09-25 +- 影响面:`chart-view-mapper.ts` 的 Chara 时间分配、星盘大运 Tab +- 用户现象:大运右栏是「这张盘算不出来,我们已经记录下来了。」 +- 触发条件:某段 Chara 大运 `duration_years` 为 0。虚构 1985-08-03 03:59、北京可复现,射手段为 0 年。 +- 根因:子运权重用 `finite(duration) ?? 1`,0 不会被换成 1。12 段子运权重都是 0 时总和为 0,`0/0` 得到 NaN,`new Date(NaN).toISOString()` 抛 `RangeError`。`assembleChartView` 把非 Zod 错误收成 `engine_bad_payload`。0 是 kn_rao「先保底 12 年、再按落陷减 1」的合法结果,不是引擎坏包。 +- 修复:总权重不是正数时不再构造非法日期。0 年段标注「0 年」,起止同一天,不展开子运,子运区写「本段时长为 0,不展开」。不改 `scripts/jaimini.py`。 +- 验证:真实 `_compute_chart` + `_compute_chara_dasha` golden。12 段、射手 0 年、前后日期相接、`assembleChartView({layers:["chara"]})` 为 ok。UI 不渲染该段子运。 +- 防复发:0 权重不得再进入 `Date#toISOString`。0 年是显示规则,不是引擎失败。BUG-715 的失败分档仍留给真正的坏响应。 +- 相关记录:BUG-715、BUG-1017 +- 复发自:无 +- 修复版本:本分支,待推送与部署核对 + +## BUG-1024 | 星历切到新日期后下面仍是前一天 + +- 状态:resolved(定向回归已锁;浏览器与部署未验收,不把本分支写成已上线) +- 首次发现:2026-09-24 +- 最近更新:2026-09-25 +- 影响面:`ephemeris-page.tsx`、`/api/ephemeris` +- 用户现象:顶部日期已经换成新的一天,五要素和行运还是前一天。失败后一直留着旧数据,再点也不会重取。 +- 触发条件:切到还没缓存的日期;或请求 429、非 JSON、坏包;或进页时「今天」的刷新在切日期期间回来。 +- 根因:BUG-966 为了不让整页换成等待句,删掉了缓存未命中时的清空。失败时 `load` 直接返回,effect 又只跟日期变,所以同一天不能重试。进页刷新只比较响应里的 `payload.date`,切日期期间会把今天写回去。 +- 修复:只有响应日期等于当前所选日期才算就绪,否则保留日期条,三段改静态句。失败句为「这一天的星历没取到,点日期再试一次。」再点日期或失败时的左右箭头会重试。今天的刷新和迟到响应都先看当前日期。本命盘按账户和出生资料缓存 5 分钟,引擎调用带上 `request.signal`。 +- 验证:生命周期测试覆盖未缓存静态句、迟到的今天、迟到的前一日期、429 后箭头重试。路由测试里同一账户连续两天只打一次本命 `/api/chart`,并且上游 fetch 能看到已中止的 signal。 +- 防复发:星历仍不得出现骨架、spinner 或「正在加载」。旧日期响应不得写回新日期。BUG-966 的测试只静态渲染了 `EphemerisView`,没有挂上切日期的状态机,所以没拦住这次副作用。 +- 相关记录:BUG-966、BUG-1016 +- 复发自:BUG-966 +- 修复版本:本分支,待推送与部署核对 + +## BUG-1025 | 星盘没有出生资料时只有一句话,没有去填写的入口 + +- 状态:resolved(定向回归已锁;浏览器与部署未验收,不把本分支写成已上线) +- 首次发现:2026-09-24 +- 最近更新:2026-09-25 +- 影响面:星盘失败页、`settings-url.ts`、首页设置深链 +- 用户现象:没有出生资料时只看到说明,不能从这一页去填。 +- 触发条件:`birth_profile_incomplete`,以及资料查询失败、已采用时间缺时区、时区解析失败。 +- 根因:失败页只有一段文字。`parseSettingsQuery` 以前只认 `?settings=billing`。 +- 修复:没有资料时加标题「还没有星盘资料」、原句和主按钮「去填写出生资料」,链到 `/?settings=chart`。另外三种资料失败保留各自的句子,并给同一按钮。首页据此打开设置里的星盘资料。`?settings=billing` 仍只开账户与点数。解析只在 `settings-url.ts`。 +- 验证:四种资料失败都渲染该按钮;引擎忙不渲染。`?settings=chart` 解析为星盘资料,billing 的既有断言没改。 +- 防复发:查询失败不得再写成资料不完整。这四类以外的失败不得带上「去填写出生资料」。 +- 相关记录:BUG-996、BUG-715 +- 复发自:无 +- 修复版本:本分支,待推送与部署核对 diff --git a/docs/tasks/PROGRESS-chart-ephemeris-fixes-20260924.md b/docs/tasks/PROGRESS-chart-ephemeris-fixes-20260924.md new file mode 100644 index 00000000..335404e7 --- /dev/null +++ b/docs/tasks/PROGRESS-chart-ephemeris-fixes-20260924.md @@ -0,0 +1,65 @@ +# PROGRESS · Chara 0 年大运 + 星历切日期 + 星盘无资料入口(2026-09-25) + +## 当前结论 + +T1、T2、T3、T4 都已完成,没有让步,也没有写进 BLOCKED。引擎 kn_rao 口径、`HEAVY_COMPUTE_PATHS`、数据库和 workflow 都没动。浏览器登录验收没做。没有推 `staging`,也没有推 `main`。 + +- 工作树:`G:\Ferti\Jyotisha\.worktrees\chart-ephemeris-fixes-20260924` +- 分支:`codex/chart-ephemeris-fixes-20260924` +- 基线:`origin/staging` = `514951d7`(代码提交 `824647f2`) +- BUG:1023 / 1024 / 1025 开工时仍空着,沿用任务书预留号。最大已占用号是 1022。没有占用 1026 及以后。 + +## 逐项 + +| 项 | 结果 | 说明 | +| --- | --- | --- | +| T1 | 完成 | 0 年大运显示「0 年」,不展开子运,前后日期首尾相接。`assembleChartView({layers:["chara"]})` 对真实引擎响应返回 ok。 | +| T2 | 完成 | 未缓存日期保留日期条,三段换成静态句。429 后点右箭头重试成功。挂载时的今天刷新和迟到的旧日期都不会盖住新日期。本命 `/api/chart` 按账户和出生资料短缓存,并把 `request.signal` 传进引擎调用。 | +| T3 | 完成 | 四种资料类失败都有「去填写出生资料」,链到 `/?settings=chart`,首页打开星盘资料。`?settings=billing` 的解析结果没改。 | +| T4 | 完成 | BUG 历史、DESIGN §15 / §16、VOICE 三句、CHANGELOG、本记录、真人清单和状态板同一提交。 | + +## 真实 golden + +本机没有仓库 `.venv`,Anaconda Python 3.11 能 `import swisseph`。fixture `frontend/tests/fixtures/chara-zero-year-1985-08-03-beijing.golden.json` 由下面的调用生成,没有手造引擎形状: + +- `C:\Users\74082\anaconda3\python.exe` +- `JyotishAPIHandler.__new__()._compute_chart`,再 `_compute_chara_dasha` +- 虚构 1985-08-03 03:59,纬度 39.9042,经度 116.4074,时区 8,`ayanamsa=raman`,`node_mode=mean`,`skip_vedastro_main_entry_overview`,`antardasha=true` + +生成结果 12 段,Sagittarius `duration_years` 为 0(debilitated)。`scripts/jaimini.py` 未改。 + +## 历史检索 + +开工读过 BUG-966、BUG-715、BUG-716、BUG-717、BUG-996、BUG-1016、BUG-1017,以及 chart-view / ephemeris / `birth_profile_incomplete` 的相关段。 + +- BUG-966 的「星历不得再上骨架 / 正在加载」还在。本单没有加骨架。它删掉未命中时清空状态的副作用,就是 BUG-1024。 +- BUG-715 的失败分档还在。0 年以前会在 mapper 里抛 `RangeError`,被收成 `engine_bad_payload`。现在 0 年是合法段,不再走这条失败。 +- BUG-1016 的骨架例外仍只在星盘盘位。星历没有用。 +- BUG-1017 的按需层失败出口没改。 +- BUG-996:查询失败仍不是 `birth_profile_incomplete`。四种资料失败各自留原文,只是多了同一颗按钮。 + +## 行为说明 + +失败当天,左右箭头重试这一天,不先改日期。日历里另选一天仍会换日期。成功之后箭头恢复成前一天 / 后一天。 + +本命缓存键用的是账户 id 加上出生日期、时间、经纬、时区、岁差和交点,与 `cachedPostEngine` 同一组字段、同一 5 分钟 TTL。键的 path 是 `/api/ephemeris/natal-chart`,避免和星盘页略有不同的 `/api/chart` 请求体串结果。行运盘仍按所选日期现算。 + +## 测试 + +改代码前,在 `frontend` 跑下面这组,93 条,失败 0: + +`tests/ephemeris-page.test.tsx`、`tests/ephemeris-route.test.ts`、`tests/chart-page-view.test.tsx`、`tests/chart-page-hook.test.ts`、`tests/settings-url.test.ts`、`tests/home-shell-growth-contract.test.ts`、`tests/secondary-page-entry.test.ts`、`tests/billing-panel.test.ts` + +改完后同一组再加上 `tests/ephemeris-page-lifecycle.test.tsx` 和 `tests/chart-view-zero-year.test.ts`:104 条,失败 0。改前那 93 条里没有新增失败。 + +- `.\node_modules\.bin\tsc --noEmit`:通过。 +- `npm run lint`:0 error,121 条既有 warning。本次新文件没有新增 warning,也没有为了清 warning 改业务代码。 +- 没有改既有断言的期望值,所以没有「原值 / 新值 / 原因」三栏。 +- 没有跑 Windows 全量 `npm test`,也不把别人机器上的 3809 / 56 写成这次的结果。 +- 没有跑 `next build`,没有声称 `/`、`/chart`、`/ephemeris` 的 Static 标记在本机构建里复核过。源码没有把这三条改成动态路由。 + +## 没做的验证 + +- 没有浏览器登录。真人步骤在 `docs/testing/chart-ephemeris-fixes-20260924.md`,结果都是待验。 +- 没有部署,没有对过 staging health。 +- 没有推 `origin/staging`。功能分支可以另推 `origin`,以回报里的远端核对为准。 diff --git a/docs/tasks/README.md b/docs/tasks/README.md index 347799a6..cd4be196 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -203,7 +203,7 @@ | 任务书 | 进度 | 主题 | 状态 | 落点 | | --- | --- | --- | --- | --- | | `TASK-chart-dasha-western-redesign-20260924.md` | — | **大运 / 西洋盘改版**(原型 https://claude.ai/artifact/DYovEPMqD1xwNgna5xVTre ,产品 09-24 全部同意,相位线常显):大运「现在」卡 + 时间条 + 默认折叠(原 81/144 行平铺);西洋盘 520 三圈、行星避让 + 引线、宫号居宫中、ASC/MC 盘内、行星位置表、点选过滤相位。排在 chart-ephemeris-fixes T1 之后 | 待领取 | — | -| `TASK-chart-ephemeris-fixes-20260924.md` | — | **Chara 0 年大运崩溃 + 星历切日期旧数据 + 星盘无资料空状态(BUG-1023/1024/1025)**:mapper 0/0 → `RangeError`(约 7.8% 星盘,产品定照实显示 0 年、不改引擎);BUG-966 删掉重置致日期与数据分叉(回归);无资料只有一句话,加「去填写出生资料」+ `?settings=chart` 深链(T3 排在 home-slow-network 之后)。大运 / 西洋版式重做不在本单 | 待领取 | — | +| `TASK-chart-ephemeris-fixes-20260924.md` | `PROGRESS-chart-ephemeris-fixes-20260924.md` | **Chara 0 年大运崩溃 + 星历切日期旧数据 + 星盘无资料空状态(BUG-1023/1024/1025)**:mapper 0/0 → `RangeError`(约 7.8% 星盘,产品定照实显示 0 年、不改引擎);BUG-966 删掉重置致日期与数据分叉(回归);无资料只有一句话,加「去填写出生资料」+ `?settings=chart` 深链(T3 排在 home-slow-network 之后)。大运 / 西洋版式重做不在本单 | 待验收 | `PENDING_SHA` | | `TASK-chart-page-skeleton-wait-20260924.md` | `PROGRESS-chart-page-skeleton-wait-20260924.md` | **星盘页骨架盘等待态 + 三个「永远还没拿到」死角**:「这一张盘还没拿到。」只代表 `view === null`,是唯一等待态,失败各有句子;但客户端无超时(A)、401 跳转前(B)、分盘层失败 / 429 后 `ChartVedicTab` 只看 `!selected` 不看 pending(C)三条路都让这句永远挂着。**产品 09-24 拍板推翻 09-18 方案一(BUG-966)**:星盘页盘位用骨架盘 + 呼吸动效(复用 `VedicChartSvg` 空模型,不复制几何;`prefers-reduced-motion` 静止),范围只限星盘页会出盘轮的位置,首页 / 侧栏 / 星历 / 报告的「不放骨架」合同不动;`AGENTS.md` §6 与 `DESIGN.md` §9 / §15 追加例外。三死角出口:客户端 25s 超时 → `timedOut` 句、401 先写失败体、按层失败态 `layerFailures`(429 独立档,BUG-715/723)。BUG-1016 / 1017 | 已验收(Claude 09-24:红线全绿、+46 测试、失败名单同基线、四路由标记与 gzip 同基线;BUG-1016/1017 resolved) | `1420471a`(staging 已部署);真机清单欠 | | `TASK-smalltalk-test-stdout-mock-20260921.md` | `PROGRESS-smalltalk-test-stdout-mock-20260921.md` | **测试基础设施(BUG-995)**:`consultation-smalltalk.test.ts` 为验证「SDK 不得把私密文本写进日志」,用 `t.mock.method(process.stdout/stderr, "write", …)` 全局接管标准输出——而 `node:test` 的 TAP 报告也走 `process.stdout`,于是运行器自己的 `# Subtest:` / `ok` 行被吞进测试的 `logs` 数组。实测四次全量:`default Mastra adapter` 四条用例只上报 4 / 2 / 1 / 1 条(`Subtest` 声明本身就少,不是 grep 锚点问题),`# tests` 因此 ±3 抖;单跑该文件 33 条静态展开只报 30 条,仅循环最后一个 `provider_error` 稳定可见。**更要紧**:注入必失败探针后,`invalid_schema` / `bad_json` 只剩一行 `not ok 1 - <文件绝对路径>`,**没有用例名、没有断言消息**(退出码仍是 1,门禁不会漏掉失败,不夸大)。定位 BUG-987 时靠的就是从门禁日志 grep `not ok` 抓用例名,红在这三条上那条路会断。**决策**:mock 改为记录后**透传给原始 `write`**(保留隐私断言,不换报告通道,不改 `npm test` 脚本);F3 把「比用例名列表 diff、不比 `# tests` 总数」写进 `frontend/AGENTS.md`。全仓只此一个文件用了这个手法(已 grep 确认)| 待验收 | `2503c019` | | `TASK-frontend-optimization-20260828.md` | `PROGRESS-frontend-optimization-20260828.md` | 前端优化九条 | 待核对 | 分支 `codex/frontend-optimization-20260828` | diff --git a/docs/testing/chart-ephemeris-fixes-20260924.md b/docs/testing/chart-ephemeris-fixes-20260924.md new file mode 100644 index 00000000..77f0f96f --- /dev/null +++ b/docs/testing/chart-ephemeris-fixes-20260924.md @@ -0,0 +1,13 @@ +# 星历切日期、0 年大运、星盘无资料(2026-09-25) + +状态:本环境没有可靠的浏览器登录验收。下面每条都还没在真人浏览器里走过。自动化只锁了渲染和请求生命周期,不能代替这张表。只用虚构出生时间 1985-08-03 03:59、北京,不要把真实出生资料写进记录。 + +| 项目 | 操作 | 通过标准 | 结果 | +| --- | --- | --- | --- | +| 0 年大运 | 用虚构盘打开星盘,点「大运」 | 射手那段写「0 年」,下面是「本段时长为 0,不展开」,没有子运列表。前后段日期接得上。整页不是「这张盘算不出来」 | 待验 | +| 星历连点 | Slow 3G 下打开星历,连点后一天三次 | 每次日期条立刻变成新的一天。五要素和行运马上变成「还没拿到」,事件区是「换座和停滞还在取。」没有骨架、转圈或「正在加载」。后到的旧日期不会把新日期盖回去 | 待验 | +| 断网切日期 | 星历已打开后断网,再点前一天或后一天 | 三段都是「这一天的星历没取到,点日期再试一次。」恢复网络后再点这一天或左右箭头,能重新取到。日期条一直在 | 待验 | +| 无资料 | 用没有出生日期的账号打开星盘 | 标题「还没有星盘资料」,原说明还在,主按钮「去填写出生资料」。点下去到首页设置的星盘资料。`?settings=billing` 仍只开账户与点数 | 待验 | +| 另外三种资料失败 | 若能在受控环境分别造出资料读失败、已采用时间缺时区、时区对不上 | 各自的原句还在,并且有同一颗「去填写出生资料」。不要出现「还没有星盘资料」这句标题 | 待验 | + +部署后还要核对 staging `/api/health` 的 `deployment.gitCommit`。本清单未走完之前,不能写成浏览器已验收。 diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index f06d45f9..aa428cf7 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -918,6 +918,8 @@ Three product rules for assistant markdown in `.message-markdown` (BUG-962 / 963 「传统象征」是词条,不得写成运势。页面上不另写「词条式释义,不是对你个人的判断」——行星卡本身已经是度数、星宿、D9,没有判断句。大运 Tab 底部固定:「只有两条同时指向同一段时间才算证据,单轨命中要降一级来看。」Chara 的正式名称是 **Chara Dasha(kn_rao 变体)**,不要写成 Narayana。 +0 年大运照实显示,不改引擎。某段 Chara 的 `duration_years` 为 0 时,这一段标注「0 年」,起止落在同一天,不占时间轴,也不展开子运;子运区写「本段时长为 0,不展开」。前一段的结束日就是下一段的开始日。 + ### 等待态 2026-09-24 产品决策有限推翻 09-18 方案一(BUG-716 / 966):`SecondaryPageShell` 标题、note 槽与撑满内容区不变,主盘未到时正常渲染 body 和禁用的 Tab 行;仅 D1、非 D1 分盘与西洋盘位置显示既有 SVG 空模型的骨架与原等待句。骨架无星体、度数、星座标签,几何不得复制;容器 `aria-busy=true`,装饰盘对读屏隐藏。中性 token、opacity 0.35 ↔ 0.6 / 2.4s ease-in-out,减弱动态效果时静止 0.5;不旋转、不扫光。大运、七政表格仍为静态句,首页、侧栏、星历、报告不适用此例外。首次请求(含预取)和按需层均有 25 秒客户端期限;失败移除骨架,再点该 Tab/chip 清失败并重试。401 先显式呈现登录句再跳转。同一会话再次进入直接用模块缓存,不重放等待句。出生资料保存成功后,缓存按账户和资料指纹换键,旧盘不得留在页上冒充新的日期或时间。 @@ -936,6 +938,10 @@ Three product rules for assistant markdown in `.message-markdown` (BUG-962 / 963 | 已采用的时间缺时区 | 已采用的出生时间缺了排盘要用的时区,这张盘先不能算。我们已经记录下来了。 | | 时区没有对上 | 出生地的时区没有对上,这张盘先不能算。我们已经记录下来了。 | | 结果格式对不上 | 星盘结果的格式对不上,我们已经记录下来了。 | +| 还没有出生资料 | 标题「还没有星盘资料」+ 原句 + 主按钮「去填写出生资料」 | +| 资料读不出来 / 已采用时间缺时区 / 时区没有对上 | 各自的原句 + 同一个「去填写出生资料」;不加「还没有星盘资料」 | + +资料类这四句的按钮链到 `/?settings=chart`,打开设置里的星盘资料。标题用 `--type-title-md`,整块居中,按钮是主按钮。引擎忙、超时和坏结果不加这个按钮。解析 `?settings=` 只在 `settings-url.ts`;`?settings=billing` 仍只开账户与点数。 ## 16. 星历页 @@ -944,7 +950,7 @@ Three product rules for assistant markdown in `.message-markdown` (BUG-962 / 963 - **结构:** 日期条;今日五要素;当日行运;未来九十天。顺序固定。页尾不另写定性句。 - **日期条:** 居中的 `‹ 日期 ›`——日期是主语,两枚 44px 箭头按钮是它的把手。箭头字形 `aria-hidden`,可访问名仍是「前一天」「后一天」,读屏不丢方向。**「今天」只在非今日时出现**,作为日期下方一枚文字按钮;在今日时它整个不渲染,而不是渲染成一枚禁用按钮——灰掉的按钮仍要被读、仍要被 Tab 跳过。原本三个并排等宽按钮把「前一天 / 今天 / 后一天」读成三个平级动作,日期本身反而被挤出了这一组。 - **三段同一套卡片:** 五要素 / 行运 / 未来九十天三个 `.ephemeris-section` 各是一张卡(1px 描边 + `--radius-lg` + `--color-canvas`),卡内是行。原本三段的**内容**各自画框而**段落**不画框,于是三段看起来是三种不同的东西。框归段落,段内只有行。 -- 切日期立刻换五要素和行运;没有 spinner、骨架或「正在加载」。数据未到时写静态句「这一天的五要素还没拿到。」「这一天的行运还没拿到。」 +- 切到未缓存的日期时,日期条和布局留下,五要素、行运立刻换成「这一天的五要素还没拿到。」「这一天的行运还没拿到。」事件区写「换座和停滞还在取。」,这时不用「还没接上」。没有 spinner、骨架或「正在加载」。请求失败时,三段都写「这一天的星历没取到,点日期再试一次。」再点这一天的日期,或在失败时点左右箭头,就重试这一天;日历仍可改去别的日期。旧日期的响应,包括进页时刷新的今天,不得盖住正在看的另一天。 - **五要素:** `dl` 网格保留:桌面五格一行(星期、月相日、月宿含第几足、瑜伽、迦那),格子是段落卡内的浅底块,不再各自描边。767px 以下收成五行,标签左、值右。Yoga / Karana 的吉凶只照搬引擎标签(吉 / 中 / 凶),不写成建议。卡片下固定一行:「这一段按 Lahiri 岁差算,和星盘页用的岁差不是同一套。」行运段不加这句。 - **当日行运:** 九曜的星座、度数、相对本命宫位、顺逆。桌面表格,767px 以下每行改成带标签的堆叠。没有本命上升时,相对本命列为空,并写「还没有本命上升,相对本命这一列空着。」 - **未来九十天:** 按日期升序只写事实(「某星 进入 某座」「某星 停滞转顺 · 度数」)。`/api/ephemeris_events` 未上线时整段静态说明:「换座和停滞还没接上。这一页其余部分不受影响。」不得让整页失败。 diff --git a/frontend/docs/VOICE.md b/frontend/docs/VOICE.md index eca5868b..bad1a58c 100644 --- a/frontend/docs/VOICE.md +++ b/frontend/docs/VOICE.md @@ -18,6 +18,16 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、 首页开场语只有一行,不带追问句,不用星星、月亮、陪伴一类比喻。 +## 星盘空状态、0 年大运、星历切日期 + +这三句只描述眼前缺的数据,不解释运势,也不许写成「正在加载」: + +- 星历事件还在取:「换座和停滞还在取。」 +- 星历这一天没取到:「这一天的星历没取到,点日期再试一次。」 +- 0 年大运不展开:「本段时长为 0,不展开。」时长本身写「0 年」。 + +没有出生资料时,标题是「还没有星盘资料」,按钮是「去填写出生资料」。资料读不出来、已采用时间缺时区、时区没有对上,正文仍用各自的原句,只共用这颗按钮。 + ## 报告分块导出与删除 - 列表已完成行只留「查看报告」。阅读页操作条只留「返回报告中心」与导出图标;图标读屏名「导出报告」,tooltip「导出」。不再显示「下载原始附录」或「打印」入口文案;浏览器 Ctrl+P 排版仍保留。 diff --git a/frontend/src/app/(app)/page.tsx b/frontend/src/app/(app)/page.tsx index 9e300452..4f40d25f 100644 --- a/frontend/src/app/(app)/page.tsx +++ b/frontend/src/app/(app)/page.tsx @@ -499,7 +499,8 @@ export default function Home() { const applySettingsFromSearch = (search: string) => { const query = parseSettingsQuery(search); if (!query) return; - openAccountDialog("billing", { source: query.source ?? undefined, plan: query.plan ?? undefined, tab: query.tab ?? undefined }); + if (query.pane === "chart") openAccountDialog("chart-library"); + else openAccountDialog("billing", { source: query.source ?? undefined, plan: query.plan ?? undefined, tab: query.tab ?? undefined }); const next = stripSettingsQuery(search, window.location.pathname); if (`${window.location.pathname}${window.location.search}` !== next) window.history.replaceState(null, "", next); }; diff --git a/frontend/src/app/api/ephemeris/route.ts b/frontend/src/app/api/ephemeris/route.ts index bba16d43..9f92908a 100644 --- a/frontend/src/app/api/ephemeris/route.ts +++ b/frontend/src/app/api/ephemeris/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from "next/server"; +import { chartViewEngineCacheKey, readChartViewEngineCache, writeChartViewEngineCache } from "@/lib/chart-view-engine"; import { calendarDateInTimeZone } from "@/lib/daily-starlanguage"; import { ephemerisIsoDateSchema, ephemerisOkResponseSchema, type EphemerisOkResponse } from "@/lib/ephemeris-contract"; import { @@ -23,18 +24,54 @@ const engineTimeoutMs = 20_000; type Profile = ReturnType; type BirthPayload = NonNullable>>; -async function fetchEngine(path: string, body: Record) { +function engineSignal(request: Request): AbortSignal { + return AbortSignal.any([AbortSignal.timeout(engineTimeoutMs), request.signal]); +} + +async function fetchEngine(path: string, body: Record, signal: AbortSignal) { const response = await fetch(`${jyotishApiBase}${path}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), cache: "no-store", - signal: AbortSignal.timeout(engineTimeoutMs), + signal, }); if (!response.ok) throw new Error(`jyotish_api_${response.status}`); return await response.json() as unknown; } +function natalCacheKey(userId: string, birth: BirthPayload): string { + const month = String(birth.month).padStart(2, "0"); + const day = String(birth.day).padStart(2, "0"); + const hour = String(birth.hour).padStart(2, "0"); + const minute = String(birth.minute).padStart(2, "0"); + return chartViewEngineCacheKey({ + userId, + date: `${birth.year}-${month}-${day}`, + time: `${hour}:${minute}`, + latitude: birth.lat, + longitude: birth.lon, + timezoneOffset: birth.tz, + ayanamsa: birth.ayanamsa, + nodeMode: birth.node_mode, + path: "/api/ephemeris/natal-chart", + }); +} + +async function fetchNatalChart(userId: string, birth: BirthPayload, signal: AbortSignal): Promise { + const key = natalCacheKey(userId, birth); + const hit = readChartViewEngineCache(key); + if (hit) return hit; + const payload = await fetchEngine("/api/chart", { + ...birth, + skip_vedastro_main_entry_overview: true, + }, signal); + if (!signal.aborted && payload && typeof payload === "object" && !Array.isArray(payload)) { + writeChartViewEngineCache(key, payload as Record); + } + return payload; +} + function requestedDate(request: Request, timezoneId?: string | null): string { const raw = new URL(request.url).searchParams.get("date")?.trim() ?? ""; if (raw && isEphemerisIsoDate(raw) && ephemerisIsoDateSchema.safeParse(raw).success) return raw; @@ -75,6 +112,7 @@ export async function GET(request: Request) { const birth = await dailyProfilePayload(profile, date).catch(() => null); const location = locationBody(profile); + const signal = engineSignal(request); const panchangaBody: Record = { start_date: date, end_date: date }; if (location) { panchangaBody.lat = location.lat; @@ -82,23 +120,20 @@ export async function GET(request: Request) { panchangaBody.tz = location.tz; } - const panchangaPromise = fetchEngine("/api/panchanga_range", panchangaBody).catch((error: unknown) => { + const panchangaPromise = fetchEngine("/api/panchanga_range", panchangaBody, signal).catch((error: unknown) => { console.warn("ephemeris_panchanga_unavailable", error); return null; }); const natalPromise = birth - ? fetchEngine("/api/chart", { - ...birth, - skip_vedastro_main_entry_overview: true, - }).catch((error: unknown) => { + ? fetchNatalChart(user.id, birth, signal).catch((error: unknown) => { console.warn("ephemeris_natal_chart_unavailable", error); return null; }) : Promise.resolve(null); const transitPromise = birth - ? fetchEngine("/api/chart", transitChartBody(birth, date)).catch((error: unknown) => { + ? fetchEngine("/api/chart", transitChartBody(birth, date), signal).catch((error: unknown) => { console.warn("ephemeris_transit_chart_unavailable", error); return null; }) @@ -109,7 +144,7 @@ export async function GET(request: Request) { end_date: addDaysIso(date, 90), ayanamsa: birth?.ayanamsa ?? profile.ayanamsa ?? "raman", node_mode: "mean", - }).catch((error: unknown) => { + }, signal).catch((error: unknown) => { console.warn("ephemeris_events_unavailable", error); return null; }); diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 97e8f9d6..3d1713e4 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -2193,6 +2193,9 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class font-size: var(--type-title-lg); font-weight: 500; } +.chart-page-message h2 { + font-size: var(--type-title-md); +} @media (max-width: 767px) { /* Replaces the three `*-shell { padding-inline }` rules the standalone pages diff --git a/frontend/src/components/chart-page/chart-dasha-tab.tsx b/frontend/src/components/chart-page/chart-dasha-tab.tsx index 78ce637f..2e7e3758 100644 --- a/frontend/src/components/chart-page/chart-dasha-tab.tsx +++ b/frontend/src/components/chart-page/chart-dasha-tab.tsx @@ -1,6 +1,6 @@ import { chartLayerFailureCopy } from "@/lib/chart-view-failure"; import type { ChartViewFailureStatus, ChartViewOk } from "@/lib/chart-view-contract"; -import { CHART_VIEW_COPY } from "@/lib/chart-view-labels"; +import { CHARA_ZERO_YEAR_LABEL, CHARA_ZERO_YEAR_NOTE, CHART_VIEW_COPY } from "@/lib/chart-view-labels"; function Track({ title, @@ -28,9 +28,11 @@ function Track({ >

{period.label} - {period.start} – {period.end} + {period.zeroYear ? CHARA_ZERO_YEAR_LABEL : `${period.start} – ${period.end}`}

- {period.antardashas.length > 0 ? ( + {period.zeroYear ? ( +

{CHARA_ZERO_YEAR_NOTE}

+ ) : period.antardashas.length > 0 ? (
    {period.antardashas.map((antar) => (
  • diff --git a/frontend/src/components/chart-page/chart-page-view.tsx b/frontend/src/components/chart-page/chart-page-view.tsx index 7d1d8fc8..262bf55e 100644 --- a/frontend/src/components/chart-page/chart-page-view.tsx +++ b/frontend/src/components/chart-page/chart-page-view.tsx @@ -12,6 +12,7 @@ import { } from "@/lib/chart-view-contract"; import type { ChartViewLayer } from "@/lib/chart-view-engine"; import { CHART_VIEW_COPY } from "@/lib/chart-view-labels"; +import { CHART_SETTINGS_HREF } from "@/lib/settings-url"; import { chartLayerFailureCopy } from "@/lib/chart-view-failure"; import { ChartPageSkeleton } from "./chart-page-skeleton"; import { ChartBasicsTab } from "./chart-basics-tab"; @@ -36,6 +37,19 @@ function WaitingNote({ copy }: { copy: string }) { return

    {copy}

    ; } +const PROFILE_ENTRY_REASONS = new Set([ + "profile_incomplete", + "profile_query_error", + "adopted_calculation_incomplete", + "timezone_resolver_failure", +]); + +function showsProfileEntry(view: ChartViewResponse): boolean { + if (view.status === "ok") return false; + if (view.status === "birth_profile_incomplete") return true; + return view.reason != null && PROFILE_ENTRY_REASONS.has(view.reason); +} + export function ChartPageView({ view, pendingLayers, @@ -75,7 +89,11 @@ export function ChartPageView({ > {view != null && view.status !== "ok" ? (
    + {view.status === "birth_profile_incomplete" ?

    {CHART_VIEW_COPY.profileEntryTitle}

    : null}

    {view.message}

    + {showsProfileEntry(view) ? ( + {CHART_VIEW_COPY.profileEntryAction} + ) : null}
    ) : (
    diff --git a/frontend/src/components/ephemeris/ephemeris-page.tsx b/frontend/src/components/ephemeris/ephemeris-page.tsx index e7d693c8..7cfb06ce 100644 --- a/frontend/src/components/ephemeris/ephemeris-page.tsx +++ b/frontend/src/components/ephemeris/ephemeris-page.tsx @@ -31,9 +31,13 @@ import { type PageState = | { phase: "ready"; payload: EphemerisOkResponse } + | { phase: "pending" } + | { phase: "failed" } | { phase: "unauthorized" } | { phase: "open" }; +type EphemerisDataState = "ready" | "pending" | "failed"; + function snapshotToState(): PageState { const cached = peekEphemerisPage(); if (cached?.kind === "unauthorized") return { phase: "unauthorized" }; @@ -43,32 +47,41 @@ function snapshotToState(): PageState { export function EphemerisPage() { const cachedFirst = peekEphemerisPage(); - const [date, setDate] = useState( - cachedFirst?.kind === "ready" ? cachedFirst.payload.date : null, + const initialDate = cachedFirst?.kind === "ready" ? cachedFirst.payload.date : null; + const [date, setDate] = useState(initialDate); + const [attempt, setAttempt] = useState(0); + const [knownToday, setKnownToday] = useState( + cachedFirst?.kind === "ready" ? cachedFirst.payload.today : null, ); const [state, setState] = useState(snapshotToState); + const selectedDateRef = useRef(initialDate); const cache = useRef(new Map( cachedFirst?.kind === "ready" ? [[cachedFirst.payload.date, cachedFirst.payload]] : [], )); - const load = useCallback(async (nextDate: string | null, signal: AbortSignal) => { - const query = nextDate ? `?date=${encodeURIComponent(nextDate)}` : ""; - const response = await fetch(`/api/ephemeris${query}`, { + const load = useCallback(async (nextDate: string, signal: AbortSignal) => { + const response = await fetch(`/api/ephemeris?date=${encodeURIComponent(nextDate)}`, { credentials: "same-origin", headers: { Accept: "application/json" }, signal, }); - if (signal.aborted) return; + if (signal.aborted || selectedDateRef.current !== nextDate) return; if (response.status === 401) { writeEphemerisPage({ kind: "unauthorized" }); setState({ phase: "unauthorized" }); return; } const json: unknown = await response.json().catch(() => null); + if (signal.aborted || selectedDateRef.current !== nextDate) return; const payload = parseEphemerisOkResponse(json); - if (!payload) return; + if (!payload || payload.date !== nextDate) { + setState({ phase: "failed" }); + return; + } cache.current.set(payload.date, payload); + setKnownToday(payload.today); writeEphemerisPage({ kind: "ready", payload }); + selectedDateRef.current = payload.date; setDate(payload.date); setState({ phase: "ready", payload }); }, []); @@ -83,11 +96,12 @@ export function EphemerisPage() { return; } cache.current.set(snapshot.payload.date, snapshot.payload); - setDate((current) => current ?? snapshot.payload.date); - setState((current) => { - if (current.phase === "ready" && current.payload.date !== snapshot.payload.date) return current; - return { phase: "ready", payload: snapshot.payload }; - }); + setKnownToday(snapshot.payload.today); + const viewing = selectedDateRef.current; + if (viewing && viewing !== snapshot.payload.date) return; + selectedDateRef.current = snapshot.payload.date; + setDate(snapshot.payload.date); + setState({ phase: "ready", payload: snapshot.payload }); }) .catch((error: unknown) => { if (cancelled) return; @@ -101,12 +115,14 @@ export function EphemerisPage() { useEffect(() => { if (!date || cache.current.has(date)) return; const controller = new AbortController(); - void load(date, controller.signal).catch((error: unknown) => { - if (controller.signal.aborted) return; + const requested = date; + void load(requested, controller.signal).catch((error: unknown) => { + if (controller.signal.aborted || selectedDateRef.current !== requested) return; console.warn("ephemeris_page_fetch_failed", error); + setState({ phase: "failed" }); }); return () => controller.abort(); - }, [date, load]); + }, [date, attempt, load]); if (state.phase === "unauthorized") { return ( @@ -120,17 +136,31 @@ export function EphemerisPage() { ); } - const payload = state.phase === "ready" ? state.payload : null; - if (!payload) { + const readyPayload = state.phase === "ready" && date && state.payload.date === date + ? state.payload + : null; + if (!date) { return ; } - const activeDate = date ?? payload.date ?? payload.today ?? ""; - const today = payload.today ?? activeDate; + const activeDate = date; + const today = readyPayload?.today ?? knownToday ?? activeDate; + const dataState: EphemerisDataState = state.phase === "failed" + ? "failed" + : readyPayload + ? "ready" + : "pending"; function changeDate(next: string) { + selectedDateRef.current = next; setDate(next); const cached = cache.current.get(next); - if (cached) setState({ phase: "ready", payload: cached }); + if (cached) { + setKnownToday(cached.today); + setState({ phase: "ready", payload: cached }); + return; + } + setState({ phase: "pending" }); + setAttempt((current) => current + 1); } function askAboutDate() { @@ -143,11 +173,13 @@ export function EphemerisPage() { activeDate && changeDate(shiftEphemerisDate(activeDate, -1))} - onNext={() => activeDate && changeDate(shiftEphemerisDate(activeDate, 1))} + payload={readyPayload} + dataState={dataState} + onPrevious={() => state.phase === "failed" ? changeDate(activeDate) : changeDate(shiftEphemerisDate(activeDate, -1))} + onNext={() => state.phase === "failed" ? changeDate(activeDate) : changeDate(shiftEphemerisDate(activeDate, 1))} onToday={() => today && changeDate(today)} onSelectDate={(iso) => changeDate(iso)} + onRetry={() => changeDate(activeDate)} onAsk={askAboutDate} /> ); @@ -157,17 +189,22 @@ export function EphemerisView(props: { readonly date: string; readonly today: string; readonly payload: EphemerisOkResponse | null; + readonly dataState?: EphemerisDataState; readonly onPrevious: () => void; readonly onNext: () => void; readonly onToday: () => void; readonly onSelectDate: (iso: string) => void; + readonly onRetry?: () => void; readonly onAsk: () => void; }) { - const factors = props.payload?.panchanga.available ? props.payload.panchanga.factors : []; - const bodies = props.payload?.transits.available ? props.payload.transits.bodies : []; - const natalMissing = props.payload?.transits.natalRelativeNote === "missing_natal"; - const eventsUnavailable = !props.payload || props.payload.events.status === "unavailable"; - const events = props.payload?.events.status === "ok" ? props.payload.events.items : []; + const dataState = props.dataState ?? (props.payload ? "ready" : "pending"); + const failed = dataState === "failed"; + const pending = dataState === "pending"; + const factors = !failed && !pending && props.payload?.panchanga.available ? props.payload.panchanga.factors : []; + const bodies = !failed && !pending && props.payload?.transits.available ? props.payload.transits.bodies : []; + const natalMissing = !failed && !pending && props.payload?.transits.natalRelativeNote === "missing_natal"; + const eventsUnavailable = !pending && !failed && (!props.payload || props.payload.events.status === "unavailable"); + const events = !pending && !failed && props.payload?.events.status === "ok" ? props.payload.events.items : []; const isToday = Boolean(props.date) && props.date === props.today; const [pickerOpen, setPickerOpen] = useState(false); const selectedDay = props.date ? parseEphemerisDate(props.date) : undefined; @@ -210,6 +247,9 @@ export function EphemerisView(props: { variant="ghost" className="ephemeris-date-label" aria-label={`${props.date ? formatEphemerisDate(props.date) : ""},选择日期`} + onClick={() => { + if (failed) props.onRetry?.(); + }} />} >