@starwish-ai/xingyuan-dsh 0.5.6 → 0.5.8

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.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,164 @@ 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.8] - 2026-09-04
9
+
10
+ > **Note:** this release is the fourth six-perspective quality pass. It finishes
11
+ > the promised-vs-planned caliber split across every surface: the Today tab and
12
+ > the calendar day panel now show only claimed obligations (the unclaimed pool
13
+ > lives on the Tasks tab and in chat), the growth 30-day range chart no longer
14
+ > counts unclaimed tasks as failure gaps, and every surface labels expired
15
+ > tasks honestly instead of mislabeling them as unclaimed. No data format
16
+ > changes; existing databases and recorded sessions replay as before.
17
+
18
+ ### Changed
19
+ - **Today tab shows claimed obligations only** (user decision): the unclaimed
20
+ candidate group and its claim buttons were removed from the Today tab; the
21
+ claim entry point now lives on the Tasks tab's unclaimed group and in chat
22
+ (the opening overview and `get_today_unchecked_tasks` still list them
23
+ separately, with the "never claim on the model's own" constraint). The
24
+ standalone `/xingyuan/today` page follows the same rule.
25
+ - **Calendar day panel shows claimed tasks on every date** (user decision):
26
+ previously only non-today panels filtered out unclaimed rows; today's panel
27
+ kept them with a claim button. The panel is now claimed-only for today as
28
+ well. Expired tasks still appear on their deadline day as honest "expired"
29
+ failure records (not checkable; revived via the task detail panel), matching
30
+ the chat tool labeling.
31
+ - **Growth 30-day range chart counts only claimed tasks**: unclaimed
32
+ opportunity days no longer render as failure gaps (same caliber as the month
33
+ calendar and the Today progress). The underlying plan API for future-schedule
34
+ tools keeps the planned caliber and labels unclaimed counts explicitly.
35
+ - **Honest completion ratio on the Today page**: the progress now floors —
36
+ 249/250 displays 99% instead of 100% without the "all done" banner (same
37
+ rule as wish progress).
38
+ - **Wish progress keeps its plan-caliber denominator, now labeled**: wish cards
39
+ and the wish-progress chart subtitle append "includes unclaimed tasks"
40
+ whenever any shown wish has unclaimed tasks; the future-arrangement query
41
+ tools split their counts into "to check in N, unclaimed M".
42
+ - **Expired tasks are labeled "expired" on every surface**: the chat date query
43
+ and the standalone calendar detail previously mislabeled them "unclaimed";
44
+ the React calendar panel now renders the state word as well.
45
+ - **Single-source claimed predicate**: all host-side filtering goes through
46
+ `store.ts` `isClaimed` (charts included), the day API exposes the `claimed`
47
+ boolean for the client, and the client no longer duplicates status predicates
48
+ on the day surface.
49
+
50
+ ### Added
51
+ - Route-level test locks for the promised-caliber semantics: overview totals,
52
+ month-calendar cells, day rows, the growth range (unclaimed ≠ failure gap),
53
+ and expired-task retention on the schedule surface.
54
+
55
+ ## [0.5.7] - 2026-08-29
56
+
57
+ > **Note:** this release is the third six-perspective quality pass (product,
58
+ > design, end-user, dsh-plugin engineering, architecture, UX). It closes the
59
+ > last honesty gap in the write paths, makes tab switching lossless for
60
+ > in-progress input, hardens the opportunity-date calculator against a
61
+ > process-hanging edge case, and lands the accessibility/contrast leftovers.
62
+ > No data format changes; existing databases and recorded sessions replay as before.
63
+
64
+ ### Added
65
+ - **Draft snapshots across tab switches**: typing a memory (up to 1000 chars)
66
+ or filling the quick-create forms used to be silently lost when switching
67
+ conversation view tabs (pages unmount on switch). Form drafts now survive tab
68
+ switches and panel collapse; successful saves clear them.
69
+ - **Instant growth feedback on check-in**: the check-in tool reply appends the
70
+ current level and experience progress, closing the "check in → grow" loop at
71
+ the moment of the action instead of deferring it to the Growth page.
72
+ - **Non-preset session hint on the chat-directed pages**: in "always show" mode the six tabs
73
+ render in every session, but wishes/tasks/memory empty states directed users
74
+ to a chat that has no XingYuan tools there. The Today page's "preset not
75
+ enabled" hint row now also renders on the other three pages.
76
+ - **HTTP shell tests**: route dispatch (status-code mapping, the 64 KB limit,
77
+ JSON parsing, the `ToolError.code` passthrough that powers client-side error
78
+ localization) had zero coverage; locked with mocked `IncomingMessage`/
79
+ `ServerResponse` in `test/routes-shell.test.ts`.
80
+
81
+ ### Changed
82
+ - **Claiming an overdue unclaimed task is rejected, not silently closed**
83
+ (`claim_expired`): the old flow claimed it and immediately expired-closed it
84
+ while the tool reply asserted "claimed, now in progress" and the page toast
85
+ said "go check in" — both false, and the task-line button kept offering an
86
+ action that always fails. The row shows a guidance hint instead, the chat
87
+ task card hides its claim button for the same state, and the detail panel's
88
+ revive row (extend due date) now also covers overdue-unclaimed tasks.
89
+ - **The early check-in confirms no longer assert a falsehood**: the task-row,
90
+ card and detail dialogs said "today is not a check-in day" — false when today
91
+ was already checked in (the natural path: check in via chat, then tap the
92
+ button on the updated card); the calendar dialog said "{date} is not a
93
+ check-in day" for a day the panel only lists because it IS one. All four now
94
+ state only what is true: the target day is after today, and an early check-in
95
+ commits to finishing that day.
96
+ - **Growth-ladder and completed rows dim by text color, not row opacity**:
97
+ whole-row `opacity` (0.65-0.78) blended 12 px text below the WCAG AA 4.5:1
98
+ line (≈2.6-3.1:1 measured in both themes); the hierarchy now comes from the
99
+ secondary text color (4.76/5.15:1) while transparency stays on decorative
100
+ elements only.
101
+ - **Danger icon buttons share one quiet tier**: the memory-row delete joined
102
+ the wish-card delete at 0.78 idle opacity with hover/focus restore (long
103
+ lists stop glowing red; non-text contrast ≥3:1, the stale "≥4.5:1" comment
104
+ corrected to the non-text standard).
105
+ - **Growth chart stops squeezing its columns on narrow panels**: below the
106
+ 520 px breakpoint the 30 daily columns scroll horizontally at a minimum
107
+ width (~13 px per column, up from ~10 px) instead of being flex-crushed;
108
+ a deliberate trade-off against the 24 px touch-target ideal, whose full
109
+ width would make the chart scroll several screens.
110
+ - **HITL confirm sorts callers by whether any confirm UI exists**: callers
111
+ with no confirm UI at all (`NO_PROVIDER`, `CALLER_NOT_LIVE` — both raised
112
+ before `NO_PROVIDER` in rc.2) are allowed, as before for headless; a
113
+ delegated caller (`DELEGATED_CALLER`, e.g. a session-spawned subagent) is
114
+ now failed closed with actionable guidance ("confirm in the main
115
+ conversation, then run it there") instead of the raw platform error —
116
+ keeping the always-on delete confirmation intact on delegated paths.
117
+ Recorded in the engineering doc's HITL contract section.
118
+ - **weekly/monthly expectation management**: tool descriptions and the task
119
+ guide now state that opportunity days run from the claim date (every 7 days /
120
+ monthly by claim date) and never bind to a weekday — the model must say so
121
+ instead of silently accepting "every Wednesday" requests. Recorded in Known
122
+ Limitations.
123
+ - **The recommendation tool states its ordering**: `get_recommended_tasks` is
124
+ a fixed "top 5 by historical check-in count" ranking, not curation;
125
+ description and reply now say so.
126
+ - **Dedup guidance covers paraphrase**: the wish/task guides instruct the model
127
+ to sweep the existing list semantically when substring dedup finds no hit but
128
+ the request is loosely phrased.
129
+ - **Prompt/UI drift sync**: the Tasks page has four status groups (not three)
130
+ and two more settings entries (confirm-card language, tab visibility) — the
131
+ capabilities section now matches what users see.
132
+ - **Copy/wording**: user-facing strings unify "机会日" to "打卡日" (the check-in
133
+ tool guidance already used the plain term; leftover error strings swept
134
+ too); the bare-wish card points to the
135
+ chat path for task suggestions; the task-created toast explains "claim it to
136
+ start checking in"; five English strings polished ("To check in", "entries",
137
+ "Check-ins · last 30 days", "saved when you leave the field", the grid
138
+ summary).
139
+
140
+ ### Fixed
141
+ - **Opportunity-date loops can no longer hang the process**: termination used
142
+ ISO string comparison, which silently assumes four-digit years — a sequence
143
+ crossing 9999-12-31 (only reachable via hand-edited sqlite) compared
144
+ `'10000-xx' <= '9999-xx'` as true forever, hanging daily/weekly/monthly in an
145
+ allocating loop. Termination is now numeric (UTC day numbers; the monthly
146
+ branch via `Date.UTC`, since `Date.parse('10000-…')` returns `NaN`). Locked
147
+ by termination tests including the year-boundary crossing.
148
+ - **Toast glyph semantics & screen readers**: the info glyph was "★" — the star
149
+ is the high-importance memory marker (`.xy-star-hi`); info is now "i", and
150
+ toast glyphs are `aria-hidden` so decorative symbols are not announced inside
151
+ the live region.
152
+ - **Centered header baselines**: the `.xy-meta` top margin leaked into centered
153
+ card/page heads, sinking secondary text by 2 px; the treatment the detail
154
+ area already had now covers the heads too.
155
+ - **Focus management**: closing the category rename editor returns focus (to
156
+ the row's rename button, or the page title when the row was rebuilt by a
157
+ rename); finishing the last "load more" page moves focus to the "all shown"
158
+ line and announces it (`role="status"`).
159
+ - **Docs**: reference links pointed at the nonexistent `main` branch of the
160
+ upstream repository (the default branch is `master`) and at a cookbook page
161
+ that does not exist (the conversation-node contract lives in
162
+ `subsystems/conversation`); a dsh upgrade checklist now records the two
163
+ patch-DSL hazards (schedule insert id collision, storage-domain whole-line
164
+ replacement).
165
+
8
166
  ## [0.5.6] - 2026-08-29
