fix(people,ephemeris): style /people and make the ephemeris readable (BUG-1039)
- /people had zero CSS rules for its people-archive classes. Add the approved layout: list -> detail below 1024px (data-view, ?person= history entry so Back returns to the list), two columns at 1024px and above; avatar rows with the current person highlighted, time cards with a rectified state, readable ayanamsa name, three quick actions; delete moves to the bottom of the edit view with unchanged confirm/count logic. - Ephemeris: Chinese-first panchanga names from the brief's table only (panchanga-names-zh.ts, three listed aliases, unknown names stay Sanskrit), coloured 吉/中/凶 tags, N吉/M凶 counts and a fixed-rule day summary (pure function, no model call); the header ask button becomes a bottom hint card; transit rows show glyph, 「行星 在 星座」, house line and degree; the Lahiri note names the subject's own ayanamsa. - API, engine and database unchanged; /people and /ephemeris stay Static. - DESIGN, VOICE, BUG_HISTORY, CHANGELOG, BLOCKED, PROGRESS and a manual checklist updated. Two existing assertions changed, with 原值/新值/原因. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
13c70a162a
commit
2981da6fbc
+22
-7
@@ -904,6 +904,18 @@ Three product rules for assistant markdown in `.message-markdown` (BUG-962 / 963
|
||||
|
||||
上述为实现约束;登录页面、窄屏和慢网交互仍按真人清单待验,不以组件探针代替。
|
||||
|
||||
### 星盘档案页布局(2026-09-26,BUG-1039)
|
||||
|
||||
`/people` 上一轮只有类名、没有一条样式。布局按原型定稿,断点只用星盘页已有的 1024:
|
||||
|
||||
- **小于 1024px:列表 → 详情。** 同一路由里用 `.people-archive[data-view]` 切换,只显示一栏。点人时压一条 `?person=<id>` 历史记录,浏览器返回和详情顶部的「‹ 返回」走同一条路回到列表;刷新详情地址直接打开那个人。桌面点人不压历史。
|
||||
- **1024px 及以上:两栏。** 左列表(18–22rem),右详情;「‹ 返回」隐藏。右栏正在看的那一行用中性浅底(`is-open`),强调色只给当前人物。
|
||||
- **列表行**(`.people-archive-row`,最小 64px):40px 圆形首字头像(本人写「我」)+ 名字(本人后跟灰底「本人」标签)+ 一行等宽 `tabular-nums` 的「出生日期 · 排盘时间 · 城市」+ 行尾「›」。资料不完整时这一行写「这份资料还不完整」。**当前人物**(`is-current`,与顶栏「当前星盘」同一个人)是 `--color-action-strong` 描边 + `--color-action-soft` 底。
|
||||
- **添加**:虚线卡片「+ 添加一个人」(48px,`--color-border-strong` 虚线,文字 `--color-action`),下面小字「还可以保存 N 个人」,N 仍是 `MAX_SAVED_SUBJECTS - 人数`;到顶时换成原来的限额句并禁用。
|
||||
- **详情卡**:头部 48px 头像 + 名字 +「本人 · 出生地」(他人只写出生地);两格时间卡「填报时间」「排盘时间」,等宽大号数字。只有采用或确认了**校正结果**(状态 accepted / confirmed **且**有校正案例)时,排盘卡换成 `--color-success` 描边 + `--color-success-muted` 底,标签写「排盘时间 · 已校正」——旧资料读回时的 `confirmed` 兜底不算。下面是出生日期与岁差(显示「Raman(默认)」这类设置里的名字,不露小写键),再下面三个等宽快捷入口「和 TA 对话 / 看星盘 / 生成报告」(图标用侧栏同款 lucide)。他人另有「和我合盘」与「编辑」;本人底部灰字「本人不能删除」。
|
||||
- **删除只在编辑视图底部**,单独一张卡,删除确认、用量计数、查询失败句全部沿用。
|
||||
- **头像颜色**:本人用 `--color-action-strong` + `--color-on-dark`(同账户首字圆);其他人按 id 稳定地取四个元素色 `--color-element-fire / earth / air / water` 之一,字用 `--color-ink`。不新造色值。
|
||||
|
||||
### 行星表
|
||||
|
||||
三张 `.chart-page-planet-table`(印度盘宿度、西洋相位、七政十一曜)**每一张**都包在 `.chart-page-planet-table-wrap` 里:`min-width: 0` + `overflow-x: auto`。375px 下表格在自己的盒子里横滚,页面不横滚。`min-width: 0` 和 overflow 一样是必需的——grid item 默认按内容定宽,少了它表格会把页面撑宽而不是滚动。印度盘那张从四列扩到八列后 `min-width` 由 `26rem` 提到 `34rem`。
|
||||
@@ -991,16 +1003,19 @@ Three product rules for assistant markdown in `.message-markdown` (BUG-962 / 963
|
||||
|
||||
## 16. 星历页
|
||||
|
||||
独立 route `/ephemeris`。不扣点、不调模型。页面挂在 `app/(secondary)` 共享外壳上(§ Secondary page shell):页名「星历」与「带这天去提问」在 46px 顶栏里,`.ephemeris-body` 是唯一滚动的东西。
|
||||
独立 route `/ephemeris`。不扣点、不调模型。页面挂在 `app/(secondary)` 共享外壳上(§ Secondary page shell):页名「星历」在 46px 顶栏里,顶栏不放按钮;`.ephemeris-body` 是唯一滚动的东西。
|
||||
|
||||
- **结构:** 日期条;今日五要素;当日行运;未来九十天。顺序固定。页尾不另写定性句。
|
||||
- **结构:** 日期条;今日五要素;当日行运;未来九十天;底部提问提示卡。顺序固定。页尾不另写定性句。
|
||||
- **日期条:** 居中的 `‹ 日期 ›`——日期是主语,两枚 44px 箭头按钮是它的把手。箭头字形 `aria-hidden`,可访问名仍是「前一天」「后一天」,读屏不丢方向。**「今天」只在非今日时出现**,作为日期下方一枚文字按钮;在今日时它整个不渲染,而不是渲染成一枚禁用按钮——灰掉的按钮仍要被读、仍要被 Tab 跳过。原本三个并排等宽按钮把「前一天 / 今天 / 后一天」读成三个平级动作,日期本身反而被挤出了这一组。
|
||||
- **三段同一套卡片:** 五要素 / 行运 / 未来九十天三个 `.ephemeris-section` 各是一张卡(1px 描边 + `--radius-lg` + `--color-canvas`),卡内是行。原本三段的**内容**各自画框而**段落**不画框,于是三段看起来是三种不同的东西。框归段落,段内只有行。
|
||||
- 切到未缓存的日期时,日期条和布局留下,五要素、行运立刻换成「这一天的五要素还没拿到。」「这一天的行运还没拿到。」事件区写「换座和停滞还在取。」,这时不用「还没接上」。没有 spinner、骨架或「正在加载」。请求失败时,三段都写「这一天的星历没取到,点日期再试一次。」再点这一天的日期,或在失败时点左右箭头,就重试这一天;日历仍可改去别的日期。旧日期的响应,包括进页时刷新的今天,不得盖住正在看的另一天。
|
||||
- **五要素:** `dl` 网格保留:桌面五格一行(星期、月相日、月宿含第几足、瑜伽、迦那),格子是段落卡内的浅底块,不再各自描边。767px 以下收成五行,标签左、值右。Yoga / Karana 的吉凶只照搬引擎标签(吉 / 中 / 凶),不写成建议。卡片下固定一行:「这一段按 Lahiri 岁差算,和星盘页用的岁差不是同一套。」行运段不加这句。
|
||||
- **当日行运:** 九曜的星座、度数、相对本命宫位、顺逆。桌面表格,767px 以下每行改成带标签的堆叠。没有本命上升时,相对本命列为空,并写「还没有本命上升,相对本命这一列空着。」
|
||||
- **五要素(2026-09-26 可读性改版):** `dl` 网格保留:桌面五格一行(星期、月相日、月宿含第几足、瑜伽、迦那),格子是段落卡内的浅底块。767px 以下收成五行:左列标签固定 5.5rem、不加字距,行距收紧到 `--space-2`。
|
||||
- **先中文、后梵文。** 主行中文,副行 12px 三级色写引擎原名(`lang="sa-Latn"`)。中文名只来自 `src/lib/panchanga-names-zh.ts`,该表逐字抄自任务书附表,不自行翻译、不增删;匹配忽略大小写、空格、连字符,别名只有 Garija/Gara、Dwitiya/Dvitiya、Shashthi/Shashti。表里查不到的名字原样显示梵文、不出副行,并在控制台记一次 `panchanga_name_zh_missing`。星期接口里本来就是中文,不出副行。接口字段不变:`factor.value` 仍是引擎名,中文在视图层投影。§15 行星表「没有中文宿名表就不造一个」仍只管星盘页;星历这张表是产品在任务书里给定的。
|
||||
- **吉 / 中 / 凶是行右侧的彩色小标签**(`.ephemeris-quality`):吉 `--color-success`、凶 `--color-danger`、中 `--color-warning`,底是同色 `color-mix(… 12%, transparent)`。只照搬引擎标签,不写成建议。
|
||||
- **卡片顶部当日总评 + 计数。** 一句 `{月相短语},{整体}{Vishti 追加}。`,按固定规则拼(`panchangaDaySummary`,纯函数,不调模型):Purnima「满月之日」、Amavasya「新月之日」、其余白分「月亮渐盈」、黑分「月亮渐亏」;吉 ≥ 4「整体吉」、3「整体偏吉」、2「吉凶参半」、≤ 1「整体偏凶」,中不计入;迦那为 Vishti 时追加「;迦那落在毗湿提,重要的事避开」。下面两枚计数标签「N 吉」「M 凶」。五要素或其中任一吉凶缺项时,总评和计数都不出,只显示已有行。
|
||||
- 卡片下固定一行岁差说明,按资料实际岁差填名:「五要素按 Lahiri 岁差算,和星盘页的 {Raman / KP / True Pushya} 不同。」资料就是 Lahiri 时写「和星盘页相同」,读不到资料岁差时只写前半句。行运段不加这句。
|
||||
- **当日行运(2026-09-26):** 每行是一个 `li`:左 1.75rem 彩色行星符号(复用 `--color-planet-*`,与星盘页行星表同色、同字体栈)+「{行星} 在 {星座}」+ 副行「落你的第 N 宫」(仅 `natalRelativeAvailable` 为真时)与逆行时的「逆行」+ 右侧等宽度数(度°分′,`tabular-nums`)。桌面与手机同一行式,不再有表头和 `data-label` 堆叠。没有本命上升时副行不写宫位,段首写「还没有本命上升,先不写落在你的第几宫。」
|
||||
- **未来九十天:** 按日期升序只写事实(「某星 进入 某座」「某星 停滞转顺 · 度数」)。`/api/ephemeris_events` 未上线时整段静态说明:「换座和停滞还没接上。这一页其余部分不受影响。」不得让整页失败。
|
||||
- **出口:** 主按钮「带这天去提问」在顶栏,把当前日期预填进对话草稿并回到 `/`。不写「这一页是天象本身,不是对你的判断。」
|
||||
- **出口(2026-09-26 挪位):** 「带这天去提问」不在顶栏了。页面最底部一张浅色提示卡 `.ephemeris-ask-card`(`--color-action-soft` 底、`--radius-lg`、宽度与段落卡一致):左侧「想问这一天适不适合做某件事?」,右侧主按钮「带这天去提问」(44px),行为不变——把当前日期预填进对话草稿并回到 `/`。窄屏时问句自己折行,按钮不变宽。不写「这一页是天象本身,不是对你的判断。」
|
||||
- **等待:** 揭幕后不得再出现 spinner / 骨架 / 「正在加载」。首屏未到时 `SecondaryPageShell` 写「这一天的星历还没拿到。」,居中在已撑满的内容区里。未登录是静态「请先登录」加去登录,不是转圈。同一会话再次进入直接用模块缓存。
|
||||
- **文案:** 不得写运势结论,不得把三套坐标系换算或叠加。不另印「这是天象本身」这类边界说明。
|
||||
|
||||
- **文案:** 不得写运势结论,不得把三套坐标系换算或叠加。当日总评只来自上面的固定规则。不另印「这是天象本身」这类边界说明。
|
||||
|
||||
+16
-1
@@ -30,6 +30,21 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、
|
||||
|
||||
没有出生资料时,标题是「还没有星盘资料」,按钮是「去填写出生资料」。资料读不出来、已采用时间缺时区、时区没有对上,正文仍用各自的原句,只共用这颗按钮。
|
||||
|
||||
## 星历五要素总评与底部提示卡(2026-09-26)
|
||||
|
||||
- 五要素先写中文,梵文小字在下;中文名只用 `panchanga-names-zh.ts` 那张表(任务书给定),不自己翻,查不到就照写梵文。
|
||||
- 当日总评只有一种句式:`{月相短语},{整体}{Vishti 追加}。` 月相短语是「满月之日 / 新月之日 / 月亮渐盈 / 月亮渐亏」;整体是「整体吉 / 整体偏吉 / 吉凶参半 / 整体偏凶」;迦那是 Vishti 时追加「;迦那落在毗湿提,重要的事避开」。例:「满月之日,整体偏吉;迦那落在毗湿提,重要的事避开。」规则拼句,不调模型,不加别的形容词;五要素不全就不写。
|
||||
- 计数标签写「3 吉」「2 凶」,数字与字之间一个空格。吉 / 中 / 凶标签只照搬引擎。
|
||||
- 岁差说明:「五要素按 Lahiri 岁差算,和星盘页的 Raman 不同。」Raman 换成资料里实际的岁差名(Raman / KP / True Pushya);资料就是 Lahiri 时写「五要素按 Lahiri 岁差算,和星盘页相同。」;读不到时只写「五要素按 Lahiri 岁差算。」
|
||||
- 行运行:「太阳 在 狮子座」,副行「落你的第 2 宫」,逆行时加「· 逆行」。没有本命上升时段首写「还没有本命上升,先不写落在你的第几宫。」(原句「相对本命这一列空着」里的「列」已经没有了)。
|
||||
- 底部提示卡:左边「想问这一天适不适合做某件事?」,右边按钮「带这天去提问」。这是邀请用户问,不是页面给宜忌结论;页面其余地方仍不写「适合 / 不宜」。
|
||||
|
||||
## 星盘档案页(2026-09-26)
|
||||
|
||||
- 列表行一行写「出生日期 · 排盘时间 · 城市」,本人名字后跟「本人」。添加卡片写「+ 添加一个人」,下面「还可以保存 N 个人」(不加句号);到顶仍是「最多保存 5 个人的星盘」。
|
||||
- 详情两格时间卡写「填报时间」「排盘时间」;有校正结果时排盘卡写「排盘时间 · 已校正」。岁差写设置里的名字,例如「Raman(默认)」,不写小写键。
|
||||
- 快捷入口只写「和 TA 对话 / 看星盘 / 生成报告」;他人另有「和我合盘」「编辑」。本人底部灰字「本人不能删除」(不加句号)。手机详情顶部是「‹ 返回」。
|
||||
|
||||
## 报告分块导出与删除
|
||||
|
||||
- 列表已完成行只留「查看报告」。阅读页操作条只留「返回报告中心」与导出图标;图标读屏名「导出报告」,tooltip「导出」。不再显示「下载原始附录」或「打印」入口文案;浏览器 Ctrl+P 排版仍保留。
|
||||
@@ -109,7 +124,7 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、
|
||||
| 没有可显示内容 / 资料还在,过一会儿再打开 | 读不到资料:「星盘资料这会儿读不出来,我们已经记录下来了。」已采用的时间缺时区:「已采用的出生时间缺了排盘要用的时区,这张盘先不能算。我们已经记录下来了。」时区对不上:「出生地的时区没有对上,这张盘先不能算。我们已经记录下来了。」结果格式不对:「星盘结果的格式对不上,我们已经记录下来了。」 | 失败要说出是哪一种。不写「过一会儿再打开」,也不用一句没头没尾的「没有可显示内容」。 |
|
||||
| 下面是词条式释义,不是对你个人的判断。 | (不写) | 基础信息 Tab 只放行星卡。词条本身不得写成运势,但不另印这句边界说明。 |
|
||||
| Agent 未完成必要的方法与计算步骤,本次不会扣点。(模型其实写过一段) | 把模型写过的那段留下,文末由服务端加一句:「这次没跑完星盘计算,上面是模型直接写的,先看着。」 | 合同没绿但写过字时不得整轮空白。这句不是模型写的。没写过字仍报未完成、不扣点。 |
|
||||
| 这一页是天象本身,不是对你的判断。 | (不写) | 星历页只放日期、五要素、行运、九十天事件。「带这天去提问」在顶栏。不另印定性句。 |
|
||||
| 这一页是天象本身,不是对你的判断。 | (不写) | 星历页只放日期、五要素、行运、九十天事件和底部提问提示卡。「带这天去提问」在页面最底部的提示卡里(2026-09-26 从顶栏挪下来)。不另印定性句。 |
|
||||
| (校正会话上普通输入框仍可打字) | 正在打开生时校正… | 校正会话上普通输入框只有禁用态。打开失败时按钮写「重新打开生时校正」。 |
|
||||
| 9月14日 · 生时校正 / 生时校正 · 9月14日 / 生时校正 · 09:35(创建钟点) | 生时校正 · 05:07 / 生时校正 · 05:00–05:15 / 生时校正 | 校正标题写结果:已确认优先于已采用,写 `生时校正 · HH:MM`;否则当前范围写 `生时校正 · HH:MM–HH:MM`(间隔号,不是连字符);都没有则只写「生时校正」。今日节奏仍是 `今日节奏 · M月D日`。同日不再用墙钟去重。副标题仍是最后活动时间。旧标题按结果批量改;手改过的名字不动。 |
|
||||
| 这么晚还醒着,{名字}。直接说说最在意的问题吧。 | 还没睡呀,{名字} | 开场只一行、不追问、不写场景;提问交给输入框占位符 |
|
||||
|
||||
+408
-45
@@ -2179,6 +2179,7 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
|
||||
.chart-page-body,
|
||||
.ephemeris-body,
|
||||
.report-center-body,
|
||||
.people-archive,
|
||||
.chart-page-message,
|
||||
.ephemeris-message,
|
||||
.report-center-message {
|
||||
@@ -2221,6 +2222,7 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
|
||||
.chart-page-body,
|
||||
.ephemeris-body,
|
||||
.report-center-body,
|
||||
.people-archive,
|
||||
.chart-page-message,
|
||||
.ephemeris-message,
|
||||
.report-center-message,
|
||||
@@ -5031,14 +5033,22 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
|
||||
font-weight: 600;
|
||||
}
|
||||
.chart-page-planet-abbr { color: var(--color-ink-tertiary); }
|
||||
.chart-page-planet-glyph.is-sun { color: var(--color-planet-sun); }
|
||||
.chart-page-planet-glyph.is-moon { color: var(--color-planet-moon); }
|
||||
.chart-page-planet-glyph.is-mars { color: var(--color-planet-mars); }
|
||||
.chart-page-planet-glyph.is-mercury { color: var(--color-planet-mercury); }
|
||||
.chart-page-planet-glyph.is-jupiter { color: var(--color-planet-jupiter); }
|
||||
.chart-page-planet-glyph.is-venus { color: var(--color-planet-venus); }
|
||||
.chart-page-planet-glyph.is-saturn { color: var(--color-planet-saturn); }
|
||||
.chart-page-planet-glyph.is-node { color: var(--color-planet-node); }
|
||||
.chart-page-planet-glyph.is-sun,
|
||||
.ephemeris-transit-glyph.is-sun { color: var(--color-planet-sun); }
|
||||
.chart-page-planet-glyph.is-moon,
|
||||
.ephemeris-transit-glyph.is-moon { color: var(--color-planet-moon); }
|
||||
.chart-page-planet-glyph.is-mars,
|
||||
.ephemeris-transit-glyph.is-mars { color: var(--color-planet-mars); }
|
||||
.chart-page-planet-glyph.is-mercury,
|
||||
.ephemeris-transit-glyph.is-mercury { color: var(--color-planet-mercury); }
|
||||
.chart-page-planet-glyph.is-jupiter,
|
||||
.ephemeris-transit-glyph.is-jupiter { color: var(--color-planet-jupiter); }
|
||||
.chart-page-planet-glyph.is-venus,
|
||||
.ephemeris-transit-glyph.is-venus { color: var(--color-planet-venus); }
|
||||
.chart-page-planet-glyph.is-saturn,
|
||||
.ephemeris-transit-glyph.is-saturn { color: var(--color-planet-saturn); }
|
||||
.chart-page-planet-glyph.is-node,
|
||||
.ephemeris-transit-glyph.is-node { color: var(--color-planet-node); }
|
||||
.chart-page-planet-glyph.is-ascendant { color: var(--color-planet-ascendant); font-weight: 700; }
|
||||
.chart-page-planet-table td.chart-page-planet-mid { text-align: center; }
|
||||
/* Figures line up column-wise instead of drifting with glyph width. */
|
||||
@@ -5427,6 +5437,7 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
|
||||
}
|
||||
.ephemeris-panchanga-item {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: var(--space-2);
|
||||
margin: 0;
|
||||
padding: var(--space-3);
|
||||
@@ -5434,18 +5445,27 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-canvas-muted);
|
||||
}
|
||||
/* D6: a fixed-width label without the old 1.5px overline tracking. */
|
||||
.ephemeris-panchanga-item dt {
|
||||
margin: 0;
|
||||
color: var(--color-ink-tertiary);
|
||||
font-size: var(--type-overline);
|
||||
font-size: var(--type-caption);
|
||||
font-weight: 500;
|
||||
letter-spacing: 1.5px;
|
||||
}
|
||||
/* Name on the left, the 吉 / 中 / 凶 tag on the right. The tag sits inside the
|
||||
`dd` because a `dl` row may only hold `dt` / `dd`. */
|
||||
.ephemeris-panchanga-item dd {
|
||||
display: grid;
|
||||
gap: var(--space-1);
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
margin: 0;
|
||||
}
|
||||
.ephemeris-panchanga-name {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
.ephemeris-panchanga-item strong {
|
||||
color: var(--color-ink);
|
||||
font-size: var(--type-body-sm);
|
||||
@@ -5453,32 +5473,75 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
|
||||
}
|
||||
.ephemeris-panchanga-item small {
|
||||
color: var(--color-ink-tertiary);
|
||||
font-size: var(--type-caption);
|
||||
font-size: var(--type-overline);
|
||||
line-height: 1.4;
|
||||
}
|
||||
/* D6 / D7: coloured tags on a 12% tint of their own hue. Tokens only; each
|
||||
semantic hue already has a dark value. */
|
||||
.ephemeris-quality {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px var(--space-2);
|
||||
border-radius: 999px;
|
||||
font-size: var(--type-overline);
|
||||
font-weight: 500;
|
||||
line-height: 1.4;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ephemeris-quality.is-subha { color: var(--color-success); background: color-mix(in srgb, var(--color-success) 12%, transparent); }
|
||||
.ephemeris-quality.is-asubha { color: var(--color-danger); background: color-mix(in srgb, var(--color-danger) 12%, transparent); }
|
||||
.ephemeris-quality.is-mixed { color: var(--color-warning); background: color-mix(in srgb, var(--color-warning) 12%, transparent); }
|
||||
.ephemeris-summary {
|
||||
margin: 0;
|
||||
color: var(--color-ink-strong);
|
||||
font-size: var(--type-body-sm);
|
||||
line-height: 1.55;
|
||||
}
|
||||
.ephemeris-counts {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
margin: 0;
|
||||
}
|
||||
/* D9: glyph · 「行星 在 星座」 over 「落你的第 N 宫」 · degree on the right. */
|
||||
.ephemeris-transits {
|
||||
overflow: hidden;
|
||||
border-radius: var(--radius-sm);
|
||||
display: grid;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
.ephemeris-transits-head,
|
||||
.ephemeris-transits-row {
|
||||
display: grid;
|
||||
grid-template-columns: 4.5rem minmax(0, 1fr) 5rem 6.5rem 3rem;
|
||||
grid-template-columns: 1.75rem minmax(0, 1fr) auto;
|
||||
gap: var(--space-3);
|
||||
align-items: center;
|
||||
padding: var(--space-3) 0;
|
||||
min-height: 44px;
|
||||
}
|
||||
.ephemeris-transits-head {
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
color: var(--color-ink-tertiary);
|
||||
font-size: var(--type-caption);
|
||||
font-weight: 500;
|
||||
}
|
||||
.ephemeris-transits-row {
|
||||
padding: var(--space-2) 0;
|
||||
border-top: 1px solid var(--color-border);
|
||||
font-size: var(--type-body-sm);
|
||||
}
|
||||
.ephemeris-transits-row span { min-width: 0; }
|
||||
.ephemeris-transits-row:first-child { border-top: 0; }
|
||||
.ephemeris-transit-glyph {
|
||||
font-family: var(--font-planet-glyphs), "Apple Symbols", "Segoe UI Symbol", "Noto Sans Symbols", serif;
|
||||
font-size: var(--type-title-sm);
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
}
|
||||
.ephemeris-transit-main {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
color: var(--color-ink);
|
||||
}
|
||||
.ephemeris-transit-main small {
|
||||
color: var(--color-ink-tertiary);
|
||||
font-size: var(--type-overline);
|
||||
}
|
||||
.ephemeris-transit-degree {
|
||||
color: var(--color-ink-secondary);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--type-caption);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.ephemeris-events {
|
||||
display: grid;
|
||||
gap: 0;
|
||||
@@ -5503,7 +5566,26 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
|
||||
color: var(--color-ink-tertiary);
|
||||
font-size: var(--type-caption);
|
||||
}
|
||||
.ephemeris-ask { min-height: 44px; width: fit-content; }
|
||||
/* D8: the page action moved out of the header into a quiet bottom card. */
|
||||
.ephemeris-ask-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
width: min(900px, 100%);
|
||||
margin: 0 auto var(--space-6);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-action-soft);
|
||||
}
|
||||
.ephemeris-ask-card p {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
color: var(--color-ink);
|
||||
font-size: var(--type-body-sm);
|
||||
}
|
||||
.ephemeris-ask { flex: none; min-height: 44px; width: fit-content; }
|
||||
.ephemeris-message {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -5534,31 +5616,312 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
|
||||
.ephemeris-panchanga-item {
|
||||
grid-template-columns: 5.5rem minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-3) 0;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-2) 0;
|
||||
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-3) 0;
|
||||
}
|
||||
.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-section { padding: var(--space-4); }
|
||||
.ephemeris-ask { width: 100%; }
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
People archive (/people) — TASK-people-ephemeris-ui-20260926 D1–D4.
|
||||
Phone (< 1024px): list, then one person's detail in the same route
|
||||
(`data-view`, plus a history entry so Back returns to the list).
|
||||
Desktop (≥ 1024px, the chart page's cut): list left, detail right.
|
||||
============================================================ */
|
||||
.people-archive {
|
||||
display: grid;
|
||||
gap: var(--space-6);
|
||||
align-items: start;
|
||||
width: min(1040px, 100%);
|
||||
margin-inline: auto;
|
||||
padding-top: var(--space-5);
|
||||
}
|
||||
.people-archive-list,
|
||||
.people-archive-detail {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
align-content: start;
|
||||
min-width: 0;
|
||||
}
|
||||
.people-archive-rows {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
.people-archive-row {
|
||||
display: grid;
|
||||
grid-template-columns: 40px minmax(0, 1fr) auto;
|
||||
grid-template-rows: auto auto;
|
||||
column-gap: var(--space-3);
|
||||
row-gap: 2px;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
min-height: 64px;
|
||||
padding: var(--space-3);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-canvas);
|
||||
color: var(--color-ink);
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.people-archive-row:hover { background: var(--color-canvas-muted); }
|
||||
/* D2: the person the rest of the app is currently reading. */
|
||||
.people-archive-row.is-current,
|
||||
.people-archive-row.is-current:hover {
|
||||
border-color: var(--color-action-strong);
|
||||
background: var(--color-action-soft);
|
||||
}
|
||||
.people-archive-row > .people-archive-avatar { grid-row: 1 / span 2; }
|
||||
.people-archive-row-name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
min-width: 0;
|
||||
}
|
||||
.people-archive-row-name b {
|
||||
overflow: hidden;
|
||||
font-size: var(--type-title-sm);
|
||||
font-weight: 500;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.people-archive-tag {
|
||||
flex: none;
|
||||
padding: 1px var(--space-2);
|
||||
border-radius: 999px;
|
||||
background: var(--color-canvas-muted);
|
||||
color: var(--color-ink-secondary);
|
||||
font-size: var(--type-overline);
|
||||
font-weight: 500;
|
||||
}
|
||||
.people-archive-row.is-current .people-archive-tag { background: var(--color-canvas); }
|
||||
.people-archive-row-meta {
|
||||
grid-column: 2;
|
||||
overflow: hidden;
|
||||
color: var(--color-ink-tertiary);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--type-caption);
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.people-archive-chevron {
|
||||
grid-column: 3;
|
||||
grid-row: 1 / span 2;
|
||||
color: var(--color-ink-tertiary);
|
||||
font-size: var(--type-title-md);
|
||||
line-height: 1;
|
||||
}
|
||||
/* D4: 本人 takes the fill-rank accent like the account initial; everyone else
|
||||
an element fill picked from their id. Ink on the element fills, as on the
|
||||
western wheel. */
|
||||
.people-archive-avatar {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex: none;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
color: var(--color-ink);
|
||||
font-size: var(--type-body-sm);
|
||||
font-weight: 500;
|
||||
}
|
||||
.people-archive-avatar.is-large {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
font-size: var(--type-title-md);
|
||||
}
|
||||
.people-archive-avatar.is-self { background: var(--color-action-strong); color: var(--color-on-dark); }
|
||||
.people-archive-avatar.is-fire { background: var(--color-element-fire); }
|
||||
.people-archive-avatar.is-earth { background: var(--color-element-earth); }
|
||||
.people-archive-avatar.is-air { background: var(--color-element-air); }
|
||||
.people-archive-avatar.is-water { background: var(--color-element-water); }
|
||||
.people-archive-add {
|
||||
width: 100%;
|
||||
min-height: 48px;
|
||||
margin-top: var(--space-1);
|
||||
border: 1px dashed var(--color-border-strong);
|
||||
border-radius: var(--radius-lg);
|
||||
background: transparent;
|
||||
color: var(--color-action);
|
||||
font: inherit;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
.people-archive-add:hover:not(:disabled) { background: var(--color-action-soft); }
|
||||
.people-archive-add:disabled { color: var(--color-ink-tertiary); cursor: not-allowed; }
|
||||
.people-archive-hint {
|
||||
margin: 0;
|
||||
color: var(--color-ink-tertiary);
|
||||
font-size: var(--type-caption);
|
||||
text-align: center;
|
||||
}
|
||||
.people-archive-back {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
justify-self: start;
|
||||
min-height: 44px;
|
||||
margin-left: calc(-1 * var(--space-2));
|
||||
padding: 0 var(--space-2);
|
||||
border: 0;
|
||||
border-radius: var(--radius-md);
|
||||
background: transparent;
|
||||
color: var(--color-ink-secondary);
|
||||
font: inherit;
|
||||
font-size: var(--type-body-sm);
|
||||
cursor: pointer;
|
||||
}
|
||||
.people-archive-back span { font-size: var(--type-title-md); line-height: 1; }
|
||||
.people-archive-card,
|
||||
.people-archive-danger,
|
||||
.people-archive-detail > .chart-library-form {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
margin: 0;
|
||||
padding: var(--space-4);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-canvas);
|
||||
}
|
||||
.people-archive-danger { gap: var(--space-3); }
|
||||
.people-archive-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
min-width: 0;
|
||||
}
|
||||
.people-archive-head > div { min-width: 0; }
|
||||
.people-archive-head h2 {
|
||||
margin: 0;
|
||||
color: var(--color-ink);
|
||||
font-size: var(--type-title-md);
|
||||
font-weight: 500;
|
||||
}
|
||||
.people-archive-head p,
|
||||
.people-archive-incomplete,
|
||||
.people-archive-foot p {
|
||||
margin: 0;
|
||||
color: var(--color-ink-tertiary);
|
||||
font-size: var(--type-caption);
|
||||
}
|
||||
.people-archive-incomplete { color: var(--color-ink-secondary); font-size: var(--type-body-sm); }
|
||||
.people-archive-times {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.people-archive-time {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
padding: var(--space-3);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
.people-archive-time small {
|
||||
color: var(--color-ink-tertiary);
|
||||
font-size: var(--type-overline);
|
||||
font-weight: 500;
|
||||
}
|
||||
.people-archive-time b {
|
||||
color: var(--color-ink);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--type-title-md);
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: 500;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
/* D3: a rectified chart time reads as settled, in the success family. */
|
||||
.people-archive-time.is-rectified {
|
||||
border-color: var(--color-success);
|
||||
background: var(--color-success-muted);
|
||||
}
|
||||
.people-archive-time.is-rectified small { color: var(--color-success); }
|
||||
.people-archive-facts {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
gap: var(--space-2) var(--space-4);
|
||||
margin: 0;
|
||||
font-size: var(--type-body-sm);
|
||||
}
|
||||
.people-archive-facts dt { color: var(--color-ink-tertiary); }
|
||||
.people-archive-facts dd {
|
||||
margin: 0;
|
||||
color: var(--color-ink);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.people-archive-actions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.people-archive-actions button {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-1);
|
||||
min-width: 0;
|
||||
min-height: 60px;
|
||||
padding: var(--space-2) var(--space-1);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-canvas-soft);
|
||||
color: var(--color-ink);
|
||||
font: inherit;
|
||||
font-size: var(--type-caption);
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
.people-archive-actions button:hover { background: var(--color-canvas-muted); }
|
||||
.people-archive-actions svg { color: var(--color-action); }
|
||||
.people-archive-secondary {
|
||||
min-height: 44px;
|
||||
padding: 0 var(--space-4);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: transparent;
|
||||
color: var(--color-ink-strong);
|
||||
font: inherit;
|
||||
font-size: var(--type-body-sm);
|
||||
cursor: pointer;
|
||||
}
|
||||
.people-archive-secondary:hover { background: var(--color-canvas-muted); }
|
||||
.people-archive-foot { display: grid; text-align: center; }
|
||||
.people-archive-confirm {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.people-archive-confirm p {
|
||||
margin: 0;
|
||||
color: var(--color-ink-strong);
|
||||
font-size: var(--type-body-sm);
|
||||
}
|
||||
|
||||
@media (max-width: 1023px) {
|
||||
.people-archive { width: min(640px, 100%); }
|
||||
.people-archive[data-view="list"] .people-archive-detail,
|
||||
.people-archive[data-view="detail"] .people-archive-list { display: none; }
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.people-archive { grid-template-columns: minmax(18rem, 22rem) minmax(0, 1fr); }
|
||||
.people-archive-back { display: none; }
|
||||
/* The open person in the right column; the accent stays with 当前人物. */
|
||||
.people-archive-row.is-open:not(.is-current) {
|
||||
border-color: var(--color-border-strong);
|
||||
background: var(--color-canvas-muted);
|
||||
}
|
||||
}
|
||||
|
||||
/* Dynamic viewport units, applied only where the engine understands them.
|
||||
|
||||
@@ -22,14 +22,20 @@ import {
|
||||
import {
|
||||
EPHEMERIS_COPY,
|
||||
ephemerisAskDraft,
|
||||
formatDegree,
|
||||
ephemerisAyanamsaNote,
|
||||
formatEphemerisDate,
|
||||
formatHouse,
|
||||
panchangaComplete,
|
||||
panchangaDaySummary,
|
||||
panchangaDisplayRows,
|
||||
panchangaQualityCounts,
|
||||
qualityLabel,
|
||||
shiftEphemerisDate,
|
||||
parseEphemerisDate,
|
||||
toEphemerisDate,
|
||||
transitHouseLine,
|
||||
} from "@/lib/ephemeris-view";
|
||||
import { formatDegreeMinutes } from "@/components/chart-page/chart-format";
|
||||
import { PLANET_COLOR_CLASS, PLANET_GLYPH, planetGlyphKey } from "@/lib/planet-glyphs";
|
||||
|
||||
type PageState =
|
||||
| { phase: "ready"; payload: EphemerisOkResponse }
|
||||
@@ -238,19 +244,20 @@ export function EphemerisView(props: {
|
||||
const natalMissing = !failed && !pending && props.payload?.transits.natalRelativeNote === "missing_natal";
|
||||
const eventsUnavailable = !pending && !failed && (!props.payload || props.payload.events.status === "unavailable");
|
||||
const events = !pending && !failed && props.payload?.events.status === "ok" ? props.payload.events.items : [];
|
||||
const natalRelativeAvailable = !failed && !pending && props.payload?.transits.natalRelativeAvailable === true;
|
||||
const rows = panchangaDisplayRows(factors);
|
||||
const complete = panchangaComplete(factors);
|
||||
const summary = panchangaDaySummary(factors);
|
||||
const counts = panchangaQualityCounts(factors);
|
||||
const ayanamsaNote = ephemerisAyanamsaNote(props.payload?.transits.ayanamsa);
|
||||
const isToday = Boolean(props.date) && props.date === props.today;
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
const selectedDay = props.date ? parseEphemerisDate(props.date) : undefined;
|
||||
|
||||
// D8: the page action no longer sits in the header. It is a quiet hint card
|
||||
// at the very bottom of the body (below).
|
||||
return (
|
||||
<SecondaryPageShell
|
||||
title={EPHEMERIS_COPY.title}
|
||||
actions={(
|
||||
<Button type="button" className="ephemeris-ask" onClick={props.onAsk}>
|
||||
{EPHEMERIS_COPY.ask}
|
||||
</Button>
|
||||
)}
|
||||
>
|
||||
<SecondaryPageShell title={EPHEMERIS_COPY.title}>
|
||||
<div className="ephemeris-body">
|
||||
<section className="ephemeris-hero">
|
||||
{/* `‹ 日期 ›`: the date is the subject, the two arrows are its handles.
|
||||
@@ -332,22 +339,37 @@ export function EphemerisView(props: {
|
||||
<h2 id="ephemeris-panchanga-heading">{EPHEMERIS_COPY.panchangaHeading}</h2>
|
||||
{failed ? (
|
||||
<p className="ephemeris-static">{EPHEMERIS_COPY.dateFailed}</p>
|
||||
) : factors.length === 5 ? (
|
||||
<dl className="ephemeris-panchanga">
|
||||
{factors.map((factor) => (
|
||||
<div className="ephemeris-panchanga-item" key={factor.key}>
|
||||
<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>
|
||||
) : rows.length > 0 ? (
|
||||
<>
|
||||
{/* D7: fixed-rule sentence + counts; only when all five limbs came back. */}
|
||||
{summary ? <p className="ephemeris-summary">{summary}</p> : null}
|
||||
{complete ? (
|
||||
<p className="ephemeris-counts">
|
||||
<span className="ephemeris-quality is-subha">{counts.good} 吉</span>
|
||||
<span className="ephemeris-quality is-asubha">{counts.bad} 凶</span>
|
||||
</p>
|
||||
) : null}
|
||||
<dl className="ephemeris-panchanga">
|
||||
{rows.map((row) => (
|
||||
<div className="ephemeris-panchanga-item" key={row.key}>
|
||||
<dt>{row.label}</dt>
|
||||
<dd>
|
||||
<span className="ephemeris-panchanga-name">
|
||||
<strong>{row.primary}</strong>
|
||||
{row.secondary ? <small lang="sa-Latn">{row.secondary}</small> : null}
|
||||
</span>
|
||||
{row.quality ? (
|
||||
<span className={`ephemeris-quality is-${row.quality}`}>{qualityLabel(row.quality)}</span>
|
||||
) : null}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</>
|
||||
) : (
|
||||
<p className="ephemeris-static">{EPHEMERIS_COPY.panchangaUnavailable}</p>
|
||||
)}
|
||||
<p className="ephemeris-note">{EPHEMERIS_COPY.panchangaLahiriNote}</p>
|
||||
<p className="ephemeris-note">{ayanamsaNote}</p>
|
||||
</section>
|
||||
|
||||
<section className="ephemeris-section" aria-labelledby="ephemeris-transits-heading">
|
||||
@@ -356,24 +378,25 @@ export function EphemerisView(props: {
|
||||
{failed ? (
|
||||
<p className="ephemeris-static">{EPHEMERIS_COPY.dateFailed}</p>
|
||||
) : bodies.length > 0 ? (
|
||||
<div className="ephemeris-transits">
|
||||
<div className="ephemeris-transits-head" aria-hidden="true">
|
||||
<span>{EPHEMERIS_COPY.planet}</span>
|
||||
<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>
|
||||
<ul className="ephemeris-transits">
|
||||
{bodies.map((body) => {
|
||||
const glyph = planetGlyphKey(body.planet);
|
||||
const house = transitHouseLine(body.natalHouse, natalRelativeAvailable);
|
||||
const sub = [house, body.retrograde ? EPHEMERIS_COPY.retrograde : null].filter(Boolean).join(" · ");
|
||||
return (
|
||||
<li className="ephemeris-transits-row" key={body.planet}>
|
||||
<span className={glyph ? `ephemeris-transit-glyph ${PLANET_COLOR_CLASS[glyph]}` : "ephemeris-transit-glyph"} aria-hidden="true">
|
||||
{glyph ? PLANET_GLYPH[glyph] : ""}
|
||||
</span>
|
||||
<span className="ephemeris-transit-main">
|
||||
<span>{body.planetLabel} 在 {body.signLabel}</span>
|
||||
{sub ? <small>{sub}</small> : null}
|
||||
</span>
|
||||
<span className="ephemeris-transit-degree">{formatDegreeMinutes(body.degreeInSign)}</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="ephemeris-static">{EPHEMERIS_COPY.transitsUnavailable}</p>
|
||||
)}
|
||||
@@ -398,6 +421,13 @@ export function EphemerisView(props: {
|
||||
</ol>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<aside className="ephemeris-ask-card" aria-label={EPHEMERIS_COPY.ask}>
|
||||
<p>{EPHEMERIS_COPY.askPrompt}</p>
|
||||
<Button type="button" className="ephemeris-ask" onClick={props.onAsk}>
|
||||
{EPHEMERIS_COPY.ask}
|
||||
</Button>
|
||||
</aside>
|
||||
</div>
|
||||
</SecondaryPageShell>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, type FormEvent } from "react";
|
||||
import { useEffect, useRef, useState, type Dispatch, type FormEvent, type SetStateAction } from "react";
|
||||
import { FileText, MessageSquareText, Orbit } from "lucide-react";
|
||||
|
||||
import { effectiveBirthDate } from "@/lib/effective-birth-date";
|
||||
import { ChartProfileForm } from "@/components/chart-profile-form";
|
||||
@@ -14,34 +15,69 @@ import {
|
||||
readProfile,
|
||||
} from "@/lib/home-profile";
|
||||
import { emptyProfile, type Profile } from "@/lib/home-types";
|
||||
import {
|
||||
peopleAvatarInitial,
|
||||
peopleAvatarTone,
|
||||
peopleAyanamsaLabel,
|
||||
peopleDisplayName,
|
||||
peopleRowMeta,
|
||||
peopleTimeRectified,
|
||||
} from "@/lib/people-archive-view";
|
||||
import { MAX_OTHER_SUBJECTS, MAX_SAVED_SUBJECTS } from "@/lib/subject-birth";
|
||||
|
||||
type PersonRow = {
|
||||
export type PersonRow = {
|
||||
readonly id: string;
|
||||
readonly role: "self" | "other";
|
||||
readonly profile: Profile;
|
||||
readonly incomplete: boolean;
|
||||
};
|
||||
|
||||
type Usage = { id: string; sessions: number; reports: number };
|
||||
export type Usage = { id: string; sessions: number; reports: number };
|
||||
const DELETE_USAGE_UNAVAILABLE = "暂时查不到这个人的对话数量,稍后再试";
|
||||
const DELETE_FAILED = "删除失败,请重试";
|
||||
/** `?person=new` marks the add form; a reload or Forward onto it shows the list. */
|
||||
const NEW_PERSON_PARAM = "new";
|
||||
|
||||
const LIMIT_COPY = "最多保存 5 个人的星盘";
|
||||
|
||||
/** D1: below this width the page is list → detail; at and above it, two columns. */
|
||||
const PEOPLE_TWO_COLUMN_QUERY = "(min-width: 1024px)";
|
||||
/** The detail view on a phone is a history entry, so the browser Back returns to the list. */
|
||||
const PEOPLE_DETAIL_PARAM = "person";
|
||||
|
||||
function incompleteProfile(profile: Profile): boolean {
|
||||
return missingOtherProfileStep(profile) !== null;
|
||||
}
|
||||
|
||||
function twoColumnLayout(): boolean {
|
||||
return typeof window.matchMedia === "function" && window.matchMedia(PEOPLE_TWO_COLUMN_QUERY).matches;
|
||||
}
|
||||
|
||||
function detailParam(): string | null {
|
||||
return new URLSearchParams(window.location.search).get(PEOPLE_DETAIL_PARAM);
|
||||
}
|
||||
|
||||
/** Rewrites the current entry's `?person=` (null removes it); no new history entry. */
|
||||
function replaceDetailParam(id: string | null) {
|
||||
if (detailParam() === null) return;
|
||||
const url = new URL(window.location.href);
|
||||
if (id === null) url.searchParams.delete(PEOPLE_DETAIL_PARAM);
|
||||
else url.searchParams.set(PEOPLE_DETAIL_PARAM, id);
|
||||
window.history.replaceState(null, "", `${url.pathname}${url.search}${url.hash}`);
|
||||
}
|
||||
|
||||
export function PeoplePage() {
|
||||
const subjectId = useCurrentSubject();
|
||||
const [people, setPeople] = useState<PersonRow[]>([]);
|
||||
const [status, setStatus] = useState<"loading" | "ready" | "failed">("loading");
|
||||
const [selectedId, setSelectedId] = useState<string>("self");
|
||||
const [view, setView] = useState<"list" | "detail">("list");
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState<Profile>(emptyProfile);
|
||||
const [notice, setNotice] = useState("");
|
||||
const [pendingDelete, setPendingDelete] = useState<Usage | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const pushedDetail = useRef(false);
|
||||
|
||||
async function load() {
|
||||
setStatus("loading");
|
||||
@@ -71,22 +107,81 @@ export function PeoplePage() {
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => {
|
||||
// A reload on a phone detail URL opens that person again.
|
||||
const opened = detailParam();
|
||||
if (opened && opened !== NEW_PERSON_PARAM) {
|
||||
setSelectedId(opened);
|
||||
setView("detail");
|
||||
}
|
||||
void load();
|
||||
}, 0);
|
||||
return () => window.clearTimeout(timer);
|
||||
// Browser Back / Forward between the list and a detail entry.
|
||||
const onPopState = () => {
|
||||
const opened = detailParam();
|
||||
pushedDetail.current = false;
|
||||
setEditing(false);
|
||||
setPendingDelete(null);
|
||||
if (opened && opened !== NEW_PERSON_PARAM) {
|
||||
setSelectedId(opened);
|
||||
setView("detail");
|
||||
} else {
|
||||
setView("list");
|
||||
}
|
||||
};
|
||||
window.addEventListener("popstate", onPopState);
|
||||
return () => {
|
||||
window.clearTimeout(timer);
|
||||
window.removeEventListener("popstate", onPopState);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const selected = people.find((person) => person.id === selectedId) ?? people[0] ?? null;
|
||||
const otherCount = people.filter((person) => person.role === "other").length;
|
||||
const atLimit = otherCount >= MAX_OTHER_SUBJECTS;
|
||||
|
||||
function showDetail(id: string) {
|
||||
setSelectedId(id);
|
||||
setView("detail");
|
||||
if (twoColumnLayout() || pushedDetail.current) return;
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set(PEOPLE_DETAIL_PARAM, id || NEW_PERSON_PARAM);
|
||||
window.history.pushState(null, "", `${url.pathname}${url.search}${url.hash}`);
|
||||
pushedDetail.current = true;
|
||||
}
|
||||
|
||||
function showList() {
|
||||
setEditing(false);
|
||||
setPendingDelete(null);
|
||||
if (pushedDetail.current) {
|
||||
// popstate brings the list back, so Back and 「‹ 返回」 are one path.
|
||||
window.history.back();
|
||||
return;
|
||||
}
|
||||
setView("list");
|
||||
replaceDetailParam(null);
|
||||
}
|
||||
|
||||
function selectPerson(id: string) {
|
||||
setEditing(false);
|
||||
setPendingDelete(null);
|
||||
setNotice("");
|
||||
showDetail(id);
|
||||
}
|
||||
|
||||
function startAdd() {
|
||||
if (atLimit) return;
|
||||
setDraft(emptyProfile);
|
||||
setNotice("");
|
||||
setEditing(true);
|
||||
setSelectedId("");
|
||||
setPendingDelete(null);
|
||||
showDetail("");
|
||||
}
|
||||
|
||||
function startEdit(profile: Profile) {
|
||||
setDraft(profile);
|
||||
setNotice("");
|
||||
setPendingDelete(null);
|
||||
setEditing(true);
|
||||
}
|
||||
|
||||
async function saveOther(event: FormEvent<HTMLFormElement>) {
|
||||
@@ -110,7 +205,10 @@ export function PeoplePage() {
|
||||
setNotice("");
|
||||
invalidateSubjectCatalog();
|
||||
await load();
|
||||
if (payload?.profile?.id) setSelectedId(payload.profile.id);
|
||||
if (payload?.profile?.id) {
|
||||
setSelectedId(payload.profile.id);
|
||||
replaceDetailParam(payload.profile.id);
|
||||
}
|
||||
} catch {
|
||||
setNotice("保存失败,请重试");
|
||||
} finally {
|
||||
@@ -142,7 +240,8 @@ export function PeoplePage() {
|
||||
return;
|
||||
}
|
||||
if (subjectId === selected.id) setCurrentSubject("self");
|
||||
setPendingDelete(null);
|
||||
// Back to the list; on a phone that also pops the detail history entry.
|
||||
showList();
|
||||
setSelectedId("self");
|
||||
invalidateSubjectCatalog();
|
||||
await load();
|
||||
@@ -163,84 +262,216 @@ export function PeoplePage() {
|
||||
</section>
|
||||
) : null}
|
||||
{status === "ready" ? (
|
||||
<div className="people-archive">
|
||||
<section className="people-archive-list" aria-label="星盘档案">
|
||||
{people.map((person) => (
|
||||
<button
|
||||
key={person.id}
|
||||
type="button"
|
||||
className={person.id === selected?.id && !editing ? "people-archive-row is-current" : "people-archive-row"}
|
||||
onClick={() => {
|
||||
setEditing(false);
|
||||
setPendingDelete(null);
|
||||
setSelectedId(person.id);
|
||||
}}
|
||||
>
|
||||
<b>{person.profile.name.trim() || (person.role === "self" ? "我" : "未命名资料")}</b>
|
||||
<small>{person.role === "self" ? "本人" : person.incomplete ? "这份资料还不完整" : profileBirthTimeLabel(person.profile)}</small>
|
||||
</button>
|
||||
))}
|
||||
<button className="button-secondary" type="button" disabled={atLimit} onClick={startAdd}>
|
||||
添加一个人
|
||||
</button>
|
||||
{atLimit ? <p className="form-error">{LIMIT_COPY}</p> : <p>还可以保存 {MAX_SAVED_SUBJECTS - people.length} 个人。</p>}
|
||||
</section>
|
||||
<section className="people-archive-detail" aria-label="档案详情">
|
||||
{editing ? (
|
||||
<ChartProfileForm
|
||||
title={selectedId ? "编辑人物" : "添加一个人"}
|
||||
description="称呼和出生资料。填报的出生时间保存后不能再改。"
|
||||
value={draft}
|
||||
onChange={setDraft}
|
||||
nameInputId="people-archive-name"
|
||||
showAyanamsa
|
||||
onSubmit={(event) => void saveOther(event)}
|
||||
onCancel={() => setEditing(false)}
|
||||
submitLabel={saving ? "保存中" : "保存"}
|
||||
submitDisabled={saving || Boolean(missingOtherProfileStep(draft))}
|
||||
notice={notice}
|
||||
/>
|
||||
) : selected ? (
|
||||
<>
|
||||
<h2>{selected.profile.name.trim() || (selected.role === "self" ? "我" : "未命名资料")}</h2>
|
||||
{selected.incomplete ? <p>这份资料还不完整,补全后才能排盘。</p> : null}
|
||||
<p>出生日期:{effectiveBirthDate(selected.profile) || "出生日期待补全"}</p>
|
||||
<p>填报时间:{selected.profile.reportedTime || "还没填"}</p>
|
||||
<p>排盘时间:{profileBirthTimeLabel(selected.profile)}</p>
|
||||
<p>地点:{profilePlaceLabel(selected.profile)}</p>
|
||||
<p>岁差:{selected.profile.ayanamsa}</p>
|
||||
<div className="dialog-actions">
|
||||
<button type="button" className="button-secondary" onClick={() => openPerson(selected.id, "/?newChat=1")}>和 TA 对话</button>
|
||||
<button type="button" className="button-secondary" onClick={() => openPerson(selected.id, "/chart")}>看星盘</button>
|
||||
<button type="button" className="button-secondary" onClick={() => openPerson(selected.id, "/reports")}>生成报告</button>
|
||||
{selected.role === "other" ? (
|
||||
<button type="button" className="button-secondary" onClick={() => window.location.assign(`/?synastry=${selected.id}`)}>和我合盘</button>
|
||||
) : null}
|
||||
</div>
|
||||
{selected.role === "other" ? (
|
||||
<button type="button" className="button-secondary" onClick={() => {
|
||||
setDraft(selected.profile);
|
||||
setEditing(true);
|
||||
}}>编辑</button>
|
||||
) : null}
|
||||
{selected.role === "self" ? <p>本人不能删除。</p> : (
|
||||
<button type="button" className="button-primary danger-primary" onClick={() => void askDelete()}>删除这个人</button>
|
||||
)}
|
||||
{pendingDelete?.id === selected.id && selected.role === "other" ? (
|
||||
<div role="alertdialog" aria-label="确认删除">
|
||||
<p>将同时删除 {pendingDelete.sessions} 个对话和 {pendingDelete.reports} 份报告,无法恢复。</p>
|
||||
<div className="dialog-actions">
|
||||
<button type="button" className="button-secondary" onClick={() => setPendingDelete(null)}>取消</button>
|
||||
<button type="button" className="button-primary danger-primary" onClick={() => void confirmDelete()}>确认删除</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{notice ? <p className="form-error" role="alert">{notice}</p> : null}
|
||||
</>
|
||||
) : null}
|
||||
</section>
|
||||
</div>
|
||||
<PeopleArchiveView
|
||||
people={people}
|
||||
subjectId={subjectId}
|
||||
selected={selected}
|
||||
selectedId={selectedId}
|
||||
view={view}
|
||||
editing={editing}
|
||||
draft={draft}
|
||||
notice={notice}
|
||||
pendingDelete={pendingDelete}
|
||||
saving={saving}
|
||||
atLimit={atLimit}
|
||||
onSelect={selectPerson}
|
||||
onBack={showList}
|
||||
onAdd={startAdd}
|
||||
onEdit={startEdit}
|
||||
onCancelEdit={() => setEditing(false)}
|
||||
onDraftChange={setDraft}
|
||||
saveOther={saveOther}
|
||||
askDelete={askDelete}
|
||||
onCancelDelete={() => setPendingDelete(null)}
|
||||
confirmDelete={confirmDelete}
|
||||
openPerson={openPerson}
|
||||
/>
|
||||
) : null}
|
||||
</SecondaryPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
function PeopleAvatar(props: { readonly person: PersonRow; readonly large?: boolean }) {
|
||||
const tone = peopleAvatarTone(props.person.id, props.person.role);
|
||||
return (
|
||||
<span className={`people-archive-avatar is-${tone}${props.large ? " is-large" : ""}`} aria-hidden="true">
|
||||
{peopleAvatarInitial(props.person.profile, props.person.role)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export type PeopleArchiveViewProps = {
|
||||
readonly people: readonly PersonRow[];
|
||||
readonly subjectId: string;
|
||||
readonly selected: PersonRow | null;
|
||||
readonly selectedId: string;
|
||||
readonly view: "list" | "detail";
|
||||
readonly editing: boolean;
|
||||
readonly draft: Profile;
|
||||
readonly notice: string;
|
||||
readonly pendingDelete: Usage | null;
|
||||
readonly saving: boolean;
|
||||
readonly atLimit: boolean;
|
||||
readonly onSelect: (id: string) => void;
|
||||
readonly onBack: () => void;
|
||||
readonly onAdd: () => void;
|
||||
readonly onEdit: (profile: Profile) => void;
|
||||
readonly onCancelEdit: () => void;
|
||||
readonly onDraftChange: Dispatch<SetStateAction<Profile>>;
|
||||
readonly saveOther: (event: FormEvent<HTMLFormElement>) => Promise<void>;
|
||||
readonly askDelete: () => Promise<void>;
|
||||
readonly onCancelDelete: () => void;
|
||||
readonly confirmDelete: () => Promise<void>;
|
||||
readonly openPerson: (id: string, href: string) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* D1–D4. Phone (< 1024px): `data-view` shows either the list or the detail.
|
||||
* Desktop: both columns, `data-view` ignored by CSS.
|
||||
*/
|
||||
export function PeopleArchiveView({
|
||||
people,
|
||||
subjectId,
|
||||
selected,
|
||||
selectedId,
|
||||
view,
|
||||
editing,
|
||||
draft,
|
||||
notice,
|
||||
pendingDelete,
|
||||
saving,
|
||||
atLimit,
|
||||
onSelect,
|
||||
onBack,
|
||||
onAdd,
|
||||
onEdit,
|
||||
onCancelEdit,
|
||||
onDraftChange,
|
||||
saveOther,
|
||||
askDelete,
|
||||
onCancelDelete,
|
||||
confirmDelete,
|
||||
openPerson,
|
||||
}: PeopleArchiveViewProps) {
|
||||
const remaining = MAX_SAVED_SUBJECTS - people.length;
|
||||
const deleteNotice = notice === DELETE_USAGE_UNAVAILABLE || notice === DELETE_FAILED;
|
||||
return (
|
||||
<div className="people-archive" data-view={view}>
|
||||
<section className="people-archive-list" aria-label="星盘档案">
|
||||
<ul className="people-archive-rows">
|
||||
{people.map((person) => {
|
||||
const current = person.id === subjectId;
|
||||
const open = person.id === selected?.id && !editing;
|
||||
return (
|
||||
<li key={person.id}>
|
||||
<button
|
||||
type="button"
|
||||
className={`people-archive-row${current ? " is-current" : ""}${open ? " is-open" : ""}`}
|
||||
aria-current={current ? "true" : undefined}
|
||||
onClick={() => onSelect(person.id)}
|
||||
>
|
||||
<PeopleAvatar person={person} />
|
||||
<span className="people-archive-row-name">
|
||||
<b>{peopleDisplayName(person.profile, person.role)}</b>
|
||||
{person.role === "self" ? <span className="people-archive-tag">本人</span> : null}
|
||||
</span>
|
||||
<small className="people-archive-row-meta">
|
||||
{person.incomplete ? "这份资料还不完整" : peopleRowMeta(person.profile)}
|
||||
</small>
|
||||
<span className="people-archive-chevron" aria-hidden="true">›</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
<button className="people-archive-add" type="button" disabled={atLimit} onClick={onAdd}>
|
||||
+ 添加一个人
|
||||
</button>
|
||||
{atLimit ? <p className="form-error">{LIMIT_COPY}</p> : <p className="people-archive-hint">还可以保存 {remaining} 个人</p>}
|
||||
</section>
|
||||
<section className="people-archive-detail" aria-label="档案详情">
|
||||
<button type="button" className="people-archive-back" onClick={onBack}>
|
||||
<span aria-hidden="true">‹</span> 返回
|
||||
</button>
|
||||
{editing ? (
|
||||
<ChartProfileForm
|
||||
title={selectedId ? "编辑人物" : "添加一个人"}
|
||||
description="称呼和出生资料。填报的出生时间保存后不能再改。"
|
||||
value={draft}
|
||||
onChange={onDraftChange}
|
||||
nameInputId="people-archive-name"
|
||||
showAyanamsa
|
||||
onSubmit={(event) => void saveOther(event)}
|
||||
onCancel={onCancelEdit}
|
||||
submitLabel={saving ? "保存中" : "保存"}
|
||||
submitDisabled={saving || Boolean(missingOtherProfileStep(draft))}
|
||||
notice={deleteNotice ? undefined : notice}
|
||||
/>
|
||||
) : selected ? (
|
||||
<div className="people-archive-card">
|
||||
<header className="people-archive-head">
|
||||
<PeopleAvatar person={selected} large />
|
||||
<div>
|
||||
<h2>{peopleDisplayName(selected.profile, selected.role)}</h2>
|
||||
<p>{selected.role === "self" ? `本人 · ${profilePlaceLabel(selected.profile)}` : profilePlaceLabel(selected.profile)}</p>
|
||||
</div>
|
||||
</header>
|
||||
{selected.incomplete ? <p className="people-archive-incomplete">这份资料还不完整,补全后才能排盘。</p> : null}
|
||||
<div className="people-archive-times">
|
||||
<div className="people-archive-time">
|
||||
<small>填报时间</small>
|
||||
<b>{selected.profile.reportedTime || "还没填"}</b>
|
||||
</div>
|
||||
<div className={peopleTimeRectified(selected.profile) ? "people-archive-time is-rectified" : "people-archive-time"}>
|
||||
<small>{peopleTimeRectified(selected.profile) ? "排盘时间 · 已校正" : "排盘时间"}</small>
|
||||
<b>{profileBirthTimeLabel(selected.profile)}</b>
|
||||
</div>
|
||||
</div>
|
||||
<dl className="people-archive-facts">
|
||||
<dt>出生日期</dt>
|
||||
<dd>{effectiveBirthDate(selected.profile) || "出生日期待补全"}</dd>
|
||||
<dt>岁差</dt>
|
||||
<dd>{peopleAyanamsaLabel(selected.profile)}</dd>
|
||||
</dl>
|
||||
<div className="people-archive-actions">
|
||||
<button type="button" onClick={() => openPerson(selected.id, "/?newChat=1")}>
|
||||
<MessageSquareText size={18} strokeWidth={1.75} aria-hidden="true" />和 TA 对话
|
||||
</button>
|
||||
<button type="button" onClick={() => openPerson(selected.id, "/chart")}>
|
||||
<Orbit size={18} strokeWidth={1.75} aria-hidden="true" />看星盘
|
||||
</button>
|
||||
<button type="button" onClick={() => openPerson(selected.id, "/reports")}>
|
||||
<FileText size={18} strokeWidth={1.75} aria-hidden="true" />生成报告
|
||||
</button>
|
||||
</div>
|
||||
{selected.role === "other" ? (
|
||||
<button type="button" className="people-archive-secondary" onClick={() => window.location.assign(`/?synastry=${selected.id}`)}>和我合盘</button>
|
||||
) : null}
|
||||
<div className="people-archive-foot">
|
||||
{selected.role === "self" ? <p>本人不能删除</p> : (
|
||||
<button type="button" className="people-archive-secondary" onClick={() => onEdit(selected.profile)}>编辑</button>
|
||||
)}
|
||||
</div>
|
||||
{notice ? <p className="form-error" role="alert">{notice}</p> : null}
|
||||
</div>
|
||||
) : null}
|
||||
{/* D3: deleting someone lives at the bottom of their edit view; 本人 never gets it. */}
|
||||
{editing && selectedId && selected?.role === "other" ? (
|
||||
<div className="people-archive-danger">
|
||||
<button type="button" className="button-primary danger-primary" onClick={() => void askDelete()}>删除这个人</button>
|
||||
{pendingDelete?.id === selected.id && selected.role === "other" ? (
|
||||
<div role="alertdialog" aria-label="确认删除" className="people-archive-confirm">
|
||||
<p>将同时删除 {pendingDelete.sessions} 个对话和 {pendingDelete.reports} 份报告,无法恢复。</p>
|
||||
<div className="dialog-actions">
|
||||
<button type="button" className="button-secondary" onClick={onCancelDelete}>取消</button>
|
||||
<button type="button" className="button-primary danger-primary" onClick={() => void confirmDelete()}>确认删除</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{deleteNotice ? <p className="form-error" role="alert">{notice}</p> : null}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ import type {
|
||||
EphemerisQuality,
|
||||
EphemerisTransitBody,
|
||||
} from "@/lib/ephemeris-contract";
|
||||
import { isAyanamsaName, type AyanamsaName } from "@/lib/ayanamsa";
|
||||
import { normalizePanchangaName, panchangaNameZh, splitTithiName } from "@/lib/panchanga-names-zh";
|
||||
|
||||
export const EPHEMERIS_COPY = {
|
||||
title: "星历",
|
||||
@@ -13,26 +15,25 @@ export const EPHEMERIS_COPY = {
|
||||
nextDay: "后一天",
|
||||
today: "今天",
|
||||
panchangaHeading: "今日五要素",
|
||||
panchangaLahiriNote: "这一段按 Lahiri 岁差算,和星盘页用的岁差不是同一套。",
|
||||
// D10: the chart page's name is filled from the subject's own setting, not
|
||||
// hard-coded; see `ephemerisAyanamsaNote`.
|
||||
panchangaAyanamsaNoteDifferent: "五要素按 Lahiri 岁差算,和星盘页的 {chart} 不同。",
|
||||
panchangaAyanamsaNoteSame: "五要素按 Lahiri 岁差算,和星盘页相同。",
|
||||
panchangaAyanamsaNoteUnknown: "五要素按 Lahiri 岁差算。",
|
||||
panchangaUnavailable: "这一天的五要素还没拿到。",
|
||||
transitsHeading: "当日行运",
|
||||
transitsUnavailable: "这一天的行运还没拿到。",
|
||||
natalMissing: "还没有本命上升,相对本命这一列空着。",
|
||||
natalMissing: "还没有本命上升,先不写落在你的第几宫。",
|
||||
eventsHeading: "未来九十天",
|
||||
eventsPending: "换座和停滞还在取。",
|
||||
eventsUnavailable: "换座和停滞还没接上。这一页其余部分不受影响。",
|
||||
dateFailed: "这一天的星历没取到,点日期再试一次。",
|
||||
ask: "带这天去提问",
|
||||
askPrompt: "想问这一天适不适合做某件事?",
|
||||
loginTitle: "请先登录",
|
||||
loginBody: "登录后即可查看星历。",
|
||||
loginAction: "去登录",
|
||||
planet: "星体",
|
||||
sign: "星座",
|
||||
degree: "度数",
|
||||
natalHouse: "相对本命",
|
||||
motion: "顺逆",
|
||||
direct: "顺",
|
||||
retrograde: "逆",
|
||||
retrograde: "逆行",
|
||||
} as const;
|
||||
|
||||
export const EPHEMERIS_PLANET_ORDER = [
|
||||
@@ -167,6 +168,115 @@ export function qualityLabel(quality: EphemerisQuality | null): string | null {
|
||||
return quality ? QUALITY_ZH[quality] : null;
|
||||
}
|
||||
|
||||
/* ── Readable panchanga (TASK-people-ephemeris-ui-20260926 D5–D7, D10) ──────
|
||||
A projection over the unchanged `/api/ephemeris` payload: `factor.value`
|
||||
stays the engine name on the wire (星期 was already Chinese there), and the
|
||||
Chinese main line is looked up here, from `panchanga-names-zh.ts` only. */
|
||||
|
||||
export type PanchangaDisplayRow = {
|
||||
readonly key: EphemerisPanchangaFactor["key"];
|
||||
readonly label: string;
|
||||
/** Chinese first; the engine name when the table has no entry. */
|
||||
readonly primary: string;
|
||||
/** The engine's own (Sanskrit) name under a Chinese main line; null otherwise. */
|
||||
readonly secondary: string | null;
|
||||
readonly quality: EphemerisQuality | null;
|
||||
};
|
||||
|
||||
const reportedMissingNames = new Set<string>();
|
||||
|
||||
function reportMissingPanchangaName(key: string, name: string) {
|
||||
const id = `${key}:${normalizePanchangaName(name)}`;
|
||||
if (reportedMissingNames.has(id)) return;
|
||||
reportedMissingNames.add(id);
|
||||
console.warn("panchanga_name_zh_missing", { kind: key, name });
|
||||
}
|
||||
|
||||
export function panchangaDisplayRows(factors: readonly EphemerisPanchangaFactor[]): PanchangaDisplayRow[] {
|
||||
return factors.map((factor) => {
|
||||
const zh = factor.key === "vara" ? null : panchangaNameZh(factor.key, factor.value);
|
||||
if (factor.key !== "vara" && !zh) reportMissingPanchangaName(factor.key, factor.value);
|
||||
const name = zh ?? factor.value;
|
||||
return {
|
||||
key: factor.key,
|
||||
label: factor.label,
|
||||
primary: factor.detail ? `${name} · ${factor.detail}` : name,
|
||||
secondary: zh ? factor.value : null,
|
||||
quality: factor.quality,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export type PanchangaQualityCounts = { readonly good: number; readonly bad: number };
|
||||
|
||||
/** 吉 = subha, 凶 = asubha. 中 (mixed) counts as neither. */
|
||||
export function panchangaQualityCounts(factors: readonly EphemerisPanchangaFactor[]): PanchangaQualityCounts {
|
||||
return {
|
||||
good: factors.filter((factor) => factor.quality === "subha").length,
|
||||
bad: factors.filter((factor) => factor.quality === "asubha").length,
|
||||
};
|
||||
}
|
||||
|
||||
const PANCHANGA_KEYS = ["vara", "tithi", "nakshatra", "yoga", "karana"] as const;
|
||||
|
||||
/** All five limbs present, each with the engine's 吉 / 中 / 凶. */
|
||||
export function panchangaComplete(factors: readonly EphemerisPanchangaFactor[]): boolean {
|
||||
return PANCHANGA_KEYS.every((key) => {
|
||||
const factor = factors.find((item) => item.key === key);
|
||||
return Boolean(factor && factor.value.trim() && factor.quality);
|
||||
});
|
||||
}
|
||||
|
||||
function moonPhasePhrase(tithi: string): string | null {
|
||||
const parts = splitTithiName(tithi);
|
||||
if (parts.name === "purnima") return "满月之日";
|
||||
if (parts.name === "amavasya") return "新月之日";
|
||||
if (parts.paksha === "shukla") return "月亮渐盈";
|
||||
if (parts.paksha === "krishna") return "月亮渐亏";
|
||||
return null;
|
||||
}
|
||||
|
||||
function overallPhrase(good: number): string {
|
||||
if (good >= 4) return "整体吉";
|
||||
if (good === 3) return "整体偏吉";
|
||||
if (good === 2) return "吉凶参半";
|
||||
return "整体偏凶";
|
||||
}
|
||||
|
||||
/**
|
||||
* D7: one fixed-rule sentence, no model call.
|
||||
* `{月相短语},{整体}{Vishti 追加}。` — null when a limb is missing.
|
||||
*/
|
||||
export function panchangaDaySummary(factors: readonly EphemerisPanchangaFactor[]): string | null {
|
||||
if (!panchangaComplete(factors)) return null;
|
||||
const tithi = factors.find((factor) => factor.key === "tithi")!.value;
|
||||
const karana = factors.find((factor) => factor.key === "karana")!.value;
|
||||
const phase = moonPhasePhrase(tithi);
|
||||
if (!phase) return null;
|
||||
const overall = overallPhrase(panchangaQualityCounts(factors).good);
|
||||
const vishti = normalizePanchangaName(karana) === "vishti" ? ";迦那落在毗湿提,重要的事避开" : "";
|
||||
return `${phase},${overall}${vishti}。`;
|
||||
}
|
||||
|
||||
/** Same names as the ayanamsa setting (`AYANAMSA_OPTIONS`), without 「(默认)」. */
|
||||
const AYANAMSA_SHORT_NAME: Readonly<Record<AyanamsaName, string>> = {
|
||||
raman: "Raman",
|
||||
lahiri: "Lahiri",
|
||||
kp: "KP",
|
||||
true_pushya: "True Pushya",
|
||||
};
|
||||
|
||||
/**
|
||||
* D10: the five limbs are always Lahiri; the chart page uses the subject's own
|
||||
* setting (`transits.ayanamsa`). Unknown setting → only the Lahiri half.
|
||||
*/
|
||||
export function ephemerisAyanamsaNote(chartAyanamsa: string | null | undefined): string {
|
||||
const key = typeof chartAyanamsa === "string" ? chartAyanamsa.trim().toLowerCase().replace(/-/g, "_") : "";
|
||||
if (!isAyanamsaName(key)) return EPHEMERIS_COPY.panchangaAyanamsaNoteUnknown;
|
||||
if (key === "lahiri") return EPHEMERIS_COPY.panchangaAyanamsaNoteSame;
|
||||
return EPHEMERIS_COPY.panchangaAyanamsaNoteDifferent.replace("{chart}", AYANAMSA_SHORT_NAME[key]);
|
||||
}
|
||||
|
||||
export function planetLabel(planet: string): string {
|
||||
return PLANET_ZH[planet as keyof typeof PLANET_ZH] ?? planet;
|
||||
}
|
||||
@@ -195,6 +305,11 @@ export function formatHouse(house: number | null): string {
|
||||
return house === null ? "—" : `第 ${house} 宫`;
|
||||
}
|
||||
|
||||
/** D9 sub-line: 「落你的第 N 宫」, only when the natal ascendant is known. */
|
||||
export function transitHouseLine(house: number | null, natalRelativeAvailable: boolean): string | null {
|
||||
return natalRelativeAvailable && house !== null ? `落你的第 ${house} 宫` : null;
|
||||
}
|
||||
|
||||
export function formatDegree(degree: number): string {
|
||||
return `${degree.toFixed(1)}°`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* Chinese names for the four panchanga limbs the engine returns in Sanskrit
|
||||
* (Latin transliteration). TASK-people-ephemeris-ui-20260926 D5.
|
||||
*
|
||||
* The table below is the task brief's table, copied as written. It is the only
|
||||
* source: nothing here is translated by the implementer, and nothing is added
|
||||
* or dropped. A name the table does not know returns `null` and the caller
|
||||
* keeps the engine name — it is never guessed.
|
||||
*
|
||||
* Matching ignores case, spaces and hyphens. The three spelling aliases the
|
||||
* brief lists (Garija/Gara, Dwitiya/Dvitiya, Shashthi/Shashti) live in
|
||||
* `PANCHANGA_NAME_ALIASES`; no other alias is accepted.
|
||||
*/
|
||||
|
||||
export type PanchangaNameKind = "tithi" | "nakshatra" | "yoga" | "karana";
|
||||
|
||||
/** Tithi within a fortnight. Purnima / Amavasya carry their own full label. */
|
||||
export const TITHI_ZH: Readonly<Record<string, string>> = {
|
||||
Pratipada: "初一",
|
||||
Dwitiya: "初二",
|
||||
Tritiya: "初三",
|
||||
Chaturthi: "初四",
|
||||
Panchami: "初五",
|
||||
Shashthi: "初六",
|
||||
Saptami: "初七",
|
||||
Ashtami: "初八",
|
||||
Navami: "初九",
|
||||
Dashami: "初十",
|
||||
Ekadashi: "十一",
|
||||
Dwadashi: "十二",
|
||||
Trayodashi: "十三",
|
||||
Chaturdashi: "十四",
|
||||
Purnima: "望日(满月)",
|
||||
Amavasya: "朔日(新月)",
|
||||
};
|
||||
|
||||
/** Paksha prefixes: 白分 = Shukla, 黑分 = Krishna. */
|
||||
export const PAKSHA_ZH: Readonly<Record<string, string>> = {
|
||||
Shukla: "白分",
|
||||
Krishna: "黑分",
|
||||
};
|
||||
|
||||
export const NAKSHATRA_ZH: Readonly<Record<string, string>> = {
|
||||
Ashwini: "娄宿",
|
||||
Bharani: "胃宿",
|
||||
Krittika: "昴宿",
|
||||
Rohini: "毕宿",
|
||||
Mrigashira: "觜宿",
|
||||
Ardra: "参宿",
|
||||
Punarvasu: "井宿",
|
||||
Pushya: "鬼宿",
|
||||
Ashlesha: "柳宿",
|
||||
Magha: "星宿",
|
||||
"Purva Phalguni": "张宿",
|
||||
"Uttara Phalguni": "翼宿",
|
||||
Hasta: "轸宿",
|
||||
Chitra: "角宿",
|
||||
Swati: "亢宿",
|
||||
Vishakha: "氐宿",
|
||||
Anuradha: "房宿",
|
||||
Jyeshtha: "心宿",
|
||||
Mula: "尾宿",
|
||||
"Purva Ashadha": "箕宿",
|
||||
"Uttara Ashadha": "斗宿",
|
||||
Shravana: "女宿",
|
||||
Dhanishta: "虚宿",
|
||||
Shatabhisha: "危宿",
|
||||
"Purva Bhadrapada": "室宿",
|
||||
"Uttara Bhadrapada": "壁宿",
|
||||
Revati: "奎宿",
|
||||
};
|
||||
|
||||
export const YOGA_ZH: Readonly<Record<string, string>> = {
|
||||
Vishkambha: "支柱",
|
||||
Priti: "喜爱",
|
||||
Ayushman: "长寿",
|
||||
Saubhagya: "好运",
|
||||
Shobhana: "光彩",
|
||||
Atiganda: "大险",
|
||||
Sukarma: "善业",
|
||||
Dhriti: "坚持",
|
||||
Shula: "刺痛",
|
||||
Ganda: "险结",
|
||||
Vriddhi: "增长",
|
||||
Dhruva: "恒定",
|
||||
Vyaghata: "打击",
|
||||
Harshana: "欢喜",
|
||||
Vajra: "金刚",
|
||||
Siddhi: "成就",
|
||||
Vyatipata: "灾变",
|
||||
Variyan: "安逸",
|
||||
Parigha: "阻碍",
|
||||
Shiva: "吉祥",
|
||||
Siddha: "圆满",
|
||||
Sadhya: "可成",
|
||||
Shubha: "吉善",
|
||||
Shukla: "清白",
|
||||
Brahma: "梵天",
|
||||
Indra: "因陀罗",
|
||||
Vaidhriti: "分离",
|
||||
};
|
||||
|
||||
export const KARANA_ZH: Readonly<Record<string, string>> = {
|
||||
Bava: "巴瓦",
|
||||
Balava: "巴拉瓦",
|
||||
Kaulava: "考拉瓦",
|
||||
Taitila: "泰提拉",
|
||||
Garija: "加拉",
|
||||
Vanija: "瓦尼贾",
|
||||
Vishti: "毗湿提",
|
||||
Shakuni: "沙昆尼",
|
||||
Chatushpada: "四足",
|
||||
Naga: "蛇",
|
||||
Kimstughna: "金斯图格纳",
|
||||
};
|
||||
|
||||
/** Spelling alias → table key. Only the three pairs the brief names. */
|
||||
export const PANCHANGA_NAME_ALIASES: Readonly<Record<PanchangaNameKind, Readonly<Record<string, string>>>> = {
|
||||
tithi: { Dvitiya: "Dwitiya", Shashti: "Shashthi" },
|
||||
nakshatra: {},
|
||||
yoga: {},
|
||||
karana: { Gara: "Garija" },
|
||||
};
|
||||
|
||||
/** Case, spaces and hyphens do not count. */
|
||||
export function normalizePanchangaName(name: string): string {
|
||||
return name.toLowerCase().replace(/[\s-]+/g, "");
|
||||
}
|
||||
|
||||
function indexTable(table: Readonly<Record<string, string>>, aliases: Readonly<Record<string, string>>) {
|
||||
const index = new Map<string, string>();
|
||||
for (const [key, zh] of Object.entries(table)) index.set(normalizePanchangaName(key), zh);
|
||||
for (const [alias, key] of Object.entries(aliases)) {
|
||||
const zh = table[key];
|
||||
if (zh) index.set(normalizePanchangaName(alias), zh);
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
const INDEX: Readonly<Record<PanchangaNameKind, ReadonlyMap<string, string>>> = {
|
||||
tithi: indexTable(TITHI_ZH, PANCHANGA_NAME_ALIASES.tithi),
|
||||
nakshatra: indexTable(NAKSHATRA_ZH, PANCHANGA_NAME_ALIASES.nakshatra),
|
||||
yoga: indexTable(YOGA_ZH, PANCHANGA_NAME_ALIASES.yoga),
|
||||
karana: indexTable(KARANA_ZH, PANCHANGA_NAME_ALIASES.karana),
|
||||
};
|
||||
|
||||
const PAKSHA_INDEX = indexTable(PAKSHA_ZH, {});
|
||||
|
||||
export type TithiParts = {
|
||||
readonly paksha: "shukla" | "krishna" | null;
|
||||
/** Normalized tithi name without the paksha, e.g. `purnima`. */
|
||||
readonly name: string;
|
||||
};
|
||||
|
||||
/** Splits the engine's `full_name` ("Shukla Purnima") into paksha and tithi. */
|
||||
export function splitTithiName(name: string): TithiParts {
|
||||
const normalized = normalizePanchangaName(name);
|
||||
for (const paksha of ["shukla", "krishna"] as const) {
|
||||
if (normalized.startsWith(paksha) && normalized.length > paksha.length) {
|
||||
return { paksha, name: normalized.slice(paksha.length) };
|
||||
}
|
||||
}
|
||||
return { paksha: null, name: normalized };
|
||||
}
|
||||
|
||||
function tithiZh(name: string): string | null {
|
||||
const parts = splitTithiName(name);
|
||||
const day = INDEX.tithi.get(parts.name);
|
||||
if (!day) return null;
|
||||
// Full moon and new moon are whole labels on their own: 望日(满月)/ 朔日(新月).
|
||||
if (parts.name === "purnima" || parts.name === "amavasya") return day;
|
||||
if (!parts.paksha) return null;
|
||||
const paksha = PAKSHA_INDEX.get(parts.paksha);
|
||||
return paksha ? `${paksha}${day}` : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Chinese name from the brief's table, or `null` when the table has no entry.
|
||||
* A tithi needs its paksha ("Shukla Dashami" → 白分初十) unless it is Purnima
|
||||
* or Amavasya; a bare "Dashami" is not guessed into either fortnight.
|
||||
*/
|
||||
export function panchangaNameZh(kind: PanchangaNameKind, name: string): string | null {
|
||||
if (!name.trim()) return null;
|
||||
if (kind === "tithi") return tithiZh(name);
|
||||
return INDEX[kind].get(normalizePanchangaName(name)) ?? null;
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Pure display helpers for /people (TASK-people-ephemeris-ui-20260926 D2–D4).
|
||||
* No new facts: every string is read from the stored profile or an existing
|
||||
* label table.
|
||||
*/
|
||||
import { AYANAMSA_OPTIONS, resolveAyanamsa } from "@/lib/ayanamsa";
|
||||
import { effectiveBirthDate } from "@/lib/effective-birth-date";
|
||||
import { findCity, findProvince, profileBirthTimeLabel } from "@/lib/home-profile";
|
||||
import type { Profile } from "@/lib/home-types";
|
||||
|
||||
/** D4: four existing element tokens; the person's id picks one, stably. */
|
||||
export const PEOPLE_AVATAR_TONES = ["fire", "earth", "air", "water"] as const;
|
||||
export type PeopleAvatarTone = (typeof PEOPLE_AVATAR_TONES)[number] | "self";
|
||||
|
||||
export function peopleAvatarTone(id: string, role: "self" | "other"): PeopleAvatarTone {
|
||||
if (role === "self") return "self";
|
||||
let hash = 0;
|
||||
for (const character of id) hash = (hash * 31 + character.codePointAt(0)!) >>> 0;
|
||||
return PEOPLE_AVATAR_TONES[hash % PEOPLE_AVATAR_TONES.length];
|
||||
}
|
||||
|
||||
export function peopleDisplayName(profile: Profile, role: "self" | "other"): string {
|
||||
return profile.name.trim() || (role === "self" ? "我" : "未命名资料");
|
||||
}
|
||||
|
||||
/** 本人 shows 「我」; everyone else the first character of the name. */
|
||||
export function peopleAvatarInitial(profile: Profile, role: "self" | "other"): string {
|
||||
if (role === "self") return "我";
|
||||
return Array.from(peopleDisplayName(profile, role))[0] ?? "?";
|
||||
}
|
||||
|
||||
function trimCitySuffix(name: string): string {
|
||||
return name.length > 2 && name.endsWith("市") ? name.slice(0, -1) : name;
|
||||
}
|
||||
|
||||
/**
|
||||
* The city for the one-line list meta. Chinese places come from the location
|
||||
* codes; a free-text label keeps its first comma segment, or the city segment
|
||||
* of a 「中国 · 省 · 市 · 区」 label. Empty when the place is not complete.
|
||||
*/
|
||||
export function peopleCityLabel(profile: Profile): string {
|
||||
const city = findCity(findProvince(profile.provinceCode), profile.cityCode);
|
||||
if (city) return trimCitySuffix(city.name);
|
||||
const label = profile.birthPlaceLabel.trim();
|
||||
if (!label) return "";
|
||||
if (label.includes("·")) {
|
||||
const parts = label.split("·").map((part) => part.trim()).filter(Boolean);
|
||||
const withoutCountry = parts[0] === "中国" ? parts.slice(1) : parts;
|
||||
return trimCitySuffix(withoutCountry[1] ?? withoutCountry[0] ?? "");
|
||||
}
|
||||
const [first] = label.split(/[,,]/).map((part) => part.trim()).filter(Boolean);
|
||||
return first ?? "";
|
||||
}
|
||||
|
||||
/** D2: 「出生日期 · 排盘时间 · 城市」, missing parts dropped. */
|
||||
export function peopleRowMeta(profile: Profile): string {
|
||||
return [effectiveBirthDate(profile) ?? "", profileBirthTimeLabel(profile), peopleCityLabel(profile)]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
}
|
||||
|
||||
/**
|
||||
* D3: a rectification result was adopted or confirmed. The case id is required
|
||||
* too: `readProfile` falls back to `confirmed` for any legacy row that simply
|
||||
* has a time, and a hospital-record time is not a 校正结果.
|
||||
*/
|
||||
export function peopleTimeRectified(profile: Profile): boolean {
|
||||
return (profile.birthTimeStatus === "accepted" || profile.birthTimeStatus === "confirmed")
|
||||
&& profile.rectificationCaseId.trim() !== "";
|
||||
}
|
||||
|
||||
/** D3: 「Raman(默认)」, never the stored lower-case key. */
|
||||
export function peopleAyanamsaLabel(profile: Profile): string {
|
||||
const value = resolveAyanamsa(profile);
|
||||
return AYANAMSA_OPTIONS.find((option) => option.value === value)?.label ?? value;
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { parseEphemerisOkResponse, type EphemerisOkResponse } from "../src/lib/e
|
||||
import {
|
||||
EPHEMERIS_COPY,
|
||||
ephemerisAskDraft,
|
||||
ephemerisAyanamsaNote,
|
||||
panchangaFactorsFromEngine,
|
||||
shiftEphemerisDate,
|
||||
transitsFromEngine,
|
||||
@@ -120,12 +121,16 @@ test("unavailable events render a static note, not a blank page", () => {
|
||||
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);
|
||||
// 原值:`EPHEMERIS_COPY.panchangaLahiriNote`(固定句「这一段按 Lahiri 岁差算,和星盘页用的岁差不是同一套。」)
|
||||
// 新值:`ephemerisAyanamsaNote("raman")`(「五要素按 Lahiri 岁差算,和星盘页的 Raman 不同。」,fixture 的 transits.ayanamsa 是 raman)
|
||||
// 原因:TASK-people-ephemeris-ui-20260926 D10 改句式,并要求按资料实际岁差填名、不写死;位置断言(五要素之下、行运之上)一字未改。
|
||||
const lahiriNote = ephemerisAyanamsaNote("raman");
|
||||
const note = markup.indexOf(lahiriNote);
|
||||
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));
|
||||
assert.doesNotMatch(transitsBlock, new RegExp(lahiriNote));
|
||||
});
|
||||
|
||||
test("今天 is absent on today and present off it, never disabled", () => {
|
||||
@@ -163,7 +168,13 @@ test("copy stays factual: no fortune-telling, no spinner, ask prefills the date"
|
||||
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.doesNotMatch(markup, /运势|适合|不宜|建议|吉祥日/);
|
||||
// 原值:`assert.doesNotMatch(markup, /运势|适合|不宜|建议|吉祥日/)`,对整页 markup。
|
||||
// 新值:先去掉底部提示卡那一句问句(EPHEMERIS_COPY.askPrompt),再对其余 markup 断同一组词;另断问句只出现一次。
|
||||
// 原因:D8 产品定稿的底部提示卡原文是「想问这一天适不适合做某件事?」——这是邀请用户提问的问句,不是页面给出的
|
||||
// 宜忌结论;五要素、总评、行运、九十天事件里仍不得出现这组词,约束范围没有放宽。
|
||||
const withoutAskPrompt = markup.replace(EPHEMERIS_COPY.askPrompt, "");
|
||||
assert.equal(markup.split(EPHEMERIS_COPY.askPrompt).length - 1, 1);
|
||||
assert.doesNotMatch(withoutAskPrompt, /运势|适合|不宜|建议|吉祥日/);
|
||||
assert.doesNotMatch(pageSource, /InlineSpinner|正在加载|skeleton/i);
|
||||
assert.equal(ephemerisAskDraft("2026-09-15"), "请按 2026年9月15日 这一天的天象来看。");
|
||||
assert.match(pageSource, /setComposerDraft\(ephemerisAskDraft/);
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
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 { SidebarProvider } from "../src/components/ui/sidebar.tsx";
|
||||
import { parseEphemerisOkResponse, type EphemerisOkResponse, type EphemerisPanchangaFactor, type EphemerisQuality } from "../src/lib/ephemeris-contract.ts";
|
||||
import {
|
||||
EPHEMERIS_COPY,
|
||||
ephemerisAyanamsaNote,
|
||||
panchangaComplete,
|
||||
panchangaDaySummary,
|
||||
panchangaDisplayRows,
|
||||
panchangaFactorsFromEngine,
|
||||
panchangaQualityCounts,
|
||||
transitHouseLine,
|
||||
transitsFromEngine,
|
||||
} from "../src/lib/ephemeris-view.ts";
|
||||
import { cssDeclarations } from "./css-contract-test-support.ts";
|
||||
|
||||
Object.assign(globalThis, { React });
|
||||
|
||||
const pageSource = readFileSync(new URL("../src/components/ephemeris/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"));
|
||||
|
||||
type Q = EphemerisQuality | null;
|
||||
|
||||
/** Five limbs with chosen names and qualities; labels match the engine mapper. */
|
||||
function factors(input: {
|
||||
tithi?: string;
|
||||
karana?: string;
|
||||
qualities?: readonly [Q, Q, Q, Q, Q];
|
||||
}): EphemerisPanchangaFactor[] {
|
||||
const q = input.qualities ?? ["subha", "subha", "subha", "asubha", "asubha"];
|
||||
return [
|
||||
{ key: "vara", label: "星期", value: "星期六", quality: q[0], detail: null },
|
||||
{ key: "tithi", label: "月相日", value: input.tithi ?? "Shukla Purnima", quality: q[1], detail: null },
|
||||
{ key: "nakshatra", label: "月宿", value: "Uttara Bhadrapada", quality: q[2], detail: "第 1 足" },
|
||||
{ key: "yoga", label: "瑜伽", value: "Vriddhi", quality: q[3], detail: null },
|
||||
{ key: "karana", label: "迦那", value: input.karana ?? "Bava", quality: q[4], detail: null },
|
||||
];
|
||||
}
|
||||
|
||||
function withGood(good: number): readonly [Q, Q, Q, Q, Q] {
|
||||
return Array.from({ length: 5 }, (_, index) => (index < good ? "subha" : "asubha")) as unknown as readonly [Q, Q, Q, Q, Q];
|
||||
}
|
||||
|
||||
test("D7 moon phrase: Purnima, Amavasya, waxing and waning fortnights", () => {
|
||||
assert.equal(panchangaDaySummary(factors({ tithi: "Shukla Purnima", qualities: withGood(3) })), "满月之日,整体偏吉。");
|
||||
assert.equal(panchangaDaySummary(factors({ tithi: "Krishna Amavasya", qualities: withGood(3) })), "新月之日,整体偏吉。");
|
||||
assert.equal(panchangaDaySummary(factors({ tithi: "Shukla Dashami", qualities: withGood(3) })), "月亮渐盈,整体偏吉。");
|
||||
assert.equal(panchangaDaySummary(factors({ tithi: "Krishna Dashami", qualities: withGood(3) })), "月亮渐亏,整体偏吉。");
|
||||
});
|
||||
|
||||
test("D7 overall phrase for 吉 = 0 … 5", () => {
|
||||
const expected = ["整体偏凶", "整体偏凶", "吉凶参半", "整体偏吉", "整体吉", "整体吉"];
|
||||
for (let good = 0; good <= 5; good += 1) {
|
||||
assert.equal(
|
||||
panchangaDaySummary(factors({ tithi: "Shukla Tritiya", qualities: withGood(good) })),
|
||||
`月亮渐盈,${expected[good]}。`,
|
||||
`吉 = ${good}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("D7 中 counts as neither 吉 nor 凶", () => {
|
||||
const mixed = factors({ qualities: ["subha", "subha", "mixed", "mixed", "asubha"] });
|
||||
assert.deepEqual(panchangaQualityCounts(mixed), { good: 2, bad: 1 });
|
||||
assert.equal(panchangaDaySummary(mixed), "满月之日,吉凶参半。");
|
||||
});
|
||||
|
||||
test("D7 Vishti appends the avoid clause, in the brief's own example sentence", () => {
|
||||
assert.equal(
|
||||
panchangaDaySummary(factors({ tithi: "Shukla Purnima", karana: "Vishti", qualities: ["asubha", "subha", "subha", "subha", "asubha"] })),
|
||||
"满月之日,整体偏吉;迦那落在毗湿提,重要的事避开。",
|
||||
);
|
||||
});
|
||||
|
||||
test("D7 no summary when a limb or its quality is missing", () => {
|
||||
const full = factors({});
|
||||
assert.equal(panchangaComplete(full), true);
|
||||
assert.equal(panchangaDaySummary(full.filter((factor) => factor.key !== "yoga")), null);
|
||||
assert.equal(panchangaDaySummary(full.filter((factor) => factor.key !== "tithi")), null);
|
||||
assert.equal(panchangaDaySummary(factors({ qualities: ["subha", "subha", null, "subha", "subha"] })), null);
|
||||
assert.equal(panchangaComplete([]), false);
|
||||
assert.equal(panchangaDaySummary([]), null);
|
||||
});
|
||||
|
||||
test("D5 rows: Chinese first, engine name second; 星期 has no second line; unknown stays Sanskrit", () => {
|
||||
const warn = console.warn;
|
||||
const logged: unknown[][] = [];
|
||||
console.warn = (...args: unknown[]) => { logged.push(args); };
|
||||
try {
|
||||
const rows = panchangaDisplayRows(factors({ karana: "Vishti" }));
|
||||
assert.deepEqual(rows.map((row) => [row.key, row.primary, row.secondary]), [
|
||||
["vara", "星期六", null],
|
||||
["tithi", "望日(满月)", "Shukla Purnima"],
|
||||
["nakshatra", "壁宿 · 第 1 足", "Uttara Bhadrapada"],
|
||||
["yoga", "增长", "Vriddhi"],
|
||||
["karana", "毗湿提", "Vishti"],
|
||||
]);
|
||||
assert.equal(logged.length, 0);
|
||||
const unknown = panchangaDisplayRows([{ key: "yoga", label: "瑜伽", value: "Fictionalyoga", quality: "subha", detail: null }]);
|
||||
assert.equal(unknown[0].primary, "Fictionalyoga");
|
||||
assert.equal(unknown[0].secondary, null);
|
||||
assert.equal(logged.length, 1);
|
||||
assert.equal(logged[0][0], "panchanga_name_zh_missing");
|
||||
// Logged once per name, not on every render.
|
||||
panchangaDisplayRows([{ key: "yoga", label: "瑜伽", value: "Fictionalyoga", quality: "subha", detail: null }]);
|
||||
assert.equal(logged.length, 1);
|
||||
} finally {
|
||||
console.warn = warn;
|
||||
}
|
||||
});
|
||||
|
||||
test("D5 over the real engine golden: 2026-09-15 reads in Chinese", () => {
|
||||
const rows = panchangaDisplayRows(panchangaFactorsFromEngine(panchangaGolden, "2026-09-15"));
|
||||
assert.deepEqual(rows.map((row) => row.primary), ["星期二", "白分初五", "亢宿 · 第 4 足", "分离", "巴瓦"]);
|
||||
assert.deepEqual(rows.map((row) => row.secondary), [null, "Shukla Panchami", "Swati", "Vaidhriti", "Bava"]);
|
||||
});
|
||||
|
||||
test("D10 names the subject's own ayanamsa, never a hard-coded Raman", () => {
|
||||
assert.equal(ephemerisAyanamsaNote("raman"), "五要素按 Lahiri 岁差算,和星盘页的 Raman 不同。");
|
||||
assert.equal(ephemerisAyanamsaNote("kp"), "五要素按 Lahiri 岁差算,和星盘页的 KP 不同。");
|
||||
assert.equal(ephemerisAyanamsaNote("true_pushya"), "五要素按 Lahiri 岁差算,和星盘页的 True Pushya 不同。");
|
||||
assert.equal(ephemerisAyanamsaNote("lahiri"), "五要素按 Lahiri 岁差算,和星盘页相同。");
|
||||
assert.equal(ephemerisAyanamsaNote(null), "五要素按 Lahiri 岁差算。");
|
||||
});
|
||||
|
||||
test("D9 house line only when the natal ascendant is known", () => {
|
||||
assert.equal(transitHouseLine(2, true), "落你的第 2 宫");
|
||||
assert.equal(transitHouseLine(2, false), null);
|
||||
assert.equal(transitHouseLine(null, true), null);
|
||||
});
|
||||
|
||||
function payload(overrides: Partial<{ natal: boolean; factors: EphemerisPanchangaFactor[] }> = {}): EphemerisOkResponse {
|
||||
const natal = overrides.natal ?? true;
|
||||
const transits = transitsFromEngine({ transitChart: transit15, natalChart: natal ? natalGolden : null });
|
||||
const parsed = parseEphemerisOkResponse({
|
||||
status: "ok",
|
||||
date: "2026-09-15",
|
||||
today: "2026-09-15",
|
||||
timezoneId: "Asia/Shanghai",
|
||||
panchanga: {
|
||||
available: true,
|
||||
ayanamsaNote: "lahiri_not_account",
|
||||
factors: overrides.factors ?? panchangaFactorsFromEngine(panchangaGolden, "2026-09-15"),
|
||||
},
|
||||
transits: {
|
||||
available: true,
|
||||
natalRelativeAvailable: transits.natalRelativeAvailable,
|
||||
natalRelativeNote: transits.natalRelativeAvailable ? "shown" : "missing_natal",
|
||||
ayanamsa: "raman",
|
||||
bodies: transits.bodies,
|
||||
},
|
||||
events: { status: "unavailable" },
|
||||
});
|
||||
assert.ok(parsed);
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function render(data: EphemerisOkResponse) {
|
||||
return renderToStaticMarkup(React.createElement(SidebarProvider, null, React.createElement(EphemerisView, {
|
||||
date: data.date,
|
||||
today: data.today,
|
||||
payload: data,
|
||||
onPrevious() {},
|
||||
onNext() {},
|
||||
onToday() {},
|
||||
onSelectDate() {},
|
||||
onAsk() {},
|
||||
})));
|
||||
}
|
||||
|
||||
test("D6/D7 card: summary, counts and coloured tags per row", () => {
|
||||
const markup = render(payload({ factors: factors({ karana: "Vishti", qualities: ["asubha", "subha", "subha", "subha", "asubha"] }) }));
|
||||
assert.match(markup, /class="ephemeris-summary">满月之日,整体偏吉;迦那落在毗湿提,重要的事避开。</);
|
||||
assert.match(markup, /class="ephemeris-quality is-subha">3 吉</);
|
||||
assert.match(markup, /class="ephemeris-quality is-asubha">2 凶</);
|
||||
assert.equal([...markup.matchAll(/class="ephemeris-quality is-(subha|asubha|mixed)">(吉|中|凶)</g)].length, 5);
|
||||
assert.match(markup, /<strong>望日(满月)<\/strong><small lang="sa-Latn">Shukla Purnima<\/small>/);
|
||||
const summary = markup.indexOf("ephemeris-summary");
|
||||
const firstRow = markup.indexOf("ephemeris-panchanga-item");
|
||||
assert.ok(summary > 0 && summary < firstRow);
|
||||
});
|
||||
|
||||
test("D8 the ask button left the header for a bottom hint card", () => {
|
||||
const markup = render(payload());
|
||||
const header = markup.slice(0, markup.indexOf("secondary-page\""));
|
||||
assert.doesNotMatch(header, /ephemeris-ask|带这天去提问/);
|
||||
const card = markup.indexOf("ephemeris-ask-card");
|
||||
assert.ok(card > markup.indexOf(EPHEMERIS_COPY.eventsHeading), "hint card comes after the last section");
|
||||
assert.match(markup.slice(card), new RegExp(`<p>${EPHEMERIS_COPY.askPrompt}</p>[\\s\\S]*${EPHEMERIS_COPY.ask}`));
|
||||
assert.doesNotMatch(pageSource, /actions=\{\(\s*<Button type="button" className="ephemeris-ask"/);
|
||||
});
|
||||
|
||||
test("D9 transit rows: coloured glyph, 「行星 在 星座」, house line, monospace degree", () => {
|
||||
const markup = render(payload());
|
||||
assert.match(markup, /class="ephemeris-transit-glyph is-sun" aria-hidden="true">☉</);
|
||||
assert.match(markup, /太阳 在 [^<]+座/);
|
||||
assert.match(markup, /<small>落你的第 \d+ 宫/);
|
||||
assert.match(markup, /class="ephemeris-transit-degree">\d+°\d{2}′</);
|
||||
const noNatal = render(payload({ natal: false }));
|
||||
assert.doesNotMatch(noNatal, /落你的第/);
|
||||
assert.match(noNatal, new RegExp(EPHEMERIS_COPY.natalMissing));
|
||||
});
|
||||
|
||||
test("CSS: tags use semantic tokens at 12%, label loses its tracking, no raw colours", () => {
|
||||
assert.match(cssDeclarations(".ephemeris-quality.is-subha", css), /color-mix\(in srgb, var\(--color-success\) 12%, transparent\)/);
|
||||
assert.match(cssDeclarations(".ephemeris-quality.is-asubha", css), /color-mix\(in srgb, var\(--color-danger\) 12%, transparent\)/);
|
||||
assert.match(cssDeclarations(".ephemeris-quality.is-mixed", css), /color-mix\(in srgb, var\(--color-warning\) 12%, transparent\)/);
|
||||
assert.doesNotMatch(cssDeclarations(".ephemeris-panchanga-item dt", css), /letter-spacing/);
|
||||
assert.match(cssDeclarations(".ephemeris-ask", css), /min-height:\s*44px/);
|
||||
const block = css.slice(css.indexOf(".ephemeris-panchanga-item dt {"), css.indexOf("/* Dynamic viewport units"));
|
||||
assert.doesNotMatch(block, /#[0-9a-f]{3,8}\b|rgba?\(/i);
|
||||
assert.doesNotMatch(pageSource, /InlineSpinner|正在加载|skeleton/i);
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
KARANA_ZH,
|
||||
NAKSHATRA_ZH,
|
||||
TITHI_ZH,
|
||||
YOGA_ZH,
|
||||
normalizePanchangaName,
|
||||
panchangaNameZh,
|
||||
splitTithiName,
|
||||
} from "../src/lib/panchanga-names-zh.ts";
|
||||
|
||||
/* The brief's table (TASK-people-ephemeris-ui-20260926 「中文名对照表」),
|
||||
typed a second time here on purpose: the module and this list must agree
|
||||
entry for entry, so an edit to either one alone fails. */
|
||||
const BRIEF_TITHI: ReadonlyArray<readonly [string, string]> = [
|
||||
["Pratipada", "初一"], ["Dwitiya", "初二"], ["Tritiya", "初三"], ["Chaturthi", "初四"],
|
||||
["Panchami", "初五"], ["Shashthi", "初六"], ["Saptami", "初七"], ["Ashtami", "初八"],
|
||||
["Navami", "初九"], ["Dashami", "初十"], ["Ekadashi", "十一"], ["Dwadashi", "十二"],
|
||||
["Trayodashi", "十三"], ["Chaturdashi", "十四"],
|
||||
];
|
||||
const BRIEF_NAKSHATRA: ReadonlyArray<readonly [string, string]> = [
|
||||
["Ashwini", "娄宿"], ["Bharani", "胃宿"], ["Krittika", "昴宿"], ["Rohini", "毕宿"],
|
||||
["Mrigashira", "觜宿"], ["Ardra", "参宿"], ["Punarvasu", "井宿"], ["Pushya", "鬼宿"],
|
||||
["Ashlesha", "柳宿"], ["Magha", "星宿"], ["Purva Phalguni", "张宿"], ["Uttara Phalguni", "翼宿"],
|
||||
["Hasta", "轸宿"], ["Chitra", "角宿"], ["Swati", "亢宿"], ["Vishakha", "氐宿"],
|
||||
["Anuradha", "房宿"], ["Jyeshtha", "心宿"], ["Mula", "尾宿"], ["Purva Ashadha", "箕宿"],
|
||||
["Uttara Ashadha", "斗宿"], ["Shravana", "女宿"], ["Dhanishta", "虚宿"], ["Shatabhisha", "危宿"],
|
||||
["Purva Bhadrapada", "室宿"], ["Uttara Bhadrapada", "壁宿"], ["Revati", "奎宿"],
|
||||
];
|
||||
const BRIEF_YOGA: ReadonlyArray<readonly [string, string]> = [
|
||||
["Vishkambha", "支柱"], ["Priti", "喜爱"], ["Ayushman", "长寿"], ["Saubhagya", "好运"],
|
||||
["Shobhana", "光彩"], ["Atiganda", "大险"], ["Sukarma", "善业"], ["Dhriti", "坚持"],
|
||||
["Shula", "刺痛"], ["Ganda", "险结"], ["Vriddhi", "增长"], ["Dhruva", "恒定"],
|
||||
["Vyaghata", "打击"], ["Harshana", "欢喜"], ["Vajra", "金刚"], ["Siddhi", "成就"],
|
||||
["Vyatipata", "灾变"], ["Variyan", "安逸"], ["Parigha", "阻碍"], ["Shiva", "吉祥"],
|
||||
["Siddha", "圆满"], ["Sadhya", "可成"], ["Shubha", "吉善"], ["Shukla", "清白"],
|
||||
["Brahma", "梵天"], ["Indra", "因陀罗"], ["Vaidhriti", "分离"],
|
||||
];
|
||||
const BRIEF_KARANA: ReadonlyArray<readonly [string, string]> = [
|
||||
["Bava", "巴瓦"], ["Balava", "巴拉瓦"], ["Kaulava", "考拉瓦"], ["Taitila", "泰提拉"],
|
||||
["Garija", "加拉"], ["Vanija", "瓦尼贾"], ["Vishti", "毗湿提"], ["Shakuni", "沙昆尼"],
|
||||
["Chatushpada", "四足"], ["Naga", "蛇"], ["Kimstughna", "金斯图格纳"],
|
||||
];
|
||||
|
||||
test("the module tables are exactly the brief's tables, nothing added or dropped", () => {
|
||||
assert.deepEqual(
|
||||
Object.entries(TITHI_ZH).sort(),
|
||||
[...BRIEF_TITHI, ["Purnima", "望日(满月)"], ["Amavasya", "朔日(新月)"]].sort(),
|
||||
);
|
||||
assert.deepEqual(Object.entries(NAKSHATRA_ZH).sort(), [...BRIEF_NAKSHATRA].sort());
|
||||
assert.deepEqual(Object.entries(YOGA_ZH).sort(), [...BRIEF_YOGA].sort());
|
||||
assert.deepEqual(Object.entries(KARANA_ZH).sort(), [...BRIEF_KARANA].sort());
|
||||
assert.equal(Object.keys(NAKSHATRA_ZH).length, 27);
|
||||
assert.equal(Object.keys(YOGA_ZH).length, 27);
|
||||
assert.equal(Object.keys(KARANA_ZH).length, 11);
|
||||
});
|
||||
|
||||
test("every tithi reads 白分 / 黑分 + day, full and new moon keep their own label", () => {
|
||||
for (const [name, day] of BRIEF_TITHI) {
|
||||
assert.equal(panchangaNameZh("tithi", `Shukla ${name}`), `白分${day}`, name);
|
||||
assert.equal(panchangaNameZh("tithi", `Krishna ${name}`), `黑分${day}`, name);
|
||||
}
|
||||
assert.equal(panchangaNameZh("tithi", "Shukla Purnima"), "望日(满月)");
|
||||
assert.equal(panchangaNameZh("tithi", "Krishna Amavasya"), "朔日(新月)");
|
||||
assert.equal(panchangaNameZh("tithi", "Purnima"), "望日(满月)");
|
||||
assert.equal(panchangaNameZh("tithi", "Amavasya"), "朔日(新月)");
|
||||
// A bare day name has no fortnight; it is not guessed into one.
|
||||
assert.equal(panchangaNameZh("tithi", "Dashami"), null);
|
||||
});
|
||||
|
||||
test("every nakshatra, yoga and karana in the brief resolves", () => {
|
||||
for (const [name, zh] of BRIEF_NAKSHATRA) assert.equal(panchangaNameZh("nakshatra", name), zh, name);
|
||||
for (const [name, zh] of BRIEF_YOGA) assert.equal(panchangaNameZh("yoga", name), zh, name);
|
||||
for (const [name, zh] of BRIEF_KARANA) assert.equal(panchangaNameZh("karana", name), zh, name);
|
||||
});
|
||||
|
||||
test("the three listed aliases resolve to the same entry", () => {
|
||||
assert.equal(panchangaNameZh("karana", "Gara"), "加拉");
|
||||
assert.equal(panchangaNameZh("karana", "Garija"), "加拉");
|
||||
assert.equal(panchangaNameZh("tithi", "Shukla Dvitiya"), "白分初二");
|
||||
assert.equal(panchangaNameZh("tithi", "Krishna Dwitiya"), "黑分初二");
|
||||
assert.equal(panchangaNameZh("tithi", "Shukla Shashti"), "白分初六");
|
||||
assert.equal(panchangaNameZh("tithi", "Krishna Shashthi"), "黑分初六");
|
||||
});
|
||||
|
||||
test("case, spaces and hyphens are ignored", () => {
|
||||
assert.equal(normalizePanchangaName("Uttara-Bhadra pada"), "uttarabhadrapada");
|
||||
assert.equal(panchangaNameZh("nakshatra", "uttara bhadrapada"), "壁宿");
|
||||
assert.equal(panchangaNameZh("nakshatra", "UTTARA-BHADRAPADA"), "壁宿");
|
||||
assert.equal(panchangaNameZh("nakshatra", "PurvaPhalguni"), "张宿");
|
||||
assert.equal(panchangaNameZh("tithi", "shukla-purnima"), "望日(满月)");
|
||||
assert.equal(panchangaNameZh("karana", " vishti "), "毗湿提");
|
||||
});
|
||||
|
||||
test("a name the table does not know returns null so the caller keeps the engine name", () => {
|
||||
assert.equal(panchangaNameZh("nakshatra", "Abhijit"), null);
|
||||
assert.equal(panchangaNameZh("yoga", ""), null);
|
||||
assert.equal(panchangaNameZh("karana", "Unknown"), null);
|
||||
// A yoga name is not looked up in another limb's table.
|
||||
assert.equal(panchangaNameZh("nakshatra", "Vriddhi"), null);
|
||||
/* Engine spellings (`scripts/muhurta.py`) that the brief's table spells
|
||||
differently. Not aliased here: the brief allows only the three aliases
|
||||
above, so these stay in Sanskrit until the table is ruled on — see
|
||||
PROGRESS-people-ephemeris-ui-20260926 「待裁决」. */
|
||||
assert.equal(panchangaNameZh("yoga", "Aindra"), null);
|
||||
assert.equal(panchangaNameZh("yoga", "Variyana"), null);
|
||||
assert.equal(panchangaNameZh("nakshatra", "Dhanishtha"), null);
|
||||
});
|
||||
|
||||
test("tithi split separates the fortnight from the day", () => {
|
||||
assert.deepEqual(splitTithiName("Shukla Purnima"), { paksha: "shukla", name: "purnima" });
|
||||
assert.deepEqual(splitTithiName("Krishna Amavasya"), { paksha: "krishna", name: "amavasya" });
|
||||
assert.deepEqual(splitTithiName("Navami"), { paksha: null, name: "navami" });
|
||||
});
|
||||
@@ -0,0 +1,236 @@
|
||||
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 { PeopleArchiveView, type PeopleArchiveViewProps, type PersonRow } from "../src/components/people/people-page.tsx";
|
||||
import { emptyProfile, type Profile } from "../src/lib/home-types.ts";
|
||||
import {
|
||||
PEOPLE_AVATAR_TONES,
|
||||
peopleAvatarInitial,
|
||||
peopleAvatarTone,
|
||||
peopleAyanamsaLabel,
|
||||
peopleCityLabel,
|
||||
peopleRowMeta,
|
||||
peopleTimeRectified,
|
||||
} from "../src/lib/people-archive-view.ts";
|
||||
import { cssDeclarations } from "./css-contract-test-support.ts";
|
||||
|
||||
Object.assign(globalThis, { React });
|
||||
|
||||
const css = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
|
||||
const source = readFileSync(new URL("../src/components/people/people-page.tsx", import.meta.url), "utf8");
|
||||
|
||||
/* Fictional people only (AGENTS §8). */
|
||||
const selfProfile: Profile = {
|
||||
...emptyProfile,
|
||||
name: "示例甲",
|
||||
date: "1990-01-02",
|
||||
time: "04:53",
|
||||
reportedTime: "05:00",
|
||||
birthTimeStatus: "accepted",
|
||||
rectificationCaseId: "fictional-case-1",
|
||||
activeDate: "1990-01-02",
|
||||
birthPlaceLabel: "中国 · 浙江省 · 杭州市 · 西湖区",
|
||||
latitude: 30.25,
|
||||
longitude: 120.13,
|
||||
timezoneId: "Asia/Shanghai",
|
||||
timezoneOffset: 8,
|
||||
ayanamsa: "raman",
|
||||
};
|
||||
const otherProfile: Profile = {
|
||||
...emptyProfile,
|
||||
name: "示例乙",
|
||||
date: "1992-03-12",
|
||||
time: "10:01",
|
||||
reportedTime: "10:01",
|
||||
birthTimeStatus: "reported",
|
||||
birthPlaceLabel: "Lisbon, Portugal",
|
||||
latitude: 38.72,
|
||||
longitude: -9.14,
|
||||
timezoneId: "Europe/Lisbon",
|
||||
timezoneOffset: 0,
|
||||
ayanamsa: "lahiri",
|
||||
};
|
||||
const people: PersonRow[] = [
|
||||
{ id: "self", role: "self", profile: selfProfile, incomplete: false },
|
||||
{ id: "11111111-1111-4111-8111-111111111111", role: "other", profile: otherProfile, incomplete: false },
|
||||
];
|
||||
|
||||
function props(overrides: Partial<PeopleArchiveViewProps> = {}): PeopleArchiveViewProps {
|
||||
const noop = () => {};
|
||||
const asyncNoop = async () => {};
|
||||
return {
|
||||
people,
|
||||
subjectId: "self",
|
||||
selected: people[0],
|
||||
selectedId: "self",
|
||||
view: "list",
|
||||
editing: false,
|
||||
draft: emptyProfile,
|
||||
notice: "",
|
||||
pendingDelete: null,
|
||||
saving: false,
|
||||
atLimit: false,
|
||||
onSelect: noop,
|
||||
onBack: noop,
|
||||
onAdd: noop,
|
||||
onEdit: noop,
|
||||
onCancelEdit: noop,
|
||||
onDraftChange: noop,
|
||||
saveOther: asyncNoop,
|
||||
askDelete: asyncNoop,
|
||||
onCancelDelete: noop,
|
||||
confirmDelete: asyncNoop,
|
||||
openPerson: noop,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const render = (overrides: Partial<PeopleArchiveViewProps> = {}) =>
|
||||
renderToStaticMarkup(React.createElement(PeopleArchiveView, props(overrides)));
|
||||
|
||||
test("D2 list rows: avatar, name, 本人 tag, one monospace meta line, chevron", () => {
|
||||
const markup = render();
|
||||
assert.equal([...markup.matchAll(/class="people-archive-row[ "]/g)].length, 2);
|
||||
assert.match(markup, /<span class="people-archive-avatar is-self" aria-hidden="true">我<\/span>/);
|
||||
assert.match(markup, /<b>示例甲<\/b><span class="people-archive-tag">本人<\/span>/);
|
||||
assert.match(markup, /<small class="people-archive-row-meta">1990-01-02 · 04:53 · 杭州<\/small>/);
|
||||
assert.match(markup, /<small class="people-archive-row-meta">1992-03-12 · 10:01 · Lisbon<\/small>/);
|
||||
assert.equal([...markup.matchAll(/class="people-archive-chevron" aria-hidden="true">›</g)].length, 2);
|
||||
assert.match(markup, /class="people-archive-add"[^>]*>+ 添加一个人</);
|
||||
assert.match(markup, /class="people-archive-hint">还可以保存 3 个人</);
|
||||
});
|
||||
|
||||
test("D2 the current person carries is-current, whoever it is", () => {
|
||||
const selfCurrent = render();
|
||||
assert.match(selfCurrent, /class="people-archive-row is-current[^"]*" aria-current="true"[\s\S]*示例甲/);
|
||||
const otherCurrent = render({ subjectId: people[1].id });
|
||||
const rows = otherCurrent.split("<li>").slice(1);
|
||||
assert.doesNotMatch(rows[0], /is-current/);
|
||||
assert.match(rows[1], /class="people-archive-row is-current/);
|
||||
});
|
||||
|
||||
test("D2 at the limit the add card is disabled and the limit sentence shows", () => {
|
||||
const markup = render({ atLimit: true });
|
||||
assert.match(markup, /class="people-archive-add" type="button" disabled=""/);
|
||||
assert.match(markup, /最多保存 5 个人的星盘/);
|
||||
assert.doesNotMatch(markup, /还可以保存/);
|
||||
});
|
||||
|
||||
test("D1 the view flag drives list → detail; the back control is in the detail column", () => {
|
||||
assert.match(render(), /class="people-archive" data-view="list"/);
|
||||
const detail = render({ view: "detail" });
|
||||
assert.match(detail, /class="people-archive" data-view="detail"/);
|
||||
const detailColumn = detail.slice(detail.indexOf("people-archive-detail"));
|
||||
assert.match(detailColumn, /class="people-archive-back"[^>]*><span aria-hidden="true">‹<\/span> 返回</);
|
||||
});
|
||||
|
||||
test("D3 detail: header, two time cards (rectified in the success family), date, readable ayanamsa, three actions", () => {
|
||||
const markup = render({ view: "detail" });
|
||||
assert.match(markup, /<h2>示例甲<\/h2><p>本人 · 中国 · 浙江省 · 杭州市 · 西湖区<\/p>/);
|
||||
assert.match(markup, /class="people-archive-time"><small>填报时间<\/small><b>05:00<\/b>/);
|
||||
assert.match(markup, /class="people-archive-time is-rectified"><small>排盘时间 · 已校正<\/small><b>04:53<\/b>/);
|
||||
assert.match(markup, /<dt>出生日期<\/dt><dd>1990-01-02<\/dd><dt>岁差<\/dt><dd>Raman(默认)<\/dd>/);
|
||||
assert.doesNotMatch(markup, /<dd>raman<\/dd>/);
|
||||
const actions = markup.slice(markup.indexOf("people-archive-actions"));
|
||||
for (const label of ["和 TA 对话", "看星盘", "生成报告"]) assert.match(actions, new RegExp(label));
|
||||
});
|
||||
|
||||
test("D3 本人 has no delete anywhere; the grey foot says so", () => {
|
||||
const markup = render({ view: "detail" });
|
||||
assert.match(markup, /class="people-archive-foot"><p>本人不能删除<\/p>/);
|
||||
assert.doesNotMatch(markup, /删除这个人|确认删除/);
|
||||
});
|
||||
|
||||
test("D3 another person's detail offers 编辑 and 和我合盘, and delete only in the edit view", () => {
|
||||
const other = people[1];
|
||||
const detail = render({ view: "detail", selected: other, selectedId: other.id });
|
||||
assert.match(detail, /class="people-archive-time"><small>排盘时间<\/small><b>10:01<\/b>/);
|
||||
assert.match(detail, /<dd>Lahiri<\/dd>/);
|
||||
assert.match(detail, /和我合盘/);
|
||||
assert.match(detail, />编辑</);
|
||||
assert.doesNotMatch(detail, /删除这个人/);
|
||||
|
||||
const editing = render({ view: "detail", selected: other, selectedId: other.id, editing: true, draft: other.profile });
|
||||
assert.match(editing, /编辑人物/);
|
||||
const form = editing.indexOf("chart-library-form");
|
||||
const danger = editing.indexOf("people-archive-danger");
|
||||
assert.ok(form > 0 && danger > form, "delete sits under the edit form");
|
||||
assert.match(editing.slice(danger), /删除这个人/);
|
||||
|
||||
const confirm = render({
|
||||
view: "detail", selected: other, selectedId: other.id, editing: true, draft: other.profile,
|
||||
pendingDelete: { id: other.id, sessions: 2, reports: 1 },
|
||||
});
|
||||
assert.match(confirm, /将同时删除 2 个对话和 1 份报告,无法恢复。/);
|
||||
|
||||
const adding = render({ view: "detail", selected: people[0], selectedId: "", editing: true });
|
||||
assert.match(adding, /添加一个人/);
|
||||
assert.doesNotMatch(adding, /删除这个人/);
|
||||
});
|
||||
|
||||
test("D3 the delete-count failure shows under the delete button, not as a form success", () => {
|
||||
const other = people[1];
|
||||
const markup = render({
|
||||
view: "detail", selected: other, selectedId: other.id, editing: true, draft: other.profile,
|
||||
notice: "暂时查不到这个人的对话数量,稍后再试",
|
||||
});
|
||||
const danger = markup.slice(markup.indexOf("people-archive-danger"));
|
||||
assert.match(danger, /class="form-error" role="alert">暂时查不到这个人的对话数量,稍后再试/);
|
||||
assert.doesNotMatch(markup.slice(0, markup.indexOf("people-archive-danger")), /暂时查不到/);
|
||||
});
|
||||
|
||||
test("D4 avatar tone is stable per id and only uses element tones", () => {
|
||||
const id = people[1].id;
|
||||
assert.equal(peopleAvatarTone(id, "other"), peopleAvatarTone(id, "other"));
|
||||
assert.ok((PEOPLE_AVATAR_TONES as readonly string[]).includes(peopleAvatarTone(id, "other")));
|
||||
assert.equal(peopleAvatarTone("self", "self"), "self");
|
||||
const tones = new Set(["a", "b", "c", "d", "e", "f", "g", "h"].map((value) => peopleAvatarTone(value, "other")));
|
||||
assert.ok(tones.size > 1, "different ids spread over the tones");
|
||||
assert.equal(peopleAvatarInitial(otherProfile, "other"), "示");
|
||||
assert.equal(peopleAvatarInitial(selfProfile, "self"), "我");
|
||||
assert.equal(peopleAvatarInitial({ ...emptyProfile }, "other"), "未");
|
||||
});
|
||||
|
||||
test("helpers: city, meta, rectified and ayanamsa label", () => {
|
||||
assert.equal(peopleCityLabel(selfProfile), "杭州");
|
||||
assert.equal(peopleCityLabel(otherProfile), "Lisbon");
|
||||
assert.equal(peopleCityLabel(emptyProfile), "");
|
||||
assert.equal(peopleRowMeta({ ...otherProfile, birthPlaceLabel: "" }), "1992-03-12 · 10:01");
|
||||
assert.equal(peopleTimeRectified(selfProfile), true);
|
||||
assert.equal(peopleTimeRectified({ ...selfProfile, birthTimeStatus: "confirmed" }), true);
|
||||
assert.equal(peopleTimeRectified(otherProfile), false);
|
||||
// A legacy row with a time reads back as `confirmed` but has no rectification case.
|
||||
assert.equal(peopleTimeRectified({ ...otherProfile, birthTimeStatus: "confirmed", rectificationCaseId: "" }), false);
|
||||
assert.equal(peopleAyanamsaLabel(selfProfile), "Raman(默认)");
|
||||
assert.equal(peopleAyanamsaLabel({ ...selfProfile, ayanamsa: "kp" }), "KP");
|
||||
});
|
||||
|
||||
test("CSS: every people-archive class the component uses has a rule (BUG-1039)", () => {
|
||||
// Class names only: every `className=` string / template in the component (the
|
||||
// import path and the form's input id also contain "people-archive").
|
||||
const classStrings = [...source.matchAll(/className=\{?(?:`([^`]+)`|"([^"]+)"|[^}]*?\? "([^"]+)" : "([^"]+)")/g)]
|
||||
.flatMap((hit) => hit.slice(1).filter(Boolean));
|
||||
const used = new Set(classStrings.flatMap((value) => value.match(/people-archive[a-z-]*/g) ?? []));
|
||||
const missing = [...used].filter((name) => !new RegExp(`\\.${name}(?![a-z-])`).test(css));
|
||||
assert.deepEqual(missing, []);
|
||||
assert.ok(used.size >= 20);
|
||||
});
|
||||
|
||||
test("CSS: touch targets, list/detail switch below 1024, two columns at 1024", () => {
|
||||
const minHeight = (selector: string) => Number(cssDeclarations(selector, css).match(/min-height:\s*(\d+)px/)?.[1] ?? 0);
|
||||
assert.ok(minHeight(".people-archive-row") >= 44);
|
||||
assert.ok(minHeight(".people-archive-add") >= 44);
|
||||
assert.ok(minHeight(".people-archive-back") >= 44);
|
||||
assert.ok(minHeight(".people-archive-actions button") >= 44);
|
||||
assert.ok(minHeight(".people-archive-secondary") >= 44);
|
||||
assert.match(css, /@media \(max-width: 1023px\) \{[\s\S]*?\.people-archive\[data-view="list"\] \.people-archive-detail,\s*\.people-archive\[data-view="detail"\] \.people-archive-list \{ display: none; \}/);
|
||||
assert.match(css, /@media \(min-width: 1024px\) \{\s*\.people-archive \{ grid-template-columns:/);
|
||||
assert.match(cssDeclarations(".people-archive-row.is-current", css), /var\(--color-action-strong\)[\s\S]*var\(--color-action-soft\)/);
|
||||
assert.match(cssDeclarations(".people-archive-time.is-rectified", css), /var\(--color-success\)/);
|
||||
for (const tone of PEOPLE_AVATAR_TONES) {
|
||||
assert.match(cssDeclarations(`.people-archive-avatar.is-${tone}`, css), new RegExp(`var\\(--color-element-${tone}\\)`));
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user