@starwish-ai/xingyuan-dsh 0.5.2 → 0.5.4

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 (42) hide show
  1. package/CHANGELOG.md +187 -0
  2. package/lib/client/api.js +21 -2
  3. package/lib/client/cards.js +28 -19
  4. package/lib/client/hooks.js +29 -0
  5. package/lib/client/i18n.js +26 -14
  6. package/lib/client/index.js +5 -3
  7. package/lib/client/pages/calendar.js +10 -4
  8. package/lib/client/pages/detail.js +36 -10
  9. package/lib/client/pages/format.js +3 -2
  10. package/lib/client/pages/memory.js +5 -0
  11. package/lib/client/pages/settings.js +73 -21
  12. package/lib/client/pages/today.js +7 -4
  13. package/lib/client.js +219 -58
  14. package/lib/consistency-sweep.js +47 -0
  15. package/lib/domain.js +2 -1
  16. package/lib/growth.js +5 -16
  17. package/lib/index.js +18 -1
  18. package/lib/micro.js +10 -9
  19. package/lib/pref-policy.js +38 -0
  20. package/lib/pref-settings.js +27 -0
  21. package/lib/preset/charts.js +38 -26
  22. package/lib/preset/prompts.js +6 -0
  23. package/lib/preset/side.js +12 -23
  24. package/lib/preset/tools.js +37 -19
  25. package/lib/routes/api.js +86 -60
  26. package/lib/routes/index.js +6 -3
  27. package/lib/routes/pages-html.js +61 -12
  28. package/lib/store.js +103 -34
  29. package/lib/types/client/hooks.d.ts +5 -0
  30. package/lib/types/client/i18n.d.ts +15 -9
  31. package/lib/types/client/pages/detail.d.ts +11 -2
  32. package/lib/types/client/pages/format.d.ts +2 -1
  33. package/lib/types/client/pages/settings.d.ts +11 -6
  34. package/lib/types/consistency-sweep.d.ts +26 -0
  35. package/lib/types/domain.d.ts +4 -1
  36. package/lib/types/events.d.ts +12 -0
  37. package/lib/types/pref-policy.d.ts +34 -0
  38. package/lib/types/pref-settings.d.ts +34 -0
  39. package/lib/types/preset/side.d.ts +6 -7
  40. package/lib/types/routes/pages-html.d.ts +5 -5
  41. package/lib/types/store.d.ts +18 -2
  42. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -5,6 +5,193 @@ 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.4] - 2026-08-28
