@starwish-ai/xingyuan-dsh 0.5.4 → 0.5.5

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 (60) hide show
  1. package/CHANGELOG.md +117 -0
  2. package/README.en-US.md +1 -0
  3. package/README.md +1 -0
  4. package/lib/client/api.js +15 -2
  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 +205 -13
  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 +37 -9
  14. package/lib/client/pages/memory.js +31 -19
  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 +3 -4
  18. package/lib/client/pages/tasks.js +29 -28
  19. package/lib/client/pages/today.js +38 -16
  20. package/lib/client/pages/wishes.js +32 -18
  21. package/lib/client/styles.js +152 -62
  22. package/lib/client/ui.js +60 -2
  23. package/lib/client/view-state.js +24 -0
  24. package/lib/client.js +930 -209
  25. package/lib/growth.js +1 -1
  26. package/lib/index.js +3 -1
  27. package/lib/pref-policy.js +11 -0
  28. package/lib/pref-settings.js +3 -1
  29. package/lib/preset/charts.js +3 -2
  30. package/lib/preset/hitl.js +33 -6
  31. package/lib/preset/prompts.js +14 -14
  32. package/lib/preset/side.js +1 -0
  33. package/lib/preset/tools.js +62 -45
  34. package/lib/routes/api.js +80 -85
  35. package/lib/routes/errors.js +5 -2
  36. package/lib/routes/index.js +47 -44
  37. package/lib/routes/pages-html.js +5 -4
  38. package/lib/session-log-repair.js +20 -1
  39. package/lib/store.js +99 -9
  40. package/lib/types/client/api.d.ts +1 -1
  41. package/lib/types/client/chart-labels.d.ts +32 -0
  42. package/lib/types/client/hooks.d.ts +5 -0
  43. package/lib/types/client/i18n.d.ts +100 -7
  44. package/lib/types/client/pages/format.d.ts +8 -0
  45. package/lib/types/client/pages/quick-create.d.ts +4 -0
  46. package/lib/types/client/pages/settings.d.ts +3 -0
  47. package/lib/types/client/pages/types.d.ts +2 -1
  48. package/lib/types/client/styles.d.ts +7 -4
  49. package/lib/types/client/ui.d.ts +16 -1
  50. package/lib/types/client/view-state.d.ts +15 -0
  51. package/lib/types/pref-policy.d.ts +11 -0
  52. package/lib/types/preset/hitl.d.ts +7 -2
  53. package/lib/types/preset/tools.d.ts +3 -0
  54. package/lib/types/routes/api.d.ts +1 -1
  55. package/lib/types/routes/errors.d.ts +4 -3
  56. package/lib/types/routes/index.d.ts +3 -1
  57. package/lib/types/store.d.ts +30 -0
  58. package/lib/types/types.d.ts +3 -0
  59. package/lib/types.js +2 -0
  60. package/package.json +2 -2
