feat(web): add read-only /ephemeris page
This commit is contained in:
@@ -1,5 +1,9 @@
|
||||
# 印度占星 Skill 更新日志
|
||||
|
||||
## 2026-09-15 — 新增只读星历页:五要素、当日行运、九十天事件
|
||||
|
||||
登录后可直接打开 `/ephemeris`,看当天的五要素、九曜行运(含相对本命宫位)和未来九十天的换座与停滞。不扣点、不调模型。日期可前后切换。这一页只摆天象,不写对你的判断;底部「带这天去提问」会把该日期带进对话。五要素按 Lahiri 计算,和星盘页的岁差可能不是同一套,页上有说明。换座与停滞若后端尚未上线,该段显示静态说明,其余部分照常。Skill 版本未 bump。
|
||||
|
||||
## 2026-09-15 — 侧栏可以打开星盘,不必先花点数等模型
|
||||
|
||||
登录后侧栏「新建对话」下面多了「星盘」和「星历」。星盘页打开即有:印度盘(D1–D30)、基础信息、Vimshottari 与 Chara 两条大运,不扣点、不调模型。西洋盘和七政四余在计算端点可用时点亮;拿不到时那一栏说明原因,整页照常。星历入口会进 `/ephemeris`,该页尚未合入时打开是 404,这是预期。Skill 版本未 bump。
|
||||
|
||||
+17
-1
@@ -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
|
||||
- 复发自:无
|
||||
- 修复版本:未修
|
||||
|
||||
@@ -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**。
|
||||
@@ -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+ | 待领取 | — |
|
||||
|
||||
## 命名与归档
|
||||
|
||||
@@ -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. **看:** 「请先登录」,可以去登录。不是转圈,也不是整页白屏。
|
||||
|
||||
## 结论
|
||||
|
||||
> 待填。测完写日期 + 设备 + 浏览器 + 逐条正常/有问题。
|
||||
@@ -602,3 +602,16 @@ Admin 的 antd `<Spin>` 是独立设计系统,不在此表。
|
||||
|
||||
「传统象征」是词条式释义,带边界句「不是对你个人的判断」,不得写成运势。大运 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 / 骨架 / 「正在加载」。未登录是静态「请先登录」加去登录,不是转圈。
|
||||
- **文案:** 这一页是天象本身。不得写运势结论,不得把三套坐标系换算或叠加。
|
||||
|
||||
|
||||
@@ -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<typeof globalBirthProfileFromAccountRow>;
|
||||
type BirthPayload = NonNullable<Awaited<ReturnType<typeof dailyProfilePayload>>>;
|
||||
|
||||
async function fetchEngine(path: string, body: Record<string, unknown>) {
|
||||
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<string, unknown> = { 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<string, unknown> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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 <EphemerisPage />;
|
||||
}
|
||||
@@ -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%; }
|
||||
}
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
const [state, setState] = useState<PageState>({ phase: "open" });
|
||||
const cache = useRef(new Map<string, EphemerisOkResponse>());
|
||||
|
||||
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 (
|
||||
<main className="ephemeris-shell ephemeris-message">
|
||||
<h1>{EPHEMERIS_COPY.loginTitle}</h1>
|
||||
<p>{EPHEMERIS_COPY.loginBody}</p>
|
||||
<Button render={<Link href="/login" />} nativeButton={false}>{EPHEMERIS_COPY.loginAction}</Button>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<EphemerisView
|
||||
date={activeDate}
|
||||
today={today}
|
||||
payload={payload}
|
||||
onPrevious={() => 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 (
|
||||
<main className="ephemeris-shell">
|
||||
<header className="ephemeris-topbar">
|
||||
<Link className="ephemeris-back" href="/">{EPHEMERIS_COPY.back}</Link>
|
||||
</header>
|
||||
|
||||
<section className="ephemeris-hero">
|
||||
<h1>{EPHEMERIS_COPY.title}</h1>
|
||||
<p className="ephemeris-date-label">{props.date ? formatEphemerisDate(props.date) : ""}</p>
|
||||
<div className="ephemeris-date-bar" role="group" aria-label="切换日期">
|
||||
<Button type="button" variant="outline" className="ephemeris-date-button" onClick={props.onPrevious}>
|
||||
{EPHEMERIS_COPY.previousDay}
|
||||
</Button>
|
||||
<Button type="button" variant="outline" className="ephemeris-date-button" onClick={props.onToday} disabled={isToday}>
|
||||
{EPHEMERIS_COPY.today}
|
||||
</Button>
|
||||
<Button type="button" variant="outline" className="ephemeris-date-button" onClick={props.onNext}>
|
||||
{EPHEMERIS_COPY.nextDay}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="ephemeris-section" aria-labelledby="ephemeris-panchanga-heading">
|
||||
<h2 id="ephemeris-panchanga-heading">{EPHEMERIS_COPY.panchangaHeading}</h2>
|
||||
{factors.length === 5 ? (
|
||||
<dl className="ephemeris-panchanga">
|
||||
{factors.map((factor) => (
|
||||
<div className="ephemeris-panchanga-item" key={factor.key}>
|
||||
<dt>{factor.label}</dt>
|
||||
<dd>
|
||||
<strong>{factor.value}{factor.detail ? ` · ${factor.detail}` : ""}</strong>
|
||||
{qualityLabel(factor.quality) ? <small>{qualityLabel(factor.quality)}</small> : null}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
) : (
|
||||
<p className="ephemeris-static">{EPHEMERIS_COPY.panchangaUnavailable}</p>
|
||||
)}
|
||||
<p className="ephemeris-note">{EPHEMERIS_COPY.panchangaLahiriNote}</p>
|
||||
</section>
|
||||
|
||||
<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 ? (
|
||||
<div className="ephemeris-transits">
|
||||
<div className="ephemeris-transits-head" aria-hidden="true">
|
||||
<span>{EPHEMERIS_COPY.planet}</span>
|
||||
<span>{EPHEMERIS_COPY.sign}</span>
|
||||
<span>{EPHEMERIS_COPY.degree}</span>
|
||||
<span>{EPHEMERIS_COPY.natalHouse}</span>
|
||||
<span>{EPHEMERIS_COPY.motion}</span>
|
||||
</div>
|
||||
{bodies.map((body) => (
|
||||
<div className="ephemeris-transits-row" key={body.planet}>
|
||||
<span data-label={EPHEMERIS_COPY.planet}>{body.planetLabel}</span>
|
||||
<span data-label={EPHEMERIS_COPY.sign}>{body.signLabel}</span>
|
||||
<span data-label={EPHEMERIS_COPY.degree}>{formatDegree(body.degreeInSign)}</span>
|
||||
<span data-label={EPHEMERIS_COPY.natalHouse}>{formatHouse(body.natalHouse)}</span>
|
||||
<span data-label={EPHEMERIS_COPY.motion}>{body.retrograde ? EPHEMERIS_COPY.retrograde : EPHEMERIS_COPY.direct}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="ephemeris-static">{EPHEMERIS_COPY.transitsUnavailable}</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="ephemeris-section" aria-labelledby="ephemeris-events-heading">
|
||||
<h2 id="ephemeris-events-heading">{EPHEMERIS_COPY.eventsHeading}</h2>
|
||||
{eventsUnavailable ? (
|
||||
<p className="ephemeris-static">{EPHEMERIS_COPY.eventsUnavailable}</p>
|
||||
) : (
|
||||
<ol className="ephemeris-events">
|
||||
{events.map((event) => (
|
||||
<li key={`${event.date}-${event.kind}-${event.planet}-${event.fact}`}>
|
||||
<time dateTime={event.date}>{formatEphemerisDate(event.date)}</time>
|
||||
<span>{event.fact}</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="ephemeris-footer">
|
||||
<p>{EPHEMERIS_COPY.footerNote}</p>
|
||||
<Button type="button" className="ephemeris-ask" onClick={props.onAsk}>
|
||||
{EPHEMERIS_COPY.ask}
|
||||
</Button>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -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<typeof ephemerisQualitySchema>;
|
||||
|
||||
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<typeof ephemerisPanchangaFactorSchema>;
|
||||
|
||||
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<typeof ephemerisTransitBodySchema>;
|
||||
|
||||
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<typeof ephemerisEventItemSchema>;
|
||||
|
||||
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<typeof ephemerisEventsSchema>;
|
||||
|
||||
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<typeof ephemerisOkResponseSchema>;
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -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<string, string> = {
|
||||
Sunday: "星期日",
|
||||
Monday: "星期一",
|
||||
Tuesday: "星期二",
|
||||
Wednesday: "星期三",
|
||||
Thursday: "星期四",
|
||||
Friday: "星期五",
|
||||
Saturday: "星期六",
|
||||
};
|
||||
|
||||
const QUALITY_ZH: Record<EphemerisQuality, string> = {
|
||||
subha: "吉",
|
||||
mixed: "中",
|
||||
asubha: "凶",
|
||||
};
|
||||
|
||||
const FACTOR_LABEL: Record<EphemerisPanchangaFactor["key"], string> = {
|
||||
vara: "星期",
|
||||
tithi: "月相日",
|
||||
nakshatra: "月宿",
|
||||
yoga: "瑜伽",
|
||||
karana: "迦那",
|
||||
};
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: 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<string, unknown> | 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<string, unknown>): "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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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<string, unknown> | 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<string, unknown>;
|
||||
upstream: Array<{ url: string; body: Record<string, unknown> }>;
|
||||
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");
|
||||
});
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user