diff --git a/CHANGELOG.md b/CHANGELOG.md index 772d1ee9..8636d29e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # 印度占星 Skill 更新日志 +## 2026-09-15 — 新增只读星历页:五要素、当日行运、九十天事件 + +登录后可直接打开 `/ephemeris`,看当天的五要素、九曜行运(含相对本命宫位)和未来九十天的换座与停滞。不扣点、不调模型。日期可前后切换。这一页只摆天象,不写对你的判断;底部「带这天去提问」会把该日期带进对话。五要素按 Lahiri 计算,和星盘页的岁差可能不是同一套,页上有说明。换座与停滞若后端尚未上线,该段显示静态说明,其余部分照常。Skill 版本未 bump。 + ## 2026-09-15 — 侧栏可以打开星盘,不必先花点数等模型 登录后侧栏「新建对话」下面多了「星盘」和「星历」。星盘页打开即有:印度盘(D1–D30)、基础信息、Vimshottari 与 Chara 两条大运,不扣点、不调模型。西洋盘和七政四余在计算端点可用时点亮;拿不到时那一栏说明原因,整页照常。星历入口会进 `/ephemeris`,该页尚未合入时打开是 404,这是预期。Skill 版本未 bump。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index b0a73816..4b812515 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -10930,6 +10930,22 @@ - 修复:未修。 - 验证:未修,无回归测试。 - 防复发:待产品决策后另出单。岁差必须按请求参数走,不得写死。 -- 相关记录:无 +- 相关记录:BUG-707(星历页只标注、不修) - 复发自:无 - 修复版本:未修(investigating) + +## BUG-707 | 星历页五要素按 Lahiri,星盘页按账户岁差(默认 Raman) + +- 状态:investigating +- 首次发现:2026-09-15 +- 最近更新:2026-09-15 +- 影响面:`/api/panchanga_range`、星历页五要素段、星盘页 / `/api/chart` 的账户岁差 +- 用户现象:同一账户在星盘页看到的是 Raman 盘(账户默认自 `80102459` 起已是 Raman),在星历页五要素看到的却是 Lahiri 口径。 +- 触发条件:已登录并打开 `/ephemeris`;五要素来自 `/api/panchanga_range`。请求里即使带 `ayanamsa: "raman"`,`report.calculation_policy.panchanga` 仍是 `"SwissEph Lahiri at sunrise-relative reference time"`。 +- 根因:未判定。同一现象以 BUG-703 为准,本条不另开根因。 +- 修复:本轮不修。星历页五要素段标注「这一段按 Lahiri 岁差算,和星盘页用的岁差不是同一套。」行运段走 `/api/chart`、用账户岁差,不加这句。 +- 验证:`frontend/tests/ephemeris-page.test.tsx` 锁定标注只出现在五要素段。真人清单见 `docs/testing/ephemeris-page-20260915.md`。 +- 防复发:在产品未拍板前,不得把 panchanga 默认岁差改成与 `/api/chart` 静默对齐,也不得删掉星历页这行标注。 +- 相关记录:BUG-703 +- 复发自:无 +- 修复版本:未修 diff --git a/docs/tasks/PROGRESS-ephemeris-page-20260915.md b/docs/tasks/PROGRESS-ephemeris-page-20260915.md new file mode 100644 index 00000000..5992dfc3 --- /dev/null +++ b/docs/tasks/PROGRESS-ephemeris-page-20260915.md @@ -0,0 +1,60 @@ +# PROGRESS · P1 星历页(2026-09-15) + +- 执行分支:`codex/ephemeris-page-20260915` +- 工作树:`.worktrees/ephemeris-page-20260915` +- 任务书基线:`2d7698ea`(其后纯文档)。本机开工 `HEAD` = `origin/staging` = `4cacea8e` + +## 开工前置 + +| 项 | 结果 | +| --- | --- | +| 分支 | `codex/ephemeris-page-20260915` 跟踪 `origin/staging` | +| `frontend/src/app/page.tsx` 行数 | 1951 行量级(本机按换行计 1952,未改此文件) | +| `tsc --noEmit` | 0 错(改前) | +| `npm run lint` | 0 error(改前;既有 warning 未动) | +| 本机 | Windows;任务书 Linux 路径已按 `frontend/` 改 | + +## 做了什么 + +任务 1:BFF `frontend/src/app/api/ephemeris/route.ts`。登录校验、`consumeUserRequestRateLimit("ephemeris")`、出生资料只从服务端自有资料读。上游 `/api/panchanga_range`、`/api/chart`(本命上升 + 目标日正午行运)、`/api/ephemeris_events`。后者失败该字段 `unavailable`,整页仍 200。不 import 计费与 mastra。窄合同 `ephemeris-contract.ts`。 + +任务 2:独立 route `/ephemeris`。日期条 / 五要素 / 当日行运 / 未来九十天 / 「带这天去提问」。手机五要素一张卡五行。无 spinner。让步:九十天先落 `unavailable`;提问先预填草稿再回 `/`。 + +任务 3:五要素下标注 Lahiri 与星盘页岁差不同;行运段不加。BUG-707 `investigating`。开工时 staging 尚无 BUG-703,未让号。 + +未改:`page.tsx`、`app-sidebar.tsx`、`scripts/**`、数据库。Skill 未 bump。 + +## 推送 + +合入顺序 qizheng → chart-page → 本单。前序未合入前不抢推 staging。 + +## 测试数字 + +| | `test(` 声明 | `npm test` 实测 | +| --- | ---: | ---: | +| 基线 `origin/staging` @ `4cacea8e` | 3221 | 本机未在改前跑全量;改后全量 3061 条(pass 2894 / fail 153 / skipped 14)。失败清单是 Windows CRLF、无 Docker、SKILL symlink 一类既有缺口,**没有**本单 ephemeris 用例。 | +| 改后 | 3233(+11 本单新文件 +1 `viewport-breakpoint-contract`) | 定向 19/19 pass | + +定向: + +| 套件 | 结果 | +| --- | --- | +| `ephemeris-route.test.ts` | 5 pass(401、无本命仍有五要素、events 降级、zod、禁计费/mastra) | +| `ephemeris-page.test.tsx` | 6 pass(换日、unavailable、Lahiri 标注位置、手机五要素、无运势文案) | +| `viewport-breakpoint-contract` | 5 pass | +| `class-name-definition-contract` | pass | +| `./node_modules/.bin/tsc --noEmit` | 0 错 | +| `npm run lint` | **0 error** / 120 warning(既有,未动) | + +本机 `npx next build` 在收集 `/api/consult` 时因 Windows `SKILL.md` symlink `EPERM` 失败(与既有环境缺口相同),**不能**据此声称 `/` 仍为 `○ Static` 或量首屏 gzip。留给 Gitea 门禁。浏览器 / 真机项见 `docs/testing/ephemeris-page-20260915.md`,不得写成通过。 + +## 既有断言三栏 + +本单没有改任何既有断言的期望值。`viewport-breakpoint-contract` 只**追加**一条星历页 767 切点测试。 + +## 偏离 + +- 「带这天去提问」未新建会话,只预填 `composer-draft` 并跳 `/`(任务书让步 3)。 +- 未来九十天本轮只交付 `unavailable`(让步 2)。 +- 本机无 Chrome / 无登录态,浏览器项见 `docs/testing/ephemeris-page-20260915.md`,不得写成通过。 +- 合入顺序 qizheng → chart-page → 本单。开工后 `origin/staging` 仅快进一篇文档到 `3e58e85f`,两份前序代码单仍未合入,**不推 staging**。 diff --git a/docs/tasks/README.md b/docs/tasks/README.md index 401a791a..696671e7 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -224,7 +224,7 @@ | `TASK-qizheng-native-chart-20260915.md` | `PROGRESS-qizheng-native-chart-20260915.md` | **后端单(独占 `scripts/jyotish_api_server.py`)**:vendored `stem-branch` 0.8.0(Apache-2.0)归档 + API 镜像 Node runtime + 七政适配器(计都派别与宿度坐标系参数化、boundary 按实测重写)+ 三个只读端点 `/api/qizheng`、`/api/western`、`/api/ephemeris_events`。实证三条:四柱时柱按 UTC 算(BUG-700,本轮不修不调用)、`ketuMode` 写死未暴露(BUG-701)、boundary 把空神煞与未闭合庙旺说成已生成(BUG-702)。BUG 段 700–703 | 已推 `origin/staging` `e776cf9d` | `codex/qizheng-native-chart-20260915` | | `TASK-chart-page-20260915.md` | `PROGRESS-chart-page-20260915.md` | **前端单(独占 `app-sidebar.tsx`,同时加星盘与星历两个入口)**:P0 只读星盘页,五个 Tab(星盘 / 基础信息 / 大运 / 西洋盘 / 七政四余),中宫排盘参数卡,三套坐标系各自标注且禁止互相换算。不扣点不调模型不出 spinner;不碰 `page.tsx`(1951/2000);**不搬 `vedic-chart-svg.tsx`**(rectification-board 也在用)。BUG 段 704–706 | 待验收 | `codex/chart-page-20260915` | -| `TASK-ephemeris-page-20260915.md` | `PROGRESS-ephemeris-page-20260915.md` | **前端单**:P1 星历页,今日五要素 + 当日行运(相对本命宫位)+ 未来九十天换座与停滞,底部「带这天去提问」出口。页面不得出现任何运势判断。含实证缺陷:panchanga 写死 Lahiri 与账户 Raman 分裂(BUG-707,只标注不修)。侧边栏入口由 chart-page 单交付。BUG 段 707–709 | 待领取 | — | +| `TASK-ephemeris-page-20260915.md` | `PROGRESS-ephemeris-page-20260915.md` | **前端单**:P1 星历页,今日五要素 + 当日行运(相对本命宫位)+ 未来九十天换座与停滞,底部「带这天去提问」出口。页面不得出现任何运势判断。含实证缺陷:panchanga 写死 Lahiri 与账户 Raman 分裂(关联 BUG-703;本单标注为 BUG-707)。侧边栏入口由 chart-page 单交付。BUG 段 707–709 | 待验收 | `codex/ephemeris-page-20260915` | | `TASK-api-server-decomposition-20260916.md` | `PROGRESS-api-server-decomposition-20260916.md` | **重构单(串行在 qizheng 单之后)**:把业务逻辑搬出 `JyotishAPIHandler`。核心不是行数,是全仓 3 处靠 `JyotishAPIHandler.__new__` 伪造空壳 handler 借方法(`consultation_workflow_service` ×2、`capture_report_blocked_repairs_golden`、`local_accuracy_report`,MCP 也走这条),依赖方向反了、handler 没有 `headers`/`wfile` 随时可炸。四阶段:拆 `__new__` 后门 → 抽 ≥150 行业务方法 → `do_POST`/`do_GET` 改路由表 → 重新冻结行数 baseline(余量 300→50)。纯搬运不改行为,`test_api_server_security.py` 3841 行断言一条不许改。预计 11,314 → 约 9,230 行。BUG 段 710+ | 待领取 | — | ## 命名与归档 diff --git a/docs/testing/ephemeris-page-20260915.md b/docs/testing/ephemeris-page-20260915.md new file mode 100644 index 00000000..47dad2a0 --- /dev/null +++ b/docs/testing/ephemeris-page-20260915.md @@ -0,0 +1,52 @@ +# 真机 / 浏览器清单 · 星历页(2026-09-15) + +代码级合同在 `frontend/tests/ephemeris-route.test.ts` 与 `frontend/tests/ephemeris-page.test.tsx`。下面必须用已登录的 staging 看。截图不要带真实姓名、出生资料或会话正文。 + +地址:`https://staging.jyotisha.chat/ephemeris`(侧栏入口由星盘页那一单交付;对方合入前用这条 URL)。 + +## A. 日期前后切换 + +1. 打开 `/ephemeris`。 +2. **看:** 没有转圈、没有骨架、没有「正在加载」。 +3. 记下五要素(星期、月相日、月宿含第几足、瑜伽、迦那)和当日行运第一颗星的星座与度数。 +4. 点「前一天」。 +5. **看:** 五要素和行运都换了,不是同一天的数。 +6. 点「后一天」回到今天。 +7. **看:** 与步骤 3 一致。 +8. 点「今天」(若已在今天,按钮可不可点都行,但不得整页失败)。 + +## B. 五要素随日期变化;岁差说明只在这一段 + +1. 在今天和前一天之间切换至少一次。 +2. **看:** 月相日或月宿至少有一项不同。 +3. **看:** 五要素下方有一句「这一段按 Lahiri 岁差算,和星盘页用的岁差不是同一套。」 +4. **看:** 当日行运段没有这句。 +5. **看:** 页面没有任何运势判断(没有「适合 / 不宜 / 运势」这类话)。 + +## C. 事件列表降级形态 + +若 staging 上 `/api/ephemeris_events` 尚未上线: + +1. **看:** 「未来九十天」是静态说明「换座和停滞还没接上。这一页其余部分不受影响。」 +2. **看:** 五要素和行运仍然在,整页没有报错。 + +若已经上线: + +1. **看:** 事件按日期升序。 +2. **看:** 每条只写「某星 进入 某座」或「某星 停滞转顺/转逆 · 度数」,没有含义解释。 + +## D. 「带这天去提问」把日期带进对话 + +1. 切到某一天(今天或前一天均可)。 +2. 点底部「带这天去提问」。 +3. **看:** 回到对话页,输入框里预填了「请按 {该日} 这一天的天象来看。」 +4. 不要发出去也可以;发出去的内容不要截进仓库。 + +## E. 未登录 + +1. 退出登录后打开 `/ephemeris`。 +2. **看:** 「请先登录」,可以去登录。不是转圈,也不是整页白屏。 + +## 结论 + +> 待填。测完写日期 + 设备 + 浏览器 + 逐条正常/有问题。 diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index 8afee6e1..5be4e19c 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -602,3 +602,16 @@ Admin 的 antd `` 是独立设计系统,不在此表。 「传统象征」是词条式释义,带边界句「不是对你个人的判断」,不得写成运势。大运 Tab 底部固定:「只有两条同时指向同一段时间才算证据,单轨命中要降一级来看。」Chara 的正式名称是 **Chara Dasha(kn_rao 变体)**,不要写成 Narayana。 +## 16. 星历页 + +独立 route `/ephemeris`。侧栏入口由星盘页那一单加;本页不改 `app-sidebar.tsx`,合入前用直接访问 URL 验收。不扣点、不调模型。`html, body` 仍然 `overflow: hidden`,本页用 `.ephemeris-shell` 自管滚动,和报告中心同一做法。 + +- **结构:** 顶栏返回对话;标题「星历」;日期条;今日五要素;当日行运;未来九十天;底部出口卡。顺序固定。 +- **日期条:** 「前一天 / 今天 / 后一天」三个 ≥44px 真按钮。切日期立刻换五要素和行运;没有 spinner、骨架或「正在加载」。数据未到时写静态句「这一天的五要素还没拿到。」「这一天的行运还没拿到。」 +- **五要素:** 桌面五张卡一行(星期、月相日、月宿含第几足、瑜伽、迦那)。767px 以下收成一张卡里的五行,标签左、值右。Yoga / Karana 的吉凶只照搬引擎标签(吉 / 中 / 凶),不写成建议。卡片下固定一行:「这一段按 Lahiri 岁差算,和星盘页用的岁差不是同一套。」行运段不加这句。 +- **当日行运:** 九曜的星座、度数、相对本命宫位、顺逆。桌面表格,767px 以下每行改成带标签的堆叠。没有本命上升时,相对本命列为空,并写「还没有本命上升,相对本命这一列空着。」 +- **未来九十天:** 按日期升序只写事实(「某星 进入 某座」「某星 停滞转顺 · 度数」)。`/api/ephemeris_events` 未上线时整段静态说明:「换座和停滞还没接上。这一页其余部分不受影响。」不得让整页失败。 +- **出口:** 底部一张卡,一句「这一页是天象本身,不是对你的判断。」主按钮「带这天去提问」把当前日期预填进对话草稿并回到 `/`。 +- **等待:** 揭幕后不得再出现 spinner / 骨架 / 「正在加载」。未登录是静态「请先登录」加去登录,不是转圈。 +- **文案:** 这一页是天象本身。不得写运势结论,不得把三套坐标系换算或叠加。 + diff --git a/frontend/src/app/api/ephemeris/route.ts b/frontend/src/app/api/ephemeris/route.ts new file mode 100644 index 00000000..bba16d43 --- /dev/null +++ b/frontend/src/app/api/ephemeris/route.ts @@ -0,0 +1,187 @@ +import { NextResponse } from "next/server"; + +import { calendarDateInTimeZone } from "@/lib/daily-starlanguage"; +import { ephemerisIsoDateSchema, ephemerisOkResponseSchema, type EphemerisOkResponse } from "@/lib/ephemeris-contract"; +import { + addDaysIso, + eventsFromEngine, + isEphemerisIsoDate, + panchangaFactorsFromEngine, + transitsFromEngine, +} from "@/lib/ephemeris-view"; +import { dailyProfilePayload } from "@/lib/global-birth-payloads"; +import { consumeUserRequestRateLimit } from "@/lib/request-rate-limit"; +import { ACCOUNT_BIRTH_SELECT, globalBirthProfileFromAccountRow } from "@/lib/server-owned-birth-profile"; +import { createServerSupabaseClient } from "@/lib/supabase/server"; + +export const runtime = "nodejs"; +export const maxDuration = 30; + +const jyotishApiBase = process.env.JYOTISH_API_BASE ?? "http://127.0.0.1:5200"; +const engineTimeoutMs = 20_000; + +type Profile = ReturnType; +type BirthPayload = NonNullable>>; + +async function fetchEngine(path: string, body: Record) { + const response = await fetch(`${jyotishApiBase}${path}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + cache: "no-store", + signal: AbortSignal.timeout(engineTimeoutMs), + }); + if (!response.ok) throw new Error(`jyotish_api_${response.status}`); + return await response.json() as unknown; +} + +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; + return calendarDateInTimeZone(new Date(), timezoneId); +} + +function locationBody(profile: Profile): { lat: number; lon: number; tz: number } | null { + const lat = profile.latitude; + const lon = profile.longitude; + const tz = profile.timezoneOffset; + if (typeof lat !== "number" || typeof lon !== "number" || typeof tz !== "number") return null; + if (!Number.isFinite(lat) || !Number.isFinite(lon) || !Number.isFinite(tz)) return null; + return { lat, lon, tz }; +} + +export async function GET(request: Request) { + const supabase = await createServerSupabaseClient(); + const { data: { user } } = await supabase.auth.getUser(); + if (!user) return NextResponse.json({ status: "unauthenticated" }, { status: 401 }); + + const limited = consumeUserRequestRateLimit("ephemeris", user.id); + if (!limited.ok) { + return NextResponse.json({ + status: "unavailable", + reason: "rate_limited", + }, { status: 429, headers: { "Retry-After": String(limited.retryAfterSeconds) } }); + } + + const { data: row } = await supabase + .from("profiles") + .select(ACCOUNT_BIRTH_SELECT) + .eq("id", user.id) + .maybeSingle(); + + const profile = globalBirthProfileFromAccountRow(row); + const today = calendarDateInTimeZone(new Date(), profile.timezoneId); + const date = requestedDate(request, profile.timezoneId); + const birth = await dailyProfilePayload(profile, date).catch(() => null); + const location = locationBody(profile); + + const panchangaBody: Record = { start_date: date, end_date: date }; + if (location) { + panchangaBody.lat = location.lat; + panchangaBody.lon = location.lon; + panchangaBody.tz = location.tz; + } + + const panchangaPromise = fetchEngine("/api/panchanga_range", panchangaBody).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) => { + console.warn("ephemeris_natal_chart_unavailable", error); + return null; + }) + : Promise.resolve(null); + + const transitPromise = birth + ? fetchEngine("/api/chart", transitChartBody(birth, date)).catch((error: unknown) => { + console.warn("ephemeris_transit_chart_unavailable", error); + return null; + }) + : Promise.resolve(null); + + const eventsPromise = fetchEngine("/api/ephemeris_events", { + start_date: date, + end_date: addDaysIso(date, 90), + ayanamsa: birth?.ayanamsa ?? profile.ayanamsa ?? "raman", + node_mode: "mean", + }).catch((error: unknown) => { + console.warn("ephemeris_events_unavailable", error); + return null; + }); + + const [panchangaRaw, natalRaw, transitRaw, eventsRaw] = await Promise.all([ + panchangaPromise, + natalPromise, + transitPromise, + eventsPromise, + ]); + + const factors = panchangaFactorsFromEngine(panchangaRaw, date); + const transits = transitRaw + ? transitsFromEngine({ transitChart: transitRaw, natalChart: natalRaw }) + : { bodies: [], natalRelativeAvailable: false }; + const events = eventsFromEngine(eventsRaw); + + const payload: EphemerisOkResponse = { + status: "ok", + date, + today, + timezoneId: profile.timezoneId ?? null, + panchanga: { + available: factors.length === 5, + ayanamsaNote: "lahiri_not_account", + factors, + }, + transits: { + available: transits.bodies.length > 0, + natalRelativeAvailable: transits.natalRelativeAvailable, + natalRelativeNote: transits.natalRelativeAvailable ? "shown" : "missing_natal", + ayanamsa: typeof birth?.ayanamsa === "string" ? birth.ayanamsa : null, + bodies: transits.bodies, + }, + events, + }; + + const parsed = ephemerisOkResponseSchema.safeParse(payload); + if (!parsed.success) { + console.warn("ephemeris_response_contract_failed", parsed.error.flatten()); + return NextResponse.json({ + status: "ok", + date, + today, + timezoneId: profile.timezoneId ?? null, + panchanga: { available: false, ayanamsaNote: "lahiri_not_account", factors: [] }, + transits: { + available: false, + natalRelativeAvailable: false, + natalRelativeNote: "missing_natal", + ayanamsa: null, + bodies: [], + }, + events: { status: "unavailable" }, + } satisfies EphemerisOkResponse); + } + return NextResponse.json(parsed.data); +} + +function transitChartBody(birth: BirthPayload, date: string): Record { + const [year, month, day] = date.split("-").map(Number); + return { + year, + month, + day, + hour: 12, + minute: 0, + lat: birth.lat, + lon: birth.lon, + tz: birth.tz, + ayanamsa: birth.ayanamsa, + node_mode: birth.node_mode, + skip_vedastro_main_entry_overview: true, + }; +} diff --git a/frontend/src/app/ephemeris/page.tsx b/frontend/src/app/ephemeris/page.tsx new file mode 100644 index 00000000..e3ed41de --- /dev/null +++ b/frontend/src/app/ephemeris/page.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from "next"; + +import { EphemerisPage } from "@/components/ephemeris/ephemeris-page"; +import "../site-styles"; + +export const metadata: Metadata = { + title: "星历 · Jyotisha", + robots: { index: false, follow: false }, +}; + +export default function EphemerisRoutePage() { + return ; +} diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index c7d49cf9..1725da75 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -4860,3 +4860,261 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class .technique-audit > summary::before { transition: none; } .answer-detail > summary::before { transition: none; } } + +/* ============================================================ + Ephemeris page — facts only, own scroll (html/body overflow hidden) + ============================================================ */ +.ephemeris-shell { + width: 100%; + height: 100%; + min-height: 0; + overflow-x: hidden; + overflow-y: auto; + overscroll-behavior: contain; + -webkit-overflow-scrolling: touch; + padding: 0 var(--space-6) var(--space-16); + background: var(--color-canvas-soft); + color: var(--color-ink); +} +.ephemeris-topbar, +.ephemeris-hero, +.ephemeris-section, +.ephemeris-footer { + width: min(900px, 100%); + margin-inline: auto; +} +.ephemeris-topbar { + min-height: 64px; + display: flex; + align-items: center; +} +.ephemeris-back { + min-height: 44px; + display: inline-flex; + align-items: center; + padding: 0 var(--space-3); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + background: var(--color-canvas); + color: var(--color-ink-secondary); + font-size: var(--type-body-sm); + font-weight: 500; + text-decoration: none; +} +.ephemeris-back:hover { border-color: var(--color-border-strong); color: var(--color-ink); } +.ephemeris-back:focus-visible { + outline: 2px solid var(--color-focus); + outline-offset: 2px; +} +.ephemeris-hero { + display: grid; + gap: var(--space-4); + padding: var(--space-6) 0 var(--space-8); +} +.ephemeris-hero h1 { + margin: 0; + color: var(--color-ink); + font-family: var(--font-display); + font-size: var(--type-display-lg); + font-weight: 400; + letter-spacing: -1px; + line-height: 1.1; + text-wrap: balance; +} +.ephemeris-date-label { + margin: 0; + color: var(--color-ink-secondary); + font-size: var(--type-title-sm); + font-weight: 500; +} +.ephemeris-date-bar { + display: flex; + flex-wrap: wrap; + gap: var(--space-3); +} +.ephemeris-date-button { + min-width: 44px; + min-height: 44px; +} +.ephemeris-section { + display: grid; + gap: var(--space-3); + margin-bottom: var(--space-10); +} +.ephemeris-section h2 { + margin: 0; + color: var(--color-ink); + font-family: var(--font-display); + font-size: var(--type-title-md); + font-weight: 400; +} +.ephemeris-static, +.ephemeris-note { + margin: 0; + color: var(--color-ink-tertiary); + font-size: var(--type-caption); + line-height: 1.4; +} +.ephemeris-static { + font-size: var(--type-body-sm); + color: var(--color-ink-secondary); +} +.ephemeris-panchanga { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: var(--space-3); + margin: 0; +} +.ephemeris-panchanga-item { + display: grid; + gap: var(--space-2); + margin: 0; + padding: var(--space-4); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + background: var(--color-canvas); +} +.ephemeris-panchanga-item dt { + margin: 0; + color: var(--color-ink-tertiary); + font-size: var(--type-overline); + font-weight: 500; + letter-spacing: 1.5px; +} +.ephemeris-panchanga-item dd { + display: grid; + gap: var(--space-1); + margin: 0; +} +.ephemeris-panchanga-item strong { + color: var(--color-ink); + font-size: var(--type-body-sm); + font-weight: 500; +} +.ephemeris-panchanga-item small { + color: var(--color-ink-tertiary); + font-size: var(--type-caption); +} +.ephemeris-transits { + overflow: hidden; + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + background: var(--color-canvas); +} +.ephemeris-transits-head, +.ephemeris-transits-row { + display: grid; + grid-template-columns: 4.5rem minmax(0, 1fr) 5rem 6.5rem 3rem; + gap: var(--space-3); + align-items: center; + padding: var(--space-3) var(--space-4); + min-height: 44px; +} +.ephemeris-transits-head { + background: var(--color-canvas-muted); + color: var(--color-ink-tertiary); + font-size: var(--type-caption); + font-weight: 500; +} +.ephemeris-transits-row { + border-top: 1px solid var(--color-border); + font-size: var(--type-body-sm); +} +.ephemeris-transits-row span { min-width: 0; } +.ephemeris-events { + display: grid; + gap: 0; + margin: 0; + padding: 0; + list-style: none; + overflow: hidden; + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + background: var(--color-canvas); +} +.ephemeris-events li { + display: grid; + grid-template-columns: 9.5rem minmax(0, 1fr); + gap: var(--space-4); + align-items: center; + min-height: 44px; + padding: var(--space-3) var(--space-4); + border-top: 1px solid var(--color-border); + font-size: var(--type-body-sm); +} +.ephemeris-events li:first-child { border-top: 0; } +.ephemeris-events time { + color: var(--color-ink-tertiary); + font-size: var(--type-caption); +} +.ephemeris-footer { + display: grid; + gap: var(--space-4); + padding: var(--space-6); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + background: var(--color-canvas); +} +.ephemeris-footer p { + margin: 0; + color: var(--color-ink-secondary); + font-size: var(--type-body-sm); +} +.ephemeris-ask { min-height: 44px; width: fit-content; } +.ephemeris-message { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: var(--space-4); + text-align: center; +} +.ephemeris-message h1 { + margin: 0; + font-family: var(--font-display); + font-size: var(--type-display-sm); + font-weight: 400; +} +.ephemeris-message p { margin: 0; color: var(--color-ink-secondary); font-size: var(--type-body-sm); } + +@media (max-width: 767px) { + .ephemeris-shell { padding-inline: var(--space-4); } + .ephemeris-hero h1 { font-size: var(--type-display-md); letter-spacing: -.5px; } + .ephemeris-date-bar { display: grid; grid-template-columns: 1fr 1fr 1fr; } + .ephemeris-date-button { width: 100%; } + .ephemeris-panchanga { + grid-template-columns: 1fr; + gap: 0; + overflow: hidden; + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + background: var(--color-canvas); + } + .ephemeris-panchanga-item { + grid-template-columns: 5.5rem minmax(0, 1fr); + align-items: center; + gap: var(--space-4); + padding: var(--space-4); + border: 0; + border-top: 1px solid var(--color-border); + border-radius: 0; + background: transparent; + } + .ephemeris-panchanga-item:first-child { border-top: 0; } + .ephemeris-panchanga-item dd { justify-items: end; text-align: right; } + .ephemeris-transits-head { display: none; } + .ephemeris-transits-row { + grid-template-columns: 1fr 1fr; + gap: var(--space-2) var(--space-4); + padding: var(--space-4); + } + .ephemeris-transits-row span::before { + content: attr(data-label); + display: block; + color: var(--color-ink-tertiary); + font-size: var(--type-caption); + font-weight: 500; + } + .ephemeris-events li { grid-template-columns: 1fr; gap: var(--space-1); } + .ephemeris-ask { width: 100%; } +} diff --git a/frontend/src/components/ephemeris/ephemeris-page.tsx b/frontend/src/components/ephemeris/ephemeris-page.tsx new file mode 100644 index 00000000..82557626 --- /dev/null +++ b/frontend/src/components/ephemeris/ephemeris-page.tsx @@ -0,0 +1,208 @@ +"use client"; + +import Link from "next/link"; +import { useCallback, useEffect, useRef, useState } from "react"; + +import { Button } from "@/components/ui/button"; +import { setComposerDraft } from "@/lib/composer-draft"; +import { parseEphemerisOkResponse, type EphemerisOkResponse } from "@/lib/ephemeris-contract"; +import { + EPHEMERIS_COPY, + ephemerisAskDraft, + formatDegree, + formatEphemerisDate, + formatHouse, + qualityLabel, + shiftEphemerisDate, +} from "@/lib/ephemeris-view"; + +type PageState = + | { phase: "ready"; payload: EphemerisOkResponse } + | { phase: "unauthorized" } + | { phase: "open" }; + +export function EphemerisPage() { + const [date, setDate] = useState(null); + const [state, setState] = useState({ phase: "open" }); + const cache = useRef(new Map()); + + const load = useCallback(async (nextDate: string | null, signal: AbortSignal) => { + const query = nextDate ? `?date=${encodeURIComponent(nextDate)}` : ""; + const response = await fetch(`/api/ephemeris${query}`, { + credentials: "same-origin", + headers: { Accept: "application/json" }, + signal, + }); + if (signal.aborted) return; + if (response.status === 401) { + setState({ phase: "unauthorized" }); + return; + } + const json: unknown = await response.json().catch(() => null); + const payload = parseEphemerisOkResponse(json); + if (!payload) return; + cache.current.set(payload.date, payload); + setDate(payload.date); + setState({ phase: "ready", payload }); + }, []); + + 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; + console.warn("ephemeris_page_fetch_failed", error); + }); + return () => controller.abort(); + }, [date, load]); + + if (state.phase === "unauthorized") { + return ( +
+

