@starwish-ai/xingyuan-dsh 0.5.6 → 0.5.7

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,117 @@ 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.7] - 2026-08-29
9
+
10
+ > **Note:** this release is the third six-perspective quality pass (product,
11
+ > design, end-user, dsh-plugin engineering, architecture, UX). It closes the
12
+ > last honesty gap in the write paths, makes tab switching lossless for
13
+ > in-progress input, hardens the opportunity-date calculator against a
14
+ > process-hanging edge case, and lands the accessibility/contrast leftovers.
15
+ > No data format changes; existing databases and recorded sessions replay as before.
16
+
17
+ ### Added
18
+ - **Draft snapshots across tab switches**: typing a memory (up to 1000 chars)
19
+ or filling the quick-create forms used to be silently lost when switching
20
+ conversation view tabs (pages unmount on switch). Form drafts now survive tab
21
+ switches and panel collapse; successful saves clear them.
22
+ - **Instant growth feedback on check-in**: the check-in tool reply appends the
23
+ current level and experience progress, closing the "check in → grow" loop at
24
+ the moment of the action instead of deferring it to the Growth page.
25
+ - **Non-preset session hint on the chat-directed pages**: in "always show" mode the six tabs
26
+ render in every session, but wishes/tasks/memory empty states directed users
27
+ to a chat that has no XingYuan tools there. The Today page's "preset not
28
+ enabled" hint row now also renders on the other three pages.
29
+ - **HTTP shell tests**: route dispatch (status-code mapping, the 64 KB limit,
30
+ JSON parsing, the `ToolError.code` passthrough that powers client-side error
31
+ localization) had zero coverage; locked with mocked `IncomingMessage`/
32
+ `ServerResponse` in `test/routes-shell.test.ts`.
33
+
34
+ ### Changed
35
+ - **Claiming an overdue unclaimed task is rejected, not silently closed**
36
+ (`claim_expired`): the old flow claimed it and immediately expired-closed it
37
+ while the tool reply asserted "claimed, now in progress" and the page toast
38
+ said "go check in" — both false, and the task-line button kept offering an
39
+ action that always fails. The row shows a guidance hint instead, the chat
40
+ task card hides its claim button for the same state, and the detail panel's
41
+ revive row (extend due date) now also covers overdue-unclaimed tasks.
42
+ - **The early check-in confirms no longer assert a falsehood**: the task-row,
43
+ card and detail dialogs said "today is not a check-in day" — false when today
44
+ was already checked in (the natural path: check in via chat, then tap the
45
+ button on the updated card); the calendar dialog said "{date} is not a
46
+ check-in day" for a day the panel only lists because it IS one. All four now
47
+ state only what is true: the target day is after today, and an early check-in
48
+ commits to finishing that day.
49
+ - **Growth-ladder and completed rows dim by text color, not row opacity**:
50
+ whole-row `opacity` (0.65-0.78) blended 12 px text below the WCAG AA 4.5:1
51
+ line (≈2.6-3.1:1 measured in both themes); the hierarchy now comes from the
52
+ secondary text color (4.76/5.15:1) while transparency stays on decorative
53
+ elements only.
54
+ - **Danger icon buttons share one quiet tier**: the memory-row delete joined
55
+ the wish-card delete at 0.78 idle opacity with hover/focus restore (long
56
+ lists stop glowing red; non-text contrast ≥3:1, the stale "≥4.5:1" comment
57
+ corrected to the non-text standard).
58
+ - **Growth chart stops squeezing its columns on narrow panels**: below the
59
+ 520 px breakpoint the 30 daily columns scroll horizontally at a minimum
60
+ width (~13 px per column, up from ~10 px) instead of being flex-crushed;
61
+ a deliberate trade-off against the 24 px touch-target ideal, whose full
62
+ width would make the chart scroll several screens.
63
+ - **HITL confirm sorts callers by whether any confirm UI exists**: callers
64
+ with no confirm UI at all (`NO_PROVIDER`, `CALLER_NOT_LIVE` — both raised
65
+ before `NO_PROVIDER` in rc.2) are allowed, as before for headless; a
66
+ delegated caller (`DELEGATED_CALLER`, e.g. a session-spawned subagent) is
67
+ now failed closed with actionable guidance ("confirm in the main
68
+ conversation, then run it there") instead of the raw platform error —
69
+ keeping the always-on delete confirmation intact on delegated paths.
70
+ Recorded in the engineering doc's HITL contract section.
71
+ - **weekly/monthly expectation management**: tool descriptions and the task
72
+ guide now state that opportunity days run from the claim date (every 7 days /
73
+ monthly by claim date) and never bind to a weekday — the model must say so
74
+ instead of silently accepting "every Wednesday" requests. Recorded in Known
75
+ Limitations.
76
+ - **The recommendation tool states its ordering**: `get_recommended_tasks` is
77
+ a fixed "top 5 by historical check-in count" ranking, not curation;
78
+ description and reply now say so.
79
+ - **Dedup guidance covers paraphrase**: the wish/task guides instruct the model
80
+ to sweep the existing list semantically when substring dedup finds no hit but
81
+ the request is loosely phrased.
82
+ - **Prompt/UI drift sync**: the Tasks page has four status groups (not three)
83
+ and two more settings entries (confirm-card language, tab visibility) — the
84
+ capabilities section now matches what users see.
85
+ - **Copy/wording**: user-facing strings unify "机会日" to "打卡日" (the check-in
86
+ tool guidance already used the plain term; leftover error strings swept
87
+ too); the bare-wish card points to the
88
+ chat path for task suggestions; the task-created toast explains "claim it to
89
+ start checking in"; five English strings polished ("To check in", "entries",
90
+ "Check-ins · last 30 days", "saved when you leave the field", the grid
91
+ summary).
92
+
93
+ ### Fixed
94
+ - **Opportunity-date loops can no longer hang the process**: termination used
95
+ ISO string comparison, which silently assumes four-digit years — a sequence
96
+ crossing 9999-12-31 (only reachable via hand-edited sqlite) compared
97
+ `'10000-xx' <= '9999-xx'` as true forever, hanging daily/weekly/monthly in an
98
+ allocating loop. Termination is now numeric (UTC day numbers; the monthly
99
+ branch via `Date.UTC`, since `Date.parse('10000-…')` returns `NaN`). Locked
100
+ by termination tests including the year-boundary crossing.
101
+ - **Toast glyph semantics & screen readers**: the info glyph was "★" — the star
102
+ is the high-importance memory marker (`.xy-star-hi`); info is now "i", and
103
+ toast glyphs are `aria-hidden` so decorative symbols are not announced inside
104
+ the live region.
105
+ - **Centered header baselines**: the `.xy-meta` top margin leaked into centered
106
+ card/page heads, sinking secondary text by 2 px; the treatment the detail
107
+ area already had now covers the heads too.
108
+ - **Focus management**: closing the category rename editor returns focus (to
109
+ the row's rename button, or the page title when the row was rebuilt by a
110
+ rename); finishing the last "load more" page moves focus to the "all shown"
111
+ line and announces it (`role="status"`).
112
+ - **Docs**: reference links pointed at the nonexistent `main` branch of the
113
+ upstream repository (the default branch is `master`) and at a cookbook page
114
+ that does not exist (the conversation-node contract lives in
115
+ `subsystems/conversation`); a dsh upgrade checklist now records the two
116
+ patch-DSL hazards (schedule insert id collision, storage-domain whole-line
117
+ replacement).
118
+
8
119
  ## [0.5.6] - 2026-08-29
