@starwish-ai/xingyuan-dsh 0.5.4 → 0.5.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/CHANGELOG.md +200 -0
  2. package/README.en-US.md +2 -1
  3. package/README.md +2 -1
  4. package/lib/client/api.js +23 -3
  5. package/lib/client/cards.js +16 -12
  6. package/lib/client/chart-labels.js +107 -0
  7. package/lib/client/hooks.js +17 -0
  8. package/lib/client/i18n.js +221 -20
  9. package/lib/client/pages/calendar.js +23 -9
  10. package/lib/client/pages/color-swatch.js +3 -0
  11. package/lib/client/pages/detail.js +58 -8
  12. package/lib/client/pages/format.js +9 -0
  13. package/lib/client/pages/growth.js +38 -9
  14. package/lib/client/pages/memory.js +48 -22
  15. package/lib/client/pages/quick-create.js +26 -4
  16. package/lib/client/pages/settings.js +67 -9
  17. package/lib/client/pages/task-line.js +31 -13
  18. package/lib/client/pages/tasks.js +31 -28
  19. package/lib/client/pages/today.js +44 -17
  20. package/lib/client/pages/wishes.js +32 -18
  21. package/lib/client/styles.js +166 -65
  22. package/lib/client/ui.js +60 -2
  23. package/lib/client/view-state.js +24 -0
  24. package/lib/client.js +998 -230
  25. package/lib/domain.js +68 -1
  26. package/lib/growth.js +9 -9
  27. package/lib/index.js +3 -1
  28. package/lib/opportunity.js +9 -2
  29. package/lib/pref-policy.js +11 -0
  30. package/lib/pref-settings.js +3 -1
  31. package/lib/preset/charts.js +3 -2
  32. package/lib/preset/hitl.js +33 -6
  33. package/lib/preset/prompts.js +21 -17
  34. package/lib/preset/side.js +1 -0
  35. package/lib/preset/tools.js +65 -48
  36. package/lib/routes/api.js +81 -90
  37. package/lib/routes/errors.js +5 -2
  38. package/lib/routes/index.js +47 -44
  39. package/lib/routes/pages-html.js +5 -4
  40. package/lib/session-log-repair.js +20 -1
  41. package/lib/sqlite.js +10 -2
  42. package/lib/store.js +117 -19
  43. package/lib/types/client/api.d.ts +2 -2
  44. package/lib/types/client/chart-labels.d.ts +32 -0
  45. package/lib/types/client/hooks.d.ts +5 -0
  46. package/lib/types/client/i18n.d.ts +111 -11
  47. package/lib/types/client/pages/format.d.ts +8 -0
  48. package/lib/types/client/pages/quick-create.d.ts +4 -0
  49. package/lib/types/client/pages/settings.d.ts +3 -0
  50. package/lib/types/client/pages/task-line.d.ts +4 -0
  51. package/lib/types/client/pages/types.d.ts +2 -1
  52. package/lib/types/client/styles.d.ts +7 -4
  53. package/lib/types/client/ui.d.ts +16 -1
  54. package/lib/types/client/view-state.d.ts +15 -0
  55. package/lib/types/domain.d.ts +9 -0
  56. package/lib/types/opportunity.d.ts +5 -0
  57. package/lib/types/pref-policy.d.ts +11 -0
  58. package/lib/types/preset/hitl.d.ts +7 -2
  59. package/lib/types/preset/tools.d.ts +3 -0
  60. package/lib/types/routes/api.d.ts +1 -1
  61. package/lib/types/routes/errors.d.ts +4 -3
  62. package/lib/types/routes/index.d.ts +3 -1
  63. package/lib/types/store.d.ts +30 -0
  64. package/lib/types/types.d.ts +3 -0
  65. package/lib/types.js +2 -0
  66. package/package.json +2 -2