9
167
 
10
168
  > **Note:** this release is the follow-up to the v0.5.5 six-perspective quality
package/lib/client/api.js CHANGED
@@ -22,6 +22,7 @@ const ERROR_KEY = {
22
22
  already_checked: 'err.already_checked',
23
23
  not_opportunity_day: 'err.not_opportunity_day',
24
24
  already_claimed: 'err.already_claimed',
25
+ claim_expired: 'err.claim_expired',
25
26
  task_closed: 'err.task_closed',
26
27
  due_past: 'err.due_past',
27
28
  due_too_far: 'err.due_too_far',
@@ -58,6 +59,15 @@ export function describeError(e) {
58
59
  export async function postAction(path, body) {
59
60
  return postJson(`/xingyuan/api/action/${path}`, body);
60
61
  }
62
+ /** 当日数据 URL:今日页/日历页多条取数路径共用同一构造(§5.10 URL 纪律——
63
+ * 带 query 请求统一 URLSearchParams、同资源共用构造函数,防分隔符/编码口径漂移)。 */
64
+ export function dayUrl(date) {
65
+ return `/xingyuan/api/day?${new URLSearchParams({ date }).toString()}`;
66
+ }
67
+ /** 月历数据 URL:日历页取数路径同规构造(月份参数由 URLSearchParams 编码)。 */
68
+ export function calendarUrl(month) {
69
+ return `/xingyuan/api/calendar?${new URLSearchParams({ month }).toString()}`;
70
+ }
61
71
  /** 请求超时:手动 AbortController——AbortSignal.timeout 为 Baseline 2024 "Newly available",
62
72
  * 不满足 dsh 壳的保守浏览器矩阵(同 color-mix 禁令的判断口径),不用。 */
63
73
  const REQUEST_TIMEOUT_MS = 15_000;
@@ -93,7 +93,10 @@ function TaskView(props) {
93
93
  perform();
94
94
  };
95
95
  const deleted = event.op === 'deleted';
96
- const showClaim = !deleted && phase === 'idle' && task.status === 'pending';
96
+ // 截止日已过的待领取任务写路径拒绝领取(store.claim_expired):卡片按钮同口径隐藏,
97
+ // 避免点按必失败(卡片是快照,today 取渲染当时——与写路径校验同一新鲜口径)
98
+ const claimExpired = task.status === 'pending' && task.dueDate !== undefined && task.dueDate < localYmd(new Date());
99
+ const showClaim = !deleted && phase === 'idle' && task.status === 'pending' && !claimExpired;
97
100
  const showCheckIn = !deleted && phase !== 'done' && (task.status === 'in_progress' || phase === 'claimed');
98
101
  const preview = event.opportunityPreview.length > 0
99
102
  ? t('task.upcoming', { dates: event.opportunityPreview.map((date) => formatShortDate(date)).join(activeLocale() === 'en' ? ', ' : '、') })
@@ -82,6 +82,7 @@ export const CHART_SUBTITLE_KEYS = {
82
82
  '按愿望 TopN': 'chart.subtitle.byWishTopN',
83
83
  '按实际打卡时刻分桶;过滤范围为打卡日区间': 'chart.subtitle.byHourBucket',
84
84
  '%(应打天数完成率)': 'chart.subtitle.progressPercent',
85
+ '%(应打天数完成率,含未领取任务)': 'chart.subtitle.progressPercentPending',
85
86
  };
86
87
  /**
87
88
  * 副标题本地化:固定短语查表;含数字的模板(近 N 天 / A/B 天 / 当前 A 天 / 最长 B 天 /
@@ -58,8 +58,9 @@ export const ZH_DICT = {
58
58
  'state.done': '已完成',
59
59
  // --- 愿望卡 / 愿望页 ---
60
60
  'wish.progress': '进度 {percent}%',
61
+ 'wish.progressHasPending': '含未领取任务',
61
62
  'wish.eta': '预计完成 {date}',
62
- 'wish.noTasks': '暂无下属任务',
63
+ 'wish.noTasks': '暂无下属任务——在对话里告诉我,让我为它推荐打卡任务',
63
64
  'wish.pageTitle': '我的愿望',
64
65
  'wish.summary': '{active} 个进行中{achieved}', 'wish.achievedSuffix': ' · {n} 个已达成',
65
66
  'wish.sectionAchieved': '已达成',
@@ -78,6 +79,7 @@ export const ZH_DICT = {
78
79
  'task.group.pending': '待领取',
79
80
  'task.group.achieved': '已达成',
80
81
  'task.group.expired': '已过期',
82
+ 'task.claimExpiredHint': '该任务截止日已过,无法领取——可展开详情延长截止日重新开始',
81
83
  'task.expiredHint': '截止日已过——在下方延长截止日即可重新开始,也可以在对话里让我处理。',
82
84
  'task.pageTitle': '全部任务',
83
85
  'task.totalCount': '共 {n} 个',
@@ -107,17 +109,17 @@ export const ZH_DICT = {
107
109
  'toast.undone': '打卡已撤销',
108
110
  'toast.undoneAt': '已撤销{date}的打卡',
109
111
  'toast.wishCreated': '已创建愿望「{title}」',
110
- 'toast.taskCreated': '已创建任务「{name}」',
112
+ 'toast.taskCreated': '已创建任务「{name}」,领取后开始打卡',
111
113
  'toast.deleted': '已删除「{name}」',
112
114
  'toast.categoryRenamed': '分类已改名为「{name}」',
113
115
  'toast.categoryColored': '「{name}」配色已更新',
114
116
  'toast.categoryColorReset': '「{name}」配色已重置,跟随愿望默认色',
115
117
  'toast.categoryDeleted': '已清除「{name}」的自定义颜色',
116
118
  // --- 确认弹窗 ---
117
- 'confirm.futureCheckin': '「{name}」今天不是打卡日,提前打卡{date}表示承诺当天完成。确认?',
119
+ 'confirm.futureCheckin': '「{name}」的打卡日 {date} 在今天之后,提前打卡表示承诺当天完成。确认?',
118
120
  'confirm.undoToday': '确定撤销「{name}」今天的打卡吗?进度将回退。',
119
121
  'confirm.undoAt': '确定撤销「{name}」在{date}的打卡吗?进度将回退。',
120
- 'confirm.futureAt': '「{name}」{date}不是打卡日,提前打卡表示承诺当天完成。确认?',
122
+ 'confirm.futureAt': '「{name}」的打卡日 {date} 在今天之后,提前打卡表示承诺当天完成。确认?',
121
123
  'confirm.deleteTask': '确定删除任务「{name}」吗?其打卡记录与微行动拆解将一并删除,不可恢复。',
122
124
  'confirm.deleteWish': '确定删除愿望「{name}」吗?下属任务、打卡记录与微行动拆解将一并删除,不可恢复。',
123
125
  'confirm.renameCategory': '把分类「{old}」下的全部愿望改名为「{new}」吗?(共 {count} 个)', 'confirm.deleteEmptyCategory': '删除空分类「{name}」的自定义颜色吗?',
@@ -141,20 +143,19 @@ export const ZH_DICT = {
141
143
  'cal.backToMonth': '回到本月',
142
144
  'cal.prevMonth': '上个月',
143
145
  'cal.nextMonth': '下个月',
144
- 'cal.legend.c0': '无安排',
146
+ 'cal.legend.c0': '无打卡安排',
145
147
  'cal.legend.c1': '待打卡',
146
148
  'cal.legend.c2': '部分完成',
147
149
  'cal.legend.c3': '全部完成',
148
- 'cal.cellAria.none': '{date},无安排',
150
+ 'cal.cellAria.none': '{date},无打卡安排',
149
151
  'cal.cellAria.some': '{date},已完成 {checked}/{due}',
150
- 'cal.cellTitle.none': '{date},无安排',
152
+ 'cal.cellTitle.none': '{date},无打卡安排',
151
153
  'cal.cellTitle.some': '{date},打卡 {checked}/{due}',
152
154
  'cal.panelHint': '点击日期查看当日任务,可直接补卡或取消打卡。',
153
155
  'cal.dayLoadFailed': '当日任务加载失败,请重试。',
154
156
  'cal.dayEmpty': '这一天没有任务安排。',
155
157
  'cal.state.checked': '✓ 已打卡',
156
158
  'cal.state.todo': '○ 待打卡',
157
- 'cal.state.unclaimed': '未领取',
158
159
  // --- 成长页 ---
159
160
  'growth.levelLabel': '当前等级',
160
161
  'growth.levelFallback': '初心者',
@@ -343,6 +344,7 @@ export const ZH_DICT = {
343
344
  'chart.subtitle.byWishTopN': '按愿望 TopN',
344
345
  'chart.subtitle.byHourBucket': '按实际打卡时刻分桶;过滤范围为打卡日区间',
345
346
  'chart.subtitle.progressPercent': '%(应打天数完成率)',
347
+ 'chart.subtitle.progressPercentPending': '%(应打天数完成率,含未领取任务)',
346
348
  'chart.subtitle.lastDays': '近 {n} 天',
347
349
  'chart.subtitle.daysInclPending': '{done}/{due} 天(含未领取任务)',
348
350
  'chart.subtitle.daysRatio': '{done}/{due} 天',
@@ -356,7 +358,7 @@ export const ZH_DICT = {
356
358
  'detail.grid.checked': '{date} 已打卡',
357
359
  'detail.grid.missed': '{date} 未打卡',
358
360
  'detail.grid.future': '{date} 待打卡',
359
- 'detail.next.title': '接下来的机会日:{dates}',
361
+ 'detail.next.title': '接下来的打卡日:{dates}',
360
362
  'detail.revive.label': '延长截止日(重新开始)',
361
363
  'detail.revive.action': '延长并重新开始',
362
364
  'detail.revive.done': '截止日已延长,任务重新开始',
@@ -425,8 +427,9 @@ export const ZH_DICT = {
425
427
  'err.missing_field': '缺少必填字段:{field}',
426
428
  'err.not_found': '目标不存在或已被删除',
427
429
  'err.already_checked': '该日期已打卡;如需修改,请先撤销当日打卡',
428
- 'err.not_opportunity_day': '该日期不是机会日;可在日历页选择最近的机会日',
430
+ 'err.not_opportunity_day': '该日期不是打卡日;可在日历页选择最近的打卡日',
429
431
  'err.already_claimed': '该任务已领取过',
432
+ 'err.claim_expired': '该任务截止日已过,无法领取——请先延长截止日使任务重新开始',
430
433
  'err.task_closed': '任务已完结,无法再打卡',
431
434
  'err.due_past': '截止日期不能早于今天',
432
435
  'err.due_too_far': '截止日期不能超过今天起 10 年',
@@ -435,7 +438,7 @@ export const ZH_DICT = {
435
438
  'err.bad_date': '日期格式或取值不合法(yyyy-MM-dd,且不得早于今天)',
436
439
  'err.overwrite_required': '已存在同名记录,需确认覆盖',
437
440
  'err.not_claimed': '任务尚未领取,请先领取',
438
- 'err.no_opportunity_left': '没有可勾选的打卡日:机会日已全部完成或截止',
441
+ 'err.no_opportunity_left': '没有可勾选的打卡日:应打卡的日期已全部完成或截止',
439
442
  'err.no_checkins': '该任务暂无打卡记录',
440
443
  'err.title_too_long': '标题不能超过 50 字符',
441
444
  'err.name_too_long': '任务名不能超过 100 字符',
@@ -477,8 +480,9 @@ const EN_DICT = {
477
480
  'state.deleted': 'Deleted “{title}”',
478
481
  'state.done': 'Done',
479
482
  'wish.progress': 'Progress {percent}%',
483
+ 'wish.progressHasPending': 'includes unclaimed tasks',
480
484
  'wish.eta': 'Target date {date}',
481
- 'wish.noTasks': 'No tasks yet',
485
+ 'wish.noTasks': 'No tasks yet — tell me in chat and I will suggest check-in tasks for it',
482
486
  'wish.pageTitle': 'My Wishes',
483
487
  'wish.summary': '{active} in progress{achieved}',
484
488
  'wish.achievedSuffix': ' · {n} achieved',
@@ -497,6 +501,7 @@ const EN_DICT = {
497
501
  'task.group.pending': 'Unclaimed',
498
502
  'task.group.achieved': 'Achieved',
499
503
  'task.group.expired': 'Expired',
504
+ 'task.claimExpiredHint': 'Past its due date and unclaimed — open the task detail to extend the due date and restart it',
500
505
  'task.expiredHint': 'The due date has passed — extend it below to restart the task, or just ask me in chat.',
501
506
  'task.pageTitle': 'All Tasks',
502
507
  'task.totalCount': '{n} in total',
@@ -524,16 +529,16 @@ const EN_DICT = {
524
529
  'toast.undone': 'Check-in reverted',
525
530
  'toast.undoneAt': 'Reverted the check-in on {date}',
526
531
  'toast.wishCreated': 'Wish “{title}” created',
527
- 'toast.taskCreated': 'Task “{name}” created',
532
+ 'toast.taskCreated': 'Task “{name}” created — claim it to start checking in',
528
533
  'toast.deleted': '“{name}” deleted',
529
534
  'toast.categoryRenamed': 'Category renamed to “{name}”',
530
535
  'toast.categoryColored': 'Color updated for “{name}”',
531
536
  'toast.categoryColorReset': 'Color of “{name}” reset to follow wish colors',
532
537
  'toast.categoryDeleted': 'Custom color of “{name}” cleared',
533
- 'confirm.futureCheckin': '“{name}” is not due today. Checking in early for {date} means committing to finish it that day. Continue?',
538
+ 'confirm.futureCheckin': 'The check-in day {date} for “{name}” is after today. Checking in early means committing to finish it that day. Continue?',
534
539
  'confirm.undoToday': "Undo today’s check-in for “{name}”? Progress will roll back.",
535
540
  'confirm.undoAt': 'Undo the check-in of “{name}” on {date}? Progress will roll back.',
536
- 'confirm.futureAt': '{date} is not the due day of “{name}”. Checking in early means committing to finish it that day. Continue?',
541
+ 'confirm.futureAt': 'The check-in day {date} for “{name}” is after today. Checking in early means committing to finish it that day. Continue?',
537
542
  'confirm.deleteTask': 'Delete task “{name}”? Its check-ins and micro-step plan will be removed too. This cannot be undone.',
538
543
  'confirm.deleteWish': 'Delete wish “{name}”? Its tasks, check-ins and micro-step plans will be removed too. This cannot be undone.',
539
544
  'confirm.renameCategory': 'Rename category “{old}” to “{new}” everywhere? ({count} affected)',
@@ -556,20 +561,19 @@ const EN_DICT = {
556
561
  'cal.backToMonth': 'This month',
557
562
  'cal.prevMonth': 'Previous month',
558
563
  'cal.nextMonth': 'Next month',
559
- 'cal.legend.c0': 'Free',
564
+ 'cal.legend.c0': 'No check-in planned',
560
565
  'cal.legend.c1': 'To check',
561
566
  'cal.legend.c2': 'Partial',
562
567
  'cal.legend.c3': 'Complete',
563
- 'cal.cellAria.none': '{date}, nothing scheduled',
568
+ 'cal.cellAria.none': '{date}, no check-in planned',
564
569
  'cal.cellAria.some': '{date}, completed {checked}/{due}',
565
- 'cal.cellTitle.none': '{date}, nothing scheduled',
570
+ 'cal.cellTitle.none': '{date}, no check-in planned',
566
571
  'cal.cellTitle.some': '{date}, checked {checked}/{due}',
567
572
  'cal.panelHint': 'Pick a date to see its tasks; make up or cancel check-ins inline.',
568
573
  'cal.dayLoadFailed': 'Failed to load tasks for this day. Please retry.',
569
574
  'cal.dayEmpty': 'Nothing scheduled on this day.',
570
575
  'cal.state.checked': '✓ Checked in',
571
576
  'cal.state.todo': '○ To check in',
572
- 'cal.state.unclaimed': 'Unclaimed',
573
577
  'growth.levelLabel': 'Current level',
574
578
  'growth.levelFallback': 'Beginner',
575
579
  'growth.maxed': 'Max level',
@@ -584,7 +588,7 @@ const EN_DICT = {
584
588
  'growth.stat.taskTotal': 'Tasks created',
585
589
  'growth.stat.taskDone': 'Tasks achieved',
586
590
  'growth.stat.none': 'None',
587
- 'growth.chart.title': 'Last 30 days',
591
+ 'growth.chart.title': 'Check-ins · last 30 days',
588
592
  'growth.chart.empty': 'No data yet. Your trend chart grows after the first check-in.',
589
593
  'growth.chart.hint': 'Blue = checked in, striped = missed gap, green = full day. Hover or focus a bar for daily details.',
590
594
  'growth.chart.tooltip': '{date}: checked {checked}/{total}',
@@ -621,7 +625,7 @@ const EN_DICT = {
621
625
  'memory.importanceLabel': 'Importance: {level}',
622
626
  'memory.add': '+ Add',
623
627
  'memory.saveEdit': 'Save changes',
624
- 'memory.capNote': '{total} in total, showing the latest {shown}; narrow down with search.',
628
+ 'memory.capNote': '{total} in total, showing the latest {shown} entries; narrow down with search.',
625
629
  'memory.more': 'Load more',
626
630
  'memory.loadedAll': 'All {n} shown',
627
631
  'memory.empty.title': 'No memories yet',
@@ -678,7 +682,7 @@ const EN_DICT = {
678
682
  'settings.pref.confirmWrites': 'Confirm write actions',
679
683
  'settings.pref.confirmWritesHint': 'Show an in-app confirmation before creating wishes/tasks, checking in or canceling (deletes always confirm). Turn off to execute directly.',
680
684
  'settings.pref.memoryLimit': 'Memory injection limit',
681
- 'settings.pref.memoryLimitHint': 'Max memories auto-injected per chat (5-200, default 40); saved on blur.',
685
+ 'settings.pref.memoryLimitHint': 'Max memories auto-injected per chat (5-200, default 40); saved when you leave the field.',
682
686
  'settings.pref.limitInvalid': 'Enter an integer between 5 and 200',
683
687
  'settings.pref.writeFailed': 'The change did not save; please try again.',
684
688
  'settings.pref.confirmLang': 'Confirm card language',
@@ -748,15 +752,16 @@ const EN_DICT = {
748
752
  'chart.subtitle.byWishTopN': 'By wish (top N)',
749
753
  'chart.subtitle.byHourBucket': 'Bucketed by actual check-in time; filtered to the check-in date range',
750
754
  'chart.subtitle.progressPercent': '% of scheduled-day completion',
755
+ 'chart.subtitle.progressPercentPending': '% of scheduled-day completion (incl. unclaimed)',
751
756
  'chart.subtitle.lastDays': 'Last {n} days',
752
757
  'chart.subtitle.daysInclPending': '{done}/{due} days (incl. unclaimed)',
753
758
  'chart.subtitle.daysRatio': '{done}/{due} days',
754
759
  'chart.subtitle.streak': 'Current {current}d / best {best}d',
755
760
  'detail.grid.title': 'Check-in record',
756
- 'detail.grid.summary': 'Last {total} cell(s): {checked} checked, {missed} missed, {future} pending',
761
+ 'detail.grid.summary': 'Last {total} shown: {checked} checked, {missed} missed, {future} to check in',
757
762
  'detail.legend.checked': 'Checked in',
758
763
  'detail.legend.missed': 'Missed',
759
- 'detail.legend.future': 'Pending today',
764
+ 'detail.legend.future': 'To check in',
760
765
  'detail.grid.checked': '{date} checked in',
761
766
  'detail.grid.missed': '{date} missed',
762
767
  'detail.grid.future': '{date} pending check-in',
@@ -824,8 +829,9 @@ const EN_DICT = {
824
829
  'err.missing_field': 'Missing required field: {field}',
825
830
  'err.not_found': 'Not found, or already deleted',
826
831
  'err.already_checked': 'Already checked in on this date — undo it first to change',
827
- 'err.not_opportunity_day': 'Not an opportunity day — pick the nearest one in the Calendar tab',
832
+ 'err.not_opportunity_day': 'Not a check-in day — pick the nearest one in the Calendar tab',
828
833
  'err.already_claimed': 'Already claimed',
834
+ 'err.claim_expired': 'This task is past its due date — extend the due date first to restart it',
829
835
  'err.task_closed': 'Task is closed and cannot be checked in',
830
836
  'err.due_past': 'Due date cannot be before today',
831
837
  'err.due_too_far': 'Due date cannot be more than 10 years ahead',
@@ -834,7 +840,7 @@ const EN_DICT = {
834
840
  'err.bad_date': 'Invalid date (yyyy-MM-dd, not before today)',
835
841
  'err.overwrite_required': 'Already exists; confirmation required to overwrite',
836
842
  'err.not_claimed': 'Task must be claimed first',
837
- 'err.no_opportunity_left': 'No check-in day left to tick — all opportunity days are done or past the deadline',
843
+ 'err.no_opportunity_left': 'No check-in day left to tick — all scheduled days are done or past the deadline',
838
844
  'err.no_checkins': 'No check-in records for this task',
839
845
  'err.title_too_long': 'Title exceeds 50 characters',
840
846
  'err.name_too_long': 'Task name exceeds 100 characters',
@@ -1,6 +1,6 @@
1
1
  /** 日历页:月历(机会日语义着色)+ 日期详情面板(补卡/取消打卡)。 */
2
2
  import { createElement, useEffect, useRef, useState } from 'react';
3
- import { getJson, postAction } from '../api.js';
3
+ import { calendarUrl, dayUrl, getJson, postAction } from '../api.js';
4
4
  import { t } from '../i18n.js';
5
5
  import { softConfirm, useActionGuard, usePageData, useScrollTopOnMount, useStableScrollbar, localYmd } from '../hooks.js';
6
6
  import { PageError, PageSkeleton, StaleBanner, toast } from '../ui.js';
@@ -22,7 +22,7 @@ export function CalendarPage() {
22
22
  const pickSeqRef = useRef(0);
23
23
  const stabilize = useStableScrollbar();
24
24
  const { busy, guard } = useActionGuard();
25
- const page = usePageData(() => `/xingyuan/api/calendar?month=${monthOf(offset)}`, [offset]);
25
+ const page = usePageData(() => calendarUrl(monthOf(offset)), [offset]);
26
26
  const [detail, setDetail] = useState(undefined);
27
27
  const [pickedDate, setPickedDate] = useState(undefined);
28
28
  // 拾取三态:loading(面板骨架行)/ error(错误 + 重试)/ idle——交互闭环不静默吞错
@@ -34,18 +34,25 @@ export function CalendarPage() {
34
34
  // pickedDateRef 一并失效:动作完成链凭它决定是否回填详情,
35
35
  // 不清则「打卡在途时切月」会让旧月份详情复活到新月视图
36
36
  pickedDateRef.current = undefined;
37
+ // 翻月/回本月重置自动拾取许可:回到本月时允许再自动 pick 今天
38
+ autoPickRef.current = false;
37
39
  setDetail(undefined);
38
40
  setPickedDate(undefined);
39
41
  setPickState('idle');
40
42
  }, [offset]);
41
43
  // 动作后重取详情需要知道「当前选中日」;用 ref 避免 act 闭包读到过期 state
42
44
  const pickedDateRef = useRef(undefined);
43
- const pick = (date) => {
45
+ // 自动拾取许可:首载/回本月置空后允许自动 pick 今天一次;任何 pick(含自动)置位。
46
+ // 守卫必须走 ref 而非 pickedDate state——清除 effect 的 setState 不回写闭包,
47
+ // 「点过历史日期后回本月」时旧闭包值会把自动拾取挡死(回归:面板滞留提示态)
48
+ const autoPickRef = useRef(false);
49
+ const pick = (date, scroll = true) => {
50
+ autoPickRef.current = true;
44
51
  pickedDateRef.current = date;
45
52
  setPickedDate(date);
46
53
  setPickState('loading');
47
54
  const seq = ++pickSeqRef.current;
48
- getJson(`/xingyuan/api/day?date=${date}`)
55
+ getJson(dayUrl(date))
49
56
  .then((payload) => {
50
57
  if (seq !== pickSeqRef.current)
51
58
  return;
@@ -55,11 +62,22 @@ export function CalendarPage() {
55
62
  .catch(() => { if (seq === pickSeqRef.current)
56
63
  setPickState('error'); });
57
64
  // 窄视口下详情面板可能落在折叠线下方:拾取后滚到面板近处,选中反馈不止是格子高亮
58
- window.requestAnimationFrame(() => {
59
- document.getElementById('xy-daypanel')?.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
60
- });
65
+ if (scroll) {
66
+ window.requestAnimationFrame(() => {
67
+ document.getElementById('xy-daypanel')?.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
68
+ });
69
+ }
61
70
  };
62
71
  const data = page.data;
72
+ // 默认加载今天(口径决策):首次进入/「回到本月」落在当月视图即自动展开今天详情,
73
+ // 无需用户先点一下;自动拾取不滚动页面(进入时维持顶部锚定,面板在折叠线下方属预期)
74
+ useEffect(() => {
75
+ if (offset !== 0 || data === undefined || autoPickRef.current)
76
+ return;
77
+ pick(data.today, false);
78
+ // pick 随渲染重建,但本效果只关心 offset/data 变迁,无需跟随
79
+ // eslint-disable-next-line react-hooks/exhaustive-deps
80
+ }, [offset, data]);
63
81
  const act = (action, taskId, taskName, date) => {
64
82
  // 需要确认的动作先弹应用内确认,通过后才进入写路径;无需确认的动作直接执行
65
83
  const confirmMessage = action === 'checkin' && date > (data?.today ?? '')
@@ -68,20 +86,18 @@ export function CalendarPage() {
68
86
  ? t('confirm.undoAt', { name: taskName, date })
69
87
  : undefined;
70
88
  const run = () => {
71
- // claim 服务端只读 taskId;checkin/cancel-checkin 的 date 是必读参数
72
- guard(() => postAction(action, action === 'claim' ? { taskId } : { taskId, date }).then(() => {
73
- toast(action === 'claim'
74
- ? t('toast.claimed', { name: taskName })
75
- : action === 'checkin'
76
- ? t('toast.checkinOk') + dateSuffix(date)
77
- : t('toast.undoneAt', { date: formatShortDate(date) }), 'ok');
89
+ // checkin/cancel-checkin 的 date 是必读参数(claim 已随候选池移出日历面板)
90
+ guard(() => postAction(action, { taskId, date }).then(() => {
91
+ toast(action === 'checkin'
92
+ ? t('toast.checkinOk') + dateSuffix(date)
93
+ : t('toast.undoneAt', { date: formatShortDate(date) }), 'ok');
78
94
  // busy 窗口覆盖月历重取;之后仅当操作日仍是当前选中日才回填详情——
79
95
  // 动作与快速连点另一日期竞争时,慢的旧响应不得覆盖新选中日的面板
80
96
  return page.reload().then(() => {
81
97
  if (pickedDateRef.current !== date)
82
98
  return undefined;
83
99
  const seq = ++pickSeqRef.current;
84
- return getJson(`/xingyuan/api/day?date=${date}`)
100
+ return getJson(dayUrl(date))
85
101
  .then((dayAfter) => { if (seq === pickSeqRef.current) {
86
102
  setDetail(dayAfter);
87
103
  setPickState('idle');
@@ -157,6 +173,13 @@ export function CalendarPage() {
157
173
  });
158
174
  });
159
175
  }
176
+ // 月历隐藏未领取任务(承诺口径,服务端 cell 已过滤);详情面板全日期同口径——
177
+ // 含今天:候选池不进日历(2026-08 用户裁决两轮:今日页 → 日历面板),
178
+ // 领取入口收归任务页待领取组与对话。判定消费服务端 claimed 布尔
179
+ // (§5.2 规则 7 单一判定,client 不重复写 status 谓词)
180
+ const visibleTasks = detail === undefined
181
+ ? []
182
+ : detail.tasks.filter((task) => task.claimed);
160
183
  return createElement('div', { className: 'xy-page', ref: stabilize },
161
184
  // 月份导航居中(日历惯例):‹ 标题 › 成组居中,「回到本月」绝对定位贴右缘不挤占中轴
162
185
  createElement('div', { className: 'xy-page-head xy-calnav' }, createElement('button', { className: 'xy-btn', 'aria-label': t('cal.prevMonth'), onClick: () => goMonth(-1) }, '‹'), createElement('h2', { className: 'xy-page-title' }, formatMonth(data.month)), createElement('button', { className: 'xy-btn', 'aria-label': t('cal.nextMonth'), onClick: () => goMonth(1) }, '›'), offset !== 0 ? createElement('button', { className: 'xy-btn xy-calnav-back', onClick: () => goMonth(-offset) }, t('cal.backToMonth')) : null),
@@ -206,28 +229,29 @@ export function CalendarPage() {
206
229
  ? createElement('div', { className: 'xy-meta xy-dayhint' }, t('cal.panelHint'))
207
230
  : createElement('div', { className: 'xy-daydetail' },
208
231
  // 面板头 = 本地化友好日期(含星期),与今日页标题同一语法;不再裸奔 ISO
209
- createElement('h3', { className: 'xy-section-title' }, formatFriendlyDate(detail.date)), detail.tasks.length === 0
232
+ createElement('h3', { className: 'xy-section-title' }, formatFriendlyDate(detail.date)), visibleTasks.length === 0
210
233
  ? createElement('div', { className: 'xy-meta' }, t('cal.dayEmpty'))
211
- : createElement('ul', { className: 'xy-grouplist' }, detail.tasks.map((task) => {
234
+ : createElement('ul', { className: 'xy-grouplist' }, visibleTasks.map((task) => {
212
235
  // 元信息分段拼接:空段不产悬挂分隔符(如已完结任务无状态词)
236
+ // 面板行已按 claimed 过滤(未领取不达此处);已领取且不可勾且未勾
237
+ // = 过期关闭(达标关闭在计划面已剔除——失败记录,与工具面「— 已过期」同口径)
213
238
  const stateText = task.checked
214
239
  ? t('cal.state.checked')
215
240
  : task.canCheckIn ? t('cal.state.todo')
216
- : task.status === 'pending' ? t('cal.state.unclaimed') : '';
241
+ : t('task.status.expired');
217
242
  const segments = [
218
243
  cycleLabel(task.cycle),
219
244
  ...(task.wishName !== undefined ? [task.wishName] : []),
220
245
  ...(stateText !== '' ? [stateText] : []),
221
246
  ];
222
247
  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(' · '))),
223
- // 领取仅限今日:claim 无日期语义,claimDate 恒为领取当天——
224
- // 过去/未来选中日的「领取」按钮会造出锚点与所见不符的任务
225
- task.status === 'pending' && !task.checked && detail.date === data.today
226
- ? createElement('button', { className: 'xy-btn', disabled: busy, onClick: () => act('claim', task.taskId, task.name, detail.date) }, t('action.claim'))
227
- : !task.checked && task.status === 'in_progress'
228
- ? createElement('button', { className: 'xy-btn xy-btn-primary', disabled: busy || !task.canCheckIn, onClick: () => act('checkin', task.taskId, task.name, detail.date) }, t('action.checkinThisDay'))
229
- : task.checked && task.canCancel
230
- ? createElement('button', { className: 'xy-btn', disabled: busy, onClick: () => act('cancel-checkin', task.taskId, task.name, detail.date) }, t('action.cancelCheckin'))
231
- : null);
248
+ // 打卡按钮消费 host 单一真值 canCheckIn(=未勾选且进行中):
249
+ // 「已领取」不等于「可打卡」——过期关闭等已完结任务不渲染按钮,
250
+ // 避免「打卡此日」disabled 死分支(评审回归 C1);未领取行已过滤
251
+ task.canCheckIn
252
+ ? createElement('button', { className: 'xy-btn xy-btn-primary', disabled: busy, onClick: () => act('checkin', task.taskId, task.name, detail.date) }, t('action.checkinThisDay'))
253
+ : task.checked && task.canCancel
254
+ ? createElement('button', { className: 'xy-btn', disabled: busy, onClick: () => act('cancel-checkin', task.taskId, task.name, detail.date) }, t('action.cancelCheckin'))
255
+ : null);
232
256
  })))));
233
257
  }