@starwish-ai/xingyuan-dsh 0.5.3 → 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 (66) hide show
  1. package/CHANGELOG.md +211 -0
  2. package/README.en-US.md +1 -0
  3. package/README.md +1 -0
  4. package/lib/client/api.js +36 -4
  5. package/lib/client/cards.js +38 -25
  6. package/lib/client/chart-labels.js +107 -0
  7. package/lib/client/hooks.js +46 -0
  8. package/lib/client/i18n.js +225 -27
  9. package/lib/client/pages/calendar.js +33 -13
  10. package/lib/client/pages/color-swatch.js +3 -0
  11. package/lib/client/pages/detail.js +94 -18
  12. package/lib/client/pages/format.js +12 -2
  13. package/lib/client/pages/growth.js +37 -9
  14. package/lib/client/pages/memory.js +36 -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 +44 -19
  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 +1044 -245
  25. package/lib/consistency-sweep.js +47 -0
  26. package/lib/growth.js +6 -17
  27. package/lib/index.js +15 -1
  28. package/lib/micro.js +10 -9
  29. package/lib/pref-policy.js +11 -0
  30. package/lib/pref-settings.js +3 -1
  31. package/lib/preset/charts.js +41 -28
  32. package/lib/preset/hitl.js +33 -6
  33. package/lib/preset/prompts.js +20 -14
  34. package/lib/preset/side.js +1 -0
  35. package/lib/preset/tools.js +72 -48
  36. package/lib/routes/api.js +120 -99
  37. package/lib/routes/errors.js +5 -2
  38. package/lib/routes/index.js +47 -41
  39. package/lib/routes/pages-html.js +66 -16
  40. package/lib/session-log-repair.js +20 -1
  41. package/lib/store.js +202 -43
  42. package/lib/types/client/api.d.ts +1 -1
  43. package/lib/types/client/chart-labels.d.ts +32 -0
  44. package/lib/types/client/hooks.d.ts +10 -0
  45. package/lib/types/client/i18n.d.ts +112 -16
  46. package/lib/types/client/pages/detail.d.ts +11 -2
  47. package/lib/types/client/pages/format.d.ts +10 -1
  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/types.d.ts +2 -1
  51. package/lib/types/client/styles.d.ts +7 -4
  52. package/lib/types/client/ui.d.ts +16 -1
  53. package/lib/types/client/view-state.d.ts +15 -0
  54. package/lib/types/consistency-sweep.d.ts +26 -0
  55. package/lib/types/events.d.ts +12 -0
  56. package/lib/types/pref-policy.d.ts +11 -0
  57. package/lib/types/preset/hitl.d.ts +7 -2
  58. package/lib/types/preset/tools.d.ts +3 -0
  59. package/lib/types/routes/api.d.ts +1 -1
  60. package/lib/types/routes/errors.d.ts +4 -3
  61. package/lib/types/routes/index.d.ts +3 -1
  62. package/lib/types/routes/pages-html.d.ts +5 -5
  63. package/lib/types/store.d.ts +48 -2
  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,217 @@ 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