{EPHEMERIS_COPY.loginTitle}

+

{EPHEMERIS_COPY.loginBody}

+ +
+ ); + } + + const payload = state.phase === "ready" ? state.payload : null; + const activeDate = date ?? payload?.date ?? payload?.today ?? ""; + const today = payload?.today ?? activeDate; + + function changeDate(next: string) { + setDate(next); + const cached = cache.current.get(next); + if (cached) setState({ phase: "ready", payload: cached }); + else setState({ phase: "open" }); + } + + function askAboutDate() { + if (!activeDate) return; + setComposerDraft(ephemerisAskDraft(activeDate)); + window.location.assign("/"); + } + + return ( + activeDate && changeDate(shiftEphemerisDate(activeDate, -1))} + onNext={() => activeDate && changeDate(shiftEphemerisDate(activeDate, 1))} + onToday={() => today && changeDate(today)} + onAsk={askAboutDate} + /> + ); +} + +export function EphemerisView(props: { + readonly date: string; + readonly today: string; + readonly payload: EphemerisOkResponse | null; + readonly onPrevious: () => void; + readonly onNext: () => void; + readonly onToday: () => 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 isToday = Boolean(props.date) && props.date === props.today; + + return ( +
+
+ {EPHEMERIS_COPY.back} +
+ +
+

{EPHEMERIS_COPY.title}

+

{props.date ? formatEphemerisDate(props.date) : ""}

+
+ + + +
+
+ +
+

{EPHEMERIS_COPY.panchangaHeading}

+ {factors.length === 5 ? ( +
+ {factors.map((factor) => ( +
+
{factor.label}
+
+ {factor.value}{factor.detail ? ` · ${factor.detail}` : ""} + {qualityLabel(factor.quality) ? {qualityLabel(factor.quality)} : null} +
+
+ ))} +
+ ) : ( +

{EPHEMERIS_COPY.panchangaUnavailable}

+ )} +

{EPHEMERIS_COPY.panchangaLahiriNote}

+
+ +
+

{EPHEMERIS_COPY.transitsHeading}

+ {natalMissing ?

{EPHEMERIS_COPY.natalMissing}

: null} + {bodies.length > 0 ? ( +
+ + {bodies.map((body) => ( +
+ {body.planetLabel} + {body.signLabel} + {formatDegree(body.degreeInSign)} + {formatHouse(body.natalHouse)} + {body.retrograde ? EPHEMERIS_COPY.retrograde : EPHEMERIS_COPY.direct} +
+ ))} +
+ ) : ( +

{EPHEMERIS_COPY.transitsUnavailable}

+ )} +
+ +
+

{EPHEMERIS_COPY.eventsHeading}

+ {eventsUnavailable ? ( +

{EPHEMERIS_COPY.eventsUnavailable}

+ ) : ( +
    + {events.map((event) => ( +
  1. + + {event.fact} +
  2. + ))} +
+ )} +
+ +
+

{EPHEMERIS_COPY.footerNote}

+ +
+
+ ); +} diff --git a/frontend/src/lib/ephemeris-contract.ts b/frontend/src/lib/ephemeris-contract.ts new file mode 100644 index 00000000..7581b737 --- /dev/null +++ b/frontend/src/lib/ephemeris-contract.ts @@ -0,0 +1,74 @@ +import { z } from "zod"; + +export const ephemerisIsoDateSchema = z.string().regex(/^\d{4}-\d{2}-\d{2}$/); + +export const ephemerisQualitySchema = z.enum(["subha", "asubha", "mixed"]); +export type EphemerisQuality = z.infer; + +export const ephemerisPanchangaFactorSchema = z.object({ + key: z.enum(["vara", "tithi", "nakshatra", "yoga", "karana"]), + label: z.string().min(1).max(20), + value: z.string().min(1).max(80), + quality: ephemerisQualitySchema.nullable(), + detail: z.string().max(40).nullable(), +}); +export type EphemerisPanchangaFactor = z.infer; + +export const ephemerisTransitBodySchema = z.object({ + planet: z.string().min(1).max(20), + planetLabel: z.string().min(1).max(20), + sign: z.string().min(1).max(20), + signLabel: z.string().min(1).max(20), + degreeInSign: z.number().min(0).max(30), + retrograde: z.boolean(), + natalHouse: z.number().int().min(1).max(12).nullable(), +}); +export type EphemerisTransitBody = z.infer; + +export const ephemerisEventItemSchema = z.object({ + date: ephemerisIsoDateSchema, + kind: z.enum(["ingress", "station"]), + planet: z.string().min(1).max(20), + planetLabel: z.string().min(1).max(20), + fact: z.string().min(1).max(80), +}); +export type EphemerisEventItem = z.infer; + +export const ephemerisEventsSchema = z.discriminatedUnion("status", [ + z.object({ status: z.literal("unavailable") }), + z.object({ + status: z.literal("ok"), + items: z.array(ephemerisEventItemSchema).max(400), + }), +]); +export type EphemerisEvents = z.infer; + +export const ephemerisOkResponseSchema = z.object({ + status: z.literal("ok"), + date: ephemerisIsoDateSchema, + today: ephemerisIsoDateSchema, + timezoneId: z.string().max(80).nullable(), + panchanga: z.object({ + available: z.boolean(), + ayanamsaNote: z.literal("lahiri_not_account"), + factors: z.array(ephemerisPanchangaFactorSchema).max(5), + }), + transits: z.object({ + available: z.boolean(), + natalRelativeAvailable: z.boolean(), + natalRelativeNote: z.enum(["missing_natal", "shown"]).nullable(), + ayanamsa: z.string().max(40).nullable(), + bodies: z.array(ephemerisTransitBodySchema).max(9), + }), + events: ephemerisEventsSchema, +}); +export type EphemerisOkResponse = z.infer; + +export const ephemerisUnauthenticatedSchema = z.object({ + status: z.literal("unauthenticated"), +}); + +export function parseEphemerisOkResponse(value: unknown): EphemerisOkResponse | null { + const parsed = ephemerisOkResponseSchema.safeParse(value); + return parsed.success ? parsed.data : null; +} diff --git a/frontend/src/lib/ephemeris-view.ts b/frontend/src/lib/ephemeris-view.ts new file mode 100644 index 00000000..8be1b283 --- /dev/null +++ b/frontend/src/lib/ephemeris-view.ts @@ -0,0 +1,362 @@ +import type { + EphemerisEventItem, + EphemerisEvents, + EphemerisPanchangaFactor, + EphemerisQuality, + EphemerisTransitBody, +} from "@/lib/ephemeris-contract"; + +export const EPHEMERIS_COPY = { + title: "星历", + back: "← 对话", + previousDay: "前一天", + nextDay: "后一天", + today: "今天", + panchangaHeading: "今日五要素", + panchangaLahiriNote: "这一段按 Lahiri 岁差算,和星盘页用的岁差不是同一套。", + panchangaUnavailable: "这一天的五要素还没拿到。", + transitsHeading: "当日行运", + transitsUnavailable: "这一天的行运还没拿到。", + natalMissing: "还没有本命上升,相对本命这一列空着。", + eventsHeading: "未来九十天", + eventsUnavailable: "换座和停滞还没接上。这一页其余部分不受影响。", + footerNote: "这一页是天象本身,不是对你的判断。", + ask: "带这天去提问", + loginTitle: "请先登录", + loginBody: "登录后即可查看星历。", + loginAction: "去登录", + planet: "星体", + sign: "星座", + degree: "度数", + natalHouse: "相对本命", + motion: "顺逆", + direct: "顺", + retrograde: "逆", +} as const; + +export const EPHEMERIS_PLANET_ORDER = [ + "Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn", "Rahu", "Ketu", +] as const; + +const SIGNS = [ + "Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo", + "Libra", "Scorpio", "Sagittarius", "Capricorn", "Aquarius", "Pisces", +] as const; + +const SIGN_ZH: Record<(typeof SIGNS)[number], string> = { + Aries: "白羊座", + Taurus: "金牛座", + Gemini: "双子座", + Cancer: "巨蟹座", + Leo: "狮子座", + Virgo: "处女座", + Libra: "天秤座", + Scorpio: "天蝎座", + Sagittarius: "射手座", + Capricorn: "摩羯座", + Aquarius: "水瓶座", + Pisces: "双鱼座", +}; + +const PLANET_ZH: Record<(typeof EPHEMERIS_PLANET_ORDER)[number], string> = { + Sun: "太阳", + Moon: "月亮", + Mars: "火星", + Mercury: "水星", + Jupiter: "木星", + Venus: "金星", + Saturn: "土星", + Rahu: "罗睺", + Ketu: "计都", +}; + +const VARA_ZH: Record = { + Sunday: "星期日", + Monday: "星期一", + Tuesday: "星期二", + Wednesday: "星期三", + Thursday: "星期四", + Friday: "星期五", + Saturday: "星期六", +}; + +const QUALITY_ZH: Record = { + subha: "吉", + mixed: "中", + asubha: "凶", +}; + +const FACTOR_LABEL: Record = { + vara: "星期", + tithi: "月相日", + nakshatra: "月宿", + yoga: "瑜伽", + karana: "迦那", +}; + +function asRecord(value: unknown): Record | null { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? value as Record + : null; +} + +function asText(value: unknown): string | null { + return typeof value === "string" && value.trim() ? value.trim() : null; +} + +function asNumber(value: unknown): number | null { + if (typeof value === "number" && Number.isFinite(value)) return value; + if (typeof value === "string" && value.trim()) { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; + } + return null; +} + +function nestedText(value: unknown, key: string): string | null { + return asText(asRecord(value)?.[key]); +} + +function nestedNumber(value: unknown, key: string): number | null { + return asNumber(asRecord(value)?.[key]); +} + +export function isEphemerisIsoDate(value: string): boolean { + if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false; + const [year, month, day] = value.split("-").map(Number); + const date = new Date(Date.UTC(year, month - 1, day)); + return date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day; +} + +export function shiftEphemerisDate(iso: string, days: number): string { + const [year, month, day] = iso.split("-").map(Number); + const next = new Date(Date.UTC(year, month - 1, day + days)); + return next.toISOString().slice(0, 10); +} + +export function formatEphemerisDate(iso: string): string { + const [year, month, day] = iso.split("-"); + if (!year || !month || !day) return iso; + return `${Number(year)}年${Number(month)}月${Number(day)}日`; +} + +export function ephemerisAskDraft(iso: string): string { + return `请按 ${formatEphemerisDate(iso)} 这一天的天象来看。`; +} + +export function qualityLabel(quality: EphemerisQuality | null): string | null { + return quality ? QUALITY_ZH[quality] : null; +} + +export function planetLabel(planet: string): string { + return PLANET_ZH[planet as keyof typeof PLANET_ZH] ?? planet; +} + +export function signLabel(sign: string): string { + return SIGN_ZH[sign as keyof typeof SIGN_ZH] ?? sign; +} + +export function signIndex(sign: string | null, explicit?: number | null): number | null { + if (typeof explicit === "number" && Number.isInteger(explicit) && explicit >= 0 && explicit <= 11) { + return explicit; + } + if (!sign) return null; + const index = (SIGNS as readonly string[]).indexOf(sign); + return index >= 0 ? index : null; +} + +export function natalHouseFromSigns(transitSign: string | null, natalAscSign: string | null, transitIdx?: number | null, natalIdx?: number | null): number | null { + const transit = signIndex(transitSign, transitIdx ?? null); + const natal = signIndex(natalAscSign, natalIdx ?? null); + if (transit === null || natal === null) return null; + return ((transit - natal + 12) % 12) + 1; +} + +export function formatHouse(house: number | null): string { + return house === null ? "—" : `第 ${house} 宫`; +} + +export function formatDegree(degree: number): string { + return `${degree.toFixed(1)}°`; +} + +function parseQuality(value: unknown): EphemerisQuality | null { + return value === "subha" || value === "asubha" || value === "mixed" ? value : null; +} + +function chartModule(chart: unknown): Record | null { + const root = asRecord(chart); + return asRecord(root?.modules)?.chart && asRecord(asRecord(root?.modules)?.chart) + ? asRecord(asRecord(root?.modules)?.chart) + : root; +} + +export function panchangaFactorsFromEngine(report: unknown, date: string): EphemerisPanchangaFactor[] { + const root = asRecord(report); + const payload = asRecord(root?.report) ?? root; + const days = Array.isArray(payload?.days) ? payload.days : []; + const day = days.map(asRecord).find((entry) => asText(entry?.query_date) === date) ?? asRecord(days[0]); + const panchanga = asRecord(day?.panchanga); + if (!panchanga) return []; + + const varaName = nestedText(panchanga.vara, "vara") ?? nestedText(panchanga.vara, "name"); + const tithiName = nestedText(panchanga.tithi, "full_name") ?? nestedText(panchanga.tithi, "name"); + const nakshatraName = nestedText(panchanga.nakshatra, "nakshatra") ?? nestedText(panchanga.nakshatra, "name"); + const yogaName = nestedText(panchanga.yoga, "yoga") ?? nestedText(panchanga.yoga, "name"); + const karanaName = nestedText(panchanga.karana, "karana") ?? nestedText(panchanga.karana, "name"); + const pada = nestedNumber(panchanga.nakshatra, "pada"); + if (!varaName || !tithiName || !nakshatraName || !yogaName || !karanaName) return []; + + const factors: EphemerisPanchangaFactor[] = [ + { + key: "vara", + label: FACTOR_LABEL.vara, + value: VARA_ZH[varaName] ?? varaName, + quality: parseQuality(asRecord(panchanga.vara)?.quality), + detail: null, + }, + { + key: "tithi", + label: FACTOR_LABEL.tithi, + value: tithiName, + quality: parseQuality(asRecord(panchanga.tithi)?.quality), + detail: null, + }, + { + key: "nakshatra", + label: FACTOR_LABEL.nakshatra, + value: nakshatraName, + quality: parseQuality(asRecord(panchanga.nakshatra)?.quality), + detail: pada !== null && pada >= 1 && pada <= 4 ? `第 ${pada} 足` : null, + }, + { + key: "yoga", + label: FACTOR_LABEL.yoga, + value: yogaName, + quality: parseQuality(asRecord(panchanga.yoga)?.quality), + detail: null, + }, + { + key: "karana", + label: FACTOR_LABEL.karana, + value: karanaName, + quality: parseQuality(asRecord(panchanga.karana)?.quality), + detail: null, + }, + ]; + return factors; +} + +export function transitsFromEngine(input: { + transitChart: unknown; + natalChart: unknown | null; +}): { bodies: EphemerisTransitBody[]; natalRelativeAvailable: boolean } { + const transit = chartModule(input.transitChart); + const natal = input.natalChart ? chartModule(input.natalChart) : null; + const planets = asRecord(transit?.planets); + const natalAsc = asRecord(natal?.ascendant); + const natalSign = asText(natalAsc?.sign); + const natalIdx = asNumber(natalAsc?.sign_idx); + const natalRelativeAvailable = signIndex(natalSign, natalIdx) !== null; + if (!planets) return { bodies: [], natalRelativeAvailable }; + + const bodies: EphemerisTransitBody[] = []; + for (const planet of EPHEMERIS_PLANET_ORDER) { + const row = asRecord(planets[planet]); + const sign = asText(row?.sign); + const degree = asNumber(row?.degree_in_sign) ?? asNumber(row?.degree); + if (!sign || degree === null) continue; + const degreeInSign = ((degree % 30) + 30) % 30; + const retrograde = row?.retrograde === true || (asNumber(row?.speed) ?? 0) < 0; + bodies.push({ + planet, + planetLabel: planetLabel(planet), + sign, + signLabel: signLabel(sign), + degreeInSign, + retrograde, + natalHouse: natalHouseFromSigns(sign, natalSign, asNumber(row?.sign_idx), natalIdx), + }); + } + return { bodies, natalRelativeAvailable }; +} + +function eventKind(value: unknown): "ingress" | "station" | null { + const raw = asText(value)?.toLowerCase(); + if (raw === "ingress" || raw === "enter" || raw === "sign_ingress") return "ingress"; + if (raw === "station" || raw === "stationary" || raw === "station_direct" || raw === "station_retrograde") { + return "station"; + } + return null; +} + +function stationMotion(row: Record): "direct" | "retrograde" | null { + const raw = (asText(row.motion) ?? asText(row.direction) ?? asText(row.station) ?? asText(row.kind) ?? "").toLowerCase(); + if (raw.includes("direct") || raw.includes("顺")) return "direct"; + if (raw.includes("retro") || raw.includes("逆")) return "retrograde"; + if (row.retrograde === true) return "retrograde"; + if (row.retrograde === false) return "direct"; + return null; +} + +function eventFact(input: { + kind: "ingress" | "station"; + planetLabel: string; + signLabel: string | null; + degree: number | null; + motion: "direct" | "retrograde" | null; +}): string | null { + if (input.kind === "ingress") { + if (!input.signLabel) return null; + return `${input.planetLabel} 进入 ${input.signLabel}`; + } + const turn = input.motion === "direct" ? "停滞转顺" : input.motion === "retrograde" ? "停滞转逆" : "停滞"; + if (input.degree === null) return `${input.planetLabel} ${turn}`; + return `${input.planetLabel} ${turn} · ${formatDegree(input.degree)}`; +} + +export function eventsFromEngine(payload: unknown): EphemerisEvents { + const root = asRecord(payload); + if (!root) return { status: "unavailable" }; + const list = Array.isArray(root.events) + ? root.events + : Array.isArray(asRecord(root.report)?.events) + ? asRecord(root.report)?.events + : Array.isArray(root.items) + ? root.items + : null; + if (!Array.isArray(list)) return { status: "unavailable" }; + + const items: EphemerisEventItem[] = []; + for (const entry of list) { + const row = asRecord(entry); + if (!row) continue; + const date = asText(row.date) ?? asText(row.event_date) ?? asText(row.when); + const kind = eventKind(row.kind ?? row.type ?? row.event); + const planet = asText(row.planet) ?? asText(row.body); + if (!date || !isEphemerisIsoDate(date) || !kind || !planet) continue; + const sign = asText(row.sign); + const degree = asNumber(row.degree_in_sign) ?? asNumber(row.degree); + const fact = eventFact({ + kind, + planetLabel: planetLabel(planet), + signLabel: sign ? signLabel(sign) : null, + degree, + motion: stationMotion(row), + }); + if (!fact) continue; + items.push({ + date, + kind, + planet, + planetLabel: planetLabel(planet), + fact, + }); + } + items.sort((left, right) => left.date.localeCompare(right.date) || left.planet.localeCompare(right.planet)); + return { status: "ok", items }; +} + +export function addDaysIso(iso: string, days: number): string { + return shiftEphemerisDate(iso, days); +} diff --git a/frontend/src/lib/request-rate-limit.ts b/frontend/src/lib/request-rate-limit.ts index d14010fb..3f67e647 100644 --- a/frontend/src/lib/request-rate-limit.ts +++ b/frontend/src/lib/request-rate-limit.ts @@ -20,6 +20,7 @@ export const requestRateLimits = { locationSearch: { limit: 20, windowMs: 60_000 }, locationTimezone: { limit: 30, windowMs: 60_000 }, dailyStarlanguage: { limit: 10, windowMs: 60_000 }, + ephemeris: { limit: 30, windowMs: 60_000 }, synastry: { limit: 8, windowMs: 60 * 60_000 }, sessionWrite: { limit: 40, windowMs: 60_000 }, } as const; diff --git a/frontend/tests/ephemeris-page.test.tsx b/frontend/tests/ephemeris-page.test.tsx new file mode 100644 index 00000000..968749a3 --- /dev/null +++ b/frontend/tests/ephemeris-page.test.tsx @@ -0,0 +1,133 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import React from "react"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { EphemerisView } from "../src/components/ephemeris/ephemeris-page.tsx"; +import { parseEphemerisOkResponse, type EphemerisOkResponse } from "../src/lib/ephemeris-contract.ts"; +import { + EPHEMERIS_COPY, + ephemerisAskDraft, + panchangaFactorsFromEngine, + shiftEphemerisDate, + transitsFromEngine, +} from "../src/lib/ephemeris-view.ts"; + +Object.assign(globalThis, { React }); + +const pageSource = readFileSync(new URL("../src/components/ephemeris/ephemeris-page.tsx", import.meta.url), "utf8"); +const routePage = readFileSync(new URL("../src/app/ephemeris/page.tsx", import.meta.url), "utf8"); +const css = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8"); +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", events: EphemerisOkResponse["events"] = { status: "unavailable" }): 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, + }); + assert.ok(parsed); + return parsed; +} + +function render(payload: EphemerisOkResponse | null, date = payload?.date ?? "2026-09-15") { + return renderToStaticMarkup(React.createElement(EphemerisView, { + date, + today: "2026-09-15", + payload, + onPrevious() {}, + onNext() {}, + onToday() {}, + onAsk() {}, + })); +} + +test("the ephemeris page is its own route and never grows page.tsx", () => { + assert.match(routePage, /from "@\/components\/ephemeris\/ephemeris-page"/); + assert.doesNotMatch(readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8"), /ephemeris-page/); + assert.doesNotMatch(readFileSync(new URL("../src/components/app-sidebar.tsx", import.meta.url), "utf8"), /ephemeris/); +}); + +test("date switching replaces panchanga and transits", () => { + const fourteenth = payloadFor("2026-09-14"); + const fifteenth = payloadFor("2026-09-15"); + assert.notEqual( + fourteenth.panchanga.factors.find((item) => item.key === "tithi")?.value, + fifteenth.panchanga.factors.find((item) => item.key === "tithi")?.value, + ); + const moon14 = fourteenth.transits.bodies.find((item) => item.planet === "Moon"); + const moon15 = fifteenth.transits.bodies.find((item) => item.planet === "Moon"); + assert.notEqual(moon14?.degreeInSign, moon15?.degreeInSign); + + const markup14 = render(fourteenth); + const markup15 = render(fifteenth); + assert.match(markup14, /Shukla Chaturthi/); + assert.match(markup15, /Shukla Panchami/); + assert.doesNotMatch(markup15, /Shukla Chaturthi/); + assert.match(markup14, /2026年9月14日/); + assert.match(markup15, /2026年9月15日/); + assert.equal(shiftEphemerisDate("2026-09-15", -1), "2026-09-14"); +}); + +test("unavailable events render a static note, not a blank page", () => { + const markup = render(payloadFor("2026-09-15")); + assert.match(markup, new RegExp(EPHEMERIS_COPY.eventsUnavailable)); + assert.match(markup, /Shukla Panchami/); + assert.match(markup, /狮子座/); + assert.doesNotMatch(markup, /spinner|正在加载|skeleton/i); +}); + +test("the Lahiri note sits under panchanga and not under transits", () => { + const markup = render(payloadFor("2026-09-15")); + const panchanga = markup.indexOf(EPHEMERIS_COPY.panchangaHeading); + const note = markup.indexOf(EPHEMERIS_COPY.panchangaLahiriNote); + const transits = markup.indexOf(EPHEMERIS_COPY.transitsHeading); + assert.ok(panchanga >= 0 && note >= 0 && transits >= 0); + assert.ok(panchanga < note && note < transits); + const transitsBlock = markup.slice(transits); + assert.doesNotMatch(transitsBlock, new RegExp(EPHEMERIS_COPY.panchangaLahiriNote)); +}); + +test("mobile CSS turns five panchanga cards into one stacked card", () => { + const markup = render(payloadFor("2026-09-15")); + assert.match(markup, /class="ephemeris-panchanga"/); + assert.equal([...markup.matchAll(/ephemeris-panchanga-item/g)].length, 5); + assert.match(css, /@media \(max-width: 767px\) \{[\s\S]*\.ephemeris-panchanga \{[\s\S]*grid-template-columns:\s*1fr/); + assert.match(css, /@media \(max-width: 767px\) \{[\s\S]*\.ephemeris-panchanga-item \{[\s\S]*grid-template-columns:\s*5\.5rem minmax\(0, 1fr\)/); + assert.match(css, /\.ephemeris-date-button \{[\s\S]*min-height:\s*44px/); +}); + +test("copy stays factual: no fortune-telling, no spinner, ask prefills the date", () => { + const markup = render(payloadFor("2026-09-15")); + assert.match(markup, new RegExp(EPHEMERIS_COPY.footerNote)); + assert.match(markup, new RegExp(EPHEMERIS_COPY.ask)); + assert.match(markup, new RegExp(EPHEMERIS_COPY.previousDay)); + assert.match(markup, new RegExp(EPHEMERIS_COPY.nextDay)); + assert.match(markup, new RegExp(EPHEMERIS_COPY.today)); + assert.doesNotMatch(markup, /运势|适合|不宜|建议|吉祥日/); + assert.doesNotMatch(pageSource, /InlineSpinner|正在加载|skeleton/i); + assert.equal(ephemerisAskDraft("2026-09-15"), "请按 2026年9月15日 这一天的天象来看。"); + assert.match(pageSource, /setComposerDraft\(ephemerisAskDraft/); +}); diff --git a/frontend/tests/ephemeris-route.test.ts b/frontend/tests/ephemeris-route.test.ts new file mode 100644 index 00000000..834520c4 --- /dev/null +++ b/frontend/tests/ephemeris-route.test.ts @@ -0,0 +1,218 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; + +import { parseEphemerisOkResponse } from "../src/lib/ephemeris-contract.ts"; +import { + eventsFromEngine, + natalHouseFromSigns, + panchangaFactorsFromEngine, + transitsFromEngine, +} from "../src/lib/ephemeris-view.ts"; + +const routeSource = readFileSync(new URL("../src/app/api/ephemeris/route.ts", import.meta.url), "utf8"); +const viewSource = readFileSync(new URL("../src/lib/ephemeris-view.ts", import.meta.url), "utf8"); +const frontendRoot = fileURLToPath(new URL("../", import.meta.url)); +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 transitGolden = JSON.parse(readFileSync(new URL("./fixtures/ephemeris-chart-transit-2026-09-15.golden.json", import.meta.url), "utf8")); + +const completeRow = { + name: "Example", + birth_date: "1990-04-09", + reported_birth_time: "13:24", + active_birth_time: "13:24", + birth_time: "13:24", + birth_time_status: "confirmed", + latitude: 31.19, + longitude: 121.44, + timezone_offset: 8, + timezone_id: "Asia/Shanghai", + ayanamsa: "raman", +}; + +function executeRoute(input: { + readonly user: boolean; + readonly row: Record | null; + readonly date?: string; + readonly eventsStatus?: number; +}) { + const script = String.raw` + import { mock } from "node:test"; + import { readFileSync } from "node:fs"; + import { join } from "node:path"; + import { pathToFileURL } from "node:url"; + + 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 eventsStatus = ${input.eventsStatus ?? 404}; + 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"); + const transit = fixture("ephemeris-chart-transit-2026-09-15.golden.json"); + + let billingImported = false; + mock.module("@/lib/consultation-billing", { + namedExports: { + charge() { billingImported = true; throw new Error("billing must not run"); }, + }, + }); + mock.module("@/mastra", { + namedExports: { + getAgent() { throw new Error("mastra must not run"); }, + }, + }); + mock.module("@/lib/request-rate-limit", { + namedExports: { + consumeUserRequestRateLimit: () => ({ ok: true }), + }, + }); + mock.module("@/lib/daily-starlanguage", { + namedExports: { + calendarDateInTimeZone: () => "2026-09-15", + }, + }); + mock.module("@/lib/supabase/server", { + namedExports: { + createServerSupabaseClient: async () => ({ + auth: { getUser: async () => ({ data: { user: user ? { id: "user-1" } : null }, error: null }) }, + from: () => ({ + select() { return this; }, + eq() { return this; }, + async maybeSingle() { return { data: row, error: null }; }, + }), + }), + }, + }); + + const upstream = []; + globalThis.fetch = async (inputUrl, init) => { + const url = String(inputUrl); + const body = init?.body ? JSON.parse(String(init.body)) : {}; + upstream.push({ url, body }); + if (url.endsWith("/api/panchanga_range")) { + return new Response(JSON.stringify(panchanga), { status: 200, headers: { "content-type": "application/json" } }); + } + if (url.endsWith("/api/chart")) { + const packet = Number(body.year) === 1990 ? natal : transit; + return new Response(JSON.stringify(packet), { status: 200, headers: { "content-type": "application/json" } }); + } + if (url.endsWith("/api/ephemeris_events")) { + return new Response(JSON.stringify({ error: "not_found" }), { status: eventsStatus, headers: { "content-type": "application/json" } }); + } + throw new Error("unexpected_upstream " + url); + }; + + 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)); + console.log(JSON.stringify({ + status: response.status, + body: await response.json(), + upstream, + billingImported, + })); + `; + const result = spawnSync(process.execPath, [ + "--experimental-test-module-mocks", + "--import", + "tsx", + "--input-type=module", + "--eval", + script, + ], { cwd: frontendRoot, encoding: "utf8" }); + assert.equal(result.status, 0, result.stderr || result.stdout); + const line = result.stdout.trim().split("\n").at(-1) ?? "{}"; + return JSON.parse(line) as { + status: number; + body: Record; + upstream: Array<{ url: string; body: Record }>; + billingImported: boolean; + }; +} + +test("the ephemeris BFF does not import billing or mastra", () => { + assert.match(routeSource, /createServerSupabaseClient/); + assert.match(routeSource, /ACCOUNT_BIRTH_SELECT/); + assert.match(routeSource, /globalBirthProfileFromAccountRow/); + assert.match(routeSource, /consumeUserRequestRateLimit\("ephemeris"/); + assert.match(routeSource, /"\/api\/panchanga_range"/); + assert.match(routeSource, /"\/api\/chart"/); + assert.match(routeSource, /"\/api\/ephemeris_events"/); + assert.match(routeSource, /eventsFromEngine\(eventsRaw\)/); + assert.doesNotMatch(routeSource, /consultation-billing|@\/mastra/); + assert.doesNotMatch(viewSource, /consultation-billing|@\/mastra/); +}); + +test("unauthenticated ephemeris requests are 401", () => { + const result = executeRoute({ user: false, row: null }); + assert.equal(result.status, 401); + assert.equal(result.body.status, "unauthenticated"); + assert.equal(result.upstream.length, 0); + assert.equal(result.billingImported, false); +}); + +test("panchanga still returns without a birth profile, and natal-relative stays empty", () => { + const result = executeRoute({ user: true, row: { timezone_id: "Asia/Shanghai" } }); + assert.equal(result.status, 200); + const parsed = parseEphemerisOkResponse(result.body); + assert.ok(parsed); + assert.equal(parsed?.panchanga.available, true); + assert.equal(parsed?.panchanga.factors.length, 5); + assert.equal(parsed?.transits.available, false); + assert.equal(parsed?.transits.natalRelativeAvailable, false); + assert.equal(parsed?.transits.natalRelativeNote, "missing_natal"); + assert.equal(parsed?.events.status, "unavailable"); + assert.equal(result.upstream.some((item) => item.url.endsWith("/api/chart")), false); + assert.equal(result.billingImported, false); +}); + +test("ephemeris_events failure degrades instead of failing the page", () => { + const result = executeRoute({ user: true, row: completeRow, eventsStatus: 404 }); + assert.equal(result.status, 200); + const parsed = parseEphemerisOkResponse(result.body); + assert.ok(parsed); + assert.equal(parsed?.panchanga.available, true); + assert.equal(parsed?.transits.available, true); + assert.equal(parsed?.transits.natalRelativeAvailable, true); + assert.equal(parsed?.events.status, "unavailable"); + assert.equal(result.billingImported, false); + assert.ok(result.upstream.some((item) => item.url.endsWith("/api/ephemeris_events"))); +}); + +test("golden engine packets pass the narrow response contract", () => { + const factors = panchangaFactorsFromEngine(panchangaGolden, "2026-09-15"); + assert.equal(factors.length, 5); + assert.equal(factors.find((item) => item.key === "tithi")?.value, "Shukla Panchami"); + assert.equal(factors.find((item) => item.key === "nakshatra")?.detail, "第 4 足"); + + const transits = transitsFromEngine({ transitChart: transitGolden, natalChart: natalGolden }); + assert.equal(transits.bodies.length, 9); + assert.equal(transits.natalRelativeAvailable, true); + const sun = transits.bodies.find((item) => item.planet === "Sun"); + assert.equal(sun?.signLabel, "狮子座"); + assert.equal(sun?.natalHouse, natalHouseFromSigns("Leo", "Cancer")); + assert.equal(sun?.natalHouse, 2); + + const parsed = parseEphemerisOkResponse({ + status: "ok", + date: "2026-09-15", + today: "2026-09-15", + timezoneId: "Asia/Shanghai", + panchanga: { available: true, ayanamsaNote: "lahiri_not_account", factors }, + transits: { + available: true, + natalRelativeAvailable: true, + natalRelativeNote: "shown", + ayanamsa: "raman", + bodies: transits.bodies, + }, + events: { status: "unavailable" }, + }); + assert.ok(parsed); + assert.equal(eventsFromEngine(null).status, "unavailable"); + assert.equal(eventsFromEngine({ error: "not_found" }).status, "unavailable"); +}); diff --git a/frontend/tests/fixtures/ephemeris-chart-natal.golden.json b/frontend/tests/fixtures/ephemeris-chart-natal.golden.json new file mode 100644 index 00000000..3a6f60d4 --- /dev/null +++ b/frontend/tests/fixtures/ephemeris-chart-natal.golden.json @@ -0,0 +1,181 @@ +{ + "success": true, + "ascendant": { + "sign": "Cancer", + "lon": 114.7386, + "degree_in_sign": 24.7386 + }, + "planets": { + "Sun": { + "sign": "Pisces", + "sign_idx": 11, + "lon": 356.8297826612266, + "degree_in_sign": 26.8298, + "retrograde": false, + "speed": 0.982054 + }, + "Moon": { + "sign": "Virgo", + "sign_idx": 5, + "lon": 166.68921101260668, + "degree_in_sign": 16.6892, + "retrograde": false, + "speed": 12.13875 + }, + "Mars": { + "sign": "Capricorn", + "sign_idx": 9, + "lon": 298.9782668397336, + "degree_in_sign": 28.9783, + "retrograde": false, + "speed": 0.747034 + }, + "Mercury": { + "sign": "Aries", + "sign_idx": 0, + "lon": 15.401434803721337, + "degree_in_sign": 15.4014, + "retrograde": false, + "speed": 1.373062 + }, + "Jupiter": { + "sign": "Gemini", + "sign_idx": 2, + "lon": 71.43866204534243, + "degree_in_sign": 11.4387, + "retrograde": false, + "speed": 0.125774 + }, + "Venus": { + "sign": "Aquarius", + "sign_idx": 10, + "lon": 310.66046300130137, + "degree_in_sign": 10.6605, + "retrograde": false, + "speed": 1.040143 + }, + "Saturn": { + "sign": "Capricorn", + "sign_idx": 9, + "lon": 272.52509944765643, + "degree_in_sign": 2.5251, + "retrograde": false, + "speed": 0.041357 + }, + "Rahu": { + "sign": "Capricorn", + "sign_idx": 9, + "lon": 290.98515555732365, + "degree_in_sign": 20.9852, + "retrograde": true, + "speed": -0.052991 + }, + "Ketu": { + "sign": "Cancer", + "sign_idx": 3, + "lon": 110.98515555732365, + "degree_in_sign": 20.9852, + "retrograde": true, + "speed": -0.052991 + } + }, + "modules": { + "chart": { + "ascendant": { + "sign": "Cancer", + "lon": 114.7386 + }, + "planets": { + "Sun": { + "sign": "Pisces", + "sign_idx": 11, + "lon": 356.8297826612266, + "degree_in_sign": 26.8298, + "retrograde": false, + "speed": 0.982054 + }, + "Moon": { + "sign": "Virgo", + "sign_idx": 5, + "lon": 166.68921101260668, + "degree_in_sign": 16.6892, + "retrograde": false, + "speed": 12.13875 + }, + "Mars": { + "sign": "Capricorn", + "sign_idx": 9, + "lon": 298.9782668397336, + "degree_in_sign": 28.9783, + "retrograde": false, + "speed": 0.747034 + }, + "Mercury": { + "sign": "Aries", + "sign_idx": 0, + "lon": 15.401434803721337, + "degree_in_sign": 15.4014, + "retrograde": false, + "speed": 1.373062 + }, + "Jupiter": { + "sign": "Gemini", + "sign_idx": 2, + "lon": 71.43866204534243, + "degree_in_sign": 11.4387, + "retrograde": false, + "speed": 0.125774 + }, + "Venus": { + "sign": "Aquarius", + "sign_idx": 10, + "lon": 310.66046300130137, + "degree_in_sign": 10.6605, + "retrograde": false, + "speed": 1.040143 + }, + "Saturn": { + "sign": "Capricorn", + "sign_idx": 9, + "lon": 272.52509944765643, + "degree_in_sign": 2.5251, + "retrograde": false, + "speed": 0.041357 + }, + "Rahu": { + "sign": "Capricorn", + "sign_idx": 9, + "lon": 290.98515555732365, + "degree_in_sign": 20.9852, + "retrograde": true, + "speed": -0.052991 + }, + "Ketu": { + "sign": "Cancer", + "sign_idx": 3, + "lon": 110.98515555732365, + "degree_in_sign": 20.9852, + "retrograde": true, + "speed": -0.052991 + } + } + } + }, + "calculation_contract": { + "contract_version": "1.0.0", + "algorithm": "sidereal_natal_chart", + "requested": { + "ayanamsa": "raman", + "node_mode": "mean", + "timezone_offset": 8 + }, + "effective": { + "ayanamsa": "raman", + "node_mode": "mean", + "timezone_offset": 8.0, + "timezone_source": "explicit_offset", + "ephemeris_source": "swisseph_calc_ut", + "ephemeris_flags_verified": false + } + } +} diff --git a/frontend/tests/fixtures/ephemeris-chart-transit-2026-09-14.golden.json b/frontend/tests/fixtures/ephemeris-chart-transit-2026-09-14.golden.json new file mode 100644 index 00000000..85651222 --- /dev/null +++ b/frontend/tests/fixtures/ephemeris-chart-transit-2026-09-14.golden.json @@ -0,0 +1,181 @@ +{ + "success": true, + "ascendant": { + "sign": "Scorpio", + "lon": 229.0199, + "degree_in_sign": 19.0199 + }, + "planets": { + "Sun": { + "sign": "Leo", + "sign_idx": 4, + "lon": 148.59028213550158, + "degree_in_sign": 28.5903, + "retrograde": false, + "speed": 0.974059 + }, + "Moon": { + "sign": "Libra", + "sign_idx": 6, + "lon": 185.76198893157513, + "degree_in_sign": 5.762, + "retrograde": false, + "speed": 12.796987 + }, + "Mars": { + "sign": "Gemini", + "sign_idx": 2, + "lon": 88.82930298273124, + "degree_in_sign": 28.8293, + "retrograde": false, + "speed": 0.613543 + }, + "Mercury": { + "sign": "Virgo", + "sign_idx": 5, + "lon": 162.9993322697507, + "degree_in_sign": 12.9993, + "retrograde": false, + "speed": 1.627112 + }, + "Jupiter": { + "sign": "Cancer", + "sign_idx": 3, + "lon": 113.61487798614769, + "degree_in_sign": 23.6149, + "retrograde": false, + "speed": 0.199146 + }, + "Venus": { + "sign": "Libra", + "sign_idx": 6, + "lon": 189.5880715851048, + "degree_in_sign": 9.5881, + "retrograde": false, + "speed": 0.580994 + }, + "Saturn": { + "sign": "Pisces", + "sign_idx": 11, + "lon": 350.0593111228965, + "degree_in_sign": 20.0593, + "retrograde": true, + "speed": -0.070072 + }, + "Rahu": { + "sign": "Aquarius", + "sign_idx": 10, + "lon": 305.82288975646713, + "degree_in_sign": 5.8229, + "retrograde": true, + "speed": -0.052979 + }, + "Ketu": { + "sign": "Leo", + "sign_idx": 4, + "lon": 125.82288975646713, + "degree_in_sign": 5.8229, + "retrograde": true, + "speed": -0.052979 + } + }, + "modules": { + "chart": { + "ascendant": { + "sign": "Scorpio", + "lon": 229.0199 + }, + "planets": { + "Sun": { + "sign": "Leo", + "sign_idx": 4, + "lon": 148.59028213550158, + "degree_in_sign": 28.5903, + "retrograde": false, + "speed": 0.974059 + }, + "Moon": { + "sign": "Libra", + "sign_idx": 6, + "lon": 185.76198893157513, + "degree_in_sign": 5.762, + "retrograde": false, + "speed": 12.796987 + }, + "Mars": { + "sign": "Gemini", + "sign_idx": 2, + "lon": 88.82930298273124, + "degree_in_sign": 28.8293, + "retrograde": false, + "speed": 0.613543 + }, + "Mercury": { + "sign": "Virgo", + "sign_idx": 5, + "lon": 162.9993322697507, + "degree_in_sign": 12.9993, + "retrograde": false, + "speed": 1.627112 + }, + "Jupiter": { + "sign": "Cancer", + "sign_idx": 3, + "lon": 113.61487798614769, + "degree_in_sign": 23.6149, + "retrograde": false, + "speed": 0.199146 + }, + "Venus": { + "sign": "Libra", + "sign_idx": 6, + "lon": 189.5880715851048, + "degree_in_sign": 9.5881, + "retrograde": false, + "speed": 0.580994 + }, + "Saturn": { + "sign": "Pisces", + "sign_idx": 11, + "lon": 350.0593111228965, + "degree_in_sign": 20.0593, + "retrograde": true, + "speed": -0.070072 + }, + "Rahu": { + "sign": "Aquarius", + "sign_idx": 10, + "lon": 305.82288975646713, + "degree_in_sign": 5.8229, + "retrograde": true, + "speed": -0.052979 + }, + "Ketu": { + "sign": "Leo", + "sign_idx": 4, + "lon": 125.82288975646713, + "degree_in_sign": 5.8229, + "retrograde": true, + "speed": -0.052979 + } + } + } + }, + "calculation_contract": { + "contract_version": "1.0.0", + "algorithm": "sidereal_natal_chart", + "requested": { + "ayanamsa": "raman", + "node_mode": "mean", + "timezone_offset": 8 + }, + "effective": { + "ayanamsa": "raman", + "node_mode": "mean", + "timezone_offset": 8.0, + "timezone_source": "explicit_offset", + "ephemeris_source": "swisseph_calc_ut", + "ephemeris_flags_verified": false + } + } +} diff --git a/frontend/tests/fixtures/ephemeris-chart-transit-2026-09-15.golden.json b/frontend/tests/fixtures/ephemeris-chart-transit-2026-09-15.golden.json new file mode 100644 index 00000000..376e25ee --- /dev/null +++ b/frontend/tests/fixtures/ephemeris-chart-transit-2026-09-15.golden.json @@ -0,0 +1,181 @@ +{ + "success": true, + "ascendant": { + "sign": "Scorpio", + "lon": 229.8645, + "degree_in_sign": 19.8645 + }, + "planets": { + "Sun": { + "sign": "Leo", + "sign_idx": 4, + "lon": 149.56454270412283, + "degree_in_sign": 29.5645, + "retrograde": false, + "speed": 0.974534 + }, + "Moon": { + "sign": "Libra", + "sign_idx": 6, + "lon": 198.40783300527767, + "degree_in_sign": 18.4078, + "retrograde": false, + "speed": 12.499695 + }, + "Mars": { + "sign": "Gemini", + "sign_idx": 2, + "lon": 89.44195420047637, + "degree_in_sign": 29.442, + "retrograde": false, + "speed": 0.611833 + }, + "Mercury": { + "sign": "Virgo", + "sign_idx": 5, + "lon": 164.6174049106857, + "degree_in_sign": 14.6174, + "retrograde": false, + "speed": 1.60915 + }, + "Jupiter": { + "sign": "Cancer", + "sign_idx": 3, + "lon": 113.8134898743071, + "degree_in_sign": 23.8135, + "retrograde": false, + "speed": 0.198149 + }, + "Venus": { + "sign": "Libra", + "sign_idx": 6, + "lon": 190.15828739707854, + "degree_in_sign": 10.1583, + "retrograde": false, + "speed": 0.559372 + }, + "Saturn": { + "sign": "Pisces", + "sign_idx": 11, + "lon": 349.9888038157556, + "degree_in_sign": 19.9888, + "retrograde": true, + "speed": -0.070861 + }, + "Rahu": { + "sign": "Aquarius", + "sign_idx": 10, + "lon": 305.7698787805865, + "degree_in_sign": 5.7699, + "retrograde": true, + "speed": -0.052967 + }, + "Ketu": { + "sign": "Leo", + "sign_idx": 4, + "lon": 125.76987878058651, + "degree_in_sign": 5.7699, + "retrograde": true, + "speed": -0.052967 + } + }, + "modules": { + "chart": { + "ascendant": { + "sign": "Scorpio", + "lon": 229.8645 + }, + "planets": { + "Sun": { + "sign": "Leo", + "sign_idx": 4, + "lon": 149.56454270412283, + "degree_in_sign": 29.5645, + "retrograde": false, + "speed": 0.974534 + }, + "Moon": { + "sign": "Libra", + "sign_idx": 6, + "lon": 198.40783300527767, + "degree_in_sign": 18.4078, + "retrograde": false, + "speed": 12.499695 + }, + "Mars": { + "sign": "Gemini", + "sign_idx": 2, + "lon": 89.44195420047637, + "degree_in_sign": 29.442, + "retrograde": false, + "speed": 0.611833 + }, + "Mercury": { + "sign": "Virgo", + "sign_idx": 5, + "lon": 164.6174049106857, + "degree_in_sign": 14.6174, + "retrograde": false, + "speed": 1.60915 + }, + "Jupiter": { + "sign": "Cancer", + "sign_idx": 3, + "lon": 113.8134898743071, + "degree_in_sign": 23.8135, + "retrograde": false, + "speed": 0.198149 + }, + "Venus": { + "sign": "Libra", + "sign_idx": 6, + "lon": 190.15828739707854, + "degree_in_sign": 10.1583, + "retrograde": false, + "speed": 0.559372 + }, + "Saturn": { + "sign": "Pisces", + "sign_idx": 11, + "lon": 349.9888038157556, + "degree_in_sign": 19.9888, + "retrograde": true, + "speed": -0.070861 + }, + "Rahu": { + "sign": "Aquarius", + "sign_idx": 10, + "lon": 305.7698787805865, + "degree_in_sign": 5.7699, + "retrograde": true, + "speed": -0.052967 + }, + "Ketu": { + "sign": "Leo", + "sign_idx": 4, + "lon": 125.76987878058651, + "degree_in_sign": 5.7699, + "retrograde": true, + "speed": -0.052967 + } + } + } + }, + "calculation_contract": { + "contract_version": "1.0.0", + "algorithm": "sidereal_natal_chart", + "requested": { + "ayanamsa": "raman", + "node_mode": "mean", + "timezone_offset": 8 + }, + "effective": { + "ayanamsa": "raman", + "node_mode": "mean", + "timezone_offset": 8.0, + "timezone_source": "explicit_offset", + "ephemeris_source": "swisseph_calc_ut", + "ephemeris_flags_verified": false + } + } +} diff --git a/frontend/tests/fixtures/ephemeris-panchanga-range.golden.json b/frontend/tests/fixtures/ephemeris-panchanga-range.golden.json new file mode 100644 index 00000000..63be74a5 --- /dev/null +++ b/frontend/tests/fixtures/ephemeris-panchanga-range.golden.json @@ -0,0 +1,176 @@ +{ + "success": true, + "endpoint": "panchanga_range", + "report": { + "calculation_policy": { + "panchanga": "SwissEph Lahiri at sunrise-relative reference time", + "inauspicious_periods": "traditional daytime eight-segment rule; local clock times", + "sunrise_sunset": "SwissEph rise_trans", + "end_times": "SwissEph boundary bisection when location/timezone are available", + "sub_day_windows": "traditional Choghadiya and planetary Hora segmented by local sunrise/sunset", + "durmuhurtha": "Raman p22 day/night 15-segment table, marked as avoidance context until activity scoring replay is complete", + "festival_rules": "conservative tithi/nakshatra/vara vrata rules; masa-dependent festivals marked as candidates", + "condition_tags": "searchable row tags derived from vrata, activity verdicts, warnings and sub-day windows", + "next_precision_step": "add Tithi/Nakshatra end times and festival/vrata rules" + }, + "days": [ + { + "query_date": "2026-09-14", + "panchanga": { + "tithi": { + "tithi_num": 4, + "paksha": "Shukla", + "tithi_in_paksha": 4, + "name": "Chaturthi", + "full_name": "Shukla Chaturthi", + "quality": "asubha", + "moon_sun_diff": 36.99 + }, + "nakshatra": { + "nakshatra": "Chitra", + "nakshatra_idx": 13, + "pada": 4, + "type": "mridu", + "quality": "subha", + "moon_lon": 185.56 + }, + "yoga": { + "yoga": "Aindra", + "yoga_idx": 25, + "quality": "subha", + "sun_moon_sum": 334.13 + }, + "karana": { + "karana": "Vanija", + "karana_num": 6, + "quality": "subha", + "is_vishti": false + }, + "vara": { + "vara": "Monday", + "vara_lord": "Moon", + "quality": "subha", + "weekday_idx": 1 + }, + "hora": { + "hora_lord": "Mercury", + "hora_num": 7, + "quality": "subha", + "hora_from_sunrise": 6.0 + }, + "overall_score": 0.83, + "overall_quality": "吉(Subha)", + "warnings": [], + "auspicious_count": 5, + "total_elements": 6 + } + }, + { + "query_date": "2026-09-15", + "panchanga": { + "tithi": { + "tithi_num": 5, + "paksha": "Shukla", + "tithi_in_paksha": 5, + "name": "Panchami", + "full_name": "Shukla Panchami", + "quality": "subha", + "moon_sun_diff": 48.67 + }, + "nakshatra": { + "nakshatra": "Swati", + "nakshatra_idx": 14, + "pada": 4, + "type": "chara", + "quality": "mixed", + "moon_lon": 198.21 + }, + "yoga": { + "yoga": "Vaidhriti", + "yoga_idx": 26, + "quality": "asubha", + "sun_moon_sum": 347.76 + }, + "karana": { + "karana": "Bava", + "karana_num": 8, + "quality": "subha", + "is_vishti": false + }, + "vara": { + "vara": "Tuesday", + "vara_lord": "Mars", + "quality": "asubha", + "weekday_idx": 2 + }, + "hora": { + "hora_lord": "Jupiter", + "hora_num": 7, + "quality": "subha", + "hora_from_sunrise": 6.0 + }, + "overall_score": 0.58, + "overall_quality": "中(Mixed)", + "warnings": [ + "⚠️ Vaidhriti Yoga——不利时段" + ], + "auspicious_count": 3, + "total_elements": 6 + } + }, + { + "query_date": "2026-09-16", + "panchanga": { + "tithi": { + "tithi_num": 6, + "paksha": "Shukla", + "tithi_in_paksha": 6, + "name": "Shashthi", + "full_name": "Shukla Shashthi", + "quality": "mixed", + "moon_sun_diff": 60.07 + }, + "nakshatra": { + "nakshatra": "Vishakha", + "nakshatra_idx": 15, + "pada": 4, + "type": "mixed", + "quality": "mixed", + "moon_lon": 210.59 + }, + "yoga": { + "yoga": "Vishkambha", + "yoga_idx": 0, + "quality": "asubha", + "sun_moon_sum": 1.11 + }, + "karana": { + "karana": "Kaulava", + "karana_num": 10, + "quality": "subha", + "is_vishti": false + }, + "vara": { + "vara": "Wednesday", + "vara_lord": "Mercury", + "quality": "mixed", + "weekday_idx": 3 + }, + "hora": { + "hora_lord": "Venus", + "hora_num": 7, + "quality": "subha", + "hora_from_sunrise": 6.0 + }, + "overall_score": 0.58, + "overall_quality": "中(Mixed)", + "warnings": [ + "⚠️ Vishkambha Yoga——不利时段" + ], + "auspicious_count": 2, + "total_elements": 6 + } + } + ] + } +} diff --git a/frontend/tests/viewport-breakpoint-contract.test.ts b/frontend/tests/viewport-breakpoint-contract.test.ts index ac9e1a3d..ebae414d 100644 --- a/frontend/tests/viewport-breakpoint-contract.test.ts +++ b/frontend/tests/viewport-breakpoint-contract.test.ts @@ -40,3 +40,8 @@ test("report TOC keeps a content-width exception at 860px", () => { assert.match(css, /Report TOC is the third column/); assert.match(css, /@media \(max-width: 860px\) \{[\s\S]*\.personal-report-md-layout/); }); + +test("ephemeris page stacks at the global mobile cut", () => { + assert.match(css, /@media \(max-width: 767px\) \{[\s\S]*\.ephemeris-shell/); + assert.match(css, /@media \(max-width: 767px\) \{[\s\S]*\.ephemeris-panchanga \{[\s\S]*grid-template-columns:\s*1fr/); +});