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.
This commit is contained in:
jesse-ux
2026-09-25 02:10:48 +08:00
parent 82c8aeb06a
commit 19ea0f06c1
25 changed files with 744 additions and 60 deletions
+7
View File
@@ -1,5 +1,12 @@
# 印度占星 Skill 更新日志
## 2026-09-25 — 0 年大运、星历切日期、星盘无资料入口(待验收)
- Chara 出现 0 年大运时照实显示「0 年」,不占时间轴、不展开子运,不再把整张盘打成「算不出来」。不改引擎口径。
- 星历切到未缓存的日期时留下日期条,五要素和行运立刻换成静态句;失败可以再点日期或箭头重试。旧日期的响应不会盖住新日期。
- 星盘缺少出生资料,或资料读失败、时区不完整时,可以「去填写出生资料」。
- Skill 版本不 bump。浏览器和部署未验收。
## 2026-09-25 — 校正会话标题改为结果时间
- 历史校正会话的名字改成显示算出来的出生时间或当前范围,不再在标题里重复日期。
+48
View File
@@ -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
- 复发自:无
- 修复版本:本分支,待推送与部署核对
@@ -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`,以回报里的远端核对为准。
+1 -1
View File
@@ -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` |
@@ -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`。本清单未走完之前,不能写成浏览器已验收。
+7 -1
View File
@@ -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` 未上线时整段静态说明:「换座和停滞还没接上。这一页其余部分不受影响。」不得让整页失败。
+10
View File
@@ -18,6 +18,16 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、
首页开场语只有一行,不带追问句,不用星星、月亮、陪伴一类比喻。
## 星盘空状态、0 年大运、星历切日期
这三句只描述眼前缺的数据,不解释运势,也不许写成「正在加载」:
- 星历事件还在取:「换座和停滞还在取。」
- 星历这一天没取到:「这一天的星历没取到,点日期再试一次。」
- 0 年大运不展开:「本段时长为 0,不展开。」时长本身写「0 年」。
没有出生资料时,标题是「还没有星盘资料」,按钮是「去填写出生资料」。资料读不出来、已采用时间缺时区、时区没有对上,正文仍用各自的原句,只共用这颗按钮。
## 报告分块导出与删除
- 列表已完成行只留「查看报告」。阅读页操作条只留「返回报告中心」与导出图标;图标读屏名「导出报告」,tooltip「导出」。不再显示「下载原始附录」或「打印」入口文案;浏览器 Ctrl+P 排版仍保留。
+2 -1
View File
@@ -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);
};
+44 -9
View File
@@ -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<typeof globalBirthProfileFromAccountRow>;
type BirthPayload = NonNullable<Awaited<ReturnType<typeof dailyProfilePayload>>>;
async function fetchEngine(path: string, body: Record<string, unknown>) {
function engineSignal(request: Request): AbortSignal {
return AbortSignal.any([AbortSignal.timeout(engineTimeoutMs), request.signal]);
}
async function fetchEngine(path: string, body: Record<string, unknown>, 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<unknown> {
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<string, unknown>);
}
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<string, unknown> = { 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;
});
+3
View File
@@ -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
@@ -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({
>
<p>
<strong>{period.label}</strong>
<span>{period.start} – {period.end}</span>
<span>{period.zeroYear ? CHARA_ZERO_YEAR_LABEL : `${period.start} – ${period.end}`}</span>
</p>
{period.antardashas.length > 0 ? (
{period.zeroYear ? (
<p className="chart-page-note">{CHARA_ZERO_YEAR_NOTE}</p>
) : period.antardashas.length > 0 ? (
<ul>
{period.antardashas.map((antar) => (
<li key={`${antar.lord}-${antar.start}`} className={antar.current ? "is-current" : undefined}>
@@ -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 <p className="chart-page-note">{copy}</p>;
}
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" ? (
<section className="chart-page-message">
{view.status === "birth_profile_incomplete" ? <h2>{CHART_VIEW_COPY.profileEntryTitle}</h2> : null}
<p>{view.message}</p>
{showsProfileEntry(view) ? (
<a className="button-primary" href={CHART_SETTINGS_HREF}>{CHART_VIEW_COPY.profileEntryAction}</a>
) : null}
</section>
) : (
<div className="chart-page-body">
@@ -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<string | null>(
cachedFirst?.kind === "ready" ? cachedFirst.payload.date : null,
const initialDate = cachedFirst?.kind === "ready" ? cachedFirst.payload.date : null;
const [date, setDate] = useState<string | null>(initialDate);
const [attempt, setAttempt] = useState(0);
const [knownToday, setKnownToday] = useState<string | null>(
cachedFirst?.kind === "ready" ? cachedFirst.payload.today : null,
);
const [state, setState] = useState<PageState>(snapshotToState);
const selectedDateRef = useRef<string | null>(initialDate);
const cache = useRef(new Map<string, EphemerisOkResponse>(
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 <SecondaryPageShell title={EPHEMERIS_COPY.title} waiting={EPHEMERIS_WAITING_COPY} />;
}
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() {
<EphemerisView
date={activeDate}
today={today}
payload={payload}
onPrevious={() => 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?.();
}}
/>}
>
<CalendarIcon aria-hidden="true" />
@@ -257,7 +297,9 @@ export function EphemerisView(props: {
<section className="ephemeris-section" aria-labelledby="ephemeris-panchanga-heading">
<h2 id="ephemeris-panchanga-heading">{EPHEMERIS_COPY.panchangaHeading}</h2>
{factors.length === 5 ? (
{failed ? (
<p className="ephemeris-static">{EPHEMERIS_COPY.dateFailed}</p>
) : factors.length === 5 ? (
<dl className="ephemeris-panchanga">
{factors.map((factor) => (
<div className="ephemeris-panchanga-item" key={factor.key}>
@@ -278,7 +320,9 @@ export function EphemerisView(props: {
<section className="ephemeris-section" aria-labelledby="ephemeris-transits-heading">
<h2 id="ephemeris-transits-heading">{EPHEMERIS_COPY.transitsHeading}</h2>
{natalMissing ? <p className="ephemeris-note">{EPHEMERIS_COPY.natalMissing}</p> : null}
{bodies.length > 0 ? (
{failed ? (
<p className="ephemeris-static">{EPHEMERIS_COPY.dateFailed}</p>
) : bodies.length > 0 ? (
<div className="ephemeris-transits">
<div className="ephemeris-transits-head" aria-hidden="true">
<span>{EPHEMERIS_COPY.planet}</span>
@@ -304,7 +348,11 @@ export function EphemerisView(props: {
<section className="ephemeris-section" aria-labelledby="ephemeris-events-heading">
<h2 id="ephemeris-events-heading">{EPHEMERIS_COPY.eventsHeading}</h2>
{eventsUnavailable ? (
{failed ? (
<p className="ephemeris-static">{EPHEMERIS_COPY.dateFailed}</p>
) : pending ? (
<p className="ephemeris-static">{EPHEMERIS_COPY.eventsPending}</p>
) : eventsUnavailable ? (
<p className="ephemeris-static">{EPHEMERIS_COPY.eventsUnavailable}</p>
) : (
<ol className="ephemeris-events">
+3
View File
@@ -86,6 +86,9 @@ export const chartViewPeriodSchema = z.object({
export const chartViewMahadashaSchema = chartViewPeriodSchema.extend({
sign: z.string().nullable(),
antardashas: z.array(chartViewPeriodSchema),
// Present only for a Chara mahadasha whose engine duration is exactly 0.
// It stays on the list, occupies no timeline, and has no antardashas.
zeroYear: z.literal(true).optional(),
});
export const chartViewDashaTrackSchema = z.object({
+6
View File
@@ -140,6 +140,10 @@ export const ANTARDASHA_DERIVED_NOTE = "分运按标准比例从大运边界推
export const CHARA_ANTARDASHA_DATE_NOTE = "分运时长来自引擎的十二等分;起止日期按大运边界推算,不是引擎单独返回的日历字段。";
export const CHARA_ZERO_YEAR_LABEL = "0 年";
export const CHARA_ZERO_YEAR_NOTE = "本段时长为 0,不展开";
export const WESTERN_UNAVAILABLE_COPY = "西洋盘这一栏要等回归黄道端点上线。现在还没有,先空着——不会拿印度盘的度数改写成回归黄道。";
export const QIZHENG_UNAVAILABLE_COPY = "七政四余这一栏要等宿度端点上线。现在还没有,先空着。";
@@ -161,6 +165,8 @@ export const CHART_VIEW_COPY = {
waitingVarga: "这一分盘还没拿到。",
unauthenticated: "请先登录后再看星盘。",
incomplete: "还没有可用来排盘的出生资料。把日期、时间和地点填进星盘资料后,打开就能看见盘。",
profileEntryTitle: "还没有星盘资料",
profileEntryAction: "去填写出生资料",
profileQueryError: "星盘资料这会儿读不出来,我们已经记录下来了。",
adoptedIncomplete: "已采用的出生时间缺了排盘要用的时区,这张盘先不能算。我们已经记录下来了。",
timezoneFailure: "出生地的时区没有对上,这张盘先不能算。我们已经记录下来了。",
+18 -5
View File
@@ -82,11 +82,19 @@ function isoDay(value: string): string {
function addWeightedSpan(startIso: string, endIso: string, index: number, weights: readonly number[]): { start: string; end: string } {
const start = Date.parse(`${startIso}T00:00:00Z`);
const end = Date.parse(`${endIso}T00:00:00Z`);
const total = weights.reduce((sum, weight) => sum + weight, 0);
const before = weights.slice(0, index).reduce((sum, weight) => sum + weight, 0);
const total = weights.reduce((sum, weight) => sum + (Number.isFinite(weight) ? weight : 0), 0);
// A zero-year Chara mahadasha gives every antardasha weight 0. `??` does not
// replace 0, so total is 0 and `before / total` is NaN. Never call
// `Date#toISOString` on that — it throws RangeError and fails the whole chart.
if (!Number.isFinite(start) || !Number.isFinite(end) || !(total > 0)) {
return { start: startIso, end: startIso };
}
const before = weights.slice(0, index).reduce((sum, weight) => sum + (Number.isFinite(weight) ? weight : 0), 0);
const weight = Number.isFinite(weights[index]) ? weights[index]! : 0;
const span = end - start;
const from = start + span * (before / total);
const to = index === weights.length - 1 ? end : start + span * ((before + weights[index]!) / total);
const to = index === weights.length - 1 ? end : start + span * ((before + weight) / total);
if (!Number.isFinite(from) || !Number.isFinite(to)) return { start: startIso, end: startIso };
return { start: new Date(from).toISOString().slice(0, 10), end: new Date(to).toISOString().slice(0, 10) };
}
@@ -390,12 +398,16 @@ function charaTrack(charaPacket: Record<string, unknown> | null, birthDate: stri
if (!row) return [];
const sign = text(row.sign);
const lord = text(row.lord) ?? sign ?? "—";
const duration = finite(row.duration_years) ?? 1;
const rawDuration = finite(row.duration_years);
const duration = rawDuration === null ? 1 : rawDuration;
const zeroYear = duration === 0;
const start = cursor;
const end = addYears(start, duration);
cursor = end;
const rawAntar = Array.isArray(row.antardashas) ? row.antardashas : [];
const antardashas = rawAntar.length > 0
const antardashas = zeroYear
? []
: rawAntar.length > 0
? rawAntar.flatMap((antar, index) => {
const item = record(antar);
if (!item) return [];
@@ -430,6 +442,7 @@ function charaTrack(charaPacket: Record<string, unknown> | null, birthDate: stri
end,
current: containsDay(start, end, asOf),
derived: false,
...(zeroYear ? { zeroYear: true as const } : {}),
antardashas,
}];
});
+2
View File
@@ -19,7 +19,9 @@ export const EPHEMERIS_COPY = {
transitsUnavailable: "这一天的行运还没拿到。",
natalMissing: "还没有本命上升,相对本命这一列空着。",
eventsHeading: "未来九十天",
eventsPending: "换座和停滞还在取。",
eventsUnavailable: "换座和停滞还没接上。这一页其余部分不受影响。",
dateFailed: "这一天的星历没取到,点日期再试一次。",
ask: "带这天去提问",
loginTitle: "请先登录",
loginBody: "登录后即可查看星历。",
+6 -2
View File
@@ -3,12 +3,14 @@ import type { BillingPaneTab, MembershipPlanAlias } from "@/lib/membership";
export const SETTINGS_QUERY_KEY = "settings";
export type SettingsQuery = {
readonly pane: "billing";
readonly pane: "billing" | "chart";
readonly source: string | null;
readonly plan: MembershipPlanAlias | null;
readonly tab: BillingPaneTab | null;
};
export const CHART_SETTINGS_HREF = "/?settings=chart";
const PLAN_ALIASES: ReadonlySet<string> = new Set(["trial", "monthly", "yearly"]);
const BILLING_TABS: ReadonlySet<string> = new Set(["membership", "credits", "redeem", "orders"]);
@@ -18,7 +20,9 @@ function searchParams(search: string) {
export function parseSettingsQuery(search: string): SettingsQuery | null {
const params = searchParams(search);
if (params.get(SETTINGS_QUERY_KEY) !== "billing") return null;
const pane = params.get(SETTINGS_QUERY_KEY);
if (pane === "chart") return { pane: "chart", source: null, plan: null, tab: null };
if (pane !== "billing") return null;
const planRaw = params.get("plan");
const tabRaw = params.get("tab");
return {
+31
View File
@@ -11,7 +11,9 @@ import { SidebarProvider } from "../src/components/ui/sidebar.tsx";
import { assembleChartView } from "../src/lib/chart-view-load.ts";
import { CHART_VIEW_TABS, type ChartViewOk } from "../src/lib/chart-view-contract.ts";
import { CHART_VIEW_LAYERS } from "../src/lib/chart-view-engine.ts";
import { chartViewFailureResponse } from "../src/lib/chart-view-failure.ts";
import { CHART_VIEW_COPY } from "../src/lib/chart-view-labels.ts";
import { CHART_SETTINGS_HREF } from "../src/lib/settings-url.ts";
import { cssDeclarations } from "./css-contract-test-support.ts";
Object.assign(globalThis, { React });
@@ -264,6 +266,35 @@ test("the chart page shell is visible before the natal chart arrives", () => {
assert.doesNotMatch(markup, /chart-page-eyebrow/);
});
test("profile data failures offer the chart settings entry and other failures do not", () => {
const reasons = [
"profile_incomplete",
"profile_query_error",
"adopted_calculation_incomplete",
"timezone_resolver_failure",
] as const;
for (const reason of reasons) {
const view = chartViewFailureResponse(reason);
const markup = withSidebarProvider(React.createElement(ChartPageView, { view }));
assert.match(markup, new RegExp(view.message));
assert.ok(markup.includes(`href="${CHART_SETTINGS_HREF}"`));
assert.match(markup, new RegExp(CHART_VIEW_COPY.profileEntryAction));
if (reason === "profile_incomplete") {
assert.match(markup, new RegExp(CHART_VIEW_COPY.profileEntryTitle));
assert.match(globalStyles, /\.chart-page-message h2 \{[^}]*font-size:\s*var\(--type-title-md\)/);
} else {
assert.doesNotMatch(markup, new RegExp(CHART_VIEW_COPY.profileEntryTitle));
}
assert.doesNotMatch(markup, /正在加载|skeleton|spinner/i);
}
const engine = withSidebarProvider(React.createElement(ChartPageView, {
view: chartViewFailureResponse("engine_busy"),
}));
assert.match(engine, new RegExp(CHART_VIEW_COPY.busy));
assert.equal(engine.includes(CHART_VIEW_COPY.profileEntryAction), false);
assert.equal(engine.includes(CHART_SETTINGS_HREF), false);
});
test("failure copy has no eyebrow and does not promise a later retry", () => {
const markup = withSidebarProvider(React.createElement(ChartPageView, {
view: { status: "chart_unavailable", billed: false, message: CHART_VIEW_COPY.unavailable },
+107
View File
@@ -0,0 +1,107 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import React from "react";
import test from "node:test";
import { renderToStaticMarkup } from "react-dom/server";
import { ChartDashaTab } from "../src/components/chart-page/chart-dasha-tab.tsx";
import type { ChartViewOk } from "../src/lib/chart-view-contract.ts";
import { assembleChartView } from "../src/lib/chart-view-load.ts";
import { CHARA_ZERO_YEAR_LABEL, CHARA_ZERO_YEAR_NOTE } from "../src/lib/chart-view-labels.ts";
import { buildChartView, type ChartViewProfileInput } from "../src/lib/chart-view-mapper.ts";
Object.assign(globalThis, { React });
/**
* Generated from the real engine, not a hand-built shape:
* C:\Users\74082\anaconda3\python.exe
* JyotishAPIHandler.__new__()._compute_chart + _compute_chara_dasha
* 1985-08-03 03:59, lat 39.9042, lon 116.4074, tz 8, ayanamsa raman, node_mode mean,
* skip_vedastro_main_entry_overview, antardasha true.
* Sagittarius duration_years is 0 (debilitated). scripts/jaimini.py was not modified.
*/
const golden = JSON.parse(readFileSync(new URL("./fixtures/chara-zero-year-1985-08-03-beijing.golden.json", import.meta.url), "utf8")) as {
chart: Record<string, unknown>;
chara: Record<string, unknown>;
};
const profile: ChartViewProfileInput = {
name: "虚构",
date: "1985-08-03",
time: "03:59",
placeLabel: "北京",
latitude: 39.9042,
longitude: 116.4074,
timezoneOffset: 8,
timezoneId: "Asia/Shanghai",
ayanamsa: "raman",
birthTimeStatus: "reported",
};
function viewFrom(chara: Record<string, unknown>): ChartViewOk {
return buildChartView({
chart: golden.chart,
varga: null,
chara,
western: null,
qizheng: null,
profile,
asOf: "2026-09-25",
});
}
test("a real zero-year Chara mahadasha keeps twelve continuous periods and does not throw", async () => {
const view = viewFrom(golden.chara);
const periods = view.dasha.chara.periods;
assert.equal(periods.length, 12);
const sagittarius = periods.find((period) => period.sign === "Sagittarius");
assert.ok(sagittarius);
assert.equal(sagittarius.zeroYear, true);
assert.equal(sagittarius.antardashas.length, 0);
assert.equal(sagittarius.start, sagittarius.end);
for (let index = 0; index < periods.length - 1; index += 1) {
assert.equal(periods[index]?.end, periods[index + 1]?.start);
}
const assembled = await assembleChartView({
userId: "synthetic",
accountId: "synthetic",
profileFingerprint: "fixture-zero-year",
profile,
asOf: "2026-09-25",
layers: ["chara"],
postEngine: async (path) => {
if (path === "/api/chart") return { status: "ok", payload: golden.chart };
if (path === "/api/dasha/chara") return { status: "ok", payload: golden.chara };
return { status: "http_error", path, elapsedMs: 0, httpStatus: 404 };
},
});
assert.equal(assembled.httpStatus, 200);
assert.equal(assembled.body.status, "ok");
});
test("zero antardasha weights on a positive mahadasha do not produce an invalid date", () => {
const chara = structuredClone(golden.chara) as {
result: { chara_dasha: { dasha_sequence: Array<{ duration_years: number; antardashas?: Array<{ duration_years: number }> }> } };
};
const positive = chara.result.chara_dasha.dasha_sequence.find((row) => row.duration_years > 0);
assert.ok(positive?.antardashas?.length);
for (const antar of positive.antardashas ?? []) antar.duration_years = 0;
const view = viewFrom(chara);
const mapped = view.dasha.chara.periods.find((period) => period.sign !== "Sagittarius" && period.antardashas.length > 0);
assert.ok(mapped);
for (const antar of mapped.antardashas) {
assert.match(antar.start, /^\d{4}-\d{2}-\d{2}$/);
assert.match(antar.end, /^\d{4}-\d{2}-\d{2}$/);
}
});
test("the zero-year Chara row shows 0 年 and does not render sub-periods", () => {
const view = viewFrom(golden.chara);
const markup = renderToStaticMarkup(React.createElement(ChartDashaTab, { view }));
const marker = markup.indexOf(CHARA_ZERO_YEAR_LABEL);
const note = markup.indexOf(CHARA_ZERO_YEAR_NOTE, marker);
assert.ok(marker >= 0 && note > marker);
const slice = markup.slice(Math.max(0, marker - 40), note);
assert.match(slice, /射手/);
assert.doesNotMatch(markup.slice(marker, note), /<ul/);
});
@@ -0,0 +1,167 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import React from "react";
import test from "node:test";
import { EphemerisPage } from "../src/components/ephemeris/ephemeris-page.tsx";
import { SidebarProvider } from "../src/components/ui/sidebar.tsx";
import { EPHEMERIS_COPY, panchangaFactorsFromEngine, transitsFromEngine } from "../src/lib/ephemeris-view.ts";
import { parseEphemerisOkResponse, type EphemerisOkResponse } from "../src/lib/ephemeris-contract.ts";
import { resetSecondaryPageDataForTests, writeEphemerisPage } from "../src/lib/secondary-page-data.ts";
import { createClientLifecycleHarness } from "./react-client-lifecycle-test-support.ts";
Object.assign(globalThis, { React });
const panchangaGolden = JSON.parse(readFileSync(new URL("./fixtures/ephemeris-panchanga-range.golden.json", import.meta.url), "utf8"));
const natalGolden = JSON.parse(readFileSync(new URL("./fixtures/ephemeris-chart-natal.golden.json", import.meta.url), "utf8"));
const transit15 = JSON.parse(readFileSync(new URL("./fixtures/ephemeris-chart-transit-2026-09-15.golden.json", import.meta.url), "utf8"));
const transit14 = JSON.parse(readFileSync(new URL("./fixtures/ephemeris-chart-transit-2026-09-14.golden.json", import.meta.url), "utf8"));
function payloadFor(date: "2026-09-14" | "2026-09-15"): EphemerisOkResponse {
const transits = transitsFromEngine({
transitChart: date === "2026-09-14" ? transit14 : transit15,
natalChart: natalGolden,
});
const parsed = parseEphemerisOkResponse({
status: "ok",
date,
today: "2026-09-15",
timezoneId: "Asia/Shanghai",
panchanga: {
available: true,
ayanamsaNote: "lahiri_not_account",
factors: panchangaFactorsFromEngine(panchangaGolden, date),
},
transits: {
available: true,
natalRelativeAvailable: true,
natalRelativeNote: "shown",
ayanamsa: "raman",
bodies: transits.bodies,
},
events: { status: "unavailable" },
});
assert.ok(parsed);
return parsed;
}
function asDate(payload: EphemerisOkResponse, date: string): EphemerisOkResponse {
return { ...payload, date };
}
function json(payload: EphemerisOkResponse) {
return new Response(JSON.stringify(payload), { status: 200, headers: { "content-type": "application/json" } });
}
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((next) => { resolve = next; });
return { promise, resolve };
}
async function withPage(run: (h: ReturnType<typeof createClientLifecycleHarness>) => Promise<void>) {
resetSecondaryPageDataForTests();
const originalFetch = globalThis.fetch;
const h = createClientLifecycleHarness();
Object.assign(window, { location: { assign() {}, pathname: "/ephemeris", search: "" } });
try {
await run(h);
assert.deepEqual(h.errors, []);
} finally {
await h.close();
globalThis.fetch = originalFetch;
resetSecondaryPageDataForTests();
}
}
function click(h: ReturnType<typeof createClientLifecycleHarness>, label: string) {
const node = h.elements().find((element) => element.getAttribute("aria-label") === label);
assert.ok(node, label);
return h.event(node);
}
test("switching to an uncached date keeps the date bar and ignores a late today refresh", async () => withPage(async (h) => {
const today = payloadFor("2026-09-15");
const sixteenth = asDate(payloadFor("2026-09-14"), "2026-09-16");
writeEphemerisPage({ kind: "ready", payload: today });
const refresh = deferred<Response>();
const requested = deferred<Response>();
globalThis.fetch = async (input) => {
const url = String(input);
if (url.includes("date=2026-09-16")) return requested.promise;
return refresh.promise;
};
await h.render(React.createElement(SidebarProvider, null, React.createElement(EphemerisPage)));
await h.idle();
assert.match(h.container.text, /2026年9月15日/);
assert.match(h.container.text, /Shukla Panchami/);
await click(h, EPHEMERIS_COPY.nextDay);
await h.idle();
assert.match(h.container.text, /2026年9月16日/);
assert.match(h.container.text, new RegExp(EPHEMERIS_COPY.panchangaUnavailable));
assert.match(h.container.text, new RegExp(EPHEMERIS_COPY.transitsUnavailable));
assert.match(h.container.text, new RegExp(EPHEMERIS_COPY.eventsPending));
assert.doesNotMatch(h.container.text, /Shukla Panchami|还没接上|正在加载|skeleton/i);
assert.ok(h.elements().some((element) => element.getAttribute("aria-label") === EPHEMERIS_COPY.previousDay));
await h.update(() => refresh.resolve(json(today)));
await h.idle();
assert.match(h.container.text, /2026年9月16日/);
assert.doesNotMatch(h.container.text, /Shukla Panchami/);
await h.update(() => requested.resolve(json(sixteenth)));
await h.idle();
assert.match(h.container.text, /2026年9月16日/);
assert.match(h.container.text, /Shukla Chaturthi/);
assert.doesNotMatch(h.container.text, /Shukla Panchami/);
}));
test("a late response for the previous date cannot overwrite the date now on screen", async () => withPage(async (h) => {
writeEphemerisPage({ kind: "ready", payload: payloadFor("2026-09-15") });
const first = deferred<Response>();
const second = deferred<Response>();
globalThis.fetch = async (input) => {
const url = String(input);
if (url.includes("date=2026-09-16")) return first.promise;
if (url.includes("date=2026-09-17")) return second.promise;
return json(payloadFor("2026-09-15"));
};
await h.render(React.createElement(SidebarProvider, null, React.createElement(EphemerisPage)));
await h.idle();
await click(h, EPHEMERIS_COPY.nextDay);
await h.idle();
await click(h, EPHEMERIS_COPY.nextDay);
await h.idle();
assert.match(h.container.text, /2026年9月17日/);
await h.update(() => first.resolve(json(asDate(payloadFor("2026-09-14"), "2026-09-16"))));
await h.idle();
assert.match(h.container.text, /2026年9月17日/);
assert.match(h.container.text, new RegExp(EPHEMERIS_COPY.panchangaUnavailable));
assert.doesNotMatch(h.container.text, /Shukla Chaturthi/);
}));
test("429 shows the failure sentence and the same date or an arrow retries successfully", async () => withPage(async (h) => {
writeEphemerisPage({ kind: "ready", payload: payloadFor("2026-09-15") });
const sixteenth = asDate(payloadFor("2026-09-14"), "2026-09-16");
let calls = 0;
globalThis.fetch = async (input) => {
const url = String(input);
if (!url.includes("date=2026-09-16")) return json(payloadFor("2026-09-15"));
calls += 1;
if (calls === 1) {
return new Response(JSON.stringify({ status: "unavailable", reason: "rate_limited" }), { status: 429 });
}
return json(sixteenth);
};
await h.render(React.createElement(SidebarProvider, null, React.createElement(EphemerisPage)));
await h.idle();
await click(h, EPHEMERIS_COPY.nextDay);
await h.idle();
assert.equal((h.container.text.match(new RegExp(EPHEMERIS_COPY.dateFailed, "g")) ?? []).length, 3);
assert.match(h.container.text, /2026年9月16日/);
assert.doesNotMatch(h.container.text, /Shukla Panchami|正在加载|skeleton/i);
await click(h, EPHEMERIS_COPY.nextDay);
await h.idle();
assert.equal(calls, 2);
assert.match(h.container.text, /2026年9月16日/);
assert.match(h.container.text, /Shukla Chaturthi/);
assert.doesNotMatch(h.container.text, new RegExp(EPHEMERIS_COPY.dateFailed));
}));
+37
View File
@@ -168,3 +168,40 @@ test("copy stays factual: no fortune-telling, no spinner, ask prefills the date"
assert.equal(ephemerisAskDraft("2026-09-15"), "请按 2026年9月15日 这一天的天象来看。");
assert.match(pageSource, /setComposerDraft\(ephemerisAskDraft/);
});
test("an uncached date keeps the date bar and replaces the three sections with static sentences", () => {
const markup = withSidebarProvider(React.createElement(EphemerisView, {
date: "2026-09-16",
today: "2026-09-15",
payload: null,
dataState: "pending",
onPrevious() {},
onNext() {},
onToday() {},
onSelectDate() {},
onAsk() {},
}));
assert.match(markup, /2026年9月16日/);
assert.match(markup, new RegExp(EPHEMERIS_COPY.previousDay));
assert.match(markup, new RegExp(EPHEMERIS_COPY.panchangaUnavailable));
assert.match(markup, new RegExp(EPHEMERIS_COPY.transitsUnavailable));
assert.match(markup, new RegExp(EPHEMERIS_COPY.eventsPending));
assert.doesNotMatch(markup, /还没接上|正在加载|skeleton|spinner/i);
});
test("a failed date shows the retry sentence in each section", () => {
const markup = withSidebarProvider(React.createElement(EphemerisView, {
date: "2026-09-16",
today: "2026-09-15",
payload: null,
dataState: "failed",
onPrevious() {},
onNext() {},
onToday() {},
onSelectDate() {},
onAsk() {},
}));
assert.equal((markup.match(new RegExp(EPHEMERIS_COPY.dateFailed, "g")) ?? []).length, 3);
assert.match(markup, /2026年9月16日/);
assert.doesNotMatch(markup, /正在加载|skeleton|spinner/i);
});
+47 -6
View File
@@ -37,7 +37,9 @@ function executeRoute(input: {
readonly user: boolean;
readonly row: Record<string, unknown> | null;
readonly date?: string;
readonly dates?: readonly string[];
readonly eventsStatus?: number;
readonly abortOnFetch?: boolean;
}) {
const script = String.raw`
import { mock } from "node:test";
@@ -48,8 +50,9 @@ function executeRoute(input: {
const moduleUrl = (path) => pathToFileURL(process.cwd() + "/" + path).href;
const user = ${input.user ? "true" : "false"};
const row = ${JSON.stringify(input.row)};
const date = ${JSON.stringify(input.date ?? "2026-09-15")};
const dates = ${JSON.stringify(input.dates ?? [input.date ?? "2026-09-15"])};
const eventsStatus = ${input.eventsStatus ?? 404};
const abortOnFetch = ${input.abortOnFetch ? "true" : "false"};
const fixture = (name) => JSON.parse(readFileSync(join(process.cwd(), "tests/fixtures", name), "utf8"));
const panchanga = fixture("ephemeris-panchanga-range.golden.json");
const natal = fixture("ephemeris-chart-natal.golden.json");
@@ -90,10 +93,16 @@ function executeRoute(input: {
});
const upstream = [];
let signalAborted = null;
const controller = new AbortController();
globalThis.fetch = async (inputUrl, init) => {
const url = String(inputUrl);
const body = init?.body ? JSON.parse(String(init.body)) : {};
upstream.push({ url, body });
if (abortOnFetch && signalAborted === null) {
controller.abort();
signalAborted = Boolean(init?.signal?.aborted);
}
upstream.push({ url, body, signalAborted: Boolean(init?.signal?.aborted) });
if (url.endsWith("/api/panchanga_range")) {
return new Response(JSON.stringify(panchanga), { status: 200, headers: { "content-type": "application/json" } });
}
@@ -108,12 +117,18 @@ function executeRoute(input: {
};
const { GET } = await import(moduleUrl("src/app/api/ephemeris/route.ts"));
const response = await GET(new Request("https://staging.jyotisha.chat/api/ephemeris?date=" + date));
const responses = [];
for (const date of dates) {
const response = await GET(new Request("https://staging.jyotisha.chat/api/ephemeris?date=" + date, abortOnFetch ? { signal: controller.signal } : undefined));
responses.push({ status: response.status, body: await response.json() });
}
console.log(JSON.stringify({
status: response.status,
body: await response.json(),
status: responses[0].status,
body: responses[0].body,
responses,
upstream,
billingImported,
signalAborted,
}));
`;
const result = spawnSync(process.execPath, [
@@ -129,8 +144,10 @@ function executeRoute(input: {
return JSON.parse(line) as {
status: number;
body: Record<string, unknown>;
upstream: Array<{ url: string; body: Record<string, unknown> }>;
responses: Array<{ status: number; body: Record<string, unknown> }>;
upstream: Array<{ url: string; body: Record<string, unknown>; signalAborted: boolean }>;
billingImported: boolean;
signalAborted: boolean | null;
};
}
@@ -226,3 +243,27 @@ test("golden engine packets pass the narrow response contract", () => {
assert.equal(eventsFromEngine(null).status, "unavailable");
assert.equal(eventsFromEngine({ error: "not_found" }).status, "unavailable");
});
test("the same account loads the natal chart once across two dates and forwards the request signal", () => {
assert.match(routeSource, /request\.signal/);
assert.match(routeSource, /readChartViewEngineCache/);
assert.match(routeSource, /writeChartViewEngineCache/);
const result = executeRoute({
user: true,
row: completeRow,
dates: ["2026-09-15", "2026-09-16"],
});
assert.equal(result.responses.length, 2);
assert.equal(result.responses[0]?.body.date, "2026-09-15");
assert.equal(result.responses[1]?.body.date, "2026-09-16");
const charts = result.upstream.filter((item) => item.url.endsWith("/api/chart"));
const natal = charts.filter((item) => item.body.year === 1990);
const transit = charts.filter((item) => item.body.year !== 1990);
assert.equal(natal.length, 1);
assert.equal(transit.length, 2);
assert.equal(result.billingImported, false);
const aborted = executeRoute({ user: true, row: completeRow, abortOnFetch: true });
assert.equal(aborted.signalAborted, true);
assert.equal(aborted.upstream[0]?.signalAborted, true);
});
File diff suppressed because one or more lines are too long
+17 -1
View File
@@ -1,7 +1,8 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { parseSettingsQuery, stripSettingsQuery } from "../src/lib/settings-url.ts";
import { CHART_SETTINGS_HREF, parseSettingsQuery, stripSettingsQuery } from "../src/lib/settings-url.ts";
test("parses a billing settings query with source, plan and tab", () => {
const query = parseSettingsQuery("?settings=billing&source=legacy&plan=monthly&tab=orders");
@@ -29,6 +30,21 @@ test("ignores unknown plan and tab aliases", () => {
});
});
test("parses the chart settings deep link without changing billing", () => {
assert.deepEqual(parseSettingsQuery("?settings=chart"), {
pane: "chart",
source: null,
plan: null,
tab: null,
});
assert.equal(parseSettingsQuery("?settings=profile"), null);
assert.equal(CHART_SETTINGS_HREF, "/?settings=chart");
const page = readFileSync(new URL("../src/app/(app)/page.tsx", import.meta.url), "utf8");
assert.match(page, /query\.pane === "chart"\) openAccountDialog\("chart-library"\)/);
assert.match(page, /openAccountDialog\("billing", \{ source: query\.source \?\? undefined, plan: query\.plan \?\? undefined, tab: query\.tab \?\? undefined \}\)/);
assert.equal(stripSettingsQuery("?settings=chart"), "/");
});
test("strips settings fields and keeps the session query", () => {
assert.equal(
stripSettingsQuery("?settings=billing&source=legacy&c=11111111-1111-4111-8111-111111111111"),