package/CHANGELOG.md CHANGED
@@ -5,6 +5,206 @@ All notable changes to this project are documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.5.6] - 2026-08-29
9
+
10
+ > **Note:** this release is the follow-up to the v0.5.5 six-perspective quality
11
+ > pass: the same six reviews re-ran against 0.5.5 and every accepted finding is
12
+ > fixed here — one data-safety gate, honesty fixes in the growth ladder, focus
13
+ > management on the highest-frequency action, and a batch of contrast, copy and
14
+ > localization closures. No data format changes; existing databases and recorded
15
+ > sessions replay as before.
16
+
17
+ ### Added
18
+ - **Write-path schema gate** (`makeXingyuanStore`): the storage domain validates
19
+ records only when cold-opening the medium — a write path that skipped a field
20
+ check (e.g. an over-long task name from the model) used to persist silently and
21
+ then brick the next startup (`invalid-record` refuses to open, plugin dead until
22
+ manual surgery). Every `put`/`update`/`global.set` is now guarded by the declared
23
+ zod schema at the single store assembly point and rejected at write time with a
24
+ stable `invalid_record` code; valid records parse as identity, reads pay nothing.
25
+ Locked by `test/domain-guard.test.ts`.
26
+ - **Network-failure copy**: connection-level fetch failures (dsh restarting) no
27
+ longer surface the browser's raw English (`Failed to fetch` / `Load failed`) in
28
+ the Chinese UI; they map to a localized "network request failed, retry" message.
29
+ - **Task rows keep keyboard focus after group-migrating actions**: a successful
30
+ check-in moves the row between the Today page's open/done lists and destroyed the
31
+ pressed button, dropping focus to `<body>`; focus now falls back to the page
32
+ title (the same pattern as undo). The same treatment covers a check-in that
33
+ closes a task anywhere task rows render, and claiming a task on the Tasks page,
34
+ where the row moves between status groups.
35
+ - **Memory page**: the search empty-state no longer flashes between keystrokes
36
+ while the debounce is pending, a settled zero-hit search now shows the "no
37
+ matches" copy instead of the "no memories yet" empty-library one (the server's
38
+ `total` is query-filtered, so the two were indistinguishable before), and the
39
+ key field's server minimum (2 characters) is validated inline with its guidance
40
+ message instead of a generic error.
41
+
42
+ ### Changed
43
+ - **Level rewards describe what exists**: the growth ladder no longer promises
44
+ avatar frames, task templates or style gating that the product does not have
45
+ (coach styles were always free in Settings); rewards are now the honorary title
46
+ itself, capped by the Lv.10 pinnacle. Server table and both dictionaries updated
47
+ together (the Chinese dictionary is locked to the server table by
48
+ `test/growth.test.ts`).
49
+ - **Levels read "荣誉" not "权益"** on the Growth page hero and in `get_growth_stats`
50
+ output, matching the honorary rewards.
51
+ - **Today plan keeps the achieved-closing row**: checking the last opportunity day
52
+ closes the task, which used to remove the row from the Today page entirely (the
53
+ counter shrank right after checking in). A closed-achieved task with a check-in
54
+ on that date stays in the done group with its undo entry; cancelling the check-in
55
+ revives the task through the existing freshness path.
56
+ - **Calendar hover preserves state colors**: hovering a partially/fully-completed
57
+ day no longer washes the semantic amber/green chip with the accent hover tint —
58
+ the state color is exactly what the hover was inspecting.
59
+ - **Contrast closures**: micro-action card secondary text and skipped steps use the
60
+ `label-on-2` pair and a higher muted opacity so both themes stay above the
61
+ 4.5:1 line (the same lift applies to deleted wish cards in chat); calendar/
62
+ check-in-cell state foreground colors moved into the theme token region as
63
+ paired `--xyd-on-c2` / `--xyd-on-c3` / `--xyd-on-dcell` tokens.
64
+ - **Date validation accepts only real calendar dates**: `2026-02-30` used to pass
65
+ the format check (`Date.parse` rolls it over instead of returning NaN) and then
66
+ silently produce a task with an empty opportunity sequence — or land in the
67
+ check-in table as a fake day that skews the streak replay. Task due dates, wish
68
+ estimated dates and check-in target dates (including the any-date back-fill on
69
+ no-deadline tasks) now share the opportunity calculator's semantic `isIsoDate`
70
+ round-trip check, and the routes' duplicate implementation converges onto it.
71
+ - **Prompt coherence**: the check-in example moved out of the "no-confirm mode"
72
+ section into confirm mode (check-in is a confirm-gated write) and no longer
73
+ demonstrates a streak number the tool does not return; list-tool outputs tell the
74
+ model to use bracketed IDs only for follow-up calls, not to render them to the
75
+ user; `generate_chart`'s `wishId` parameter names the camelCase chart keys.
76
+ - **Memory search bar wraps** so the saved notice is not clipped off narrow panels.
77
+ - **Copy/wording**: the Settings preference-unavailable hint no longer hardcodes a
78
+ stale count; the English "clear all memories" dialog is grammatical for one item;
79
+ the Growth page EXP/experience units are consistent in Chinese; an unknown level
80
+ falls back to a localized name instead of an empty label; the 404 API error and
81
+ the wish-not-found tool/route error carry localizable codes; the English
82
+ check-in grid summary is grammatical for one cell; README wording aligned with
83
+ the UI ("连续加成", straight quotes in English).
84
+
85
+ ### Fixed
86
+ - **sqlite backend**: a corrupted table row now reports `malformed-medium` like the
87
+ global slot does, instead of a raw `SyntaxError` with a split troubleshooting path.
88
+ - **Mock generator**: the detail-panel fixture in `debug/gen-mock.ts` takes an id
89
+ parameter so the wish/task scenarios no longer emit duplicate DOM ids.
90
+
91
+ ## [0.5.5] - 2026-08-28
92
+
93
+ > **Note:** this release is a six-perspective quality pass (product, visual design,
94
+ > end user, dsh-platform conformance, architecture, UX) over every interaction,
95
+ > display and code path. It closes the failure side of the habit loop — an expired
96
+ > task used to be indistinguishable from an achieved one and was a dead end on the
97
+ > pages — and makes the plugin honest in English wherever it previously leaked
98
+ > built-in Chinese vocabulary. No data format changes; existing databases and
99
+ > recorded sessions replay as before.
100
+
101
+ ### Added
102
+ - **Expired vs achieved, end to end**: task views carry `closedReason`; the Tasks
103
+ page splits the former "Closed" bucket into **Achieved** and **Expired** groups,
104
+ task rows say which one they are, and an expired task's detail panel explains the
105
+ state and offers an in-page **extend due date → restart** action (same store write
106
+ path as the chat-side `update_task`). An expired task is no longer a dead row with
107
+ a delete button.
108
+ - **Confirm card language** (`xingyuan-pref.confirmLang`, default Chinese): the
109
+ platform does not expose UI language to host-side plugins (verified against rc.2),
110
+ so the in-chat confirmation card's header, buttons and question text now follow
111
+ this explicit setting with full English strings; the Settings page gains a
112
+ "Confirm card language" select with optimistic save and write verification.
113
+ - **Built-in chart vocabulary localizes on the client**: chart cards map known
114
+ built-in Chinese titles, enum labels (weekdays, hour buckets, task statuses,
115
+ series names) and fixed subtitles to the interface language, while user data
116
+ (wish/task names) and unknown values replay verbatim. Coverage is locked by
117
+ `test/chart-labels.test.ts` against the real chart builder, so a new built-in
118
+ word without a mapping fails the build.
119
+ - **Level names localize**: the Growth page renders level names and rewards from
120
+ the interface language by level number (server strings remain the Chinese
121
+ fallback); locked to the server table by the same test.
122
+ - **Route contract compliance**: `/xingyuan` prefix registration is effect-scoped —
123
+ the disposer returned by `webServer.register` is no longer discarded, so hot
124
+ reload / re-activation can no longer die on a duplicate-prefix throw or leave a
125
+ stale handler serving a closed domain. A loader-level remount test with a
126
+ duplicate-throwing webServer stub pins this.
127
+ - New contract tests: `style-contract` (no `color-mix(` anywhere; semi-transparent
128
+ colors only inside the token zone; key semantic tokens must exist in both
129
+ themes), `chart-labels` (built-in chart vocabulary fully covered by the
130
+ localization maps, and client level names locked verbatim to the server table),
131
+ `sqlite-backend` (version-mismatch rejection, corrupt-global rejection,
132
+ write → cold-read persistence), plus a tool-suite audit pinning the 10-minute
133
+ HITL timeout on every confirmation-waiting tool and the "use real IDs from list
134
+ results" note on every ID parameter.
135
+
136
+ ### Changed
137
+ - **System prompts no longer assert gated behavior**: six spots claimed the system
138
+ "will pop a confirmation card" unconditionally — wrong when the write-confirmation
139
+ setting is off (tool descriptions already hedged; the prompts now hedge the same
140
+ way, "删除始终弹出" stays asserted since deletes always confirm).
141
+ - **Memory overwrite asks once**: creating a memory with an existing key showed the
142
+ overwrite confirmation, then the server rejection re-asked the identical question;
143
+ the confirmed path now submits with `overwrite: true` directly.
144
+ - **Settings tab-visibility tells the truth**: memory-mode (non-loopback) and
145
+ read-only states get their own notices instead of silently grey controls, and
146
+ writes verify post-settle snapshot like the chat-preferences controls, toasting
147
+ on silent revert (`scope.set()` resolves even on failure).
148
+ - **Honest stale-data degradation**: when a refetch fails after a successful action
149
+ (or on focus refetch), pages keep the loaded data and show a "refresh failed,
150
+ data may be outdated" banner with inline retry instead of flipping to a full-page
151
+ error; Today's retry now reloads both endpoints at once.
152
+ - **Motion & touch discipline**: the growth bar drops its layout-animating `height`
153
+ transition and the toggle knob animates `transform` only; every purely decorative
154
+ hover is gated behind `(hover:hover) and (pointer:fine)` so touch screens no
155
+ longer stick hover tints; error toasts use a dedicated assertive `role=alert`
156
+ container; the confirm dialog locks body scroll; after deletes/undoes destroy the
157
+ triggered row, focus moves to the page title instead of falling to `<body>`.
158
+ - **Cross-tab state survives**: memory search query, calendar month offset and
159
+ expanded task details persist across view-tab switches via a module-scoped
160
+ snapshot store; view pages scroll back to top on mount.
161
+ - **Visual tokens**: new `--xyd-label-on-2` pair fixes secondary text on secondary
162
+ surfaces (≈4.3:1 → ≥5:1 in both themes) across the task-card preview strip, group
163
+ count pills, level chips, future grid cells and the growth chart axis ticks;
164
+ success/warn washes, calendar state fills,
165
+ shadows and hero colors are explicit themed token pairs (no more shared-alpha
166
+ washes that vanished on dark); calendar hover wash no longer paints over the
167
+ picked chip; 待打卡 chips gain the inset ring their legend dot always had;
168
+ standalone dark "complete" chip darkens to AA contrast; 11px CJK text raises to
169
+ 12px; month-adjacent days brighten from 1.8:1 to readable-but-dim; the mock
170
+ generator itself is `color-mix`-free again.
171
+ - **Error codes completed end-to-end**: `not_claimed`, `no_opportunity_left`,
172
+ `no_checkins`, `title_too_long`, `name_too_long`, `once_today_only`,
173
+ `payload_too_large`, `bad_json_body`, `bad_coach_style`, `bad_interests` now flow
174
+ from store/routes to localized client strings (English users no longer receive
175
+ raw Chinese domain messages); HTTP status suffixes localize their brackets.
176
+ - **Dates**: hover tooltips on the check-in grid and calendar cells localize while
177
+ aria keeps ISO precision; memory timestamps render as medium dates; the growth
178
+ chart axis uses Intl ticks (Aug 27 / 8-27); the English "Commit {date}" button
179
+ becomes "Early check-in · {date}" so the button itself carries check-in semantics.
180
+ - **Writes stay in one place**: memory delete/clear and profile updates go through
181
+ store use cases (`deleteMemory`, `clearMemories`, `updateProfileGlobal`) shared by
182
+ tools and routes; profile fields clamp at the same limits on both faces (nickname
183
+ 50 / occupation 100 / interests 20×50) — the tool path was previously unbounded
184
+ and injects into every system prompt turn.
185
+ - **Batch reads honor the store contract**: wish/task list paths use
186
+ `freshWishes` and a single shared checkin-count index instead of per-item
187
+ full-table rescans for the completed-day counts; the
188
+ chart builder computes its checkin index only for the one chart that consumes it;
189
+ semantically invalid dates (`2026-13-01`) are rejected with a stable `bad_date`
190
+ code instead of an unlocalizable RangeError; session-log repair sweeps its
191
+ `.xy-repair-*` atomic-write temp files; `./types` export resolves types and
192
+ runtime to the same module.
193
+ - **Copy honesty**: delete confirmations (chat and pages) now mention the
194
+ micro-step plan that the cascade removes; quick-create forms soft-confirm on an
195
+ exact duplicate name/title (the chat path dedupes via `check_similar_*`, the
196
+ page path previously did not); wish progress bars expose `role=progressbar`; the
197
+ four create tools declare the same 10-minute HITL timeout as their siblings;
198
+ task-related ID parameters all carry the "use real IDs from list results"
199
+ discipline.
200
+
201
+ ### Fixed
202
+ - `start_micro_action` gates on a freshened task status, so a task that expired
203
+ since its last write can no longer start a breakdown.
204
+ - `create_wish_with_tasks` no longer over-claims atomicity in its pre-validation
205
+ comment (task-field failures are honestly reported; the wish and created tasks
206
+ are kept).
207
+
8
208
  ## [0.5.4] - 2026-08-28