package/CHANGELOG.md CHANGED
@@ -5,6 +5,123 @@ 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.5] - 2026-08-28
9
+
10
+ > **Note:** this release is a six-perspective quality pass (product, visual design,
11
+ > end user, dsh-platform conformance, architecture, UX) over every interaction,
12
+ > display and code path. It closes the failure side of the habit loop — an expired
13
+ > task used to be indistinguishable from an achieved one and was a dead end on the
14
+ > pages — and makes the plugin honest in English wherever it previously leaked
15
+ > built-in Chinese vocabulary. No data format changes; existing databases and
16
+ > recorded sessions replay as before.
17
+
18
+ ### Added
19
+ - **Expired vs achieved, end to end**: task views carry `closedReason`; the Tasks
20
+ page splits the former "Closed" bucket into **Achieved** and **Expired** groups,
21
+ task rows say which one they are, and an expired task's detail panel explains the
22
+ state and offers an in-page **extend due date → restart** action (same store write
23
+ path as the chat-side `update_task`). An expired task is no longer a dead row with
24
+ a delete button.
25
+ - **Confirm card language** (`xingyuan-pref.confirmLang`, default Chinese): the
26
+ platform does not expose UI language to host-side plugins (verified against rc.2),
27
+ so the in-chat confirmation card's header, buttons and question text now follow
28
+ this explicit setting with full English strings; the Settings page gains a
29
+ "Confirm card language" select with optimistic save and write verification.
30
+ - **Built-in chart vocabulary localizes on the client**: chart cards map known
31
+ built-in Chinese titles, enum labels (weekdays, hour buckets, task statuses,
32
+ series names) and fixed subtitles to the interface language, while user data
33
+ (wish/task names) and unknown values replay verbatim. Coverage is locked by
34
+ `test/chart-labels.test.ts` against the real chart builder, so a new built-in
35
+ word without a mapping fails the build.
36
+ - **Level names localize**: the Growth page renders level names and rewards from
37
+ the interface language by level number (server strings remain the Chinese
38
+ fallback); locked to the server table by the same test.
39
+ - **Route contract compliance**: `/xingyuan` prefix registration is effect-scoped —
40
+ the disposer returned by `webServer.register` is no longer discarded, so hot
41
+ reload / re-activation can no longer die on a duplicate-prefix throw or leave a
42
+ stale handler serving a closed domain. A loader-level remount test with a
43
+ duplicate-throwing webServer stub pins this.
44
+ - New contract tests: `style-contract` (no `color-mix(` anywhere; semi-transparent
45
+ colors only inside the token zone; key semantic tokens must exist in both
46
+ themes), `chart-labels` (built-in chart vocabulary fully covered by the
47
+ localization maps, and client level names locked verbatim to the server table),
48
+ `sqlite-backend` (version-mismatch rejection, corrupt-global rejection,
49
+ write → cold-read persistence), plus a tool-suite audit pinning the 10-minute
50
+ HITL timeout on every confirmation-waiting tool and the "use real IDs from list
51
+ results" note on every ID parameter.
52
+
53
+ ### Changed
54
+ - **System prompts no longer assert gated behavior**: six spots claimed the system
55
+ "will pop a confirmation card" unconditionally — wrong when the write-confirmation
56
+ setting is off (tool descriptions already hedged; the prompts now hedge the same
57
+ way, "删除始终弹出" stays asserted since deletes always confirm).
58
+ - **Memory overwrite asks once**: creating a memory with an existing key showed the
59
+ overwrite confirmation, then the server rejection re-asked the identical question;
60
+ the confirmed path now submits with `overwrite: true` directly.
61
+ - **Settings tab-visibility tells the truth**: memory-mode (non-loopback) and
62
+ read-only states get their own notices instead of silently grey controls, and
63
+ writes verify post-settle snapshot like the chat-preferences controls, toasting
64
+ on silent revert (`scope.set()` resolves even on failure).
65
+ - **Honest stale-data degradation**: when a refetch fails after a successful action
66
+ (or on focus refetch), pages keep the loaded data and show a "refresh failed,
67
+ data may be outdated" banner with inline retry instead of flipping to a full-page
68
+ error; Today's retry now reloads both endpoints at once.
69
+ - **Motion & touch discipline**: the growth bar drops its layout-animating `height`
70
+ transition and the toggle knob animates `transform` only; every purely decorative
71
+ hover is gated behind `(hover:hover) and (pointer:fine)` so touch screens no
72
+ longer stick hover tints; error toasts use a dedicated assertive `role=alert`
73
+ container; the confirm dialog locks body scroll; after deletes/undoes destroy the
74
+ triggered row, focus moves to the page title instead of falling to `<body>`.
75
+ - **Cross-tab state survives**: memory search query, calendar month offset and
76
+ expanded task details persist across view-tab switches via a module-scoped
77
+ snapshot store; view pages scroll back to top on mount.
78
+ - **Visual tokens**: new `--xyd-label-on-2` pair fixes secondary text on secondary
79
+ surfaces (≈4.3:1 → ≥5:1 in both themes) across the task-card preview strip, group
80
+ count pills, level chips, future grid cells and the growth chart axis ticks;
81
+ success/warn washes, calendar state fills,
82
+ shadows and hero colors are explicit themed token pairs (no more shared-alpha
83
+ washes that vanished on dark); calendar hover wash no longer paints over the
84
+ picked chip; 待打卡 chips gain the inset ring their legend dot always had;
85
+ standalone dark "complete" chip darkens to AA contrast; 11px CJK text raises to
86
+ 12px; month-adjacent days brighten from 1.8:1 to readable-but-dim; the mock
87
+ generator itself is `color-mix`-free again.
88
+ - **Error codes completed end-to-end**: `not_claimed`, `no_opportunity_left`,
89
+ `no_checkins`, `title_too_long`, `name_too_long`, `once_today_only`,
90
+ `payload_too_large`, `bad_json_body`, `bad_coach_style`, `bad_interests` now flow
91
+ from store/routes to localized client strings (English users no longer receive
92
+ raw Chinese domain messages); HTTP status suffixes localize their brackets.
93
+ - **Dates**: hover tooltips on the check-in grid and calendar cells localize while
94
+ aria keeps ISO precision; memory timestamps render as medium dates; the growth
95
+ chart axis uses Intl ticks (Aug 27 / 8-27); the English "Commit {date}" button
96
+ becomes "Early check-in · {date}" so the button itself carries check-in semantics.
97
+ - **Writes stay in one place**: memory delete/clear and profile updates go through
98
+ store use cases (`deleteMemory`, `clearMemories`, `updateProfileGlobal`) shared by
99
+ tools and routes; profile fields clamp at the same limits on both faces (nickname
100
+ 50 / occupation 100 / interests 20×50) — the tool path was previously unbounded
101
+ and injects into every system prompt turn.
102
+ - **Batch reads honor the store contract**: wish/task list paths use
103
+ `freshWishes` and a single shared checkin-count index instead of per-item
104
+ full-table rescans for the completed-day counts; the
105
+ chart builder computes its checkin index only for the one chart that consumes it;
106
+ semantically invalid dates (`2026-13-01`) are rejected with a stable `bad_date`
107
+ code instead of an unlocalizable RangeError; session-log repair sweeps its
108
+ `.xy-repair-*` atomic-write temp files; `./types` export resolves types and
109
+ runtime to the same module.
110
+ - **Copy honesty**: delete confirmations (chat and pages) now mention the
111
+ micro-step plan that the cascade removes; quick-create forms soft-confirm on an
112
+ exact duplicate name/title (the chat path dedupes via `check_similar_*`, the
113
+ page path previously did not); wish progress bars expose `role=progressbar`; the
114
+ four create tools declare the same 10-minute HITL timeout as their siblings;
115
+ task-related ID parameters all carry the "use real IDs from list results"
116
+ discipline.
117
+
118
+ ### Fixed
119
+ - `start_micro_action` gates on a freshened task status, so a task that expired
120
+ since its last write can no longer start a breakdown.
121
+ - `create_wish_with_tasks` no longer over-claims atomicity in its pre-validation
122
+ comment (task-field failures are honestly reported; the wish and created tasks
123
+ are kept).
124
+
8
125
  ## [0.5.4] - 2026-08-28
