@starwish-ai/xingyuan-dsh 0.5.7 → 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 +47 -0
- package/lib/client/api.js +9 -0
- package/lib/client/chart-labels.js +1 -0
- package/lib/client/i18n.js +10 -8
- package/lib/client/pages/calendar.js +51 -27
- package/lib/client/pages/detail.js +1 -1
- package/lib/client/pages/growth.js +1 -1
- package/lib/client/pages/today.js +24 -23
- package/lib/client/pages/wishes.js +8 -2
- package/lib/client.js +55 -42
- package/lib/preset/charts.js +12 -9
- package/lib/preset/prompts.js +21 -5
- package/lib/preset/tools.js +32 -14
- package/lib/routes/api.js +24 -12
- package/lib/routes/pages-html.js +23 -24
- package/lib/store.js +16 -11
- package/lib/types/client/api.d.ts +5 -0
- package/lib/types/client/i18n.d.ts +5 -4
- package/lib/types/client/pages/types.d.ts +2 -0
- package/lib/types/preset/prompts.d.ts +4 -0
- package/lib/types/store.d.ts +5 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,53 @@ 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
|
+
|
|
8
55
|
## [0.5.7] - 2026-08-29
|
|
9
56
|
|
|
10
57
|
> **Note:** this release is the third six-perspective quality pass (product,
|
package/lib/client/api.js
CHANGED
|
@@ -59,6 +59,15 @@ export function describeError(e) {
|
|
|
59
59
|
export async function postAction(path, body) {
|
|
60
60
|
return postJson(`/xingyuan/api/action/${path}`, body);
|
|
61
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
|
+
}
|
|
62
71
|
/** 请求超时:手动 AbortController——AbortSignal.timeout 为 Baseline 2024 "Newly available",
|
|
63
72
|
* 不满足 dsh 壳的保守浏览器矩阵(同 color-mix 禁令的判断口径),不用。 */
|
|
64
73
|
const REQUEST_TIMEOUT_MS = 15_000;
|
|
@@ -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 天 /
|
package/lib/client/i18n.js
CHANGED
|
@@ -58,6 +58,7 @@ export const ZH_DICT = {
|
|
|
58
58
|
'state.done': '已完成',
|
|
59
59
|
// --- 愿望卡 / 愿望页 ---
|
|
60
60
|
'wish.progress': '进度 {percent}%',
|
|
61
|
+
'wish.progressHasPending': '含未领取任务',
|
|
61
62
|
'wish.eta': '预计完成 {date}',
|
|
62
63
|
'wish.noTasks': '暂无下属任务——在对话里告诉我,让我为它推荐打卡任务',
|
|
63
64
|
'wish.pageTitle': '我的愿望',
|
|
@@ -142,20 +143,19 @@ export const ZH_DICT = {
|
|
|
142
143
|
'cal.backToMonth': '回到本月',
|
|
143
144
|
'cal.prevMonth': '上个月',
|
|
144
145
|
'cal.nextMonth': '下个月',
|
|
145
|
-
'cal.legend.c0': '
|
|
146
|
+
'cal.legend.c0': '无打卡安排',
|
|
146
147
|
'cal.legend.c1': '待打卡',
|
|
147
148
|
'cal.legend.c2': '部分完成',
|
|
148
149
|
'cal.legend.c3': '全部完成',
|
|
149
|
-
'cal.cellAria.none': '{date}
|
|
150
|
+
'cal.cellAria.none': '{date},无打卡安排',
|
|
150
151
|
'cal.cellAria.some': '{date},已完成 {checked}/{due}',
|
|
151
|
-
'cal.cellTitle.none': '{date}
|
|
152
|
+
'cal.cellTitle.none': '{date},无打卡安排',
|
|
152
153
|
'cal.cellTitle.some': '{date},打卡 {checked}/{due}',
|
|
153
154
|
'cal.panelHint': '点击日期查看当日任务,可直接补卡或取消打卡。',
|
|
154
155
|
'cal.dayLoadFailed': '当日任务加载失败,请重试。',
|
|
155
156
|
'cal.dayEmpty': '这一天没有任务安排。',
|
|
156
157
|
'cal.state.checked': '✓ 已打卡',
|
|
157
158
|
'cal.state.todo': '○ 待打卡',
|
|
158
|
-
'cal.state.unclaimed': '未领取',
|
|
159
159
|
// --- 成长页 ---
|
|
160
160
|
'growth.levelLabel': '当前等级',
|
|
161
161
|
'growth.levelFallback': '初心者',
|
|
@@ -344,6 +344,7 @@ export const ZH_DICT = {
|
|
|
344
344
|
'chart.subtitle.byWishTopN': '按愿望 TopN',
|
|
345
345
|
'chart.subtitle.byHourBucket': '按实际打卡时刻分桶;过滤范围为打卡日区间',
|
|
346
346
|
'chart.subtitle.progressPercent': '%(应打天数完成率)',
|
|
347
|
+
'chart.subtitle.progressPercentPending': '%(应打天数完成率,含未领取任务)',
|
|
347
348
|
'chart.subtitle.lastDays': '近 {n} 天',
|
|
348
349
|
'chart.subtitle.daysInclPending': '{done}/{due} 天(含未领取任务)',
|
|
349
350
|
'chart.subtitle.daysRatio': '{done}/{due} 天',
|
|
@@ -479,6 +480,7 @@ const EN_DICT = {
|
|
|
479
480
|
'state.deleted': 'Deleted “{title}”',
|
|
480
481
|
'state.done': 'Done',
|
|
481
482
|
'wish.progress': 'Progress {percent}%',
|
|
483
|
+
'wish.progressHasPending': 'includes unclaimed tasks',
|
|
482
484
|
'wish.eta': 'Target date {date}',
|
|
483
485
|
'wish.noTasks': 'No tasks yet — tell me in chat and I will suggest check-in tasks for it',
|
|
484
486
|
'wish.pageTitle': 'My Wishes',
|
|
@@ -559,20 +561,19 @@ const EN_DICT = {
|
|
|
559
561
|
'cal.backToMonth': 'This month',
|
|
560
562
|
'cal.prevMonth': 'Previous month',
|
|
561
563
|
'cal.nextMonth': 'Next month',
|
|
562
|
-
'cal.legend.c0': '
|
|
564
|
+
'cal.legend.c0': 'No check-in planned',
|
|
563
565
|
'cal.legend.c1': 'To check',
|
|
564
566
|
'cal.legend.c2': 'Partial',
|
|
565
567
|
'cal.legend.c3': 'Complete',
|
|
566
|
-
'cal.cellAria.none': '{date},
|
|
568
|
+
'cal.cellAria.none': '{date}, no check-in planned',
|
|
567
569
|
'cal.cellAria.some': '{date}, completed {checked}/{due}',
|
|
568
|
-
'cal.cellTitle.none': '{date},
|
|
570
|
+
'cal.cellTitle.none': '{date}, no check-in planned',
|
|
569
571
|
'cal.cellTitle.some': '{date}, checked {checked}/{due}',
|
|
570
572
|
'cal.panelHint': 'Pick a date to see its tasks; make up or cancel check-ins inline.',
|
|
571
573
|
'cal.dayLoadFailed': 'Failed to load tasks for this day. Please retry.',
|
|
572
574
|
'cal.dayEmpty': 'Nothing scheduled on this day.',
|
|
573
575
|
'cal.state.checked': '✓ Checked in',
|
|
574
576
|
'cal.state.todo': '○ To check in',
|
|
575
|
-
'cal.state.unclaimed': 'Unclaimed',
|
|
576
577
|
'growth.levelLabel': 'Current level',
|
|
577
578
|
'growth.levelFallback': 'Beginner',
|
|
578
579
|
'growth.maxed': 'Max level',
|
|
@@ -751,6 +752,7 @@ const EN_DICT = {
|
|
|
751
752
|
'chart.subtitle.byWishTopN': 'By wish (top N)',
|
|
752
753
|
'chart.subtitle.byHourBucket': 'Bucketed by actual check-in time; filtered to the check-in date range',
|
|
753
754
|
'chart.subtitle.progressPercent': '% of scheduled-day completion',
|
|
755
|
+
'chart.subtitle.progressPercentPending': '% of scheduled-day completion (incl. unclaimed)',
|
|
754
756
|
'chart.subtitle.lastDays': 'Last {n} days',
|
|
755
757
|
'chart.subtitle.daysInclPending': '{done}/{due} days (incl. unclaimed)',
|
|
756
758
|
'chart.subtitle.daysRatio': '{done}/{due} days',
|
|
@@ -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(() =>
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
59
|
-
|
|
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
|
-
//
|
|
72
|
-
guard(() => postAction(action,
|
|
73
|
-
toast(action === '
|
|
74
|
-
? t('toast.
|
|
75
|
-
:
|
|
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(
|
|
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)),
|
|
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' },
|
|
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
|
-
:
|
|
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
|
-
//
|
|
224
|
-
//
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
:
|
|
228
|
-
|
|
229
|
-
: task.
|
|
230
|
-
|
|
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
|
}
|
|
@@ -106,7 +106,7 @@ export function TaskDetailPanel(props) {
|
|
|
106
106
|
/** 取详情的 Promise 形态:act 里用于把 busy 窗口延伸到刷新完成。 */
|
|
107
107
|
const fetchDetail = () => {
|
|
108
108
|
setError(undefined);
|
|
109
|
-
return getJson(`/xingyuan/api/task-detail
|
|
109
|
+
return getJson(`/xingyuan/api/task-detail?${new URLSearchParams({ taskId: props.taskId }).toString()}`)
|
|
110
110
|
.then((payload) => setDetail(payload))
|
|
111
111
|
.catch((e) => { setError(e instanceof Error ? e.message : String(e)); });
|
|
112
112
|
};
|
|
@@ -47,7 +47,7 @@ export function GrowthPage() {
|
|
|
47
47
|
const growth = usePageData('/xingyuan/api/growth');
|
|
48
48
|
const range = usePageData(() => {
|
|
49
49
|
const { start, end } = recentRangeDays(30);
|
|
50
|
-
return `/xingyuan/api/range
|
|
50
|
+
return `/xingyuan/api/range?${new URLSearchParams({ start, end }).toString()}`;
|
|
51
51
|
}, []);
|
|
52
52
|
// 悬停列数据源(null = 未悬停)
|
|
53
53
|
const [hoverIdx, setHoverIdx] = useState(null);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/** 今日页:今日打卡总览 + 一键打卡/领取 + 已完成撤销。 */
|
|
2
2
|
import { createElement, useSyncExternalStore } from 'react';
|
|
3
|
-
import { postAction } from '../api.js';
|
|
3
|
+
import { dayUrl, postAction } from '../api.js';
|
|
4
4
|
import { toast } from '../ui.js';
|
|
5
5
|
import { useXyT } from '../i18n.js';
|
|
6
6
|
import { softConfirm, useActionGuard, usePageData, useScrollTopOnMount, useStableScrollbar, localYmd } from '../hooks.js';
|
|
@@ -14,7 +14,7 @@ export function TodayPage() {
|
|
|
14
14
|
// 「始终显示 × 非星愿会话」轻提示:控制器写值,本页订阅渲染(默认 false 零开销)
|
|
15
15
|
const showNoPresetHint = useSyncExternalStore(todayHintStore.subscribe, todayHintStore.getSnapshot);
|
|
16
16
|
const overview = usePageData('/xingyuan/api/overview');
|
|
17
|
-
const day = usePageData(() =>
|
|
17
|
+
const day = usePageData(() => dayUrl(localYmd(new Date())));
|
|
18
18
|
const { busy, guard } = useActionGuard();
|
|
19
19
|
const reload = () => Promise.all([overview.reload(), day.reload()]).then(() => undefined);
|
|
20
20
|
const act = (action, body, doneText) => {
|
|
@@ -46,27 +46,26 @@ export function TodayPage() {
|
|
|
46
46
|
if (data === undefined || dayData === undefined)
|
|
47
47
|
return createElement(PageSkeleton);
|
|
48
48
|
const stale = overview.error !== undefined || day.error !== undefined;
|
|
49
|
-
|
|
49
|
+
// 完成率 floor(诚实口径,与愿望进度同规):round 会显示「249/250 · 100%」却无
|
|
50
|
+
// 「全部完成」横幅(AGENTS.md §5.2 进度口径);floor 只在真正全完成时到 100。
|
|
51
|
+
const ratio = data.total > 0 ? Math.floor((data.checked / data.total) * 100) : 0;
|
|
50
52
|
const doneItems = dayData.tasks.filter((task) => task.checked);
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
},
|
|
66
|
-
|
|
67
|
-
className: 'xy-btn', disabled: busy,
|
|
68
|
-
onClick: () => act('claim', { taskId: task.taskId }, () => t('toast.claimed', { name: task.name })),
|
|
69
|
-
}, t('action.claim'))));
|
|
53
|
+
// 今日只呈现已领取义务(2026-08 用户裁决:候选池不进今日页,领取入口=任务页待领取组、
|
|
54
|
+
// 日历今天面板/对话):claimed 布尔来自服务端单一判定(§5.2 规则 7),client 不重复写
|
|
55
|
+
// status 谓词;canCheckIn 显式并入(与日历面板同为 host 真值门控)——防计划层未来改动
|
|
56
|
+
// 时静默复造「可点但服务端拒绝」,不变量(claimed 且未勾 ⇒ in_progress)失效即隐身该行
|
|
57
|
+
const openItems = dayData.tasks.filter((task) => !task.checked && task.claimed && task.canCheckIn);
|
|
58
|
+
const openRows = openItems.map((task) => 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' }, `${cycleLabel(task.cycle)}${task.wishName !== undefined ? ` · ${task.wishName}` : ''}${task.hint !== undefined ? ` · ${task.hint}` : ''}`)), createElement('button', {
|
|
59
|
+
className: 'xy-btn xy-btn-primary',
|
|
60
|
+
// 展示行已按 canCheckIn 显式过滤(上方 openItems),按钮无需再判——
|
|
61
|
+
// disabled 仅服务动作在途的 busy 窗口,不再有「disabled 但无解释」的死分支
|
|
62
|
+
disabled: busy,
|
|
63
|
+
onClick: () => {
|
|
64
|
+
void act('checkin', { taskId: task.taskId }, (p) => t('toast.checkinOk') + dateSuffix(typeof p.date === 'string' ? String(p.date) : undefined))
|
|
65
|
+
// 行随刷新从「待打卡」组迁入「已完成」组、原按钮销毁,焦点交给页面标题兜底
|
|
66
|
+
.then(focusPageTitle, () => { });
|
|
67
|
+
},
|
|
68
|
+
}, t('action.checkin'))));
|
|
70
69
|
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),
|
|
71
70
|
// 与待完成行同口径:周期 + 所属愿望(hint 是当天行动备忘,完成后不再展示)
|
|
72
71
|
createElement('span', { className: 'xy-meta' }, `${cycleLabel(task.cycle)}${task.wishName !== undefined ? ` · ${task.wishName}` : ''}`)), createElement('button', {
|
|
@@ -108,7 +107,9 @@ export function TodayPage() {
|
|
|
108
107
|
// 刷新失败但旧数据仍在:诚实降级(写已成功,页面数据可能滞后),可就地重试
|
|
109
108
|
stale ? createElement(StaleBanner, { onRetry: () => void reload() }) : null,
|
|
110
109
|
// 始终显示模式下非星愿会话:概览卡下一行轻提示(页面可浏览/操作,对话能力受限)
|
|
111
|
-
showNoPresetHint ? createElement('p', { className: 'xy-hint' }, t('today.noPresetHint')) : null,
|
|
110
|
+
showNoPresetHint ? createElement('p', { className: 'xy-hint' }, t('today.noPresetHint')) : null, openRows.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(openRows.length))), createElement('ul', { className: 'xy-grouplist' }, openRows)) : 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,
|
|
111
|
+
// 空态 = 今天没有已领取义务(候选池不走今日页;领取入口在任务页/日历今天面板/对话)
|
|
112
|
+
data.total === 0
|
|
112
113
|
? createElement(PageEmpty, { title: t('today.empty.title'), hint: t('today.empty.hint') })
|
|
113
114
|
: null);
|
|
114
115
|
}
|
|
@@ -73,7 +73,12 @@ export function WishesPage() {
|
|
|
73
73
|
const card = (wish) => createElement('div', { key: wish.wishId, className: 'xy-wishcard' }, createElement('div', { className: 'xy-card-head' }, createElement('span', {
|
|
74
74
|
className: 'xy-badge xy-badge-cat',
|
|
75
75
|
style: categoryVars(wish.colorKey, wish.categoryName),
|
|
76
|
-
}, wish.categoryName), createElement('span', { className: 'xy-title' }, wish.title),
|
|
76
|
+
}, wish.categoryName), createElement('span', { className: 'xy-title' }, wish.title),
|
|
77
|
+
// 长期尺度 = 计划口径(分母含未领取任务的应打天数,§5.2 规则 7):愿望含未领取
|
|
78
|
+
// 任务时必须呈现「含未领取任务」标注,避免把计划分母误读为承诺分母
|
|
79
|
+
createElement('span', { className: 'xy-progress-num' }, t('wish.progress', { percent: wish.progress }), wish.tasks.some((task) => task.status === 'pending')
|
|
80
|
+
? createElement('span', { className: 'xy-meta' }, ` · ${t('wish.progressHasPending')}`)
|
|
81
|
+
: null), createElement('button', {
|
|
77
82
|
className: 'xy-btn xy-btn-danger xy-btn-icon',
|
|
78
83
|
disabled: deleting,
|
|
79
84
|
'aria-label': t('common.delete') + ' · ' + wish.title,
|
|
@@ -89,7 +94,8 @@ export function WishesPage() {
|
|
|
89
94
|
'aria-valuemin': 0,
|
|
90
95
|
'aria-valuemax': 100,
|
|
91
96
|
'aria-valuenow': wish.progress,
|
|
92
|
-
'aria-label': t('wish.progress', { percent: wish.progress })
|
|
97
|
+
'aria-label': t('wish.progress', { percent: wish.progress })
|
|
98
|
+
+ (wish.tasks.some((task) => task.status === 'pending') ? ` · ${t('wish.progressHasPending')}` : ''),
|
|
93
99
|
}, createElement('div', { className: 'xy-bar-fill', style: { transform: `scaleX(${Math.min(wish.progress, 100) / 100})` } })), wish.tasks.length > 0
|
|
94
100
|
? createElement('div', { className: 'xy-wishtasks' }, ...wishTasks(wish))
|
|
95
101
|
: createElement('div', { className: 'xy-meta' }, t('wish.noTasks')));
|