+
125
+ ## [0.5.4] - 2026-08-28
126
+
127
+ > **Note:** trailing chart statistics no longer count future pre-checked days (see
128
+ > *Changed*). Chart cards generated by this version carry a "generated on" stamp so a
129
+ > stale card is distinguishable from current data; cards written by earlier versions
130
+ > replay unchanged, without the stamp.
131
+
132
+ **Charts and records now tell the truth about time.** Future pre-checked days were
133
+ counted by every trailing statistic as if they had already happened — a "how is this
134
+ week going" chart could be inflated by check-ins for days that have not occurred, and a
135
+ task's check-in grid could end up showing nothing but future days while today's check-in
136
+ was invisible. Undoing a check-in said "revert today's?" while actually reverting a
137
+ future pre-check, because the pages let the server fall back to "most recent record".
138
+
139
+ ### Added
140
+ - **Chart snapshot stamp**: chart events carry `generatedAt` and the card renders a
141
+ localized "generated on" date; cards generated by older versions replay without it
142
+ (the field is optional and degrades honestly).
143
+ - **"No schedule" ≠ 0%**: in the daily completion-rate trend, days with no scheduled
144
+ opportunity render as an empty slot labelled "no schedule" (tooltip and screen reader
145
+ alike) instead of a 0% data point.
146
+ - The completion-rate chart annotates its subtitle with "including unclaimed tasks"
147
+ when pending tasks count toward the total, so the denominator is never a mystery.
148
+ - **Startup consistency sweep** (`src/consistency-sweep.ts`): check-ins whose task no
149
+ longer exists, tasks whose wish was deleted out from under them, and dangling
150
+ micro-action states are cleaned once per launch through the same cascade the delete
151
+ tools use. Idempotent, runs after activation without blocking it, and pinned by
152
+ `test/integrity.test.ts`.
153
+ - **Task-detail grid window**: the check-in grid shows the last 28 opportunity days up
154
+ to today, plus every checked day — future pre-checks stay visible (they are standing
155
+ commitments), and check-ins outside the opportunity series (deadline-free tasks) make
156
+ the grid their check-in history. Today is never squeezed out of the window, however
157
+ many future days are pre-checked; unchecked future days remain in the "upcoming"
158
+ preview line.
159
+ - **gzip content negotiation** for the standalone pages (`/xingyuan/today`, `/calendar`,
160
+ `/growth`) per RFC 9110 §12.5.3: absent header accepts any coding, an empty value or
161
+ an explicit opt-out gets plaintext, `Vary: Accept-Encoding` is always sent, and the
162
+ compressed template is cached per body.
163
+ - **Request timeout**: client fetches abort after 15 s with a localized message instead
164
+ of hanging forever.
165
+ - **Focus refetch**: page data refetches when the window regains focus if older than
166
+ 60 s, and always when the local day has changed — overnight sessions no longer show
167
+ yesterday's "today".
168
+ - **Undo readability**: the detail panel's undo button announces the exact day it will
169
+ revert (screen-reader label), and its visibility no longer requires today to be the
170
+ checked day — a pre-check can be undone right where it was made.
171
+
172
+ ### Fixed
173
+ - **The cancel dialog said "today" while a different day was reverted.** The today page
174
+ and the task detail panel posted the cancel without a date and the server falls back
175
+ to the most recent check-in — which a future pre-check satisfies. Every page now sends
176
+ the explicit date; the dialog, the toast and the reverted record all name the same day.
177
+ - `weekComparison` folded future pre-checks into weekday buckets as if they were in the
178
+ current week; the week window is now a closed interval from last Monday to today.
179
+ - Wish progress **rounded** instead of flooring: 249/250 displayed as 100% and could
180
+ archive the wish one day early.
181
+ - Checking in an expired (closed) task failed with a generic error; it now explains that
182
+ the deadline must be extended to revive the task before further check-ins.
183
+ - Concurrent writes to the global document (coach style, profile, category colors,
184
+ micro-action state) could lose updates under interleaving; all writers now serialize
185
+ through one promise chain, a failed writer no longer poisons the chain, and no-op
186
+ mutations skip the write entirely.
187
+ - The memory page could deadlock with a perpetual "loading more" spinner when an
188
+ in-flight request was superseded by a newer one (the reset ran on only one of two
189
+ completion paths).
190
+ - The calendar's post-action detail refetch swallowed failures; the panel now shows an
191
+ error state with a retry button instead of silently keeping stale content.
192
+ - A deadline-free one-shot task disappeared from the today page the moment it was
193
+ checked; the completed row now stays revocable for the rest of the day (it never
194
+ reappears the next day).
195
+ - `/xingyuan/api/day` and `/xingyuan/api/range` answered a malformed `date` with an
196
+ empty day list or an unlabelled `RangeError`; both now reject with a structured
197
+ `bad_date` error, consistent with the POST action surface.
198
+ - Creating a task with a name over 100 characters through a page action failed with a
199
+ bare validation message; the error now states the limit and the actual length.
200
+ - The claim button in the calendar is gated to the picked day being today, matching the
201
+ server-side anchor semantics.
202
+ - Display dates across cards, toasts and pages are localized (ISO strings remain only in
203
+ confirm-dialog copy and screen-reader strings, per the documented convention).
204
+ - The standalone growth page used `color-mix()`, which some shells render as an invalid
205
+ declaration; the darker tone is now precomputed in JS.
206
+
207
+ ### Changed
208
+ - Trailing chart statistics (trend, daily rate, week comparison, by-category, ranking,
209
+ time-of-day, weekday activity) only count check-ins up to today; the calendar heatmap
210
+ deliberately keeps future pre-checks visible. Chart cards are snapshots of their
211
+ generation time — the coach guide tells the model to regenerate for current data.
212
+ - `cancel_check_in_task` parameter guidance now instructs the model to pass the date
213
+ whenever the user names a day (including "today"), so the "most recent" fallback is a
214
+ deliberate choice rather than an accident.
215
+ - zh copy no longer pads localized dates with spaces ("已撤销 8月29日 的打卡" →
216
+ "已撤销8月29日的打卡"), and the grid legend says "待打卡" where it previously said
217
+ "未来机会日" for what is now only ever today's pending slot.
218
+
8
219
  ## [0.5.3] - 2026-08-28