9
126
 
10
127
  > **Note:** trailing chart statistics no longer count future pre-checked days (see
package/README.en-US.md CHANGED
@@ -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
@@ -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,10 +24,21 @@ 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
43
  /** 把任意抛错转成用户可读文案:ActionError 按键本地化;其余直出消息。 */
33
44
  export function describeError(e) {
@@ -72,18 +83,20 @@ export async function getJson(path) {
72
83
  return parsePayload(response, () => t('common.requestFailed'));
73
84
  }
74
85
  async function parsePayload(response, fallback) {
86
+ // HTTP 层错误的状态码后缀走本地化键(zh 全角 / en 半角括号),不硬编码全角括号
87
+ const withStatus = (message) => `${message}${t('common.httpStatus', { status: response.status })}`;
75
88
  let payload;
76
89
  try {
77
90
  payload = await response.json();
78
91
  }
79
92
  catch {
80
- throw new Error(`${fallback()}(${response.status})`);
93
+ throw new Error(withStatus(fallback()));
81
94
  }
82
95
  if (!response.ok) {
83
96
  if (typeof payload.code === 'string' && payload.code !== '') {
84
97
  throw new ActionError(payload.code, payload.error ?? fallback(), payload.params);
85
98
  }
86
- throw new Error(payload.error ?? `${fallback()}(${response.status})`);
99
+ throw new Error(payload.error !== undefined ? withStatus(payload.error) : withStatus(fallback()));
87
100
  }
88
101
  return payload;
89
102
  }
@@ -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) … })` 表达「确认后才继续」。