9
120
 
10
121
  > **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',
@@ -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' ? ', ' : '、') })
@@ -59,7 +59,7 @@ export const ZH_DICT = {
59
59
  // --- 愿望卡 / 愿望页 ---
60
60
  'wish.progress': '进度 {percent}%',
61
61
  'wish.eta': '预计完成 {date}',
62
- 'wish.noTasks': '暂无下属任务',
62
+ 'wish.noTasks': '暂无下属任务——在对话里告诉我,让我为它推荐打卡任务',
63
63
  'wish.pageTitle': '我的愿望',
64
64
  'wish.summary': '{active} 个进行中{achieved}', 'wish.achievedSuffix': ' · {n} 个已达成',
65
65
  'wish.sectionAchieved': '已达成',
@@ -78,6 +78,7 @@ export const ZH_DICT = {
78
78
  'task.group.pending': '待领取',
79
79
  'task.group.achieved': '已达成',
80
80
  'task.group.expired': '已过期',
81
+ 'task.claimExpiredHint': '该任务截止日已过,无法领取——可展开详情延长截止日重新开始',
81
82
  'task.expiredHint': '截止日已过——在下方延长截止日即可重新开始,也可以在对话里让我处理。',
82
83
  'task.pageTitle': '全部任务',
83
84
  'task.totalCount': '共 {n} 个',
@@ -107,17 +108,17 @@ export const ZH_DICT = {
107
108
  'toast.undone': '打卡已撤销',
108
109
  'toast.undoneAt': '已撤销{date}的打卡',
109
110
  'toast.wishCreated': '已创建愿望「{title}」',
110
- 'toast.taskCreated': '已创建任务「{name}」',
111
+ 'toast.taskCreated': '已创建任务「{name}」,领取后开始打卡',
111
112
  'toast.deleted': '已删除「{name}」',
112
113
  'toast.categoryRenamed': '分类已改名为「{name}」',
113
114
  'toast.categoryColored': '「{name}」配色已更新',
114
115
  'toast.categoryColorReset': '「{name}」配色已重置,跟随愿望默认色',
115
116
  'toast.categoryDeleted': '已清除「{name}」的自定义颜色',
116
117
  // --- 确认弹窗 ---
117
- 'confirm.futureCheckin': '「{name}」今天不是打卡日,提前打卡{date}表示承诺当天完成。确认?',
118
+ 'confirm.futureCheckin': '「{name}」的打卡日 {date} 在今天之后,提前打卡表示承诺当天完成。确认?',
118
119
  'confirm.undoToday': '确定撤销「{name}」今天的打卡吗?进度将回退。',
119
120
  'confirm.undoAt': '确定撤销「{name}」在{date}的打卡吗?进度将回退。',
120
- 'confirm.futureAt': '「{name}」{date}不是打卡日,提前打卡表示承诺当天完成。确认?',
121
+ 'confirm.futureAt': '「{name}」的打卡日 {date} 在今天之后,提前打卡表示承诺当天完成。确认?',
121
122
  'confirm.deleteTask': '确定删除任务「{name}」吗?其打卡记录与微行动拆解将一并删除,不可恢复。',
122
123
  'confirm.deleteWish': '确定删除愿望「{name}」吗?下属任务、打卡记录与微行动拆解将一并删除,不可恢复。',
123
124
  'confirm.renameCategory': '把分类「{old}」下的全部愿望改名为「{new}」吗?(共 {count} 个)', 'confirm.deleteEmptyCategory': '删除空分类「{name}」的自定义颜色吗?',
@@ -356,7 +357,7 @@ export const ZH_DICT = {
356
357
  'detail.grid.checked': '{date} 已打卡',
357
358
  'detail.grid.missed': '{date} 未打卡',
358
359
  'detail.grid.future': '{date} 待打卡',
359
- 'detail.next.title': '接下来的机会日:{dates}',
360
+ 'detail.next.title': '接下来的打卡日:{dates}',
360
361
  'detail.revive.label': '延长截止日(重新开始)',
361
362
  'detail.revive.action': '延长并重新开始',
362
363
  'detail.revive.done': '截止日已延长,任务重新开始',
@@ -425,8 +426,9 @@ export const ZH_DICT = {
425
426
  'err.missing_field': '缺少必填字段:{field}',
426
427
  'err.not_found': '目标不存在或已被删除',
427
428
  'err.already_checked': '该日期已打卡;如需修改,请先撤销当日打卡',
428
- 'err.not_opportunity_day': '该日期不是机会日;可在日历页选择最近的机会日',
429
+ 'err.not_opportunity_day': '该日期不是打卡日;可在日历页选择最近的打卡日',
429
430
  'err.already_claimed': '该任务已领取过',
431
+ 'err.claim_expired': '该任务截止日已过,无法领取——请先延长截止日使任务重新开始',
430
432
  'err.task_closed': '任务已完结,无法再打卡',
431
433
  'err.due_past': '截止日期不能早于今天',
432
434
  'err.due_too_far': '截止日期不能超过今天起 10 年',
@@ -435,7 +437,7 @@ export const ZH_DICT = {
435
437
  'err.bad_date': '日期格式或取值不合法(yyyy-MM-dd,且不得早于今天)',
436
438
  'err.overwrite_required': '已存在同名记录,需确认覆盖',
437
439
  'err.not_claimed': '任务尚未领取,请先领取',
438
- 'err.no_opportunity_left': '没有可勾选的打卡日:机会日已全部完成或截止',
440
+ 'err.no_opportunity_left': '没有可勾选的打卡日:应打卡的日期已全部完成或截止',
439
441
  'err.no_checkins': '该任务暂无打卡记录',
440
442
  'err.title_too_long': '标题不能超过 50 字符',
441
443
  'err.name_too_long': '任务名不能超过 100 字符',
@@ -478,7 +480,7 @@ const EN_DICT = {
478
480
  'state.done': 'Done',
479
481
  'wish.progress': 'Progress {percent}%',
480
482
  'wish.eta': 'Target date {date}',
481
- 'wish.noTasks': 'No tasks yet',
483
+ 'wish.noTasks': 'No tasks yet — tell me in chat and I will suggest check-in tasks for it',
482
484
  'wish.pageTitle': 'My Wishes',
483
485
  'wish.summary': '{active} in progress{achieved}',
484
486
  'wish.achievedSuffix': ' · {n} achieved',
@@ -497,6 +499,7 @@ const EN_DICT = {
497
499
  'task.group.pending': 'Unclaimed',
498
500
  'task.group.achieved': 'Achieved',
499
501
  'task.group.expired': 'Expired',
502
+ 'task.claimExpiredHint': 'Past its due date and unclaimed — open the task detail to extend the due date and restart it',
500
503
  'task.expiredHint': 'The due date has passed — extend it below to restart the task, or just ask me in chat.',
501
504
  'task.pageTitle': 'All Tasks',
502
505
  'task.totalCount': '{n} in total',
@@ -524,16 +527,16 @@ const EN_DICT = {
524
527
  'toast.undone': 'Check-in reverted',
525
528
  'toast.undoneAt': 'Reverted the check-in on {date}',
526
529
  'toast.wishCreated': 'Wish “{title}” created',
527
- 'toast.taskCreated': 'Task “{name}” created',
530
+ 'toast.taskCreated': 'Task “{name}” created — claim it to start checking in',
528
531
  'toast.deleted': '“{name}” deleted',
529
532
  'toast.categoryRenamed': 'Category renamed to “{name}”',
530
533
  'toast.categoryColored': 'Color updated for “{name}”',
531
534
  'toast.categoryColorReset': 'Color of “{name}” reset to follow wish colors',
532
535
  '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?',
536
+ 'confirm.futureCheckin': 'The check-in day {date} for “{name}” is after today. Checking in early means committing to finish it that day. Continue?',
534
537
  'confirm.undoToday': "Undo today’s check-in for “{name}”? Progress will roll back.",
535
538
  '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?',
539
+ 'confirm.futureAt': 'The check-in day {date} for “{name}” is after today. Checking in early means committing to finish it that day. Continue?',
537
540
  'confirm.deleteTask': 'Delete task “{name}”? Its check-ins and micro-step plan will be removed too. This cannot be undone.',
538
541
  'confirm.deleteWish': 'Delete wish “{name}”? Its tasks, check-ins and micro-step plans will be removed too. This cannot be undone.',
539
542
  'confirm.renameCategory': 'Rename category “{old}” to “{new}” everywhere? ({count} affected)',
@@ -584,7 +587,7 @@ const EN_DICT = {
584
587
  'growth.stat.taskTotal': 'Tasks created',
585
588
  'growth.stat.taskDone': 'Tasks achieved',
586
589
  'growth.stat.none': 'None',
587
- 'growth.chart.title': 'Last 30 days',
590
+ 'growth.chart.title': 'Check-ins · last 30 days',
588
591
  'growth.chart.empty': 'No data yet. Your trend chart grows after the first check-in.',
589
592
  'growth.chart.hint': 'Blue = checked in, striped = missed gap, green = full day. Hover or focus a bar for daily details.',
590
593
  'growth.chart.tooltip': '{date}: checked {checked}/{total}',
@@ -621,7 +624,7 @@ const EN_DICT = {
621
624
  'memory.importanceLabel': 'Importance: {level}',
622
625
  'memory.add': '+ Add',
623
626
  'memory.saveEdit': 'Save changes',
624
- 'memory.capNote': '{total} in total, showing the latest {shown}; narrow down with search.',
627
+ 'memory.capNote': '{total} in total, showing the latest {shown} entries; narrow down with search.',
625
628
  'memory.more': 'Load more',
626
629
  'memory.loadedAll': 'All {n} shown',
627
630
  'memory.empty.title': 'No memories yet',
@@ -678,7 +681,7 @@ const EN_DICT = {
678
681
  'settings.pref.confirmWrites': 'Confirm write actions',
679
682
  '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
683
  'settings.pref.memoryLimit': 'Memory injection limit',
681
- 'settings.pref.memoryLimitHint': 'Max memories auto-injected per chat (5-200, default 40); saved on blur.',
684
+ 'settings.pref.memoryLimitHint': 'Max memories auto-injected per chat (5-200, default 40); saved when you leave the field.',
682
685
  'settings.pref.limitInvalid': 'Enter an integer between 5 and 200',
683
686
  'settings.pref.writeFailed': 'The change did not save; please try again.',
684
687
  'settings.pref.confirmLang': 'Confirm card language',
@@ -753,10 +756,10 @@ const EN_DICT = {
753
756
  'chart.subtitle.daysRatio': '{done}/{due} days',
754
757
  'chart.subtitle.streak': 'Current {current}d / best {best}d',
755
758
  'detail.grid.title': 'Check-in record',
756
- 'detail.grid.summary': 'Last {total} cell(s): {checked} checked, {missed} missed, {future} pending',
759
+ 'detail.grid.summary': 'Last {total} shown: {checked} checked, {missed} missed, {future} to check in',
757
760
  'detail.legend.checked': 'Checked in',
758
761
  'detail.legend.missed': 'Missed',
759
- 'detail.legend.future': 'Pending today',
762
+ 'detail.legend.future': 'To check in',
760
763
  'detail.grid.checked': '{date} checked in',
761
764
  'detail.grid.missed': '{date} missed',
762
765
  'detail.grid.future': '{date} pending check-in',
@@ -824,8 +827,9 @@ const EN_DICT = {
824
827
  'err.missing_field': 'Missing required field: {field}',
825
828
  'err.not_found': 'Not found, or already deleted',
826
829
  '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',
830
+ 'err.not_opportunity_day': 'Not a check-in day — pick the nearest one in the Calendar tab',
828
831
  'err.already_claimed': 'Already claimed',
832
+ 'err.claim_expired': 'This task is past its due date — extend the due date first to restart it',
829
833
  'err.task_closed': 'Task is closed and cannot be checked in',
830
834
  'err.due_past': 'Due date cannot be before today',
831
835
  'err.due_too_far': 'Due date cannot be more than 10 years ahead',
@@ -834,7 +838,7 @@ const EN_DICT = {
834
838
  'err.bad_date': 'Invalid date (yyyy-MM-dd, not before today)',
835
839
  'err.overwrite_required': 'Already exists; confirmation required to overwrite',
836
840
  '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',
841
+ 'err.no_opportunity_left': 'No check-in day left to tick — all scheduled days are done or past the deadline',
838
842
  'err.no_checkins': 'No check-in records for this task',
839
843
  'err.title_too_long': 'Title exceeds 50 characters',
840
844
  'err.name_too_long': 'Task name exceeds 100 characters',
@@ -7,7 +7,7 @@ import { createElement, useEffect, useRef, useState } from 'react';
7
7
  import { getJson, postAction, describeError } from '../api.js';
8
8
  import { useXyT } from '../i18n.js';
9
9
  import { softConfirm, softConfirmDanger, useActionGuard } from '../hooks.js';
10
- import { toast } from '../ui.js';
10
+ import { toast, focusPageTitle } from '../ui.js';
11
11
  import { categoryVars } from '../../category-color.js';
12
12
  import { SwatchRow } from './color-swatch.js';
13
13
  import { countKey } from './format.js';
@@ -82,6 +82,29 @@ export function CategoryManager(props) {
82
82
  const [renaming, setRenaming] = useState(undefined);
83
83
  const [coloring, setColoring] = useState(undefined);
84
84
  const { busy, guard } = useActionGuard();
85
+ // 改名编辑器随关闭(提交/取消/Esc)整体卸载,焦点会落空:记下触发行内「改名」
86
+ // 按钮,关闭后焦点归还。仅在「打开→关闭」迁移时执行(面板挂载的那次 effect
87
+ // 不动焦点——否则每次打开面板焦点就被拽到页面标题);改名成功后行按新名
88
+ // re-key、旧触发按钮随之销毁,且 effect 执行时 reload 数据未回、旧按钮还在
89
+ // DOM(归还焦点会在 re-key 中落空到 body)——成功路径显式清空触发引用,令
90
+ // effect 直接走页面标题兜底
91
+ const renameTriggerRef = useRef(null);
92
+ const renameOpenRef = useRef(false);
93
+ useEffect(() => {
94
+ if (renaming !== undefined) {
95
+ renameOpenRef.current = true;
96
+ return;
97
+ }
98
+ if (!renameOpenRef.current)
99
+ return;
100
+ renameOpenRef.current = false;
101
+ const trigger = renameTriggerRef.current;
102
+ renameTriggerRef.current = null;
103
+ if (trigger !== null && document.contains(trigger))
104
+ trigger.focus();
105
+ else
106
+ focusPageTitle();
107
+ }, [renaming]);
85
108
  const load = () => {
86
109
  setError(undefined);
87
110
  getJson('/xingyuan/api/categories')
@@ -94,6 +117,7 @@ export function CategoryManager(props) {
94
117
  const commitRename = (oldName, newName) => {
95
118
  guard(() => postAction('category-rename', { oldName, newName }).then(() => {
96
119
  toast(t('toast.categoryRenamed', { name: newName }), 'ok');
120
+ renameTriggerRef.current = null;
97
121
  setRenaming(undefined);
98
122
  reload();
99
123
  }));
@@ -114,7 +138,11 @@ export function CategoryManager(props) {
114
138
  }, row.name), createElement('span', { className: 'xy-meta xy-catcount' }, t(countKey(row.wishCount, 'catmgr.countOne', 'catmgr.count'), { n: row.wishCount })), createElement('span', { className: 'xy-catops' }, createElement('button', {
115
139
  className: 'xy-btn',
116
140
  disabled: busy,
117
- onClick: () => { setRenaming(row.name); setColoring(undefined); },
141
+ onClick: (e) => {
142
+ renameTriggerRef.current = e.currentTarget;
143
+ setRenaming(row.name);
144
+ setColoring(undefined);
145
+ },
118
146
  }, t('catmgr.rename')), createElement('button', {
119
147
  className: 'xy-btn',
120
148
  disabled: busy,
@@ -195,8 +195,11 @@ export function TaskDetailPanel(props) {
195
195
  // 操作区单行成组:主操作(打卡/领取)→ 条件动作(取消打卡)→ 辅助(让 AI 总结)→ 危险(删除)。
196
196
  // 删除跟随行流并靠 danger 描边区分(确认弹窗兜底防误触);不再 margin-left:auto 漂到卡缘——
197
197
  // 愿望卡里会与卡头的愿望级删除同侧对齐造成语义混淆,窄面板里则是一段突兀的空白。
198
+ // 待领取 × 截止日已过:与任务行同口径,不提供必失败的领取按钮(复活行在下方)
199
+ const claimable = task.status === 'pending'
200
+ && !(task.dueDate !== undefined && task.dueDate < today);
198
201
  const ops = [];
199
- if (task.status === 'pending') {
202
+ if (claimable) {
200
203
  ops.push(createElement('button', {
201
204
  key: 'claim', className: 'xy-btn', disabled: busy,
202
205
  onClick: () => act('claim', { taskId: task.taskId }, () => translate('toast.claimed', { name: task.name })),
@@ -265,8 +268,11 @@ export function TaskDetailPanel(props) {
265
268
  dates: detail.upcoming.map((d) => formatShortDate(d)).join(activeLocale() === 'en' ? ', ' : '、'),
266
269
  }))
267
270
  : null, createElement('div', null, createElement('span', { className: 'xy-quick-label' }, t('detail.micro.title')), microBlock),
268
- // 过期任务的复活闭环:说明行 + 页内延长截止日(失败态不再是「只能删除」的死路)
269
- task.status === 'closed' && task.closedReason === 'expired'
271
+ // 过期任务的复活闭环:说明行 + 页内延长截止日(失败态不再是「只能删除」的死路)。
272
+ // 「已过期仍待领取」同样接进来:截止日已过领取被拒,这里就是它唯一的页内出路
273
+ // (pending 任务延长截止日保持待领取,随后即可正常领取)
274
+ (task.status === 'closed' && task.closedReason === 'expired')
275
+ || (task.status === 'pending' && task.dueDate !== undefined && task.dueDate < today)
270
276
  ? createElement('div', null, createElement('span', { className: 'xy-meta' }, t('task.expiredHint')), createElement(ReviveRow, {
271
277
  taskId: task.taskId,
272
278
  today,
@@ -146,7 +146,10 @@ export function GrowthPage() {
146
146
  ? createElement('div', { key: 'xy-chart-empty', className: 'xy-meta' }, t('growth.chart.empty'))
147
147
  : [createElement('div', { key: 'xy-chart-body' }, createElement('div', { className: 'xy-tip', role: 'status' }, hoverDay !== undefined
148
148
  ? t('growth.chart.tooltip', { date: formatShortDate(hoverDay.date), checked: hoverDay.checked, total: hoverDay.total })
149
- : ''), createElement('div', { className: 'xy-growth' }, chart.cols), createElement('div', { className: 'xy-growth-axis' }, chart.ticks)),
149
+ : ''),
150
+ // 柱体与刻度同收进横向滚动容器:窄面板下保持最小柱宽(触屏可命中),
151
+ // 刻度与柱体同步滚动,宽面板内容不超宽、容器无感知
152
+ createElement('div', { className: 'xy-growth-scroll' }, createElement('div', { className: 'xy-growth' }, chart.cols), createElement('div', { className: 'xy-growth-axis' }, chart.ticks))),
150
153
  // 图例与柱体编码一致:蓝柱 = 已打卡,斜纹柱 = 当日未完成缺口,绿柱 = 全勤日
151
154
  createElement('div', { key: 'xy-chart-legend', className: 'xy-legend' }, createElement('span', null, createElement('i', { className: 'xy-dot xy-dot-checked', 'aria-hidden': 'true' }), t('growth.chart.legend.checked')), createElement('span', null, createElement('i', { className: 'xy-dot xy-dot-gap', 'aria-hidden': 'true' }), t('growth.chart.legend.missed')), createElement('span', { className: 'xy-meta' }, t('growth.chart.hint')))]), createElement('h3', { className: 'xy-section-title' }, t('growth.levels.title')), createElement('div', { className: 'xy-levels' }, ...levelRows));
152
155
  }
@@ -1,10 +1,11 @@
1
1
  /** 记忆页:搜索/新增/编辑/删除/清空 + 分页加载更多(offset/limit,任意条数可浏览)。 */
2
- import { createElement, useCallback, useEffect, useRef, useState } from 'react';
2
+ import { createElement, useCallback, useEffect, useRef, useState, useSyncExternalStore } from 'react';
3
3
  import { getJson, postAction, ActionError } from '../api.js';
4
4
  import { useXyT, t as translate } from '../i18n.js';
5
5
  import { localYmd, softConfirm, softConfirmDanger, useActionGuard, usePageData, useScrollTopOnMount, useStableScrollbar } from '../hooks.js';
6
6
  import { PageEmpty, PageError, PageSkeleton, StaleBanner, toast, IconEdit, IconTrash, focusPageTitle } from '../ui.js';
7
7
  import { getViewState, setViewState } from '../view-state.js';
8
+ import { todayHintStore } from '../tab-hint.js';
8
9
  import { formatMediumDate } from './format.js';
9
10
  const MEMORY_CATEGORY_KEYS = ['memory.cat.personal', 'memory.cat.preference', 'memory.cat.habit', 'memory.cat.event', 'memory.cat.other'];
10
11
  const MEMORY_CATEGORY_IDS = ['personal', 'preference', 'habit', 'event', 'other'];
@@ -35,6 +36,8 @@ function impLabel(id) {
35
36
  }
36
37
  export function MemoryPage() {
37
38
  const t = useXyT();
39
+ // 始终显示 × 非星愿会话:与今日页同一轻提示(空态里的「在对话里告诉我」对该会话不成立)
40
+ const showNoPresetHint = useSyncExternalStore(todayHintStore.subscribe, todayHintStore.getSnapshot);
38
41
  const stabilize = useStableScrollbar();
39
42
  useScrollTopOnMount();
40
43
  // 搜索词跨标签切换保留(view-state 快照):搜到一半切走再回来不丢词
@@ -57,6 +60,10 @@ export function MemoryPage() {
57
60
  const [more, setMore] = useState([]);
58
61
  const [loadingMore, setLoadingMore] = useState(false);
59
62
  const [loadError, setLoadError] = useState(undefined);
63
+ // 翻页翻到最后一页的瞬间:焦点还停在刚被卸载的「加载更多」按钮上——移交给
64
+ // 「已显示全部」行(tabIndex=-1 + role=status,读屏同时播报),键盘/读屏不丢位置
65
+ const [reachedEnd, setReachedEnd] = useState(false);
66
+ const loadedAllRef = useRef(null);
60
67
  const moreSeqRef = useRef(0);
61
68
  useEffect(() => {
62
69
  moreSeqRef.current += 1;
@@ -76,8 +83,13 @@ export function MemoryPage() {
76
83
  setLoadingMore(true);
77
84
  setLoadError(undefined);
78
85
  getJson(memoryListUrl(debounced, shownCount, PAGE_SIZE))
79
- .then((payload) => { if (seq === moreSeqRef.current)
80
- setMore((current) => [...current, ...payload.memories]); })
86
+ .then((payload) => {
87
+ if (seq !== moreSeqRef.current)
88
+ return;
89
+ setMore((current) => [...current, ...payload.memories]);
90
+ if (shownCount + payload.memories.length >= payload.total)
91
+ setReachedEnd(true);
92
+ })
81
93
  .catch((e) => { if (seq === moreSeqRef.current)
82
94
  setLoadError(e instanceof Error ? e.message : String(e)); })
83
95
  .finally(() => { if (seq === moreSeqRef.current)
@@ -93,10 +105,17 @@ export function MemoryPage() {
93
105
  setLoadError(undefined);
94
106
  return page.reload().then(() => undefined);
95
107
  };
96
- const [keyDraft, setKeyDraft] = useState('');
97
- const [valueDraft, setValueDraft] = useState('');
98
- const [category, setCategory] = useState('other');
99
- const [importance, setImportance] = useState('medium');
108
+ // 表单草稿跨标签快照:手打(或粘贴)的长内容不因切标签静默丢失(view-state 同款)。
109
+ // 编辑态(editingKey)不跨快照:回落后草稿仍在、以新增模式呈现,撞键走覆盖确认链路
110
+ const draftSnapshot = getViewState('memory.draft', { key: '', value: '', category: 'other', importance: 'medium' });
111
+ const [keyDraft, setKeyDraftRaw] = useState(() => typeof draftSnapshot.key === 'string' ? draftSnapshot.key : '');
112
+ const [valueDraft, setValueDraftRaw] = useState(() => typeof draftSnapshot.value === 'string' ? draftSnapshot.value : '');
113
+ const [category, setCategoryRaw] = useState(() => typeof draftSnapshot.category === 'string' ? draftSnapshot.category : 'other');
114
+ const [importance, setImportanceRaw] = useState(() => typeof draftSnapshot.importance === 'string' ? draftSnapshot.importance : 'medium');
115
+ const setKeyDraft = (v) => { setKeyDraftRaw(v); setViewState('memory.draft', { key: v, value: valueDraft, category, importance }); };
116
+ const setValueDraft = (v) => { setValueDraftRaw(v); setViewState('memory.draft', { key: keyDraft, value: v, category, importance }); };
117
+ const setCategory = (v) => { setCategoryRaw(v); setViewState('memory.draft', { key: keyDraft, value: valueDraft, category: v, importance }); };
118
+ const setImportance = (v) => { setImportanceRaw(v); setViewState('memory.draft', { key: keyDraft, value: valueDraft, category, importance: v }); };
100
119
  // 写成功短通知(搜索行尾 ✓ 文案);表单校验错误就地显示(role=alert)——输入即清除
101
120
  const [notice, setNotice] = useState(undefined);
102
121
  const [formError, setFormError] = useState(undefined);
@@ -109,6 +128,12 @@ export function MemoryPage() {
109
128
  if (noticeTimer.current !== undefined)
110
129
  window.clearTimeout(noticeTimer.current);
111
130
  }, []);
131
+ useEffect(() => {
132
+ if (reachedEnd) {
133
+ loadedAllRef.current?.focus();
134
+ setReachedEnd(false);
135
+ }
136
+ }, [reachedEnd]);
112
137
  const flash = useCallback((text) => {
113
138
  setNotice(text);
114
139
  if (noticeTimer.current !== undefined)
@@ -138,14 +163,18 @@ export function MemoryPage() {
138
163
  setCategory('other');
139
164
  setImportance('medium');
140
165
  setFormError(undefined);
166
+ setViewState('memory.draft', undefined);
141
167
  };
142
168
  const startEdit = (m) => {
169
+ // 预填走 raw setter + 整份显式快照:包装 setter 逐个调用会以渲染闭包合成中间态,
170
+ // 同一事件里四次覆盖落盘的是混合值(旧草稿 × 新分类),显式整份写入消除该污染
143
171
  setEditingKey(m.key);
144
- setKeyDraft(m.key);
145
- setValueDraft(m.value);
146
- setCategory(m.category);
147
- setImportance(m.importance);
172
+ setKeyDraftRaw(m.key);
173
+ setValueDraftRaw(m.value);
174
+ setCategoryRaw(m.category);
175
+ setImportanceRaw(m.importance);
148
176
  setFormError(undefined);
177
+ setViewState('memory.draft', { key: m.key, value: m.value, category: m.category, importance: m.importance });
149
178
  window.setTimeout(() => valueInputRef.current?.focus(), 0);
150
179
  };
151
180
  /** 保存记忆本体(写成功 → 刷新 + 短通知 + 复位表单)。 */
@@ -236,7 +265,7 @@ export function MemoryPage() {
236
265
  className: 'xy-btn xy-btn-danger xy-btn-icon', disabled: busy, onClick: () => removeOne(m),
237
266
  'aria-label': `${t('common.delete')} · ${m.key}`, title: t('common.delete'),
238
267
  }, createElement(IconTrash)))));
239
- return createElement('div', { className: 'xy-page', ref: stabilize }, stale ? createElement(StaleBanner, { onRetry: () => void page.reload() }) : null, createElement('div', { className: 'xy-page-head' }, createElement('h2', { className: 'xy-page-title' }, t('memory.pageTitle')), createElement('span', { className: 'xy-meta' }, t('memory.summary', { total: data.total }))), editingKey !== undefined
268
+ return createElement('div', { className: 'xy-page', ref: stabilize }, stale ? createElement(StaleBanner, { onRetry: () => void page.reload() }) : null, showNoPresetHint ? createElement('p', { className: 'xy-hint' }, t('today.noPresetHint')) : null, createElement('div', { className: 'xy-page-head' }, createElement('h2', { className: 'xy-page-title' }, t('memory.pageTitle')), createElement('span', { className: 'xy-meta' }, t('memory.summary', { total: data.total }))), editingKey !== undefined
240
269
  ? createElement('div', { className: 'xy-meta xy-editing' }, t('memory.editing', { key: editingKey }), createElement('button', { className: 'xy-btn', onClick: resetForm }, t('memory.cancelEdit')))
241
270
  : null,
242
271
  // 添加/编辑表单收进虚线面板卡:与快速新建同一「可书写」视觉语法,不再是一行悬空输入框
@@ -282,7 +311,7 @@ export function MemoryPage() {
282
311
  createElement('section', { className: 'xy-group' }, createElement('ul', { className: 'xy-grouplist' }, rows)), hasMore
283
312
  ? createElement('div', { className: 'xy-memfoot' }, createElement('button', { className: 'xy-btn', disabled: loadingMore || busy, onClick: loadMore }, loadingMore ? `${t('common.loading')}…` : t('memory.more')), loadError !== undefined ? createElement('span', { className: 'xy-field-err' }, loadError) : null)
284
313
  : shownCount > PAGE_SIZE
285
- ? createElement('div', { className: 'xy-meta xy-memcap' }, t('memory.loadedAll', { n: shownCount }))
314
+ ? createElement('div', { className: 'xy-meta xy-memcap', ref: loadedAllRef, tabIndex: -1, role: 'status' }, t('memory.loadedAll', { n: shownCount }))
286
315
  : null, data.total > 0
287
316
  ? createElement('div', { className: 'xy-memfoot' }, createElement('button', { className: 'xy-btn xy-btn-danger', disabled: busy, onClick: clearAll }, t('memory.clearAll')), createElement('span', { className: 'xy-meta' }, t('memory.footNote')))
288
317
  : null);
@@ -11,6 +11,7 @@ import { useXyT, t as translate } from '../i18n.js';
11
11
  import { localYmd, softConfirm, useActionGuard } from '../hooks.js';
12
12
  import { toast } from '../ui.js';
13
13
  import { SwatchRow } from './color-swatch.js';
14
+ import { getViewState, setViewState } from '../view-state.js';
14
15
  import { cycleLabel } from './format.js';
15
16
  const CYCLE_KEYS = ['once', 'daily', 'weekly', 'monthly'];
16
17
  /** 颜色选择行(可选:不选 = 跟随分类哈希/覆盖)。busy 传导到色板(写在途禁点)。 */
@@ -27,9 +28,14 @@ export function WishQuickForm(props) {
27
28
  // 展开即聚焦首个字段:点「新建」后可直接打字,不再多点一跳(表单条件挂载,挂载=展开)
28
29
  const titleRef = useRef(null);
29
30
  useEffect(() => { titleRef.current?.focus(); }, []);
30
- const [title, setTitle] = useState('');
31
- const [categoryName, setCategoryName] = useState('');
32
- const [colorKey, setColorKey] = useState('');
31
+ // 表单草稿跨标签/收起快照:填到一半切走不丢内容(成功创建即清)
32
+ const wishDraft = getViewState('quick.wish', { title: '', categoryName: '', colorKey: '' });
33
+ const [title, setTitleRaw] = useState(() => typeof wishDraft.title === 'string' ? wishDraft.title : '');
34
+ const [categoryName, setCategoryNameRaw] = useState(() => typeof wishDraft.categoryName === 'string' ? wishDraft.categoryName : '');
35
+ const [colorKey, setColorKeyRaw] = useState(() => typeof wishDraft.colorKey === 'string' ? wishDraft.colorKey : '');
36
+ const setTitle = (v) => { setTitleRaw(v); setViewState('quick.wish', { title: v, categoryName, colorKey }); };
37
+ const setCategoryName = (v) => { setCategoryNameRaw(v); setViewState('quick.wish', { title, categoryName: v, colorKey }); };
38
+ const setColorKey = (v) => { setColorKeyRaw(v); setViewState('quick.wish', { title, categoryName, colorKey: v }); };
33
39
  const [fieldError, setFieldError] = useState(undefined);
34
40
  const { busy, guard } = useActionGuard();
35
41
  // 输入即清除错误:错误提示与修正动作同帧反馈,不留「已改好还报错」的死循环
@@ -65,6 +71,7 @@ export function WishQuickForm(props) {
65
71
  setTitle('');
66
72
  setCategoryName('');
67
73
  setColorKey('');
74
+ setViewState('quick.wish', undefined);
68
75
  props.onCreated();
69
76
  }));
70
77
  };
@@ -91,9 +98,14 @@ export function TaskQuickForm(props) {
91
98
  // 同 WishQuickForm:展开即聚焦名称字段
92
99
  const nameRef = useRef(null);
93
100
  useEffect(() => { nameRef.current?.focus(); }, []);
94
- const [name, setName] = useState('');
95
- const [cycle, setCycle] = useState('daily');
96
- const [dueDate, setDueDate] = useState('');
101
+ // 同 WishQuickForm:草稿跨标签/收起快照,成功创建即清
102
+ const taskDraft = getViewState('quick.task', { name: '', cycle: 'daily', dueDate: '' });
103
+ const [name, setNameRaw] = useState(() => typeof taskDraft.name === 'string' ? taskDraft.name : '');
104
+ const [cycle, setCycleRaw] = useState(() => typeof taskDraft.cycle === 'string' ? taskDraft.cycle : 'daily');
105
+ const [dueDate, setDueDateRaw] = useState(() => typeof taskDraft.dueDate === 'string' ? taskDraft.dueDate : '');
106
+ const setName = (v) => { setNameRaw(v); setViewState('quick.task', { name: v, cycle, dueDate }); };
107
+ const setCycle = (v) => { setCycleRaw(v); setViewState('quick.task', { name, cycle: v, dueDate }); };
108
+ const setDueDate = (v) => { setDueDateRaw(v); setViewState('quick.task', { name, cycle, dueDate: v }); };
97
109
  const [fieldError, setFieldError] = useState(undefined);
98
110
  const { busy, guard } = useActionGuard();
99
111
  // 输入即清除错误:与 WishQuickForm 同一反馈语义,不留「已改好还报错」的死循环
@@ -126,6 +138,7 @@ export function TaskQuickForm(props) {
126
138
  toast(translate('toast.taskCreated', { name: trimmed }), 'ok');
127
139
  setName('');
128
140
  setDueDate('');
141
+ setViewState('quick.task', undefined);
129
142
  props.onCreated();
130
143
  }));
131
144
  };
@@ -48,14 +48,18 @@ export function TaskLine(props) {
48
48
  // 近期行动日期走短格式(与「提前打卡」按钮同口径);ISO 只留给确认文案与读屏
49
49
  + `${futureDate !== undefined ? ` · ${t('task.nextDate', { date: formatShortDate(futureDate) })}` : ''}`
50
50
  + `${task.dueDate !== undefined ? ` · ${t('task.due', { date: formatShortDate(task.dueDate) })}` : ''}`), createElement('div', { style: { display: 'flex', gap: '6px', alignItems: 'center', flexWrap: 'wrap' } }, task.status === 'pending'
51
- ? createElement('button', {
52
- className: 'xy-btn', disabled: busy,
53
- onClick: () => {
54
- void act('claim', { taskId: task.taskId }, () => t('toast.claimed', { name: task.name }))
55
- .then(() => { if (focusAfterClaim)
56
- focusPageTitle(); }, () => { });
57
- },
58
- }, t('action.claim'))
51
+ ? (task.dueDate !== undefined && task.dueDate < today
52
+ // 截止日已过的待领取任务:写路径拒绝领取(claim_expired),行内给指引而非必失败按钮
53
+ // (复活入口在展开详情的「延长截止日」行,与详情面板同口径)
54
+ ? createElement('span', { className: 'xy-meta' }, t('task.claimExpiredHint'))
55
+ : createElement('button', {
56
+ className: 'xy-btn', disabled: busy,
57
+ onClick: () => {
58
+ void act('claim', { taskId: task.taskId }, () => t('toast.claimed', { name: task.name }))
59
+ .then(() => { if (focusAfterClaim)
60
+ focusPageTitle(); }, () => { });
61
+ },
62
+ }, t('action.claim')))
59
63
  : task.status === 'in_progress'
60
64
  ? createElement('button', { className: 'xy-btn xy-btn-primary', disabled: busy, onClick: checkIn }, futureDate !== undefined ? t('action.checkinFuture', { date: formatShortDate(futureDate) }) : t('action.checkin'))
61
65
  : null, trailing));