feat(web): add read-only /ephemeris page
Independent Staging Quality Gate / validate (push) Failing after 7m30s
Independent Staging Quality Gate / publish (push) Skipped

This commit is contained in:
jesse-ux
2026-09-15 16:40:56 +08:00
parent f7386e0717
commit d3a2c48ba5
20 changed files with 2325 additions and 2 deletions
+4
View File
@@ -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
View File
@@ -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/` 改 |
## 做了什么
任务 1BFF `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 pass401、无本命仍有五要素、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**。
+1 -1
View File
@@ -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.0Apache-2.0)归档 + API 镜像 Node runtime + 七政适配器(计都派别与宿度坐标系参数化、boundary 按实测重写)+ 三个只读端点 `/api/qizheng`、`/api/western`、`/api/ephemeris_events`。实证三条:四柱时柱按 UTC 算(BUG-700,本轮不修不调用)、`ketuMode` 写死未暴露(BUG-701)、boundary 把空神煞与未闭合庙旺说成已生成(BUG-702)。BUG 段 700703 | 已推 `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 段 704706 | 待验收 | `codex/chart-page-20260915` |
| `TASK-ephemeris-page-20260915.md` | `PROGRESS-ephemeris-page-20260915.md` | **前端单**:P1 星历页,今日五要素 + 当日行运(相对本命宫位)+ 未来九十天换座与停滞,底部「带这天去提问」出口。页面不得出现任何运势判断。含实证缺陷:panchanga 写死 Lahiri 与账户 Raman 分裂(BUG-707,只标注不修)。侧边栏入口由 chart-page 单交付。BUG 段 707709 | 待领取 | |
| `TASK-ephemeris-page-20260915.md` | `PROGRESS-ephemeris-page-20260915.md` | **前端单**:P1 星历页,今日五要素 + 当日行运(相对本命宫位)+ 未来九十天换座与停滞,底部「带这天去提问」出口。页面不得出现任何运势判断。含实证缺陷:panchanga 写死 Lahiri 与账户 Raman 分裂(关联 BUG-703;本单标注为 BUG-707)。侧边栏入口由 chart-page 单交付。BUG 段 707709 | 待验收 | `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+ | 待领取 | — |
## 命名与归档
+52
View File
@@ -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. **看:** 「请先登录」,可以去登录。不是转圈,也不是整页白屏。
## 结论
> 待填。测完写日期 + 设备 + 浏览器 + 逐条正常/有问题。
+13
View File
@@ -602,3 +602,16 @@ Admin 的 antd `<Spin>` 是独立设计系统,不在此表。
「传统象征」是词条式释义,带边界句「不是对你个人的判断」,不得写成运势。大运 Tab 底部固定:「只有两条同时指向同一段时间才算证据,单轨命中要降一级来看。」Chara 的正式名称是 **Chara Dashakn_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 / 骨架 / 「正在加载」。未登录是静态「请先登录」加去登录,不是转圈。
- **文案:** 这一页是天象本身。不得写运势结论,不得把三套坐标系换算或叠加。
+187
View File
@@ -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,
};
}
+13
View File
@@ -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 />;
}
+258
View File
@@ -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>
);
}
+74
View File
@@ -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;
}
+362
View File
@@ -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);
}
+1
View File
@@ -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;
+133
View File
@@ -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/);
});
+218
View File
@@ -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/);
});