9
209
 
10
210
  > **Note:** trailing chart statistics no longer count future pre-checked days (see
package/README.en-US.md CHANGED
@@ -28,7 +28,7 @@ Tools are only mounted on sessions using the XingYuan preset; other sessions are
28
28
  dsh plugin --profile web add @starwish-ai/xingyuan-dsh
29
29
  ```
30
30
 
31
- After starting the Web GUI, installation succeeded when「星愿」appears in the agent picker.
31
+ After starting the Web GUI, installation succeeded when "星愿" (XingYuan) appears in the agent picker.
32
32
 
33
33
  ## Data & Backup
34
34
 
@@ -43,6 +43,7 @@ Business data lives at `~/.dsh/xingyuan/xingyuan.sqlite` and survives uninstall
43
43
  | Write confirmation | Whether create / check-in writes need confirmation (deletion always does) |
44
44
  | Memory injection limit | Max memories injected per turn |
45
45
  | Tab visibility | Three modes (follow session / always show / always hide) plus per-tab toggles; defaults to follow session |
46
+ | Confirm card language | Language of the in-chat confirmation card (Chinese / English; the platform does not expose UI language to plugins, defaults to Chinese) |
46
47
 
47
48
  ## Development
48
49
 
package/README.md CHANGED
@@ -13,7 +13,7 @@
13
13
  - **对话驱动**:自然语言创建愿望 / 任务,自动查重相似项
14
14
  - **机会日打卡**:周期任务打卡、补卡、取消、未来预勾,进度跨天恒为最新
15
15
  - **微行动拆解**:把无从下手的目标拆成 3–7 个小步,引导式逐个完成或跳过
16
- - **成长体系**:等级 Lv.1–Lv.10、连击加成、统计卡与近 30 天柱状图
16
+ - **成长体系**:等级 Lv.1–Lv.10、连续加成、统计卡与近 30 天柱状图
17
17
  - **会话视图页**:今日 / 愿望 / 任务 / 日历 / 成长 / 记忆 六个标签页,页面按钮直连动作接口;
18
18
  默认仅星愿预设的会话显示,可在设置里切换「始终显示 / 始终隐藏」并按标签勾选
19
19
  - **记忆**:重要记忆自动注入上下文(上限可配),支持增删改查
@@ -44,6 +44,7 @@ dsh plugin --profile web add @starwish-ai/xingyuan-dsh
44
44
  | 写操作二次确认 | 创建 / 打卡类写操作是否确认(删除始终确认) |
45
45
  | 记忆注入上限 | 每次注入上下文的记忆条数上限 |
46
46
  | 标签页显示 | 三态模式(跟随会话 / 始终显示 / 始终隐藏)+ 六个标签逐个勾选;默认跟随会话 |
47
+ | 确认卡语言 | 对话中确认卡的显示语言(中文 / English;平台不向插件暴露界面语言,默认中文) |
47
48
 
48
49
  ## 开发
49
50
 
package/lib/client/api.js CHANGED
@@ -24,13 +24,31 @@ const ERROR_KEY = {
24
24
  already_claimed: 'err.already_claimed',
25
25
  task_closed: 'err.task_closed',
26
26
  due_past: 'err.due_past',
27
+ due_too_far: 'err.due_too_far',
27
28
  bad_category_name: 'err.bad_category_name',
28
29
  bad_color_key: 'err.bad_color_key',
29
30
  bad_date: 'err.bad_date',
30
31
  overwrite_required: 'err.overwrite_required',
32
+ not_claimed: 'err.not_claimed',
33
+ no_opportunity_left: 'err.no_opportunity_left',
34
+ no_checkins: 'err.no_checkins',
35
+ title_too_long: 'err.title_too_long',
36
+ name_too_long: 'err.name_too_long',
37
+ once_today_only: 'err.once_today_only',
38
+ payload_too_large: 'err.payload_too_large',
39
+ bad_json_body: 'err.bad_json_body',
40
+ bad_coach_style: 'err.bad_coach_style',
41
+ bad_interests: 'err.bad_interests',
31
42
  };
32
- /** 把任意抛错转成用户可读文案:ActionError 按键本地化;其余直出消息。 */
43
+ /** 网络层失败的浏览器原文是英文技术串(Chrome "Failed to fetch"、Safari "Load failed"、
44
+ * Firefox "NetworkError…"、undici "fetch failed"),直出会打破本地化承诺——识别后统一
45
+ * 转本地化文案。仅匹配 TypeError(fetch 在请求发出前失败的规范类型)中的已知模式,
46
+ * 其余 TypeError(代码缺陷)仍原样抛出便于排障。 */
47
+ const NETWORK_ERROR_PATTERN = /failed to fetch|load failed|networkerror|fetch failed/i;
48
+ /** 把任意抛错转成用户可读文案:网络失败/ActionError 按键本地化;其余直出消息。 */
33
49
  export function describeError(e) {
50
+ if (e instanceof TypeError && NETWORK_ERROR_PATTERN.test(e.message))
51
+ return t('common.networkError');
34
52
  if (!(e instanceof ActionError))
35
53
  return e instanceof Error ? e.message : String(e);
36
54
  // 服务端新增未知 code 时回落原始消息,避免渲染 undefined 或插值崩溃
@@ -72,18 +90,20 @@ export async function getJson(path) {
72
90
  return parsePayload(response, () => t('common.requestFailed'));
73
91
  }
74
92
  async function parsePayload(response, fallback) {
93
+ // HTTP 层错误的状态码后缀走本地化键(zh 全角 / en 半角括号),不硬编码全角括号
94
+ const withStatus = (message) => `${message}${t('common.httpStatus', { status: response.status })}`;
75
95
  let payload;
76
96
  try {
77
97
  payload = await response.json();
78
98
  }
79
99
  catch {
80
- throw new Error(`${fallback()}(${response.status})`);
100
+ throw new Error(withStatus(fallback()));
81
101
  }
82
102
  if (!response.ok) {
83
103
  if (typeof payload.code === 'string' && payload.code !== '') {
84
104
  throw new ActionError(payload.code, payload.error ?? fallback(), payload.params);
85
105
  }
86
- throw new Error(payload.error ?? `${fallback()}(${response.status})`);
106
+ throw new Error(payload.error !== undefined ? withStatus(payload.error) : withStatus(fallback()));
87
107
  }
88
108
  return payload;
89
109
  }
@@ -10,6 +10,7 @@ import { postAction, ActionError, describeError } from './api.js';
10
10
  import { toast, toastError } from './ui.js';
11
11
  import { localYmd, softConfirm } from './hooks.js';
12
12
  import { useXyT, t, activeLocale } from './i18n.js';
13
+ import { localizeChartLabel, localizeChartSeries, localizeChartSubtitle, localizeChartTitle } from './chart-labels.js';
13
14
  import { categoryVars } from '../category-color.js';
14
15
  import { cycleLabel, durationText, dateSuffix, formatMediumDate, formatShortDate } from './pages/format.js';
15
16
  function WishView(props) {
@@ -147,21 +148,23 @@ function ChartView(props) {
147
148
  const event = props.node.data.data;
148
149
  // 悬停柱下标(与成长页悬浮明细同一交互语法;null = 未悬停)
149
150
  const [hoverIdx, setHoverIdx] = useState(null);
150
- return createElement('div', { className: 'xy-card xy-chart' }, createElement('div', { className: 'xy-card-head' }, createElement('span', { className: 'xy-badge xy-badge-chart' }, t('badge.chart')), createElement('span', { className: 'xy-title' }, event.title), event.subtitle !== undefined ? createElement('span', { className: 'xy-meta' }, event.subtitle) : null),
151
+ const title = localizeChartTitle(event);
152
+ return createElement('div', { className: 'xy-card xy-chart' }, createElement('div', { className: 'xy-card-head' }, createElement('span', { className: 'xy-badge xy-badge-chart' }, t('badge.chart')), createElement('span', { className: 'xy-title' }, title), event.subtitle !== undefined ? createElement('span', { className: 'xy-meta' }, localizeChartSubtitle(event.subtitle)) : null),
151
153
  // 快照时点标注(事件溯源:卡片=当时的事实):无 generatedAt 的旧事件诚实降级不显示。
152
154
  // 中格式带年份——回放的旧卡可能跨年,「生成于 3月5日」须能定位到哪一年。
153
155
  // ISO → 本地日再格式化:直接 slice 会把 UTC 日期当本地日(UTC+8 零点后 8 小时内差一天)
154
156
  event.generatedAt !== undefined
155
157
  ? createElement('div', { className: 'xy-meta' }, t('chart.generatedAt', { date: formatMediumDate(localYmd(new Date(event.generatedAt))) }))
156
- : null, chartBody(event, t('chart.noData'), t('chart.noSchedule'), hoverIdx, setHoverIdx));
158
+ : null, chartBody(event, title, t('chart.noData'), t('chart.noSchedule'), hoverIdx, setHoverIdx));
157
159
  }
158
- /** 图表卡主体:四类渲染路径(占比条/占比列表/热力格/柱图)颜色全部走主题变量,深浅色自适应。 */
159
- function chartBody(event, noDataText, noScheduleText, hoverIdx, onHover) {
160
+ /** 图表卡主体:四类渲染路径(占比条/占比列表/热力格/柱图)颜色全部走主题变量,深浅色自适应。
161
+ * 标签/序列名经 chart-labels 本地化(服务端内建中文词的展示层映射,未知值原样回显)。 */
162
+ function chartBody(event, title, noDataText, noScheduleText, hoverIdx, onHover) {
160
163
  const { chartType, data } = event;
161
164
  if (data.length === 0)
162
165
  return createElement('div', { className: 'xy-meta' }, noDataText);
163
166
  const max = Math.max(...data.filter((d) => !d.inactive).map((d) => d.value), 1);
164
- const labelValue = (d) => `${d.label}${activeLocale() === 'en' ? ': ' : ':'}${d.inactive === true ? noScheduleText : d.value}`;
167
+ const labelValue = (d) => `${localizeChartLabel(d.label)}${activeLocale() === 'en' ? ': ' : ':'}${d.inactive === true ? noScheduleText : d.value}`;
165
168
  if (chartType === 'arcbars') {
166
169
  const ratio = Math.min(Math.max(data[0].ratio ?? 0, 0), 1);
167
170
  return createElement('div', { className: 'xy-arcwrap' }, createElement('div', { className: 'xy-arcnum' }, `${Math.round(ratio * 100)}%`),
@@ -171,7 +174,7 @@ function chartBody(event, noDataText, noScheduleText, hoverIdx, onHover) {
171
174
  if (chartType === 'pie' || chartType === 'radar') {
172
175
  // 占比分母守卫:总和为 0 时跳过百分比(避免 NaN%)
173
176
  const sum = data.reduce((s, x) => s + x.value, 0);
174
- return createElement('ul', { className: 'xy-rows' }, ...data.map((d, i) => createElement('li', { key: i, className: 'xy-row' }, createElement('span', null, d.label), createElement('span', { className: 'xy-rowval' }, `${d.value}${chartType === 'pie' && sum > 0 ? ` · ${Math.round((d.value / sum) * 100)}%` : ''}`))));
177
+ return createElement('ul', { className: 'xy-rows' }, ...data.map((d, i) => createElement('li', { key: i, className: 'xy-row' }, createElement('span', null, localizeChartLabel(d.label)), createElement('span', { className: 'xy-rowval' }, `${d.value}${chartType === 'pie' && sum > 0 ? ` · ${Math.round((d.value / sum) * 100)}%` : ''}`))));
175
178
  }
176
179
  if (chartType === 'heatmap') {
177
180
  return createElement('div', null, createElement('div', { className: 'xy-heat', 'aria-hidden': 'true' }, ...data.map((d, i) => createElement('span', {
@@ -181,7 +184,7 @@ function chartBody(event, noDataText, noScheduleText, hoverIdx, onHover) {
181
184
  style: { opacity: String(0.25 + 0.75 * (d.value / max)) },
182
185
  }))),
183
186
  // 热力格纯视觉(title 不构成可访问名):数据以屏内隐藏文本整体提供给读屏
184
- createElement('span', { className: 'xy-visually-hidden' }, data.map((d) => `${d.label} ${d.value}`).join(activeLocale() === 'en' ? '; ' : ';')));
187
+ createElement('span', { className: 'xy-visually-hidden' }, data.map((d) => `${localizeChartLabel(d.label)} ${d.value}`).join(activeLocale() === 'en' ? '; ' : ';')));
185
188
  }
186
189
  const seriesList = [...new Set(data.map((d) => d.series).filter((s) => s !== undefined))];
187
190
  const seriesColor = (index) => index <= 0 ? 'var(--xyd-accent)' : 'var(--dsw-alias-label-secondary)';
@@ -204,7 +207,8 @@ function chartBody(event, noDataText, noScheduleText, hoverIdx, onHover) {
204
207
  return [createElement('path', {
205
208
  key: i,
206
209
  d: topRoundedBar(i * step + 2, height - 18 - h, Math.max(step - 4, 2), h),
207
- style: { fill: seriesIdx > 0 ? seriesColor(seriesIdx) : 'var(--xyd-accent)', opacity: seriesIdx > 0 ? 0.55 : 0.85 },
210
+ // 次要序列 opacity 0.8:0.55 曾把次要柱对卡底压到 2.1:1(非文字 3:1 下限之下)
211
+ style: { fill: seriesIdx > 0 ? seriesColor(seriesIdx) : 'var(--xyd-accent)', opacity: seriesIdx > 0 ? 0.8 : 0.85 },
208
212
  onMouseEnter: () => onHover(i),
209
213
  },
210
214
  // 每根柱子带 <title>:原生悬停提示;精确数值另由上方悬浮明细条实时呈现
@@ -218,9 +222,9 @@ function chartBody(event, noDataText, noScheduleText, hoverIdx, onHover) {
218
222
  .map(({ d, i }) => createElement('text', {
219
223
  key: `l${i}`, x: i * step + step / 2, y: height - 4,
220
224
  fontSize: 11, textAnchor: 'middle', style: { fill: 'var(--dsw-alias-label-secondary)' },
221
- }, d.label));
225
+ }, localizeChartLabel(d.label)));
222
226
  const legend = seriesList.length > 1
223
- ? createElement('div', { className: 'xy-chart-legend' }, ...seriesList.map((name, idx) => createElement('span', { key: name }, createElement('i', { className: 'xy-dot', style: { background: seriesColor(idx), opacity: idx > 0 ? 0.55 : 1 } }), name)))
227
+ ? createElement('div', { className: 'xy-chart-legend' }, ...seriesList.map((name, idx) => createElement('span', { key: name }, createElement('i', { className: 'xy-dot', style: { background: seriesColor(idx), opacity: idx > 0 ? 0.8 : 1 } }), localizeChartSeries(name))))
224
228
  : null;
225
229
  const hovered = hoverIdx !== null ? data[hoverIdx] : undefined;
226
230
  return createElement('div', null, legend, createElement('div', { onMouseLeave: () => onHover(null) },
@@ -229,8 +233,8 @@ function chartBody(event, noDataText, noScheduleText, hoverIdx, onHover) {
229
233
  // 图表整体作为一张图暴露给读屏;逐条数值以屏内隐藏文本补充
230
234
  createElement('svg', {
231
235
  viewBox: `0 0 ${width} ${height}`, className: 'xy-svg',
232
- role: 'img', 'aria-label': event.title,
233
- }, createElement('line', { x1: 0, y1: height - 18, x2: width, y2: height - 18, style: { stroke: 'var(--dsw-alias-border-l2)' } }), ...bars, ...labels)), createElement('span', { className: 'xy-visually-hidden' }, data.map((d) => `${d.label} ${d.inactive === true ? noScheduleText : d.value}`).join(activeLocale() === 'en' ? '; ' : ';')));
236
+ role: 'img', 'aria-label': title,
237
+ }, createElement('line', { x1: 0, y1: height - 18, x2: width, y2: height - 18, style: { stroke: 'var(--dsw-alias-border-l2)' } }), ...bars, ...labels)), createElement('span', { className: 'xy-visually-hidden' }, data.map((d) => `${localizeChartLabel(d.label)} ${d.inactive === true ? noScheduleText : d.value}`).join(activeLocale() === 'en' ? '; ' : ';')));
234
238
  }
235
239
  export const CARD_VIEWS = {
236
240
  WishView,
@@ -0,0 +1,107 @@
1
+ /**
2
+ * 图表卡词表的客户端本地化。
3
+ *
4
+ * 背景(终端用户评审 P1):服务端 charts.ts 的内建标题/副标题/枚举标签是中文权威源,
5
+ * en 用户会在图表卡上直接看到「周一 / 本周 / 未分类」。事件是 whole-value 冻结快照,
6
+ * 历史事件只有中文标签,因此本地化放在消费端:按 chartKey 与「已知中文标签 → 键」
7
+ * 的映射表渲染,未知值(模型自定义标题、用户数据标签如愿望名)原样回显——诚实降级。
8
+ *
9
+ * 一致性锁定:映射表的中文键必须与 charts.ts 内建词逐字一致,由 test/chart-labels.test.ts
10
+ * 用真实 buildChart 输出对拍(内建词改动而映射表未跟 → 测试红)。
11
+ */
12
+ import { t } from './i18n.js';
13
+ /** 各 chartKey 的内建默认标题(与 charts.ts `params.title ?? '…'` 逐字同源)。 */
14
+ export const CHART_DEFAULT_TITLES = {
15
+ checkinTrend: '打卡趋势',
16
+ checkinCalendar: '打卡日历',
17
+ taskCompletionRate: '任务完成率',
18
+ checkinRateTrend: '打卡完成率',
19
+ weekComparison: '本周打卡对比',
20
+ taskStatus: '任务状态分布',
21
+ checkinByCategory: '分类打卡分布',
22
+ wishCategory: '愿望分类分布',
23
+ checkinRanking: '打卡排行',
24
+ taskDistribution: '任务分布',
25
+ wishProgress: '愿望进度',
26
+ wishAchievement: '愿望达成率',
27
+ continuousCheckin: '连续打卡',
28
+ checkinTimeDistribution: '打卡时间分布',
29
+ weeklyActivity: '周度活跃度',
30
+ };
31
+ /** 已知中文枚举标签 → 本地化键(weekComparison/weeklyActivity 星期、时段桶、状态、系列名等)。
32
+ * 导出供 test/chart-labels.test.ts 做「服务端内建词全覆盖」对拍锁定。 */
33
+ export const CHART_LABEL_KEYS = {
34
+ 周一: 'chart.weekday.1',
35
+ 周二: 'chart.weekday.2',
36
+ 周三: 'chart.weekday.3',
37
+ 周四: 'chart.weekday.4',
38
+ 周五: 'chart.weekday.5',
39
+ 周六: 'chart.weekday.6',
40
+ 周日: 'chart.weekday.7',
41
+ '凌晨(0-5)': 'chart.hour.0',
42
+ '早晨(6-8)': 'chart.hour.1',
43
+ '上午(9-11)': 'chart.hour.2',
44
+ '中午(12-13)': 'chart.hour.3',
45
+ '下午(14-17)': 'chart.hour.4',
46
+ '傍晚(18-19)': 'chart.hour.5',
47
+ '夜间(20-23)': 'chart.hour.6',
48
+ 待领取: 'task.status.pending',
49
+ 进行中: 'task.status.in_progress',
50
+ 已完结: 'task.status.closed',
51
+ 未分类: 'chart.uncategorized',
52
+ 未关联: 'chart.unlinked',
53
+ 完成率: 'chart.label.completionRate',
54
+ 达成率: 'chart.label.achievementRate',
55
+ '当前/最长': 'chart.label.currentVsBest',
56
+ };
57
+ export const CHART_SERIES_KEYS = {
58
+ 本周: 'chart.series.this',
59
+ 上周: 'chart.series.last',
60
+ };
61
+ /** 数据点标签本地化:已知中文枚举标签映射,其余(用户数据)原样回显。 */
62
+ export function localizeChartLabel(label) {
63
+ const key = CHART_LABEL_KEYS[label];
64
+ return key !== undefined ? t(key) : label;
65
+ }
66
+ /** 分组序列名本地化(图例与柱色对应关系按原字符串分组,故仅展示层替换)。 */
67
+ export function localizeChartSeries(series) {
68
+ const key = CHART_SERIES_KEYS[series];
69
+ return key !== undefined ? t(key) : series;
70
+ }
71
+ /** 标题本地化:内建默认标题按 chartKey 映射;模型自定义标题原样回显。 */
72
+ export function localizeChartTitle(event) {
73
+ const key = `chart.title.${event.chartKey}`;
74
+ if (event.title === CHART_DEFAULT_TITLES[event.chartKey])
75
+ return t(key);
76
+ return event.title;
77
+ }
78
+ /** 已知中文固定副标题 → 本地化键。 */
79
+ export const CHART_SUBTITLE_KEYS = {
80
+ 最近一年: 'chart.subtitle.lastYear',
81
+ '按任务 TopN': 'chart.subtitle.byTaskTopN',
82
+ '按愿望 TopN': 'chart.subtitle.byWishTopN',
83
+ '按实际打卡时刻分桶;过滤范围为打卡日区间': 'chart.subtitle.byHourBucket',
84
+ '%(应打天数完成率)': 'chart.subtitle.progressPercent',
85
+ };
86
+ /**
87
+ * 副标题本地化:固定短语查表;含数字的模板(近 N 天 / A/B 天 / 当前 A 天 / 最长 B 天 /
88
+ * 含未领取任务)按正则重排——服务端模板变化时未知副标题原样回显(诚实降级)。
89
+ */
90
+ export function localizeChartSubtitle(subtitle) {
91
+ const fixed = CHART_SUBTITLE_KEYS[subtitle];
92
+ if (fixed !== undefined)
93
+ return t(fixed);
94
+ let m = /^近 (\d+) 天$/.exec(subtitle);
95
+ if (m !== null)
96
+ return t('chart.subtitle.lastDays', { n: m[1] });
97
+ m = /^(\d+)\/(\d+) 天(含未领取任务)$/.exec(subtitle);
98
+ if (m !== null)
99
+ return t('chart.subtitle.daysInclPending', { done: m[1], due: m[2] });
100
+ m = /^(\d+)\/(\d+) 天$/.exec(subtitle);
101
+ if (m !== null)
102
+ return t('chart.subtitle.daysRatio', { done: m[1], due: m[2] });
103
+ m = /^当前 (\d+) 天 \/ 最长 (\d+) 天$/.exec(subtitle);
104
+ if (m !== null)
105
+ return t('chart.subtitle.streak', { current: m[1], best: m[2] });
106
+ return subtitle;
107
+ }
@@ -130,6 +130,23 @@ export function useStableScrollbar() {
130
130
  pin();
131
131
  }, [pin]);
132
132
  }
133
+ /**
134
+ * 视图页挂载时把祖先滚动容器滚回顶部:标签页切换时壳的滚动容器不重挂,
135
+ * 长列表页留下的滚动位置会原样套在下一个(可能更短的)页面上,出现「半屏空白」。
136
+ */
137
+ export function useScrollTopOnMount() {
138
+ useLayoutEffect(() => {
139
+ let el = document.querySelector('.xy-page');
140
+ for (let depth = 0; el !== null && depth < 16; depth += 1, el = el.parentElement) {
141
+ const overflowY = getComputedStyle(el).overflowY;
142
+ if (overflowY === 'auto' || overflowY === 'scroll') {
143
+ if (el.scrollTop > 0)
144
+ el.scrollTop = 0;
145
+ break;
146
+ }
147
+ }
148
+ }, []);
149
+ }
133
150
  /**
134
151
  * 写操作确认(应用内弹窗,替代原生 window.confirm):Promise 结算;
135
152
  * 调用方以 `softConfirm(msg).then((ok) => { if (ok) … })` 表达「确认后才继续」。