@starwish-ai/xingyuan-dsh 0.4.0 → 0.5.0

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,38 @@ 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.0] - 2026-08-27
9
+
10
+ Write-path unification and read-side freshness alignment between the chat tools,
11
+ the `/xingyuan` page actions, and the charts. No schema or configuration changes;
12
+ existing data stays compatible.
13
+
14
+ ### Added
15
+ - Clearable update fields: `update_task` and `update_wish` accept an **empty string**
16
+ to clear task hint / due date and wish description / color key / estimated
17
+ completion date (`undefined` still means "not mentioned — keep current value").
18
+ - Wish creation and updates are unified store use cases shared by chat tools and
19
+ page actions; validation failures carry stable error codes on both faces.
20
+ - Regression tests for plan-view freshness, clearing semantics, chart freshness,
21
+ and the calendar one-year window.
22
+
23
+ ### Changed
24
+ - Plan views refresh stale task state on read and offer check-in only for
25
+ in-progress tasks: a past-day button can no longer be enabled while the write
26
+ path would reject it with "task already closed".
27
+ - Status/progress charts (`taskStatus`, `wishProgress`, `wishAchievement`) use the
28
+ same fresh reads as the pages; the check-in calendar heatmap now actually covers
29
+ only the trailing year its subtitle promises.
30
+ - Deleting a task re-syncs its wish totals inside the delete cascade.
31
+
32
+ ### Fixed
33
+ - Prompt drift: memory guide now states high *and* medium importance memories are
34
+ auto-injected; the overflow note no longer calls cut-off memories "low value".
35
+ - SQLite backend creates its data directory synchronously at activation, removing
36
+ a cold-start race on first install.
37
+ - Client error toasts fall back to the server message for unknown error codes
38
+ instead of rendering `undefined`; `memoryInjectLimit` gains server-side bounds.
39
+
8
40
  ## [0.4.0] - 2026-08-27
9
41
 
10
42
  First stable release of the 0.4.x line: memory search fix, calendar and task-detail
package/README.en-US.md CHANGED
@@ -12,6 +12,7 @@ XingYuan is a wish & habit-building companion, shipped as a DeepSeek Harness plu
12
12
 
13
13
  - **Conversation-driven**: create wishes / tasks in natural language with duplicate detection
14
14
  - **Opportunity-day check-ins**: check-in, make-up, cancel, and future pre-checks; progress always recomputed against today
15
+ - **Micro-actions**: break an overwhelming goal into 3–7 tiny steps and walk through them guided
15
16
  - **Growth system**: levels Lv.1–Lv.10, streak bonuses, stat cards, 30-day bar chart
16
17
  - **Session view tabs**: Today / Wishes / Tasks / Calendar / Growth / Memory — buttons call action endpoints directly
17
18
  - **Memory**: important memories injected into context (limit configurable), full CRUD
@@ -50,6 +51,8 @@ pnpm build # tsc + tsdown (integration tests depend on lib/)
50
51
  pnpm test # vitest
51
52
  ```
52
53
 
54
+ Architecture, domain semantics and client-side discipline live in [AGENTS.md](./AGENTS.md) (Chinese); contributing guide in [CONTRIBUTING](./CONTRIBUTING.md).
55
+
53
56
  Releasing: push a `v*` tag to build and publish to npm automatically (prereleases get the `alpha` dist-tag).
54
57
 
55
58
  ## License
package/README.md CHANGED
@@ -12,6 +12,7 @@
12
12
 
13
13
  - **对话驱动**:自然语言创建愿望 / 任务,自动查重相似项
14
14
  - **机会日打卡**:周期任务打卡、补卡、取消、未来预勾,进度跨天恒为最新
15
+ - **微行动拆解**:把无从下手的目标拆成 3–7 个小步,引导式逐个完成或跳过
15
16
  - **成长体系**:等级 Lv.1–Lv.10、连击加成、统计卡与近 30 天柱状图
16
17
  - **会话视图页**:今日 / 愿望 / 任务 / 日历 / 成长 / 记忆 六个标签页,页面按钮直连动作接口
17
18
  - **记忆**:重要记忆自动注入上下文(上限可配),支持增删改查
@@ -50,6 +51,9 @@ pnpm build # tsc + tsdown(集成测试依赖 lib/ 产物)
50
51
  pnpm test # vitest
51
52
  ```
52
53
 
54
+ 架构、领域口径与客户端开发纪律见 [AGENTS.md](./AGENTS.md);贡献流程见
55
+ [CONTRIBUTING](./CONTRIBUTING.md)。
56
+
53
57
  发布:推送 `v*` tag 自动构建并发布到 npm(预发布版本挂 `alpha` tag)。
54
58
 
55
59
  ## License
package/lib/cascade.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { restartMicroAction } from './micro.js';
2
+ import { syncWishProgress } from './store.js';
2
3
  /** 删除任务的全部打卡记录。 */