9
+
10
+ > **Note:** trailing chart statistics no longer count future pre-checked days (see
11
+ > *Changed*). Chart cards generated by this version carry a "generated on" stamp so a
12
+ > stale card is distinguishable from current data; cards written by earlier versions
13
+ > replay unchanged, without the stamp.
14
+
15
+ **Charts and records now tell the truth about time.** Future pre-checked days were
16
+ counted by every trailing statistic as if they had already happened — a "how is this
17
+ week going" chart could be inflated by check-ins for days that have not occurred, and a
18
+ task's check-in grid could end up showing nothing but future days while today's check-in
19
+ was invisible. Undoing a check-in said "revert today's?" while actually reverting a
20
+ future pre-check, because the pages let the server fall back to "most recent record".
21
+
22
+ ### Added
23
+ - **Chart snapshot stamp**: chart events carry `generatedAt` and the card renders a
24
+ localized "generated on" date; cards generated by older versions replay without it
25
+ (the field is optional and degrades honestly).
26
+ - **"No schedule" ≠ 0%**: in the daily completion-rate trend, days with no scheduled
27
+ opportunity render as an empty slot labelled "no schedule" (tooltip and screen reader
28
+ alike) instead of a 0% data point.
29
+ - The completion-rate chart annotates its subtitle with "including unclaimed tasks"
30
+ when pending tasks count toward the total, so the denominator is never a mystery.
31
+ - **Startup consistency sweep** (`src/consistency-sweep.ts`): check-ins whose task no
32
+ longer exists, tasks whose wish was deleted out from under them, and dangling
33
+ micro-action states are cleaned once per launch through the same cascade the delete
34
+ tools use. Idempotent, runs after activation without blocking it, and pinned by
35
+ `test/integrity.test.ts`.
36
+ - **Task-detail grid window**: the check-in grid shows the last 28 opportunity days up
37
+ to today, plus every checked day — future pre-checks stay visible (they are standing
38
+ commitments), and check-ins outside the opportunity series (deadline-free tasks) make
39
+ the grid their check-in history. Today is never squeezed out of the window, however
40
+ many future days are pre-checked; unchecked future days remain in the "upcoming"
41
+ preview line.
42
+ - **gzip content negotiation** for the standalone pages (`/xingyuan/today`, `/calendar`,
43
+ `/growth`) per RFC 9110 §12.5.3: absent header accepts any coding, an empty value or
44
+ an explicit opt-out gets plaintext, `Vary: Accept-Encoding` is always sent, and the
45
+ compressed template is cached per body.
46
+ - **Request timeout**: client fetches abort after 15 s with a localized message instead
47
+ of hanging forever.
48
+ - **Focus refetch**: page data refetches when the window regains focus if older than
49
+ 60 s, and always when the local day has changed — overnight sessions no longer show
50
+ yesterday's "today".
51
+ - **Undo readability**: the detail panel's undo button announces the exact day it will
52
+ revert (screen-reader label), and its visibility no longer requires today to be the
53
+ checked day — a pre-check can be undone right where it was made.
54
+
55
+ ### Fixed
56
+ - **The cancel dialog said "today" while a different day was reverted.** The today page
57
+ and the task detail panel posted the cancel without a date and the server falls back
58
+ to the most recent check-in — which a future pre-check satisfies. Every page now sends
59
+ the explicit date; the dialog, the toast and the reverted record all name the same day.
60
+ - `weekComparison` folded future pre-checks into weekday buckets as if they were in the
61
+ current week; the week window is now a closed interval from last Monday to today.
62
+ - Wish progress **rounded** instead of flooring: 249/250 displayed as 100% and could
63
+ archive the wish one day early.
64
+ - Checking in an expired (closed) task failed with a generic error; it now explains that
65
+ the deadline must be extended to revive the task before further check-ins.
66
+ - Concurrent writes to the global document (coach style, profile, category colors,
67
+ micro-action state) could lose updates under interleaving; all writers now serialize
68
+ through one promise chain, a failed writer no longer poisons the chain, and no-op
69
+ mutations skip the write entirely.
70
+ - The memory page could deadlock with a perpetual "loading more" spinner when an
71
+ in-flight request was superseded by a newer one (the reset ran on only one of two
72
+ completion paths).
73
+ - The calendar's post-action detail refetch swallowed failures; the panel now shows an
74
+ error state with a retry button instead of silently keeping stale content.
75
+ - A deadline-free one-shot task disappeared from the today page the moment it was
76
+ checked; the completed row now stays revocable for the rest of the day (it never
77
+ reappears the next day).
78
+ - `/xingyuan/api/day` and `/xingyuan/api/range` answered a malformed `date` with an
79
+ empty day list or an unlabelled `RangeError`; both now reject with a structured
80
+ `bad_date` error, consistent with the POST action surface.
81
+ - Creating a task with a name over 100 characters through a page action failed with a
82
+ bare validation message; the error now states the limit and the actual length.
83
+ - The claim button in the calendar is gated to the picked day being today, matching the
84
+ server-side anchor semantics.
85
+ - Display dates across cards, toasts and pages are localized (ISO strings remain only in
86
+ confirm-dialog copy and screen-reader strings, per the documented convention).
87
+ - The standalone growth page used `color-mix()`, which some shells render as an invalid
88
+ declaration; the darker tone is now precomputed in JS.
89
+
90
+ ### Changed
91
+ - Trailing chart statistics (trend, daily rate, week comparison, by-category, ranking,
92
+ time-of-day, weekday activity) only count check-ins up to today; the calendar heatmap
93
+ deliberately keeps future pre-checks visible. Chart cards are snapshots of their
94
+ generation time — the coach guide tells the model to regenerate for current data.
95
+ - `cancel_check_in_task` parameter guidance now instructs the model to pass the date
96
+ whenever the user names a day (including "today"), so the "most recent" fallback is a
97
+ deliberate choice rather than an accident.
98
+ - zh copy no longer pads localized dates with spaces ("已撤销 8月29日 的打卡" →
99
+ "已撤销8月29日的打卡"), and the grid legend says "待打卡" where it previously said
100
+ "未来机会日" for what is now only ever today's pending slot.
101
+
102
+ ## [0.5.3] - 2026-08-28
103
+
104
+ > **Note:** this is a patch release but it carries one breaking change — the `xingyuan`
105
+ > settings namespace is replaced by `xingyuan-pref` (see *Removed*). Consumers of the
106
+ > `./domain` subpath must also adapt to `makeXingyuanStore()` taking a second, required
107
+ > argument. Read *Upgrading from 0.5.2 or earlier* before updating.
108
+
109
+ **Chat preferences are now always editable.** Settings → XingYuan rendered the
110
+ write-confirmation toggle and the memory-injection limit as permanently disabled until a
111
+ XingYuan session had been opened once since the last dsh start; clicking them did nothing
112
+ and produced no error. Both preferences lived in the preset-layer namespace, which only
113
+ comes into existence when the preset is first mounted, so they now live in a
114
+ bundle-resident namespace registered whenever the bundle row is active.
115
+
116
+ ### Upgrading from 0.5.2 or earlier
117
+
118
+ The two chat preferences moved from the `xingyuan` block of `$DSH_HOME/settings.yaml` to a
119
+ new `xingyuan-pref` block. **A value you had customised is not carried over** and falls back
120
+ to the default: `confirmWrites` returns to `true` (the safe direction — write actions ask for
121
+ confirmation again) and `memoryInjectLimit` to `40`. Re-apply your choices under
122
+ **Settings → XingYuan → Chat preferences**. The leftover `xingyuan:` block is inert and is
123
+ safe to delete.
124
+
125
+ ### Added
126
+ - **`xingyuan-pref` settings namespace** (`src/pref-settings.ts`), installed by the bundle
127
+ layer next to the existing `xingyuan-ui` namespace, carrying `confirmWrites` (default
128
+ `true`) and `memoryInjectLimit` (default `40`, integer `5`–`200`). The schema declares
129
+ `.step(1)`, so the Host also rejects fractional values that a hand-edited settings
130
+ document or a direct RPC write could otherwise smuggle in.
131
+ - **Shared preference policy module** (`src/pref-policy.ts`) holding the bounds, defaults
132
+ and input parser for both halves — the host module depends on
133
+ `@deepseek-ai/dsh-settings` and must never reach the browser bundle. This also removes
134
+ the `5`/`200`/`40` literals that were previously repeated in the settings page. Covered
135
+ by `test/pref-policy.test.ts`.
136
+ - **Write verification**: `scope.set()` resolves rather than rejects on failure (the client
137
+ controller catches, recovers and returns silently), so `toastError` never fired. The page
138
+ now compares the snapshot after each write and raises a "did not save" toast when the
139
+ value did not change. Both controls share one scope — and therefore one write queue — so
140
+ the comparison is gated on a per-component write sequence number to stay honest under
141
+ interleaved writes (see below).
142
+ - **Regression lock** (`test/pref-settings.test.ts`): five tests pinning that the bundle
143
+ layer registers `xingyuan-pref` as soon as the settings service is available, that the
144
+ read thunk resolves fresh on every call, that the headless path falls back to the schema
145
+ defaults, and — structurally — that **no namespace bound by the settings page is ever
146
+ registered from the preset layer**. That last one is the abstract shape of this bug and is
147
+ what stops it from coming back.
148
+ - **Four-way readiness notice** for the preferences card: loading, remote/temporary
149
+ connection (`mode === 'memory'`), namespace not registered, and read-only document. The
150
+ previous code only covered the first two, leaving the rest as silently disabled controls.
151
+
152
+ ### Fixed
153
+ - The two preferences could not be changed before the first XingYuan session of the process
154
+ — the root cause described above.
155
+ - The write-confirmation toggle rendered as **on** whenever its value was unavailable
156
+ (`value?.confirmWrites !== false` reads "unknown" as "true"), which is the worst possible
157
+ default for a safety switch. It now falls back to the schema default explicitly.
158
+ - Namespace-unavailable and read-only states disabled the controls with no explanation.
159
+ - The "did not save" toast could fire on a write that **had** been saved. Both controls share
160
+ one settings scope, and the client controller fences writes with a generation counter: when
161
+ a write is superseded by a newer one it only records a pending revision and does **not**
162
+ fold the result into the snapshot, while the superseded write's `.then` still runs first and
163
+ sees the stale value. Verification is now skipped for superseded writes, and the number
164
+ input got the pending guard the toggle already had.
165
+ - Clearing the memory-injection-limit field and blurring reported "enter an integer between 5
166
+ and 200" while simultaneously refilling the field with the saved value — two contradictory
167
+ messages at once. An empty field is again treated as abandoning the edit.
168
+ - Tool descriptions for the creation / check-in / cancel-check-in tools asserted that a system
169
+ confirmation card would appear. Toggle **Confirm write actions** off and the model kept
170
+ telling users a card had been shown when none was. Tool descriptions are baked in at
171
+ registration and dsh-tools requires a plain string (no getter), so they are now worded to
172
+ hold in both states instead of asserting the card. Delete tools are unaffected — deletes
173
+ always confirm.
174
+
175
+ ### Changed
176
+ - The preset layer no longer registers a settings namespace. It reads preferences through
177
+ `ctx.xingyuan.prefs()` — a thunk that resolves the current value on every call, so edits
178
+ still take effect immediately without re-registering tools or prompts. The remaining
179
+ `Config` fields (`batchWishLimit`, `chartTrendDays`, …) have no settings UI and stay as
180
+ composition-only entry config, which matches the upstream rule that a namespace should
181
+ carry only the user-editable subset.
182
+ - **Breaking for `./domain` consumers**: `makeXingyuanStore(domain)` now takes a second,
183
+ required argument — `makeXingyuanStore(domain, readPrefs)`, where `readPrefs` is a thunk
184
+ returning the resolved chat preferences. The required-ness is deliberate: every call site
185
+ (one in production, five in tests) is surfaced by the compiler rather than silently
186
+ missing the wiring. `XingyuanStore` gained a matching `prefs()` method.
187
+
188
+ ### Removed
189
+ - The preset-layer `xingyuan` settings namespace and the `confirmWrites` /
190
+ `memoryInjectLimit` fields of the preset `Config`. **A value customised under the old
191
+ `xingyuan:` section of `$DSH_HOME/settings.yaml` is no longer read and falls back to the
192
+ default** — `confirmWrites` therefore returns to `true`, the conservative direction. The
193
+ stale section is left in the document and is harmless.
194
+
8
195
  ## [0.5.2] - 2026-08-27