9
220
 
10
221
  > **Note:** this is a patch release but it carries one breaking change — the `xingyuan`
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) {
@@ -40,8 +51,27 @@ export function describeError(e) {
40
51
  export async function postAction(path, body) {
41
52
  return postJson(`/xingyuan/api/action/${path}`, body);
42
53
  }
54
+ /** 请求超时:手动 AbortController——AbortSignal.timeout 为 Baseline 2024 "Newly available",
55
+ * 不满足 dsh 壳的保守浏览器矩阵(同 color-mix 禁令的判断口径),不用。 */
56
+ const REQUEST_TIMEOUT_MS = 15_000;
57
+ async function fetchWithTimeout(path, init) {
58
+ const controller = new AbortController();
59
+ const timer = window.setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
60
+ try {
61
+ return await fetch(path, { ...init, signal: controller.signal });
62
+ }
63
+ catch (e) {
64
+ // 超时中止统一转本地化文案;真实网络错误原样抛出(保留连接拒绝等语义)
65
+ if (e instanceof DOMException && e.name === 'AbortError')
66
+ throw new Error(t('common.requestTimeout'));
67
+ throw e;
68
+ }
69
+ finally {
70
+ window.clearTimeout(timer);
71
+ }
72
+ }
43
73
  export async function postJson(path, body) {
44
- const response = await fetch(path, {
74
+ const response = await fetchWithTimeout(path, {
45
75
  method: 'POST',
46
76
  headers: { 'content-type': 'application/json' },
47
77
  body: JSON.stringify(body),
@@ -49,22 +79,24 @@ export async function postJson(path, body) {
49
79
  return parsePayload(response, () => t('common.actionFailed'));
50
80
  }
51
81
  export async function getJson(path) {
52
- const response = await fetch(path, { headers: { accept: 'application/json' } });
82
+ const response = await fetchWithTimeout(path, { headers: { accept: 'application/json' } });
53
83
  return parsePayload(response, () => t('common.requestFailed'));
54
84
  }
55
85
  async function parsePayload(response, fallback) {
86
+ // HTTP 层错误的状态码后缀走本地化键(zh 全角 / en 半角括号),不硬编码全角括号
87
+ const withStatus = (message) => `${message}${t('common.httpStatus', { status: response.status })}`;
56
88
  let payload;
57
89
  try {
58
90
  payload = await response.json();
59
91
  }
60
92
  catch {
61
- throw new Error(`${fallback()}(${response.status})`);
93
+ throw new Error(withStatus(fallback()));
62
94
  }
63
95
  if (!response.ok) {
64
96
  if (typeof payload.code === 'string' && payload.code !== '') {
65
97
  throw new ActionError(payload.code, payload.error ?? fallback(), payload.params);
66
98
  }
67
- throw new Error(payload.error ?? `${fallback()}(${response.status})`);
99
+ throw new Error(payload.error !== undefined ? withStatus(payload.error) : withStatus(fallback()));
68
100
  }
69
101
  return payload;
70
102
  }
@@ -10,8 +10,9 @@ 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
- import { cycleLabel, durationText, dateSuffix } from './pages/format.js';
15
+ import { cycleLabel, durationText, dateSuffix, formatMediumDate, formatShortDate } from './pages/format.js';
15
16
  function WishView(props) {
16
17
  const t = useXyT();
17
18
  const wish = props.node.data.data;
@@ -95,7 +96,7 @@ function TaskView(props) {
95
96
  const showClaim = !deleted && phase === 'idle' && task.status === 'pending';
96
97
  const showCheckIn = !deleted && phase !== 'done' && (task.status === 'in_progress' || phase === 'claimed');
97
98
  const preview = event.opportunityPreview.length > 0
98
- ? t('task.upcoming', { dates: event.opportunityPreview.join(activeLocale() === 'en' ? ', ' : '、') })
99
+ ? t('task.upcoming', { dates: event.opportunityPreview.map((date) => formatShortDate(date)).join(activeLocale() === 'en' ? ', ' : '、') })
99
100
  : undefined;
100
101
  return createElement('div', { className: `xy-card ${deleted ? 'xy-deleted' : ''}` }, createElement('div', { className: 'xy-card-head' }, createElement('span', { className: 'xy-badge xy-badge-task' }, t('badge.task')), createElement('span', { className: 'xy-title' }, deleted ? t('state.deleted', { title: task.name }) : task.name), !deleted && phase === 'done'
101
102
  ? createElement('span', { className: 'xy-meta xy-actioned' },
@@ -105,7 +106,7 @@ function TaskView(props) {
105
106
  ? createElement('button', { className: 'xy-btn xy-btn-inline', disabled: busy, onClick: () => run('claim') }, t('action.claim'))
106
107
  : null, showCheckIn
107
108
  ? createElement('button', { className: 'xy-btn xy-btn-primary xy-btn-inline', disabled: busy, onClick: () => run('checkin') }, t('action.checkin'))
108
- : null), createElement('div', { className: 'xy-meta' }, `${cycleLabel(task.checkInCycle)}${task.dueDate !== undefined ? ` · ${t('task.due', { date: task.dueDate })}` : ''} · ${durationText(task.completedDays, task.requiredDays)}`), deleted === false && preview !== undefined
109
+ : null), createElement('div', { className: 'xy-meta' }, `${cycleLabel(task.checkInCycle)}${task.dueDate !== undefined ? ` · ${t('task.due', { date: formatShortDate(task.dueDate) })}` : ''} · ${durationText(task.completedDays, task.requiredDays)}`), deleted === false && preview !== undefined
109
110
  ? createElement('div', { className: 'xy-preview' }, preview)
110
111
  : null);
111
112
  }
@@ -139,23 +140,31 @@ function CheckinView(props) {
139
140
  const event = props.node.data.data;
140
141
  const cancelled = event.op === 'cancelled';
141
142
  return createElement('div', { className: 'xy-card xy-checkin' }, createElement('div', { className: 'xy-card-head' }, createElement('span', { className: `xy-glyph ${cancelled ? 'xy-glyph-back' : 'xy-glyph-ok'}`, 'aria-hidden': 'true' }, cancelled ? '↩' : '✓'), createElement('span', { className: 'xy-title' }, cancelled
142
- ? t('checkin.cancelled', { task: event.taskName, date: event.date })
143
- : t('checkin.success', { task: event.taskName }))), createElement('div', { className: 'xy-meta' }, `${event.date}${event.wishName !== undefined ? ` · ${event.wishName}` : ''} · ${durationText(event.completedDays, event.requiredDays)}`));
143
+ ? t('checkin.cancelled', { task: event.taskName, date: formatShortDate(event.date) })
144
+ : t('checkin.success', { task: event.taskName }))), createElement('div', { className: 'xy-meta' }, `${formatShortDate(event.date)}${event.wishName !== undefined ? ` · ${event.wishName}` : ''} · ${durationText(event.completedDays, event.requiredDays)}`));
144
145
  }
145
146
  function ChartView(props) {
146
147
  const t = useXyT();
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), chartBody(event, t('chart.noData'), hoverIdx, setHoverIdx));
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),
153
+ // 快照时点标注(事件溯源:卡片=当时的事实):无 generatedAt 的旧事件诚实降级不显示。
154
+ // 中格式带年份——回放的旧卡可能跨年,「生成于 3月5日」须能定位到哪一年。
155
+ // ISO → 本地日再格式化:直接 slice 会把 UTC 日期当本地日(UTC+8 零点后 8 小时内差一天)
156
+ event.generatedAt !== undefined
157
+ ? createElement('div', { className: 'xy-meta' }, t('chart.generatedAt', { date: formatMediumDate(localYmd(new Date(event.generatedAt))) }))
158
+ : null, chartBody(event, title, t('chart.noData'), t('chart.noSchedule'), hoverIdx, setHoverIdx));
151
159
  }
152
- /** 图表卡主体:四类渲染路径(占比条/占比列表/热力格/柱图)颜色全部走主题变量,深浅色自适应。 */
153
- function chartBody(event, noDataText, hoverIdx, onHover) {
160
+ /** 图表卡主体:四类渲染路径(占比条/占比列表/热力格/柱图)颜色全部走主题变量,深浅色自适应。
161
+ * 标签/序列名经 chart-labels 本地化(服务端内建中文词的展示层映射,未知值原样回显)。 */
162
+ function chartBody(event, title, noDataText, noScheduleText, hoverIdx, onHover) {
154
163
  const { chartType, data } = event;
155
164
  if (data.length === 0)
156
165
  return createElement('div', { className: 'xy-meta' }, noDataText);
157
- const max = Math.max(...data.map((d) => d.value), 1);
158
- const labelValue = (d) => `${d.label}${activeLocale() === 'en' ? ': ' : ':'}${d.value}`;
166
+ const max = Math.max(...data.filter((d) => !d.inactive).map((d) => d.value), 1);
167
+ const labelValue = (d) => `${localizeChartLabel(d.label)}${activeLocale() === 'en' ? ': ' : ':'}${d.inactive === true ? noScheduleText : d.value}`;
159
168
  if (chartType === 'arcbars') {
160
169
  const ratio = Math.min(Math.max(data[0].ratio ?? 0, 0), 1);
161
170
  return createElement('div', { className: 'xy-arcwrap' }, createElement('div', { className: 'xy-arcnum' }, `${Math.round(ratio * 100)}%`),
@@ -165,7 +174,7 @@ function chartBody(event, noDataText, hoverIdx, onHover) {
165
174
  if (chartType === 'pie' || chartType === 'radar') {
166
175
  // 占比分母守卫:总和为 0 时跳过百分比(避免 NaN%)
167
176
  const sum = data.reduce((s, x) => s + x.value, 0);
168
- 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)}%` : ''}`))));
169
178
  }
170
179
  if (chartType === 'heatmap') {
171
180
  return createElement('div', null, createElement('div', { className: 'xy-heat', 'aria-hidden': 'true' }, ...data.map((d, i) => createElement('span', {
@@ -175,7 +184,7 @@ function chartBody(event, noDataText, hoverIdx, onHover) {
175
184
  style: { opacity: String(0.25 + 0.75 * (d.value / max)) },
176
185
  }))),
177
186
  // 热力格纯视觉(title 不构成可访问名):数据以屏内隐藏文本整体提供给读屏
178
- 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' ? '; ' : ';')));
179
188
  }
180
189
  const seriesList = [...new Set(data.map((d) => d.series).filter((s) => s !== undefined))];
181
190
  const seriesColor = (index) => index <= 0 ? 'var(--xyd-accent)' : 'var(--dsw-alias-label-secondary)';
@@ -187,19 +196,23 @@ function chartBody(event, noDataText, hoverIdx, onHover) {
187
196
  const r = Math.min(2, h, w / 2);
188
197
  return `M${x},${y + h}L${x},${y + r}Q${x},${y} ${x + r},${y}L${x + w - r},${y}Q${x + w},${y} ${x + w},${y + r}L${x + w},${y + h}Z`;
189
198
  };
190
- const bars = data.map((d, i) => {
199
+ const bars = data.flatMap((d, i) => {
200
+ // 无安排日不画柱(缺失≠0):空槽由下标占位保留,柱序与日期轴不塌缩
201
+ if (d.inactive === true)
202
+ return [];
191
203
  // 微值可见性下限:有值但按比例不足 2px 的柱给 2px,避免「有数据却看不见」
192
204
  const rawH = Math.round((d.value / max) * (height - 24));
193
205
  const h = d.value > 0 ? Math.max(rawH, 2) : rawH;
194
206
  const seriesIdx = d.series !== undefined ? seriesList.indexOf(d.series) : -1;
195
- return createElement('path', {
196
- key: i,
197
- d: topRoundedBar(i * step + 2, height - 18 - h, Math.max(step - 4, 2), h),
198
- style: { fill: seriesIdx > 0 ? seriesColor(seriesIdx) : 'var(--xyd-accent)', opacity: seriesIdx > 0 ? 0.55 : 0.85 },
199
- onMouseEnter: () => onHover(i),
200
- },
201
- // 每根柱子带 <title>:原生悬停提示;精确数值另由上方悬浮明细条实时呈现
202
- createElement('title', null, labelValue(d)));
207
+ return [createElement('path', {
208
+ key: i,
209
+ d: topRoundedBar(i * step + 2, height - 18 - h, Math.max(step - 4, 2), h),
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 },
212
+ onMouseEnter: () => onHover(i),
213
+ },
214
+ // 每根柱子带 <title>:原生悬停提示;精确数值另由上方悬浮明细条实时呈现
215
+ createElement('title', null, labelValue(d)))];
203
216
  });
204
217
  // 标签用原始下标定位:indexOf 会把重复标签(如分组系列的「周一」×2)全部钉到首个位置
205
218
  const labelStep = data.length <= 10 ? 1 : Math.ceil(data.length / 8);
@@ -209,9 +222,9 @@ function chartBody(event, noDataText, hoverIdx, onHover) {
209
222
  .map(({ d, i }) => createElement('text', {
210
223
  key: `l${i}`, x: i * step + step / 2, y: height - 4,
211
224
  fontSize: 11, textAnchor: 'middle', style: { fill: 'var(--dsw-alias-label-secondary)' },
212
- }, d.label));
225
+ }, localizeChartLabel(d.label)));
213
226
  const legend = seriesList.length > 1
214
- ? 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))))
215
228
  : null;
216
229
  const hovered = hoverIdx !== null ? data[hoverIdx] : undefined;
217
230
  return createElement('div', null, legend, createElement('div', { onMouseLeave: () => onHover(null) },
@@ -220,8 +233,8 @@ function chartBody(event, noDataText, hoverIdx, onHover) {
220
233
  // 图表整体作为一张图暴露给读屏;逐条数值以屏内隐藏文本补充
221
234
  createElement('svg', {
222
235
  viewBox: `0 0 ${width} ${height}`, className: 'xy-svg',
223
- role: 'img', 'aria-label': event.title,
224
- }, 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.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' ? '; ' : ';')));
225
238
  }
226
239
  export const CARD_VIEWS = {
227
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
+ }