3
4
  export async function removeAllCheckins(store, taskId) {
4
5
  const keys = [];
@@ -9,11 +10,8 @@ export async function removeAllCheckins(store, taskId) {
9
10
  for (const key of keys)
10
11
  await store.domain.table('checkins').delete(key);
11
12
  }
12
- /**
13
- * 彻底删除单个任务:打卡记录、微行动状态随任务级联清理。
14
- * 返回被删任务快照(供事件补发);任务不存在时返回 undefined。
15
- */
16
- export async function removeTaskCompletely(store, taskId) {
13
+ /** 销毁任务本体及其打卡/微行动痕迹(不联动愿望进度——由调用方按语义决定是否回写)。 */
14
+ async function destroyTask(store, taskId) {
17
15
  const task = store.domain.table('tasks').get(taskId);
18
16
  if (task === undefined)
19
17
  return undefined;
@@ -23,14 +21,30 @@ export async function removeTaskCompletely(store, taskId) {
23
21
  await store.domain.table('tasks').delete(taskId);
24
22
  return task;
25
23
  }
26
- /** 彻底删除愿望及其下属全部任务(含各自打卡与微行动状态);返回被级联的任务。 */
24
+ /**
25
+ * 彻底删除单个任务并回写所属愿望的库存进度(应打/已打随之减少)。
26
+ * 返回被删任务快照(供事件补发);任务不存在时返回 undefined。
27
+ */
28
+ export async function removeTaskCompletely(store, taskId) {
29
+ const task = await destroyTask(store, taskId);
30
+ // 愿望可能已被并发删除:仅在存续时回写进度(不吞存储层异常)
31
+ if (task !== undefined && task.wishId !== undefined && store.domain.table('wishes').get(task.wishId) !== undefined) {
32
+ await syncWishProgress(store, task.wishId);
33
+ }
34
+ return task;
35
+ }
36
+ /**
37
+ * 彻底删除愿望及其下属全部任务(含各自打卡与微行动状态)。
38
+ * 愿望本体同删,无需进度回写;返回被级联的任务快照。
39
+ */
27
40
  export async function removeWishCompletely(store, wishId) {
28
41
  const removed = [];
29
42
  for (const [, task] of store.domain.table('tasks').entries()) {
30
43
  if (task.wishId !== wishId)
31
44
  continue;
32
- await removeTaskCompletely(store, task.taskId);
33
- removed.push(task);
45
+ const destroyed = await destroyTask(store, task.taskId);
46
+ if (destroyed !== undefined)
47
+ removed.push(destroyed);
34
48
  }
35
49
  await store.domain.table('wishes').delete(wishId);
36
50
  return removed;
package/lib/client/api.js CHANGED
@@ -31,11 +31,11 @@ const ERROR_KEY = {
31
31
  };
32
32
  /** 把任意抛错转成用户可读文案:ActionError 按键本地化;其余直出消息。 */
33
33
  export function describeError(e) {
34
- if (e instanceof ActionError)
35
- return t(ERROR_KEY[e.code], e.params);
36
- const message = e instanceof Error ? e.message : String(e);
37
- // 兼容旧口径服务端纯中文消息里的「${date} 已打卡」模式(恢复卡片态用)
38
- return message;
34
+ if (!(e instanceof ActionError))
35
+ return e instanceof Error ? e.message : String(e);
36
+ // 服务端新增未知 code 时回落原始消息,避免渲染 undefined 或插值崩溃
37
+ const key = ERROR_KEY[e.code];
38
+ return key !== undefined ? t(key, e.params) : e.message;
39
39
  }
40
40
  export async function postAction(path, body) {
41
41
  return postJson(`/xingyuan/api/action/${path}`, body);
@@ -45,7 +45,6 @@ const ZH_DICT = {
45
45
  'common.countUnit': '{n} 次',
46
46
  'common.countUnitOne': '{n} 次',
47
47
  'common.dateSuffix': '({date})',
48
- 'common.statusSuffix': '({status})',
49
48
  // --- 卡片徽章与状态 ---
50
49
  'badge.wish': '愿望',
51
50
  'badge.task': '任务',
@@ -142,7 +141,6 @@ const ZH_DICT = {
142
141
  'cal.cellAria.some': '{date},已完成 {checked}/{due}',
143
142
  'cal.panelHint': '点击日期查看当日任务,可直接补卡或取消打卡。',
144
143
  'cal.dayLoadFailed': '当日任务加载失败,请重试。',
145
- 'cal.dayTitle': '{date} 的任务',
146
144
  'cal.dayEmpty': '这一天没有任务安排。',
147
145
  'cal.state.checked': '✓ 已打卡',
148
146
  'cal.state.todo': '○ 待打卡',
@@ -161,6 +159,7 @@ const ZH_DICT = {
161
159
  'growth.stat.wishDone': '已实现愿望',
162
160
  'growth.stat.taskTotal': '累计任务',
163
161
  'growth.stat.taskDone': '已达成任务',
162
+ 'growth.stat.none': '暂无',
164
163
  'growth.chart.title': '近 30 天打卡',
165
164
  'growth.chart.empty': '暂无打卡数据,完成第一次打卡后这里会长出你的趋势图。',
166
165
  'growth.chart.hint': '蓝色为已打卡,斜纹为当日未完成缺口,绿色柱为全勤日;悬停或聚焦柱子可看逐日明细。',
@@ -352,7 +351,6 @@ const EN_DICT = {
352
351
  'common.countUnit': '{n} times',
353
352
  'common.countUnitOne': '{n} time',
354
353
  'common.dateSuffix': ' ({date})',
355
- 'common.statusSuffix': ' ({status})',
356
354
  'badge.wish': 'Wish',
357
355
  'badge.task': 'Task',
358
356
  'badge.chart': 'Chart',
@@ -443,7 +441,6 @@ const EN_DICT = {
443
441
  'cal.cellAria.some': '{date}, completed {checked}/{due}',
444
442
  'cal.panelHint': 'Pick a date to see its tasks; make up or cancel check-ins inline.',
445
443
  'cal.dayLoadFailed': 'Failed to load tasks for this day. Please retry.',
446
- 'cal.dayTitle': 'Tasks on {date}',
447
444
  'cal.dayEmpty': 'Nothing scheduled on this day.',
448
445
  'cal.state.checked': '✓ Checked in',
449
446
  'cal.state.todo': '○ To check in',
@@ -461,6 +458,7 @@ const EN_DICT = {
461
458
  'growth.stat.wishDone': 'Wishes achieved',
462
459
  'growth.stat.taskTotal': 'Tasks created',
463
460
  'growth.stat.taskDone': 'Tasks achieved',
461
+ 'growth.stat.none': 'None',
464
462
  'growth.chart.title': 'Last 30 days',
465
463
  'growth.chart.empty': 'No data yet. Your trend chart grows after the first check-in.',
466
464
  'growth.chart.hint': 'Blue = checked in, striped = missed gap, green = full day. Hover or focus a bar for daily details.',
@@ -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, formatMonth, formatWeekday } from './format.js';
7
+ import { cycleLabel, dateSuffix, formatFriendlyDate, formatMonth } from './format.js';
8
8
  function monthOf(offset) {
9
9
  const base = new Date();
10
10
  base.setDate(1);
@@ -60,7 +60,8 @@ export function CalendarPage() {
60
60
  ? t('confirm.undoAt', { name: taskName, date })
61
61
  : undefined;
62
62
  const run = () => {
63
- guard(() => postAction(action, { taskId, date }).then(() => {
63
+ // claim 服务端只读 taskId;checkin/cancel-checkin 的 date 是必读参数
64
+ guard(() => postAction(action, action === 'claim' ? { taskId } : { taskId, date }).then(() => {
64
65
  toast(action === 'claim'
65
66
  ? t('toast.claimed', { name: taskName })
66
67
  : action === 'checkin'
@@ -186,9 +187,9 @@ export function CalendarPage() {
186
187
  }, t('common.retry')))
187
188
  : detail === undefined
188
189
  ? createElement('div', { className: 'xy-meta xy-dayhint' }, t('cal.panelHint'))
189
- : createElement('div', { className: 'xy-daydetail' }, createElement('h3', { className: 'xy-section-title' }, t('cal.dayTitle', { date: detail.date }), formatWeekday(detail.date) !== ''
190
- ? createElement('span', { style: { fontWeight: 400, marginLeft: 6 } }, formatWeekday(detail.date))
191
- : null), detail.tasks.length === 0
190
+ : createElement('div', { className: 'xy-daydetail' },
191
+ // 面板头 = 本地化友好日期(含星期),与今日页标题同一语法;不再裸奔 ISO
192
+ createElement('h3', { className: 'xy-section-title' }, formatFriendlyDate(detail.date)), detail.tasks.length === 0
192
193
  ? createElement('div', { className: 'xy-meta' }, t('cal.dayEmpty'))
193
194
  : createElement('ul', { className: 'xy-grouplist' }, detail.tasks.map((task) => {
194
195
  // 元信息分段拼接:空段不产悬挂分隔符(如已完结任务无状态词)
@@ -9,6 +9,7 @@ import { getJson, postAction } from '../api.js';
9
9
  import { useXyT, t as translate, activeLocale } from '../i18n.js';
10
10
  import { localYmd, softConfirm, softConfirmDanger, useActionGuard } from '../hooks.js';
11
11
  import { toast } from '../ui.js';
12
+ import { formatShortDate } from './format.js';
12
13
  /** 「让 AI 总结」提示词:触发者是人、产物直接可见,模板随界面语言切换(与 prompts.ts 总结分析模式同一结构诉求)。 */
13
14
  function summaryPrompt(taskName) {
14
15
  return activeLocale() === 'en'
@@ -35,6 +36,11 @@ export function DetailToggle(props) {
35
36
  onClick: props.onToggle,
36
37
  }, props.open ? t('action.collapse') : t('action.expand'));
37
38
  }
39
+ /** 'yyyy-MM-dd' → 周一为 0 的星期序号(与日历页表头「一..日」同构);解析失败回落 0。 */
40
+ function weekdayMon0(ymd) {
41
+ const date = new Date(Number(ymd.slice(0, 4)), Number(ymd.slice(5, 7)) - 1, Number(ymd.slice(8)), 12);
42
+ return Number.isNaN(date.getTime()) ? 0 : (date.getDay() + 6) % 7;
43
+ }
38
44
  /**
39
45
  * 任务详情面板:expanded 由调用方持有(TaskLine trailing 放 DetailToggle)。
40
46
  * today 缺省取本地日期;onChanged 在任何写动作成功后回调(供列表 reload,
@@ -83,23 +89,28 @@ export function TaskDetailPanel(props) {
83
89
  const task = detail.task;
84
90
  const checkedToday = detail.grid.some((cell) => cell.date === today && cell.state === 'checked');
85
91
  // 网格可访问性:28 格逐格播报是读屏灾难——格子 aria-hidden,整体以一条
86
- // 汇总 aria-label 暴露(role=img),逐日细节保留 title 供鼠标悬停
87
- const gridCells = detail.grid.slice(-28).map((cell) => {
88
- const label = cell.state === 'checked'
89
- ? t('detail.grid.checked', { date: cell.date })
90
- : cell.state === 'missed'
91
- ? t('detail.grid.missed', { date: cell.date })
92
- : t('detail.grid.future', { date: cell.date });
93
- const tone = cell.state === 'checked' ? 'xy-dcell-checked' : cell.state === 'future' ? 'xy-dcell-future' : 'xy-dcell-missed';
94
- return createElement('span', {
95
- key: cell.date,
96
- className: `xy-dcell ${tone}`,
97
- title: label,
98
- 'aria-hidden': 'true',
99
- }, String(Number(cell.date.slice(8))));
100
- });
92
+ // 汇总 aria-label 暴露(role=img),逐日细节保留 title 供鼠标悬停。
93
+ // 视觉按周对齐(周一始,GitHub/Streaks 惯例):首行前置隐形占位,今日格加 accent 环,
94
+ // 连续坚持一眼可读;换月错位不再出现流式换行的参差断行。
95
+ const recent = detail.grid.slice(-28);
96
+ const gridCells = [
97
+ ...Array.from({ length: recent.length > 0 ? weekdayMon0(recent[0].date) : 0 }, (_, i) => createElement('span', { key: `pad-${i}`, className: 'xy-dcell xy-dcell-blank', 'aria-hidden': 'true' })),
98
+ ...recent.map((cell) => {
99
+ const label = cell.state === 'checked'
100
+ ? t('detail.grid.checked', { date: cell.date })
101
+ : cell.state === 'missed'
102
+ ? t('detail.grid.missed', { date: cell.date })
103
+ : t('detail.grid.future', { date: cell.date });
104
+ const tone = cell.state === 'checked' ? 'xy-dcell-checked' : cell.state === 'future' ? 'xy-dcell-future' : 'xy-dcell-missed';
105
+ return createElement('span', {
106
+ key: cell.date,
107
+ className: `xy-dcell ${tone}${cell.date === today ? ' xy-dcell-today' : ''}`,
108
+ title: label,
109
+ 'aria-hidden': 'true',
110
+ }, String(Number(cell.date.slice(8))));
111
+ }),
112
+ ];
101
113
  const gridSummary = (() => {
102
- const recent = detail.grid.slice(-28);
103
114
  const checked = recent.filter((c) => c.state === 'checked').length;
104
115
  const missed = recent.filter((c) => c.state === 'missed').length;
105
116
  const future = recent.length - checked - missed;
@@ -180,7 +191,11 @@ export function TaskDetailPanel(props) {
180
191
  // 整块网格对读屏是一句话汇总;格子 aria-hidden,逐日细节走 title 悬停
181
192
  createElement('div', { className: 'xy-detail-grid', role: 'img', 'aria-label': gridSummary }, gridCells), createElement('div', { className: 'xy-legend' }, createElement('span', null, createElement('i', { className: 'xy-dot xy-dcell-checked', 'aria-hidden': 'true' }), t('detail.legend.checked')), createElement('span', null, createElement('i', { className: 'xy-dot xy-dcell-missed', 'aria-hidden': 'true' }), t('detail.legend.missed')), createElement('span', null, createElement('i', { className: 'xy-dot xy-dcell-future', 'aria-hidden': 'true' }), t('detail.legend.future'))))
182
193
  : null, detail.upcoming.length > 0
183
- ? createElement('div', { className: 'xy-detail-next' }, t('detail.next.title', { dates: detail.upcoming.join(activeLocale() === 'en' ? ', ' : '、') }))
194
+ ? createElement('div', { className: 'xy-detail-next' },
195
+ // 机会日列表与「提前打卡」按钮同一短格式口径(ISO 只留给确认文案与读屏)
196
+ t('detail.next.title', {
197
+ dates: detail.upcoming.map((d) => formatShortDate(d)).join(activeLocale() === 'en' ? ', ' : '、'),
198
+ }))
184
199
  : null, createElement('div', null, createElement('span', { className: 'xy-quick-label' }, t('detail.micro.title')), microBlock), createElement('div', null, createElement('span', { className: 'xy-quick-label' }, t('detail.ops.title')),
185
200
  // 周期/进度元信息不再重复:TaskLine 行本身已展示同口径信息(cycle·duration·status·next·due)
186
201
  createElement('div', { className: 'xy-detail-ops' }, ...ops)));
@@ -53,6 +53,27 @@ export function formatShortDate(ymd) {
53
53
  return ymd;
54
54
  return new Intl.DateTimeFormat(LOCALE_TAG[activeLocale()], { month: 'short', day: 'numeric' }).format(date);
55
55
  }
56
+ /**
57
+ * 友好全称日期:'2026-08-27' → 「8月27日 周四」/「Thu, Aug 27」。
58
+ * 展示性标题统一走这里,杜绝界面裸奔 ISO 串;解析失败回退原值(防御脏数据)。
59
+ */
60
+ export function formatFriendlyDate(ymd) {
61
+ const short = formatShortDate(ymd);
62
+ const wd = formatWeekday(ymd);
63
+ if (short === ymd || wd === '')
64
+ return ymd;
65
+ return activeLocale() === 'en' ? `${wd}, ${short}` : `${short} ${wd}`;
66
+ }
67
+ /** 带年份的中格式(远期目标日):'2026-11-24' → 「2026年11月24日」/「Nov 24, 2026」。 */
68
+ export function formatMediumDate(ymd) {
69
+ const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(ymd);
70
+ if (match === null)
71
+ return ymd;
72
+ const date = new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]), 12);
73
+ if (Number.isNaN(date.getTime()))
74
+ return ymd;
75
+ return new Intl.DateTimeFormat(LOCALE_TAG[activeLocale()], { dateStyle: 'medium' }).format(date);
76
+ }
56
77
  // ===== Intl 日历文案(业界实践:本地化交给 Intl.DateTimeFormat,不手写格式) =====
57
78
  const LOCALE_TAG = { zh: 'zh-CN', en: 'en-US' };
58
79
  /** '2026-01' → 「2026年1月」/「January 2026」;解析失败原样回显(防御脏数据)。 */
@@ -3,6 +3,7 @@ import { createElement, useState } from 'react';
3
3
  import { useXyT } from '../i18n.js';
4
4
  import { recentRangeDays, usePageData, useStableScrollbar } from '../hooks.js';
5
5
  import { PageError, PageSkeleton } from '../ui.js';
6
+ import { formatShortDate } from './format.js';
6
7
  /**
7
8
  * 等级色阶(Lv.1 灰 → Lv.10 玫瑰红):全部选用深色档(slate-600 → rose-700),
8
9
  * 白字对比 ≥4.5:1,深浅两种壳主题下都成立;standalone /xingyuan/growth 页内联同表。
@@ -49,7 +50,8 @@ export function GrowthPage() {
49
50
  return createElement('button', {
50
51
  key: day.date,
51
52
  className: `xy-growth-col${full ? ' xy-full' : ''}${hoverIdx === i ? ' xy-hover' : ''}`,
52
- 'aria-label': t('growth.chart.tooltip', { date: day.date, checked: day.checked, total: day.total }),
53
+ // 读屏与可见悬浮条同一短日期口径(aria 与 tooltip 同源同文案)
54
+ 'aria-label': t('growth.chart.tooltip', { date: formatShortDate(day.date), checked: day.checked, total: day.total }),
53
55
  onMouseEnter: () => setHoverIdx(i),
54
56
  onFocus: () => setHoverIdx(i),
55
57
  onBlur: () => setHoverIdx(null),
@@ -70,7 +72,9 @@ export function GrowthPage() {
70
72
  });
71
73
  return { cols, ticks, days: r.days };
72
74
  })();
73
- const stat = (value, label) => createElement('div', { className: 'xy-statcard' }, createElement('div', { className: 'xy-statnum' }, String(value)), createElement('div', { className: 'xy-meta' }, label));
75
+ /** 统计卡:hot = 强调变体(当前连续是习惯坚持的核心指标);muted = 缺省值弱化,
76
+ * 「暂无」不得比真实数据更响。 */
77
+ const stat = (value, label, variant) => createElement('div', { className: `xy-statcard${variant === 'hot' ? ' xy-statcard-hot' : ''}${variant === 'muted' ? ' xy-statcard-muted' : ''}` }, createElement('div', { className: 'xy-statnum' }, String(value)), createElement('div', { className: 'xy-meta' }, label));
74
78
  // 等级英雄卡:色阶随等级推进(Web Growth.vue 同款进阶感),进度 = 总经验/下一级门槛
75
79
  const levelNumber = g.level ?? 1;
76
80
  const tint = levelTint(levelNumber);
@@ -92,7 +96,7 @@ export function GrowthPage() {
92
96
  style: { background: `linear-gradient(135deg, color-mix(in srgb, ${tint} 76%, #000), ${tint})` },
93
97
  }, createElement('div', { className: 'xy-herobadge', style: { background: tint } }, `Lv.${levelNumber}`), createElement('div', { className: 'xy-heromain' }, createElement('div', { className: 'xy-meta xy-onhero' }, t('growth.levelLabel')), createElement('h2', { className: 'xy-herotitle' }, `Lv.${levelNumber} · ${g.levelName ?? t('growth.levelFallback')}`), createElement('div', { className: 'xy-bar xy-bar-onhero' }, createElement('div', { className: 'xy-bar-fill xy-bar-fill-solid', style: { transform: `scaleX(${Math.min(Math.max(g.levelProgress ?? 0, 0), 100) / 100})` } })), createElement('div', { className: 'xy-meta xy-onhero' }, expText))), g.rewardDescription !== undefined
94
98
  ? createElement('div', { className: 'xy-meta xy-heroreward' }, t('growth.rewardPrefix', { reward: g.rewardDescription }))
95
- : null, createElement('div', { className: 'xy-stats' }, stat(g.totalCheckinDays ?? g.totalCheckins ?? 0, t('growth.stat.checkinDays')), stat(g.currentStreak, t('growth.stat.streak')), stat(g.maxStreak, t('growth.stat.maxStreak')), stat(g.wishTotal, t('growth.stat.wishTotal')), stat(g.wishAchieved, t('growth.stat.wishDone')), stat(g.taskTotal ?? '—', t('growth.stat.taskTotal')), stat(g.taskAchieved ?? '—', t('growth.stat.taskDone'))), createElement('h3', { className: 'xy-section-title' }, t('growth.chart.title')),
99
+ : null, createElement('div', { className: 'xy-stats' }, stat(g.totalCheckinDays ?? g.totalCheckins ?? 0, t('growth.stat.checkinDays')), stat(g.currentStreak, t('growth.stat.streak'), 'hot'), stat(g.maxStreak, t('growth.stat.maxStreak')), stat(g.wishTotal, t('growth.stat.wishTotal')), stat(g.wishAchieved, t('growth.stat.wishDone')), stat(g.taskTotal ?? t('growth.stat.none'), t('growth.stat.taskTotal'), g.taskTotal === undefined ? 'muted' : undefined), stat(g.taskAchieved ?? t('growth.stat.none'), t('growth.stat.taskDone'), g.taskAchieved === undefined ? 'muted' : undefined)), createElement('h3', { className: 'xy-section-title' }, t('growth.chart.title')),
96
100
  // 图表三态整体收进面板卡(加载/失败/空数据/就绪同一容器,与全站卡片语言一致):
97
101
  // 「加载中」不得误报为「暂无打卡数据」;区间为空时同样走 empty 文案而非渲染空白坐标系。
98
102
  createElement('div', { className: 'xy-panel' }, range.data === undefined && range.error === undefined
@@ -104,7 +108,7 @@ export function GrowthPage() {
104
108
  : chart === null || chart.days.length === 0
105
109
  ? createElement('div', { key: 'xy-chart-empty', className: 'xy-meta' }, t('growth.chart.empty'))
106
110
  : [createElement('div', { key: 'xy-chart-body' }, createElement('div', { className: 'xy-tip', role: 'status' }, hoverDay !== undefined
107
- ? t('growth.chart.tooltip', { date: hoverDay.date, checked: hoverDay.checked, total: hoverDay.total })
111
+ ? t('growth.chart.tooltip', { date: formatShortDate(hoverDay.date), checked: hoverDay.checked, total: hoverDay.total })
108
112
  : ''), createElement('div', { className: 'xy-growth' }, chart.cols), createElement('div', { className: 'xy-growth-axis' }, chart.ticks)),
109
113
  // 图例与柱体编码一致:蓝柱 = 已打卡,斜纹柱 = 当日未完成缺口,绿柱 = 全勤日
110
114
  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));
@@ -3,7 +3,7 @@ import { createElement, useCallback, useEffect, useRef, useState } from 'react';
3
3
  import { getJson, postAction, ActionError } from '../api.js';
4
4
  import { useXyT, t as translate } from '../i18n.js';
5
5
  import { softConfirm, softConfirmDanger, useActionGuard, usePageData, useStableScrollbar } from '../hooks.js';
6
- import { PageEmpty, PageError, PageSkeleton, toast } from '../ui.js';
6
+ import { PageEmpty, PageError, PageSkeleton, toast, IconEdit, IconTrash } from '../ui.js';
7
7
  const MEMORY_CATEGORY_KEYS = ['memory.cat.personal', 'memory.cat.preference', 'memory.cat.habit', 'memory.cat.event', 'memory.cat.other'];
8
8
  const MEMORY_CATEGORY_IDS = ['personal', 'preference', 'habit', 'event', 'other'];
9
9
  const MEMORY_IMPORTANCE_KEYS = ['memory.imp.high', 'memory.imp.medium', 'memory.imp.low'];
@@ -204,7 +204,15 @@ export function MemoryPage() {
204
204
  }));
205
205
  });
206
206
  };
207
- const rows = filtered.map((m) => createElement('li', { key: m.key, className: 'xy-grouprow' }, createElement('div', { className: 'xy-rowmain' }, createElement('span', { className: 'xy-rowtitle' }, m.importance === 'high' ? createElement('span', { className: 'xy-star-hi', 'aria-hidden': 'true' }, '★') : null, m.key), createElement('span', { className: 'xy-meta' }, m.value), createElement('span', { className: 'xy-meta' }, `${catLabel(m.category)} · ${t('memory.importanceLabel', { level: impLabel(m.importance) })} · ${m.createdAt.slice(0, 10)}`)), createElement('div', { className: 'xy-memactions' }, createElement('button', { className: 'xy-btn', disabled: busy, onClick: () => startEdit(m) }, t('common.edit')), createElement('button', { className: 'xy-btn xy-btn-danger', disabled: busy, onClick: () => removeOne(m) }, t('common.delete')))));
207
+ const rows = filtered.map((m) => createElement('li', { key: m.key, className: 'xy-grouprow' }, createElement('div', { className: 'xy-rowmain' }, createElement('span', { className: 'xy-rowtitle' }, m.importance === 'high' ? createElement('span', { className: 'xy-star-hi', 'aria-hidden': 'true' }, '★') : null, m.key), createElement('span', { className: 'xy-meta' }, m.value), createElement('span', { className: 'xy-meta' }, `${catLabel(m.category)} · ${t('memory.importanceLabel', { level: impLabel(m.importance) })} · ${m.createdAt.slice(0, 10)}`)), createElement('div', { className: 'xy-memactions' },
208
+ // 行内图标幽灵键(≥26px 命中目标):长列表降噪,语义走 aria-label 带 key 上下文
209
+ createElement('button', {
210
+ className: 'xy-btn xy-btn-icon', disabled: busy, onClick: () => startEdit(m),
211
+ 'aria-label': `${t('common.edit')} · ${m.key}`, title: t('common.edit'),
212
+ }, createElement(IconEdit)), createElement('button', {
213
+ className: 'xy-btn xy-btn-danger xy-btn-icon', disabled: busy, onClick: () => removeOne(m),
214
+ 'aria-label': `${t('common.delete')} · ${m.key}`, title: t('common.delete'),
215
+ }, createElement(IconTrash)))));
208
216
  return createElement('div', { className: 'xy-page', ref: stabilize }, 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
209
217
  ? createElement('div', { className: 'xy-meta xy-editing' }, t('memory.editing', { key: editingKey }), createElement('button', { className: 'xy-btn', onClick: resetForm }, t('memory.cancelEdit')))
210
218
  : null,
@@ -3,7 +3,7 @@
3
3
  * POST 直连 create-wish / create-task(与工具层同校验口径:分类 2-6 字、截止≥今天);
4
4
  * 文案明示复杂拆解请走对话。字段 label 在上、错误在下;成功 toast 后回调刷新。
5
5
  */
6
- import { createElement, useState } from 'react';
6
+ import { createElement, useEffect, useRef, useState } from 'react';
7
7
  import { postAction } from '../api.js';
8
8
  import { useXyT, t as translate } from '../i18n.js';
9
9
  import { localYmd, useActionGuard } from '../hooks.js';
@@ -21,6 +21,9 @@ function ColorField(props) {
21
21
  }
22
22
  export function WishQuickForm(props) {
23
23
  const t = useXyT();
24
+ // 展开即聚焦首个字段:点「新建」后可直接打字,不再多点一跳(表单条件挂载,挂载=展开)
25
+ const titleRef = useRef(null);
26
+ useEffect(() => { titleRef.current?.focus(); }, []);
24
27
  const [title, setTitle] = useState('');
25
28
  const [categoryName, setCategoryName] = useState('');
26
29
  const [colorKey, setColorKey] = useState('');
@@ -54,6 +57,7 @@ export function WishQuickForm(props) {
54
57
  }));
55
58
  };
56
59
  return createElement('form', { className: 'xy-quick', onSubmit: (e) => { e.preventDefault(); submit(); } }, createElement('span', { className: 'xy-meta' }, t('quick.dialogHint')), createElement('div', { className: 'xy-quick-row' }, createElement('label', { className: 'xy-quick-field' }, createElement('span', { className: 'xy-quick-label' }, t('quick.wish.name')), createElement('input', {
60
+ ref: titleRef,
57
61
  className: 'xy-input', maxLength: 50, value: title,
58
62
  name: 'wish-title', autoComplete: 'off',
59
63
  placeholder: t('quick.wish.namePlaceholder'),
@@ -72,6 +76,9 @@ export function WishQuickForm(props) {
72
76
  }
73
77
  export function TaskQuickForm(props) {
74
78
  const t = useXyT();
79
+ // 同 WishQuickForm:展开即聚焦名称字段
80
+ const nameRef = useRef(null);
81
+ useEffect(() => { nameRef.current?.focus(); }, []);
75
82
  const [name, setName] = useState('');
76
83
  const [cycle, setCycle] = useState('daily');
77
84
  const [dueDate, setDueDate] = useState('');
@@ -103,6 +110,7 @@ export function TaskQuickForm(props) {
103
110
  }));
104
111
  };
105
112
  return createElement('form', { className: 'xy-quick', onSubmit: (e) => { e.preventDefault(); submit(); } }, createElement('span', { className: 'xy-meta' }, t('quick.dialogHint')), createElement('div', { className: 'xy-quick-row' }, createElement('label', { className: 'xy-quick-field' }, createElement('span', { className: 'xy-quick-label' }, t('quick.task.name')), createElement('input', {
113
+ ref: nameRef,
106
114
  className: 'xy-input', maxLength: 100, value: name,
107
115
  name: 'task-name', autoComplete: 'off',
108
116
  placeholder: t('quick.task.namePlaceholder'),
@@ -169,7 +169,7 @@ export function SettingsSection(props) {
169
169
  });
170
170
  },
171
171
  }), t('settings.pref.confirmWrites')), createElement('p', { className: 'xy-hint' }, t('settings.pref.confirmWritesHint'))), createElement('label', { className: 'xy-field' }, createElement('span', { className: 'xy-field-head' }, t('settings.pref.memoryLimit')), createElement('input', {
172
- type: 'number', min: 5, max: 200, className: 'xy-input', name: 'memoryInjectLimit', inputMode: 'numeric',
172
+ type: 'number', min: 5, max: 200, className: 'xy-input xy-input-num', name: 'memoryInjectLimit', inputMode: 'numeric',
173
173
  'aria-label': t('settings.pref.memoryLimit'),
174
174
  'aria-invalid': limitError || undefined,
175
175
  ...(limitError ? { 'aria-describedby': 'xy-limit-error' } : {}),
@@ -27,9 +27,13 @@ export function TaskLine(props) {
27
27
  void softConfirm(t('confirm.futureCheckin', { name: task.name, date: futureDate })).then((ok) => { if (ok)
28
28
  submit(); });
29
29
  };
30
- return createElement('div', { className: 'xy-taskline' }, createElement('span', { className: 'xy-taskname' }, task.name), createElement('span', { className: 'xy-meta' }, `${cycleLabel(task.cycle)} · ${durationText(task.completedDays, task.requiredDays)} · ${statusLabel(task.status)}`
31
- + `${futureDate !== undefined ? ` · ${t('task.nextDate', { date: futureDate })}` : ''}`
32
- + `${task.dueDate !== undefined ? ` · ${t('task.due', { date: task.dueDate })}` : ''}`), createElement('div', { style: { display: 'flex', gap: '6px', alignItems: 'center', flexWrap: 'wrap' } }, task.status === 'pending'
30
+ return createElement('div', { className: 'xy-taskline' }, createElement('span', { className: 'xy-taskname' }, task.name), createElement('span', { className: 'xy-meta' }, `${cycleLabel(task.cycle)} · ${durationText(task.completedDays, task.requiredDays)}`
31
+ // 状态词仅在无动作可推断时保留(已完结):进行中/待领取的状态由旁侧
32
+ // 打卡/领取按钮自解释,任务页还叠加了状态分组头——再报一遍是噪音
33
+ + `${task.status === 'closed' ? ` · ${statusLabel(task.status)}` : ''}`
34
+ // 近期行动日期走短格式(与「提前打卡」按钮同口径);ISO 只留给确认文案与读屏
35
+ + `${futureDate !== undefined ? ` · ${t('task.nextDate', { date: formatShortDate(futureDate) })}` : ''}`
36
+ + `${task.dueDate !== undefined ? ` · ${t('task.due', { date: formatShortDate(task.dueDate) })}` : ''}`), createElement('div', { style: { display: 'flex', gap: '6px', alignItems: 'center', flexWrap: 'wrap' } }, task.status === 'pending'
33
37
  ? createElement('button', {
34
38
  className: 'xy-btn', disabled: busy,
35
39
  onClick: () => act('claim', { taskId: task.taskId }, () => t('toast.claimed', { name: task.name })),
@@ -5,7 +5,7 @@ import { toast } from '../ui.js';
5
5
  import { useXyT } from '../i18n.js';
6
6
  import { softConfirm, useActionGuard, usePageData, useStableScrollbar, localYmd } from '../hooks.js';
7
7
  import { PageEmpty, PageError, PageSkeleton } from '../ui.js';
8
- import { cycleLabel, dateSuffix } from './format.js';
8
+ import { cycleLabel, dateSuffix, formatFriendlyDate } from './format.js';
9
9
  export function TodayPage() {
10
10
  const t = useXyT();
11
11
  const stabilize = useStableScrollbar();
@@ -45,7 +45,9 @@ export function TodayPage() {
45
45
  className: 'xy-btn', disabled: busy,
46
46
  onClick: () => act('claim', { taskId: task.taskId }, () => t('toast.claimed', { name: task.name })),
47
47
  }, t('action.claim'))));
48
- const doneRows = doneItems.map((task) => createElement('li', { key: task.taskId, className: 'xy-grouprow xy-done' }, createElement('div', { className: 'xy-rowmain' }, createElement('span', { className: 'xy-rowtitle' }, createElement('span', { className: 'xy-done-glyph', 'aria-hidden': 'true' }, '✓'), task.name), createElement('span', { className: 'xy-meta' }, cycleLabel(task.cycle))), createElement('button', { className: 'xy-btn', disabled: busy, onClick: () => {
48
+ const doneRows = doneItems.map((task) => createElement('li', { key: task.taskId, className: 'xy-grouprow xy-done' }, createElement('div', { className: 'xy-rowmain' }, createElement('span', { className: 'xy-rowtitle' }, createElement('span', { className: 'xy-done-glyph', 'aria-hidden': 'true' }, '✓'), task.name),
49
+ // 与待完成行同口径:周期 + 所属愿望(hint 是当天行动备忘,完成后不再展示)
50
+ createElement('span', { className: 'xy-meta' }, `${cycleLabel(task.cycle)}${task.wishName !== undefined ? ` · ${task.wishName}` : ''}`)), createElement('button', { className: 'xy-btn', disabled: busy, onClick: () => {
49
51
  void softConfirm(t('confirm.undoToday', { name: task.name })).then((ok) => {
50
52
  if (!ok)
51
53
  return;
@@ -55,10 +57,12 @@ export function TodayPage() {
55
57
  });
56
58
  } }, t('action.undoCheckin'))));
57
59
  const allDone = data.total > 0 && data.uncheckedCount === 0;
60
+ // 展示性日期走 Intl 本地化短格式(含星期);aria 与标题同源,读屏不再播 ISO 串
61
+ const heroTitle = t('today.title', { date: formatFriendlyDate(data.today) });
58
62
  return createElement('div', { className: 'xy-page', ref: stabilize },
59
63
  // 概览卡:标题、计数与进度条同卡呈现——进度条不再作为裸条悬在页头下方,
60
64
  // 0% 时也有明确的「今日进度」语境(即旧版页头下的空白横条)。
61
- createElement('section', { className: `xy-todayhero${allDone ? ' xy-todayhero-all' : ''}` }, createElement('div', { className: 'xy-todayhero-top' }, createElement('h2', { className: 'xy-page-title' }, t('today.title', { date: data.today })), data.total === 0
65
+ createElement('section', { className: `xy-todayhero${allDone ? ' xy-todayhero-all' : ''}` }, createElement('div', { className: 'xy-todayhero-top' }, createElement('h2', { className: 'xy-page-title' }, heroTitle), data.total === 0
62
66
  ? createElement('span', { className: 'xy-meta' }, t('today.noneToday'))
63
67
  : createElement('span', { className: 'xy-todayhero-num' }, t('today.summary', { checked: data.checked, total: data.total, ratio }))), data.total > 0
64
68
  ? createElement('div', {
@@ -67,7 +71,7 @@ export function TodayPage() {
67
71
  'aria-valuemin': 0,
68
72
  'aria-valuemax': 100,
69
73
  'aria-valuenow': ratio,
70
- 'aria-label': t('today.title', { date: data.today }),
74
+ 'aria-label': heroTitle,
71
75
  }, createElement('div', { className: 'xy-bar-fill', style: { transform: `scaleX(${ratio / 100})` } }))
72
76
  : null, allDone ? createElement('div', { className: 'xy-banner-ok' }, t('today.allDone')) : null), rows.length > 0 ? createElement('section', { className: 'xy-group' }, createElement('h3', { className: 'xy-group-head' }, createElement('span', { className: 'xy-group-dot xy-group-dot-warn', 'aria-hidden': 'true' }), createElement('span', null, t('today.sectionOpen')), createElement('span', { className: 'xy-group-count' }, String(rows.length))), createElement('ul', { className: 'xy-grouplist' }, rows)) : null, doneRows.length > 0 ? createElement('section', { className: 'xy-group' }, createElement('h3', { className: 'xy-group-head' }, createElement('span', { className: 'xy-group-dot xy-group-dot-ok', 'aria-hidden': 'true' }), createElement('span', null, t('today.sectionDone')), createElement('span', { className: 'xy-group-count' }, String(doneRows.length))), createElement('ul', { className: 'xy-grouplist' }, doneRows)) : null, data.total === 0
73
77
  ? createElement(PageEmpty, { art: 'rocket', title: t('today.empty.title'), hint: t('today.empty.hint') })
@@ -3,9 +3,10 @@ import { createElement, useState } from 'react';
3
3
  import { postAction } from '../api.js';
4
4
  import { useXyT } from '../i18n.js';
5
5
  import { softConfirmDanger, useActionGuard, usePageData, useStableScrollbar } from '../hooks.js';
6
- import { PageEmpty, PageError, PageSkeleton, toast } from '../ui.js';
6
+ import { PageEmpty, PageError, PageSkeleton, toast, IconTrash } from '../ui.js';
7
7
  import { categoryVars } from '../../category-color.js';
8
8
  import { TaskLine } from './task-line.js';
9
+ import { formatMediumDate } from './format.js';
9
10
  import { DetailToggle, TaskDetailPanel } from './detail.js';
10
11
  import { CategoryManager } from './categories.js';
11
12
  import { WishQuickForm } from './quick-create.js';
@@ -67,13 +68,13 @@ export function WishesPage() {
67
68
  className: 'xy-badge xy-badge-cat',
68
69
  style: categoryVars(wish.colorKey, wish.categoryName),
69
70
  }, wish.categoryName), createElement('span', { className: 'xy-title' }, wish.title), createElement('span', { className: 'xy-progress-num' }, t('wish.progress', { percent: wish.progress })), createElement('button', {
70
- className: 'xy-btn xy-btn-danger xy-wishdel',
71
+ className: 'xy-btn xy-btn-danger xy-btn-icon xy-wishdel',
71
72
  disabled: deleting,
72
73
  'aria-label': t('common.delete') + ' · ' + wish.title,
73
74
  title: t('common.delete'),
74
75
  onClick: () => removeWish(wish),
75
- }, t('common.delete'))), wish.description !== undefined ? createElement('div', { className: 'xy-meta' }, wish.description) : null, wish.estimatedCompletionDate !== undefined
76
- ? createElement('div', { className: 'xy-meta' }, t('wish.eta', { date: wish.estimatedCompletionDate }))
76
+ }, createElement(IconTrash))), wish.description !== undefined ? createElement('div', { className: 'xy-meta' }, wish.description) : null, wish.estimatedCompletionDate !== undefined
77
+ ? createElement('div', { className: 'xy-meta' }, t('wish.eta', { date: formatMediumDate(wish.estimatedCompletionDate) }))
77
78
  : null, createElement('div', { className: 'xy-bar' }, createElement('div', { className: 'xy-bar-fill', style: { transform: `scaleX(${Math.min(wish.progress, 100) / 100})` } })), wish.tasks.length > 0
78
79
  ? createElement('div', { className: 'xy-wishtasks' }, ...wishTasks(wish))
79
80
  : createElement('div', { className: 'xy-meta' }, t('wish.noTasks')));