9
196
 
10
197
  ### Added
package/lib/client/api.js CHANGED
@@ -40,8 +40,27 @@ export function describeError(e) {
40
40
  export async function postAction(path, body) {
41
41
  return postJson(`/xingyuan/api/action/${path}`, body);
42
42
  }
43
+ /** 请求超时:手动 AbortController——AbortSignal.timeout 为 Baseline 2024 "Newly available",
44
+ * 不满足 dsh 壳的保守浏览器矩阵(同 color-mix 禁令的判断口径),不用。 */
45
+ const REQUEST_TIMEOUT_MS = 15_000;
46
+ async function fetchWithTimeout(path, init) {
47
+ const controller = new AbortController();
48
+ const timer = window.setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
49
+ try {
50
+ return await fetch(path, { ...init, signal: controller.signal });
51
+ }
52
+ catch (e) {
53
+ // 超时中止统一转本地化文案;真实网络错误原样抛出(保留连接拒绝等语义)
54
+ if (e instanceof DOMException && e.name === 'AbortError')
55
+ throw new Error(t('common.requestTimeout'));
56
+ throw e;
57
+ }
58
+ finally {
59
+ window.clearTimeout(timer);
60
+ }
61
+ }
43
62
  export async function postJson(path, body) {
44
- const response = await fetch(path, {
63
+ const response = await fetchWithTimeout(path, {
45
64
  method: 'POST',
46
65
  headers: { 'content-type': 'application/json' },
47
66
  body: JSON.stringify(body),
@@ -49,7 +68,7 @@ export async function postJson(path, body) {
49
68
  return parsePayload(response, () => t('common.actionFailed'));
50
69
  }
51
70
  export async function getJson(path) {
52
- const response = await fetch(path, { headers: { accept: 'application/json' } });
71
+ const response = await fetchWithTimeout(path, { headers: { accept: 'application/json' } });
53
72
  return parsePayload(response, () => t('common.requestFailed'));
54
73
  }
55
74
  async function parsePayload(response, fallback) {
@@ -11,7 +11,7 @@ import { toast, toastError } from './ui.js';
11
11
  import { localYmd, softConfirm } from './hooks.js';
12
12
  import { useXyT, t, activeLocale } from './i18n.js';
13
13
  import { categoryVars } from '../category-color.js';
14
- import { cycleLabel, durationText, dateSuffix } from './pages/format.js';
14
+ import { cycleLabel, durationText, dateSuffix, formatMediumDate, formatShortDate } from './pages/format.js';
15
15
  function WishView(props) {
16
16
  const t = useXyT();
17
17
  const wish = props.node.data.data;
@@ -95,7 +95,7 @@ function TaskView(props) {
95
95
  const showClaim = !deleted && phase === 'idle' && task.status === 'pending';
96
96
  const showCheckIn = !deleted && phase !== 'done' && (task.status === 'in_progress' || phase === 'claimed');
97
97
  const preview = event.opportunityPreview.length > 0
98
- ? t('task.upcoming', { dates: event.opportunityPreview.join(activeLocale() === 'en' ? ', ' : '、') })
98
+ ? t('task.upcoming', { dates: event.opportunityPreview.map((date) => formatShortDate(date)).join(activeLocale() === 'en' ? ', ' : '、') })
99
99
  : undefined;
100
100
  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
101
  ? createElement('span', { className: 'xy-meta xy-actioned' },
@@ -105,7 +105,7 @@ function TaskView(props) {
105
105
  ? createElement('button', { className: 'xy-btn xy-btn-inline', disabled: busy, onClick: () => run('claim') }, t('action.claim'))
106
106
  : null, showCheckIn
107
107
  ? 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
108
+ : 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
109
  ? createElement('div', { className: 'xy-preview' }, preview)
110
110
  : null);
111
111
  }
@@ -139,23 +139,29 @@ function CheckinView(props) {
139
139
  const event = props.node.data.data;
140
140
  const cancelled = event.op === 'cancelled';
141
141
  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)}`));
142
+ ? t('checkin.cancelled', { task: event.taskName, date: formatShortDate(event.date) })
143
+ : 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
144
  }
145
145
  function ChartView(props) {
146
146
  const t = useXyT();
147
147
  const event = props.node.data.data;
148
148
  // 悬停柱下标(与成长页悬浮明细同一交互语法;null = 未悬停)
149
149
  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));
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
+ // 快照时点标注(事件溯源:卡片=当时的事实):无 generatedAt 的旧事件诚实降级不显示。
152
+ // 中格式带年份——回放的旧卡可能跨年,「生成于 3月5日」须能定位到哪一年。
153
+ // ISO → 本地日再格式化:直接 slice 会把 UTC 日期当本地日(UTC+8 零点后 8 小时内差一天)
154
+ event.generatedAt !== undefined
155
+ ? 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));
151
157
  }
152
158
  /** 图表卡主体:四类渲染路径(占比条/占比列表/热力格/柱图)颜色全部走主题变量,深浅色自适应。 */
153
- function chartBody(event, noDataText, hoverIdx, onHover) {
159
+ function chartBody(event, noDataText, noScheduleText, hoverIdx, onHover) {
154
160
  const { chartType, data } = event;
155
161
  if (data.length === 0)
156
162
  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}`;
163
+ 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}`;
159
165
  if (chartType === 'arcbars') {
160
166
  const ratio = Math.min(Math.max(data[0].ratio ?? 0, 0), 1);
161
167
  return createElement('div', { className: 'xy-arcwrap' }, createElement('div', { className: 'xy-arcnum' }, `${Math.round(ratio * 100)}%`),
@@ -187,19 +193,22 @@ function chartBody(event, noDataText, hoverIdx, onHover) {
187
193
  const r = Math.min(2, h, w / 2);
188
194
  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
195
  };
190
- const bars = data.map((d, i) => {
196
+ const bars = data.flatMap((d, i) => {
197
+ // 无安排日不画柱(缺失≠0):空槽由下标占位保留,柱序与日期轴不塌缩
198
+ if (d.inactive === true)
199
+ return [];
191
200
  // 微值可见性下限:有值但按比例不足 2px 的柱给 2px,避免「有数据却看不见」
192
201
  const rawH = Math.round((d.value / max) * (height - 24));
193
202
  const h = d.value > 0 ? Math.max(rawH, 2) : rawH;
194
203
  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)));
204
+ return [createElement('path', {
205
+ key: i,
206
+ 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 },
208
+ onMouseEnter: () => onHover(i),
209
+ },
210
+ // 每根柱子带 <title>:原生悬停提示;精确数值另由上方悬浮明细条实时呈现
211
+ createElement('title', null, labelValue(d)))];
203
212
  });
204
213
  // 标签用原始下标定位:indexOf 会把重复标签(如分组系列的「周一」×2)全部钉到首个位置
205
214
  const labelStep = data.length <= 10 ? 1 : Math.ceil(data.length / 8);
@@ -221,7 +230,7 @@ function chartBody(event, noDataText, hoverIdx, onHover) {
221
230
  createElement('svg', {
222
231
  viewBox: `0 0 ${width} ${height}`, className: 'xy-svg',
223
232
  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' ? '; ' : ';')));
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' ? '; ' : ';')));
225
234
  }
226
235
  export const CARD_VIEWS = {
227
236
  WishView,
@@ -18,10 +18,18 @@ export function recentRangeDays(days) {
18
18
  end: localYmd(now),
19
19
  };
20
20
  }
21
+ /** 窗口重新可见后的重取门槛(ms):等价 TanStack Query 的 staleTime——
22
+ * 数据在此年龄内视为新鲜,聚焦不重取;跨天翻篇则无视门槛一律重取。 */
23
+ const FOCUS_REFETCH_MIN_AGE_MS = 60_000;
21
24
  /**
22
25
  * 取数三态统一:挂载(或 deps 变化)即拉取,error 存本地化文案,
23
26
  * reload 供错误态按钮与动作后刷新复用。path 支持函数形态(如日历按月取参);
24
27
  * 内置序号守卫:快速翻页/连点时慢的旧响应不得覆盖新状态。
28
+ *
29
+ * 跨天/回焦自动新鲜化(习惯类应用的基本预期——凌晨挂着的今日页不能还是昨天):
30
+ * 监听 visibilitychange/focus,翻篇(本地日期变化)必重取,未翻篇仅当数据年龄
31
+ * 超过门槛才重取(有节流上限,不空转不定时器)。NN/g「系统应始终让用户知情」:
32
+ * 时间流逝导致的状态变化要在回到页面时即刻呈现。
25
33
  */
26
34
  export function usePageData(path, deps = []) {
27
35
  const [data, setData] = useState(undefined);
@@ -29,9 +37,13 @@ export function usePageData(path, deps = []) {
29
37
  const seqRef = useRef(0);
30
38
  const pathRef = useRef(path);
31
39
  pathRef.current = path;
40
+ const lastLoadAtRef = useRef(0);
41
+ const lastLoadDayRef = useRef(undefined);
32
42
  const load = useCallback(() => {
33
43
  const seq = ++seqRef.current;
34
44
  setError(undefined);
45
+ lastLoadAtRef.current = Date.now();
46
+ lastLoadDayRef.current = localYmd(new Date());
35
47
  return getJson(typeof pathRef.current === 'function' ? pathRef.current() : pathRef.current)
36
48
  .then((payload) => { if (seq === seqRef.current)
37
49
  setData(payload); })
@@ -39,6 +51,23 @@ export function usePageData(path, deps = []) {
39
51
  setError(describeError(e)); });
40
52
  }, deps);
41
53
  useEffect(() => { void load(); }, [load]);
54
+ useEffect(() => {
55
+ const maybeReload = () => {
56
+ if (document.visibilityState !== 'visible')
57
+ return;
58
+ const today = localYmd(new Date());
59
+ // 翻篇必取;未翻篇按数据年龄节流(TanStack Query focus 重取的 staleTime 语义)
60
+ if (lastLoadDayRef.current !== today || Date.now() - lastLoadAtRef.current > FOCUS_REFETCH_MIN_AGE_MS) {
61
+ void load();
62
+ }
63
+ };
64
+ document.addEventListener('visibilitychange', maybeReload);
65
+ window.addEventListener('focus', maybeReload);
66
+ return () => {
67
+ document.removeEventListener('visibilitychange', maybeReload);
68
+ window.removeEventListener('focus', maybeReload);
69
+ };
70
+ }, [load]);
42
71
  return { data, error, reload: load };
43
72
  }
44
73
  /**
@@ -41,6 +41,7 @@ const ZH_DICT = {
41
41
  'common.edit': '编辑',
42
42
  'common.requestFailed': '请求失败',
43
43
  'common.actionFailed': '操作失败',
44
+ 'common.requestTimeout': '请求超时,请重试',
44
45
  'common.dayUnit': '{n} 天',
45
46
  'common.countUnit': '{n} 次',
46
47
  'common.countUnitOne': '{n} 次',
@@ -86,18 +87,18 @@ const ZH_DICT = {
86
87
  'action.checkinFuture': '提前打卡 {date}',
87
88
  'action.checkinThisDay': '打卡此日',
88
89
  'action.undoCheckin': '撤销',
89
- 'action.cancelCheckin': '取消打卡', 'action.expand': '展开详情',
90
+ 'action.cancelCheckin': '取消打卡',
91
+ 'action.cancelCheckinAria': '撤销{date}的打卡', 'action.expand': '展开详情',
90
92
  'action.collapse': '收起详情',
91
93
  'action.createWish': '+ 新建愿望',
92
94
  'action.createTask': '+ 新建任务',
93
95
  'action.manageCategories': '分类管理',
94
96
  'action.askAi': '让 AI 总结',
95
- 'today.notCheckinDay': '今天不是打卡日',
96
97
  // --- 动作反馈 toast ---
97
98
  'toast.claimed': '已领取「{name}」,去打卡吧',
98
99
  'toast.checkinOk': '打卡成功',
99
100
  'toast.undone': '打卡已撤销',
100
- 'toast.undoneAt': '已撤销 {date} 的打卡',
101
+ 'toast.undoneAt': '已撤销{date}的打卡',
101
102
  'toast.wishCreated': '已创建愿望「{title}」',
102
103
  'toast.taskCreated': '已创建任务「{name}」',
103
104
  'toast.deleted': '已删除「{name}」',
@@ -106,10 +107,10 @@ const ZH_DICT = {
106
107
  'toast.categoryColorReset': '「{name}」配色已重置,跟随愿望默认色',
107
108
  'toast.categoryDeleted': '已清除「{name}」的自定义颜色',
108
109
  // --- 确认弹窗 ---
109
- 'confirm.futureCheckin': '「{name}」今天不是打卡日,提前打卡 {date} 表示承诺当天完成。确认?',
110
+ 'confirm.futureCheckin': '「{name}」今天不是打卡日,提前打卡{date}表示承诺当天完成。确认?',
110
111
  'confirm.undoToday': '确定撤销「{name}」今天的打卡吗?进度将回退。',
111
- 'confirm.undoAt': '确定撤销「{name}」在 {date} 的打卡吗?进度将回退。',
112
- 'confirm.futureAt': '「{name}」在 {date} 不是当天,提前打卡表示承诺当天完成。确认?',
112
+ 'confirm.undoAt': '确定撤销「{name}」在{date}的打卡吗?进度将回退。',
113
+ 'confirm.futureAt': '「{name}」{date}不是打卡日,提前打卡表示承诺当天完成。确认?',
113
114
  'confirm.deleteTask': '确定删除任务「{name}」吗?其打卡记录将一并删除,不可恢复。',
114
115
  'confirm.deleteWish': '确定删除愿望「{name}」吗?下属任务与打卡记录将一并删除,不可恢复。',
115
116
  'confirm.renameCategory': '把分类「{old}」下的全部愿望改名为「{new}」吗?(共 {count} 个)', 'confirm.deleteEmptyCategory': '删除空分类「{name}」的自定义颜色吗?',
@@ -230,11 +231,14 @@ const ZH_DICT = {
230
231
  'settings.pref.title': '对话偏好',
231
232
  'settings.pref.unavailable': '当前连接不支持持久化偏好(远程或临时模式),以下两项不可用。',
232
233
  'settings.pref.loading': '偏好加载中…',
234
+ 'settings.pref.notRegistered': '偏好暂不可用(组件未就绪),请稍后重试。',
235
+ 'settings.pref.readOnly': '当前设置文档为只读,无法保存修改。',
233
236
  'settings.pref.confirmWrites': '写操作二次确认',
234
237
  'settings.pref.confirmWritesHint': '创建愿望/任务、打卡、取消打卡时先弹应用内确认卡(删除始终确认);关闭后对话中的这类操作将直接执行。',
235
238
  'settings.pref.memoryLimit': '记忆注入上限',
236
239
  'settings.pref.memoryLimitHint': '每次对话自动注入上下文的记忆条数上限(5-200,默认 40);失焦后保存。',
237
240
  'settings.pref.limitInvalid': '请输入 5-200 的整数',
241
+ 'settings.pref.writeFailed': '保存未生效,请重试。',
238
242
  'settings.tabs.title': '标签页显示',
239
243
  'settings.tabs.mode.follow': '跟随会话',
240
244
  'settings.tabs.mode.show': '始终显示',
@@ -254,18 +258,20 @@ const ZH_DICT = {
254
258
  'micro.progress': '进度 {done}/{total} 完成',
255
259
  // --- 打卡卡 ---
256
260
  'checkin.success': '「{task}」打卡成功',
257
- 'checkin.cancelled': '已取消「{task}」{date} 的打卡',
261
+ 'checkin.cancelled': '已取消「{task}」{date}的打卡',
258
262
  // --- 图表卡 ---
259
263
  'chart.noData': '暂无数据',
264
+ 'chart.noSchedule': '无安排',
265
+ 'chart.generatedAt': '生成于 {date}',
260
266
  // --- 详情聚合视图 ---
261
267
  'detail.grid.title': '打卡记录',
262
- 'detail.grid.summary': '近 {total} 个机会日:已打 {checked},未打 {missed},未来 {future}',
268
+ 'detail.grid.summary': '近 {total} 格:已打 {checked},未打 {missed},待打卡 {future}',
263
269
  'detail.legend.checked': '已打卡',
264
270
  'detail.legend.missed': '未打卡',
265
- 'detail.legend.future': '未来机会日',
271
+ 'detail.legend.future': '待打卡',
266
272
  'detail.grid.checked': '{date} 已打卡',
267
273
  'detail.grid.missed': '{date} 未打卡',
268
- 'detail.grid.future': '{date} 未来机会日',
274
+ 'detail.grid.future': '{date} 待打卡',
269
275
  'detail.next.title': '接下来的机会日:{dates}',
270
276
  'detail.micro.title': '微行动进度',
271
277
  'detail.micro.idle': '尚未开始微行动拆解(可在对话里说「帮我拆解」)。',
@@ -356,6 +362,7 @@ const EN_DICT = {
356
362
  'common.edit': 'Edit',
357
363
  'common.requestFailed': 'Request failed',
358
364
  'common.actionFailed': 'Action failed',
365
+ 'common.requestTimeout': 'Request timed out, please retry',
359
366
  'common.dayUnit': '{n} days',
360
367
  'common.countUnit': '{n} times',
361
368
  'common.countUnitOne': '{n} time',
@@ -398,13 +405,13 @@ const EN_DICT = {
398
405
  'action.checkinThisDay': 'Check in',
399
406
  'action.undoCheckin': 'Undo',
400
407
  'action.cancelCheckin': 'Undo check-in',
408
+ 'action.cancelCheckinAria': 'Undo the check-in on {date}',
401
409
  'action.expand': 'Details',
402
410
  'action.collapse': 'Hide',
403
411
  'action.createWish': '+ New wish',
404
412
  'action.createTask': '+ New task',
405
413
  'action.manageCategories': 'Categories',
406
414
  'action.askAi': 'AI summary',
407
- 'today.notCheckinDay': 'Not a check-in day today',
408
415
  'toast.claimed': 'Claimed “{name}”. Time to check in!',
409
416
  'toast.checkinOk': 'Checked in',
410
417
  'toast.undone': 'Check-in reverted',
@@ -536,11 +543,14 @@ const EN_DICT = {
536
543
  'settings.pref.title': 'Chat preferences',
537
544
  'settings.pref.unavailable': 'This connection cannot persist preferences (remote or temporary mode); the two options below are disabled.',
538
545
  'settings.pref.loading': 'Loading preferences…',
546
+ 'settings.pref.notRegistered': 'Preferences are unavailable right now (component not ready); please retry later.',
547
+ 'settings.pref.readOnly': 'The settings document is read-only; changes cannot be saved.',
539
548
  'settings.pref.confirmWrites': 'Confirm write actions',
540
549
  'settings.pref.confirmWritesHint': 'Show an in-app confirmation before creating wishes/tasks, checking in or canceling (deletes always confirm). Turn off to execute directly.',
541
550
  'settings.pref.memoryLimit': 'Memory injection limit',
542
551
  'settings.pref.memoryLimitHint': 'Max memories auto-injected per chat (5-200, default 40); saved on blur.',
543
552
  'settings.pref.limitInvalid': 'Enter an integer between 5 and 200',
553
+ 'settings.pref.writeFailed': 'The change did not save; please try again.',
544
554
  'settings.tabs.title': 'Tab visibility',
545
555
  'settings.tabs.mode.follow': 'Follow session',
546
556
  'settings.tabs.mode.show': 'Always show',
@@ -560,14 +570,16 @@ const EN_DICT = {
560
570
  'checkin.success': '“{task}” checked in',
561
571
  'checkin.cancelled': 'Cancelled the check-in of “{task}” on {date}',
562
572
  'chart.noData': 'No data yet',
573
+ 'chart.noSchedule': 'Not scheduled',
574
+ 'chart.generatedAt': 'Generated on {date}',
563
575
  'detail.grid.title': 'Check-in record',
564
- 'detail.grid.summary': 'Last {total} opportunity days: {checked} checked, {missed} missed, {future} upcoming',
576
+ 'detail.grid.summary': 'Last {total} cells: {checked} checked, {missed} missed, {future} pending',
565
577
  'detail.legend.checked': 'Checked in',
566
578
  'detail.legend.missed': 'Missed',
567
- 'detail.legend.future': 'Upcoming',
579
+ 'detail.legend.future': 'Pending today',
568
580
  'detail.grid.checked': '{date} checked in',
569
581
  'detail.grid.missed': '{date} missed',
570
- 'detail.grid.future': '{date} upcoming opportunity',
582
+ 'detail.grid.future': '{date} pending check-in',
571
583
  'detail.next.title': 'Upcoming opportunity dates: {dates}',
572
584
  'detail.micro.title': 'Micro-step progress',
573
585
  'detail.micro.idle': 'No micro-step plan yet (ask “help me break it down” in chat).',
@@ -79,10 +79,12 @@ export function apply(ctx) {
79
79
  // label 用 thunk 跟随当前语言;locale 声明让壳在语言切换时刷新标签行。
80
80
  ctx.slots.inject('conversation.view', () => installTabVisibility(ctx));
81
81
  // 星愿设置整页(设置 → 星愿):教练风格/画像(星愿库)+ 二次确认开关与注入上限
82
- // (preset 命名空间)+ 标签页显隐(bundle 常驻命名空间 xingyuan-ui,未选星愿也可调)
82
+ // (bundle 常驻命名空间 xingyuan-pref)+ 标签页显隐(bundle 常驻命名空间 xingyuan-ui)。
83
+ // 两个偏好命名空间都挂在常驻层:整页由本文件无条件注册,命名空间若随 preset 懒加载
84
+ // 缺席,就会出现「整页可见但两项写不进去且静默失败」。
83
85
  ctx.slots.inject('settings.section', () => {
84
- const scope = ctx.settingsScope.bind({ namespace: 'xingyuan' });
86
+ const prefscope = ctx.settingsScope.bind({ namespace: 'xingyuan-pref' });
85
87
  const uiscope = ctx.settingsScope.bind({ namespace: 'xingyuan-ui' });
86
- return ctx.slots.register({ name: 'settings.section', id: 'xingyuan', order: 60, label: () => t('settings.tabLabel'), locale: XY_NS }, () => SettingsSection({ scope, uiscope }));
88
+ return ctx.slots.register({ name: 'settings.section', id: 'xingyuan', order: 60, label: () => t('settings.tabLabel'), locale: XY_NS }, () => SettingsSection({ scope: prefscope, uiscope }));
87
89
  });
88
90
  }
@@ -4,7 +4,7 @@ import { getJson, postAction } from '../api.js';
4
4
  import { t } from '../i18n.js';
5
5
  import { softConfirm, useActionGuard, usePageData, useStableScrollbar, localYmd } from '../hooks.js';
6
6
  import { PageError, PageSkeleton, toast } from '../ui.js';
7
- import { cycleLabel, dateSuffix, formatFriendlyDate, formatMonth } from './format.js';
7
+ import { cycleLabel, dateSuffix, formatFriendlyDate, formatMonth, formatShortDate } from './format.js';
8
8
  function monthOf(offset) {
9
9
  const base = new Date();
10
10
  base.setDate(1);
@@ -66,7 +66,7 @@ export function CalendarPage() {
66
66
  ? t('toast.claimed', { name: taskName })
67
67
  : action === 'checkin'
68
68
  ? t('toast.checkinOk') + dateSuffix(date)
69
- : t('toast.undoneAt', { date }), 'ok');
69
+ : t('toast.undoneAt', { date: formatShortDate(date) }), 'ok');
70
70
  // busy 窗口覆盖月历重取;之后仅当操作日仍是当前选中日才回填详情——
71
71
  // 动作与快速连点另一日期竞争时,慢的旧响应不得覆盖新选中日的面板
72
72
  return page.reload().then(() => {
@@ -77,7 +77,10 @@ export function CalendarPage() {
77
77
  .then((dayAfter) => { if (seq === pickSeqRef.current) {
78
78
  setDetail(dayAfter);
79
79
  setPickState('idle');
80
- } });
80
+ } })
81
+ // 动作已成功、仅详情回取失败:转面板错误态(重试即重新拾取),不静默吞掉也不误报动作失败
82
+ .catch(() => { if (seq === pickSeqRef.current)
83
+ setPickState('error'); });
81
84
  });
82
85
  }));
83
86
  };
@@ -202,7 +205,10 @@ export function CalendarPage() {
202
205
  ...(task.wishName !== undefined ? [task.wishName] : []),
203
206
  ...(stateText !== '' ? [stateText] : []),
204
207
  ];
205
- return createElement('li', { key: task.taskId, className: 'xy-grouprow' }, createElement('div', { className: 'xy-rowmain' }, createElement('span', { className: 'xy-rowtitle' }, task.name), createElement('span', { className: 'xy-meta' }, segments.join(' · '))), task.status === 'pending' && !task.checked
208
+ return createElement('li', { key: task.taskId, className: 'xy-grouprow' }, createElement('div', { className: 'xy-rowmain' }, createElement('span', { className: 'xy-rowtitle' }, task.name), createElement('span', { className: 'xy-meta' }, segments.join(' · '))),
209
+ // 领取仅限今日:claim 无日期语义,claimDate 恒为领取当天——
210
+ // 过去/未来选中日的「领取」按钮会造出锚点与所见不符的任务
211
+ task.status === 'pending' && !task.checked && detail.date === data.today
206
212
  ? createElement('button', { className: 'xy-btn', disabled: busy, onClick: () => act('claim', task.taskId, task.name, detail.date) }, t('action.claim'))
207
213
  : !task.checked && task.status === 'in_progress'
208
214
  ? createElement('button', { className: 'xy-btn xy-btn-primary', disabled: busy || !task.canCheckIn, onClick: () => act('checkin', task.taskId, task.name, detail.date) }, t('action.checkinThisDay'))