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:
Jesse_Chen
2026-09-26 06:58:41 +08:00
co-authored by Claude Opus 5.5
parent 13c70a162a
commit 2981da6fbc
18 changed files with 1942 additions and 191 deletions
+22 -7
View File
@@ -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
View File
@@ -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
View File
@@ -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>
);
+315 -84
View File
@@ -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>
);
}
+124 -9
View File
@@ -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)}°`;
}
+186
View File
@@ -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;
}
+76
View File
@@ -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;
}
+14 -3
View File
@@ -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);
});
+116
View File
@@ -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" });
});
+236
View File
@@ -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}\\)`));
}
});