@starwish-ai/xingyuan-dsh 0.5.3 → 0.5.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +94 -0
- package/lib/client/api.js +21 -2
- package/lib/client/cards.js +28 -19
- package/lib/client/hooks.js +29 -0
- package/lib/client/i18n.js +20 -14
- package/lib/client/pages/calendar.js +10 -4
- package/lib/client/pages/detail.js +36 -10
- package/lib/client/pages/format.js +3 -2
- package/lib/client/pages/memory.js +5 -0
- package/lib/client/pages/today.js +7 -4
- package/lib/client.js +119 -41
- package/lib/consistency-sweep.js +47 -0
- package/lib/growth.js +5 -16
- package/lib/index.js +12 -0
- package/lib/micro.js +10 -9
- package/lib/preset/charts.js +38 -26
- package/lib/preset/prompts.js +6 -0
- package/lib/preset/tools.js +21 -14
- package/lib/routes/api.js +86 -60
- package/lib/routes/index.js +6 -3
- package/lib/routes/pages-html.js +61 -12
- package/lib/store.js +103 -34
- package/lib/types/client/hooks.d.ts +5 -0
- package/lib/types/client/i18n.d.ts +12 -9
- package/lib/types/client/pages/detail.d.ts +11 -2
- package/lib/types/client/pages/format.d.ts +2 -1
- package/lib/types/consistency-sweep.d.ts +26 -0
- package/lib/types/events.d.ts +12 -0
- package/lib/types/routes/pages-html.d.ts +5 -5
- package/lib/types/store.d.ts +18 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,100 @@ All notable changes to this project are documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.5.4] - 2026-08-28
|
|
9
|
+
|
|
10
|
+
> **Note:** trailing chart statistics no longer count future pre-checked days (see
|
|
11
|
+
> *Changed*). Chart cards generated by this version carry a "generated on" stamp so a
|
|
12
|
+
> stale card is distinguishable from current data; cards written by earlier versions
|
|
13
|
+
> replay unchanged, without the stamp.
|
|
14
|
+
|
|
15
|
+
**Charts and records now tell the truth about time.** Future pre-checked days were
|
|
16
|
+
counted by every trailing statistic as if they had already happened — a "how is this
|
|
17
|
+
week going" chart could be inflated by check-ins for days that have not occurred, and a
|
|
18
|
+
task's check-in grid could end up showing nothing but future days while today's check-in
|
|
19
|
+
was invisible. Undoing a check-in said "revert today's?" while actually reverting a
|
|
20
|
+
future pre-check, because the pages let the server fall back to "most recent record".
|
|
21
|
+
|
|
22
|
+
### Added
|
|
23
|
+
- **Chart snapshot stamp**: chart events carry `generatedAt` and the card renders a
|
|
24
|
+
localized "generated on" date; cards generated by older versions replay without it
|
|
25
|
+
(the field is optional and degrades honestly).
|
|
26
|
+
- **"No schedule" ≠ 0%**: in the daily completion-rate trend, days with no scheduled
|
|
27
|
+
opportunity render as an empty slot labelled "no schedule" (tooltip and screen reader
|
|
28
|
+
alike) instead of a 0% data point.
|
|
29
|
+
- The completion-rate chart annotates its subtitle with "including unclaimed tasks"
|
|
30
|
+
when pending tasks count toward the total, so the denominator is never a mystery.
|
|
31
|
+
- **Startup consistency sweep** (`src/consistency-sweep.ts`): check-ins whose task no
|
|
32
|
+
longer exists, tasks whose wish was deleted out from under them, and dangling
|
|
33
|
+
micro-action states are cleaned once per launch through the same cascade the delete
|
|
34
|
+
tools use. Idempotent, runs after activation without blocking it, and pinned by
|
|
35
|
+
`test/integrity.test.ts`.
|
|
36
|
+
- **Task-detail grid window**: the check-in grid shows the last 28 opportunity days up
|
|
37
|
+
to today, plus every checked day — future pre-checks stay visible (they are standing
|
|
38
|
+
commitments), and check-ins outside the opportunity series (deadline-free tasks) make
|
|
39
|
+
the grid their check-in history. Today is never squeezed out of the window, however
|
|
40
|
+
many future days are pre-checked; unchecked future days remain in the "upcoming"
|
|
41
|
+
preview line.
|
|
42
|
+
- **gzip content negotiation** for the standalone pages (`/xingyuan/today`, `/calendar`,
|
|
43
|
+
`/growth`) per RFC 9110 §12.5.3: absent header accepts any coding, an empty value or
|
|
44
|
+
an explicit opt-out gets plaintext, `Vary: Accept-Encoding` is always sent, and the
|
|
45
|
+
compressed template is cached per body.
|
|
46
|
+
- **Request timeout**: client fetches abort after 15 s with a localized message instead
|
|
47
|
+
of hanging forever.
|
|
48
|
+
- **Focus refetch**: page data refetches when the window regains focus if older than
|
|
49
|
+
60 s, and always when the local day has changed — overnight sessions no longer show
|
|
50
|
+
yesterday's "today".
|
|
51
|
+
- **Undo readability**: the detail panel's undo button announces the exact day it will
|
|
52
|
+
revert (screen-reader label), and its visibility no longer requires today to be the
|
|
53
|
+
checked day — a pre-check can be undone right where it was made.
|
|
54
|
+
|
|
55
|
+
### Fixed
|
|
56
|
+
- **The cancel dialog said "today" while a different day was reverted.** The today page
|
|
57
|
+
and the task detail panel posted the cancel without a date and the server falls back
|
|
58
|
+
to the most recent check-in — which a future pre-check satisfies. Every page now sends
|
|
59
|
+
the explicit date; the dialog, the toast and the reverted record all name the same day.
|
|
60
|
+
- `weekComparison` folded future pre-checks into weekday buckets as if they were in the
|
|
61
|
+
current week; the week window is now a closed interval from last Monday to today.
|
|
62
|
+
- Wish progress **rounded** instead of flooring: 249/250 displayed as 100% and could
|
|
63
|
+
archive the wish one day early.
|
|
64
|
+
- Checking in an expired (closed) task failed with a generic error; it now explains that
|
|
65
|
+
the deadline must be extended to revive the task before further check-ins.
|
|
66
|
+
- Concurrent writes to the global document (coach style, profile, category colors,
|
|
67
|
+
micro-action state) could lose updates under interleaving; all writers now serialize
|
|
68
|
+
through one promise chain, a failed writer no longer poisons the chain, and no-op
|
|
69
|
+
mutations skip the write entirely.
|
|
70
|
+
- The memory page could deadlock with a perpetual "loading more" spinner when an
|
|
71
|
+
in-flight request was superseded by a newer one (the reset ran on only one of two
|
|
72
|
+
completion paths).
|
|
73
|
+
- The calendar's post-action detail refetch swallowed failures; the panel now shows an
|
|
74
|
+
error state with a retry button instead of silently keeping stale content.
|
|
75
|
+
- A deadline-free one-shot task disappeared from the today page the moment it was
|
|
76
|
+
checked; the completed row now stays revocable for the rest of the day (it never
|
|
77
|
+
reappears the next day).
|
|
78
|
+
- `/xingyuan/api/day` and `/xingyuan/api/range` answered a malformed `date` with an
|
|
79
|
+
empty day list or an unlabelled `RangeError`; both now reject with a structured
|
|
80
|
+
`bad_date` error, consistent with the POST action surface.
|
|
81
|
+
- Creating a task with a name over 100 characters through a page action failed with a
|
|
82
|
+
bare validation message; the error now states the limit and the actual length.
|
|
83
|
+
- The claim button in the calendar is gated to the picked day being today, matching the
|
|
84
|
+
server-side anchor semantics.
|
|
85
|
+
- Display dates across cards, toasts and pages are localized (ISO strings remain only in
|
|
86
|
+
confirm-dialog copy and screen-reader strings, per the documented convention).
|
|
87
|
+
- The standalone growth page used `color-mix()`, which some shells render as an invalid
|
|
88
|
+
declaration; the darker tone is now precomputed in JS.
|
|
89
|
+
|
|
90
|
+
### Changed
|
|
91
|
+
- Trailing chart statistics (trend, daily rate, week comparison, by-category, ranking,
|
|
92
|
+
time-of-day, weekday activity) only count check-ins up to today; the calendar heatmap
|
|
93
|
+
deliberately keeps future pre-checks visible. Chart cards are snapshots of their
|
|
94
|
+
generation time — the coach guide tells the model to regenerate for current data.
|
|
95
|
+
- `cancel_check_in_task` parameter guidance now instructs the model to pass the date
|
|
96
|
+
whenever the user names a day (including "today"), so the "most recent" fallback is a
|
|
97
|
+
deliberate choice rather than an accident.
|
|
98
|
+
- zh copy no longer pads localized dates with spaces ("已撤销 8月29日 的打卡" →
|
|
99
|
+
"已撤销8月29日的打卡"), and the grid legend says "待打卡" where it previously said
|
|
100
|
+
"未来机会日" for what is now only ever today's pending slot.
|
|
101
|
+
|
|
8
102
|
## [0.5.3] - 2026-08-28
|
|
9
103
|
|
|
10
104
|
> **Note:** this is a patch release but it carries one breaking change — the `xingyuan`
|
package/lib/client/api.js
CHANGED
|
@@ -40,8 +40,27 @@ export function describeError(e) {
|
|
|
40
40
|
export async function postAction(path, body) {
|
|
41
41
|
return postJson(`/xingyuan/api/action/${path}`, body);
|
|
42
42
|
}
|
|
43
|
+
/** 请求超时:手动 AbortController——AbortSignal.timeout 为 Baseline 2024 "Newly available",
|
|
44
|
+
* 不满足 dsh 壳的保守浏览器矩阵(同 color-mix 禁令的判断口径),不用。 */
|
|
45
|
+
const REQUEST_TIMEOUT_MS = 15_000;
|
|
46
|
+
async function fetchWithTimeout(path, init) {
|
|
47
|
+
const controller = new AbortController();
|
|
48
|
+
const timer = window.setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
|
49
|
+
try {
|
|
50
|
+
return await fetch(path, { ...init, signal: controller.signal });
|
|
51
|
+
}
|
|
52
|
+
catch (e) {
|
|
53
|
+
// 超时中止统一转本地化文案;真实网络错误原样抛出(保留连接拒绝等语义)
|
|
54
|
+
if (e instanceof DOMException && e.name === 'AbortError')
|
|
55
|
+
throw new Error(t('common.requestTimeout'));
|
|
56
|
+
throw e;
|
|
57
|
+
}
|
|
58
|
+
finally {
|
|
59
|
+
window.clearTimeout(timer);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
43
62
|
export async function postJson(path, body) {
|
|
44
|
-
const response = await
|
|
63
|
+
const response = await fetchWithTimeout(path, {
|
|
45
64
|
method: 'POST',
|
|
46
65
|
headers: { 'content-type': 'application/json' },
|
|
47
66
|
body: JSON.stringify(body),
|
|
@@ -49,7 +68,7 @@ export async function postJson(path, body) {
|
|
|
49
68
|
return parsePayload(response, () => t('common.actionFailed'));
|
|
50
69
|
}
|
|
51
70
|
export async function getJson(path) {
|
|
52
|
-
const response = await
|
|
71
|
+
const response = await fetchWithTimeout(path, { headers: { accept: 'application/json' } });
|
|
53
72
|
return parsePayload(response, () => t('common.requestFailed'));
|
|
54
73
|
}
|
|
55
74
|
async function parsePayload(response, fallback) {
|
package/lib/client/cards.js
CHANGED
|
@@ -11,7 +11,7 @@ import { toast, toastError } from './ui.js';
|
|
|
11
11
|
import { localYmd, softConfirm } from './hooks.js';
|
|
12
12
|
import { useXyT, t, activeLocale } from './i18n.js';
|
|
13
13
|
import { categoryVars } from '../category-color.js';
|
|
14
|
-
import { cycleLabel, durationText, dateSuffix } from './pages/format.js';
|
|
14
|
+
import { cycleLabel, durationText, dateSuffix, formatMediumDate, formatShortDate } from './pages/format.js';
|
|
15
15
|
function WishView(props) {
|
|
16
16
|
const t = useXyT();
|
|
17
17
|
const wish = props.node.data.data;
|
|
@@ -95,7 +95,7 @@ function TaskView(props) {
|
|
|
95
95
|
const showClaim = !deleted && phase === 'idle' && task.status === 'pending';
|
|
96
96
|
const showCheckIn = !deleted && phase !== 'done' && (task.status === 'in_progress' || phase === 'claimed');
|
|
97
97
|
const preview = event.opportunityPreview.length > 0
|
|
98
|
-
? t('task.upcoming', { dates: event.opportunityPreview.join(activeLocale() === 'en' ? ', ' : '、') })
|
|
98
|
+
? t('task.upcoming', { dates: event.opportunityPreview.map((date) => formatShortDate(date)).join(activeLocale() === 'en' ? ', ' : '、') })
|
|
99
99
|
: undefined;
|
|
100
100
|
return createElement('div', { className: `xy-card ${deleted ? 'xy-deleted' : ''}` }, createElement('div', { className: 'xy-card-head' }, createElement('span', { className: 'xy-badge xy-badge-task' }, t('badge.task')), createElement('span', { className: 'xy-title' }, deleted ? t('state.deleted', { title: task.name }) : task.name), !deleted && phase === 'done'
|
|
101
101
|
? createElement('span', { className: 'xy-meta xy-actioned' },
|
|
@@ -105,7 +105,7 @@ function TaskView(props) {
|
|
|
105
105
|
? createElement('button', { className: 'xy-btn xy-btn-inline', disabled: busy, onClick: () => run('claim') }, t('action.claim'))
|
|
106
106
|
: null, showCheckIn
|
|
107
107
|
? createElement('button', { className: 'xy-btn xy-btn-primary xy-btn-inline', disabled: busy, onClick: () => run('checkin') }, t('action.checkin'))
|
|
108
|
-
: null), createElement('div', { className: 'xy-meta' }, `${cycleLabel(task.checkInCycle)}${task.dueDate !== undefined ? ` · ${t('task.due', { date: task.dueDate })}` : ''} · ${durationText(task.completedDays, task.requiredDays)}`), deleted === false && preview !== undefined
|
|
108
|
+
: null), createElement('div', { className: 'xy-meta' }, `${cycleLabel(task.checkInCycle)}${task.dueDate !== undefined ? ` · ${t('task.due', { date: formatShortDate(task.dueDate) })}` : ''} · ${durationText(task.completedDays, task.requiredDays)}`), deleted === false && preview !== undefined
|
|
109
109
|
? createElement('div', { className: 'xy-preview' }, preview)
|
|
110
110
|
: null);
|
|
111
111
|
}
|
|
@@ -139,23 +139,29 @@ function CheckinView(props) {
|
|
|
139
139
|
const event = props.node.data.data;
|
|
140
140
|
const cancelled = event.op === 'cancelled';
|
|
141
141
|
return createElement('div', { className: 'xy-card xy-checkin' }, createElement('div', { className: 'xy-card-head' }, createElement('span', { className: `xy-glyph ${cancelled ? 'xy-glyph-back' : 'xy-glyph-ok'}`, 'aria-hidden': 'true' }, cancelled ? '↩' : '✓'), createElement('span', { className: 'xy-title' }, cancelled
|
|
142
|
-
? t('checkin.cancelled', { task: event.taskName, date: event.date })
|
|
143
|
-
: t('checkin.success', { task: event.taskName }))), createElement('div', { className: 'xy-meta' }, `${event.date}${event.wishName !== undefined ? ` · ${event.wishName}` : ''} · ${durationText(event.completedDays, event.requiredDays)}`));
|
|
142
|
+
? t('checkin.cancelled', { task: event.taskName, date: formatShortDate(event.date) })
|
|
143
|
+
: t('checkin.success', { task: event.taskName }))), createElement('div', { className: 'xy-meta' }, `${formatShortDate(event.date)}${event.wishName !== undefined ? ` · ${event.wishName}` : ''} · ${durationText(event.completedDays, event.requiredDays)}`));
|
|
144
144
|
}
|
|
145
145
|
function ChartView(props) {
|
|
146
146
|
const t = useXyT();
|
|
147
147
|
const event = props.node.data.data;
|
|
148
148
|
// 悬停柱下标(与成长页悬浮明细同一交互语法;null = 未悬停)
|
|
149
149
|
const [hoverIdx, setHoverIdx] = useState(null);
|
|
150
|
-
return createElement('div', { className: 'xy-card xy-chart' }, createElement('div', { className: 'xy-card-head' }, createElement('span', { className: 'xy-badge xy-badge-chart' }, t('badge.chart')), createElement('span', { className: 'xy-title' }, event.title), event.subtitle !== undefined ? createElement('span', { className: 'xy-meta' }, event.subtitle) : null),
|
|
150
|
+
return createElement('div', { className: 'xy-card xy-chart' }, createElement('div', { className: 'xy-card-head' }, createElement('span', { className: 'xy-badge xy-badge-chart' }, t('badge.chart')), createElement('span', { className: 'xy-title' }, event.title), event.subtitle !== undefined ? createElement('span', { className: 'xy-meta' }, event.subtitle) : null),
|
|
151
|
+
// 快照时点标注(事件溯源:卡片=当时的事实):无 generatedAt 的旧事件诚实降级不显示。
|
|
152
|
+
// 中格式带年份——回放的旧卡可能跨年,「生成于 3月5日」须能定位到哪一年。
|
|
153
|
+
// ISO → 本地日再格式化:直接 slice 会把 UTC 日期当本地日(UTC+8 零点后 8 小时内差一天)
|
|
154
|
+
event.generatedAt !== undefined
|
|
155
|
+
? createElement('div', { className: 'xy-meta' }, t('chart.generatedAt', { date: formatMediumDate(localYmd(new Date(event.generatedAt))) }))
|
|
156
|
+
: null, chartBody(event, t('chart.noData'), t('chart.noSchedule'), hoverIdx, setHoverIdx));
|
|
151
157
|
}
|
|
152
158
|
/** 图表卡主体:四类渲染路径(占比条/占比列表/热力格/柱图)颜色全部走主题变量,深浅色自适应。 */
|
|
153
|
-
function chartBody(event, noDataText, hoverIdx, onHover) {
|
|
159
|
+
function chartBody(event, noDataText, noScheduleText, hoverIdx, onHover) {
|
|
154
160
|
const { chartType, data } = event;
|
|
155
161
|
if (data.length === 0)
|
|
156
162
|
return createElement('div', { className: 'xy-meta' }, noDataText);
|
|
157
|
-
const max = Math.max(...data.map((d) => d.value), 1);
|
|
158
|
-
const labelValue = (d) => `${d.label}${activeLocale() === 'en' ? ': ' : ':'}${d.value}`;
|
|
163
|
+
const max = Math.max(...data.filter((d) => !d.inactive).map((d) => d.value), 1);
|
|
164
|
+
const labelValue = (d) => `${d.label}${activeLocale() === 'en' ? ': ' : ':'}${d.inactive === true ? noScheduleText : d.value}`;
|
|
159
165
|
if (chartType === 'arcbars') {
|
|
160
166
|
const ratio = Math.min(Math.max(data[0].ratio ?? 0, 0), 1);
|
|
161
167
|
return createElement('div', { className: 'xy-arcwrap' }, createElement('div', { className: 'xy-arcnum' }, `${Math.round(ratio * 100)}%`),
|
|
@@ -187,19 +193,22 @@ function chartBody(event, noDataText, hoverIdx, onHover) {
|
|
|
187
193
|
const r = Math.min(2, h, w / 2);
|
|
188
194
|
return `M${x},${y + h}L${x},${y + r}Q${x},${y} ${x + r},${y}L${x + w - r},${y}Q${x + w},${y} ${x + w},${y + r}L${x + w},${y + h}Z`;
|
|
189
195
|
};
|
|
190
|
-
const bars = data.
|
|
196
|
+
const bars = data.flatMap((d, i) => {
|
|
197
|
+
// 无安排日不画柱(缺失≠0):空槽由下标占位保留,柱序与日期轴不塌缩
|
|
198
|
+
if (d.inactive === true)
|
|
199
|
+
return [];
|
|
191
200
|
// 微值可见性下限:有值但按比例不足 2px 的柱给 2px,避免「有数据却看不见」
|
|
192
201
|
const rawH = Math.round((d.value / max) * (height - 24));
|
|
193
202
|
const h = d.value > 0 ? Math.max(rawH, 2) : rawH;
|
|
194
203
|
const seriesIdx = d.series !== undefined ? seriesList.indexOf(d.series) : -1;
|
|
195
|
-
return createElement('path', {
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
204
|
+
return [createElement('path', {
|
|
205
|
+
key: i,
|
|
206
|
+
d: topRoundedBar(i * step + 2, height - 18 - h, Math.max(step - 4, 2), h),
|
|
207
|
+
style: { fill: seriesIdx > 0 ? seriesColor(seriesIdx) : 'var(--xyd-accent)', opacity: seriesIdx > 0 ? 0.55 : 0.85 },
|
|
208
|
+
onMouseEnter: () => onHover(i),
|
|
209
|
+
},
|
|
210
|
+
// 每根柱子带 <title>:原生悬停提示;精确数值另由上方悬浮明细条实时呈现
|
|
211
|
+
createElement('title', null, labelValue(d)))];
|
|
203
212
|
});
|
|
204
213
|
// 标签用原始下标定位:indexOf 会把重复标签(如分组系列的「周一」×2)全部钉到首个位置
|
|
205
214
|
const labelStep = data.length <= 10 ? 1 : Math.ceil(data.length / 8);
|
|
@@ -221,7 +230,7 @@ function chartBody(event, noDataText, hoverIdx, onHover) {
|
|
|
221
230
|
createElement('svg', {
|
|
222
231
|
viewBox: `0 0 ${width} ${height}`, className: 'xy-svg',
|
|
223
232
|
role: 'img', 'aria-label': event.title,
|
|
224
|
-
}, createElement('line', { x1: 0, y1: height - 18, x2: width, y2: height - 18, style: { stroke: 'var(--dsw-alias-border-l2)' } }), ...bars, ...labels)), createElement('span', { className: 'xy-visually-hidden' }, data.map((d) => `${d.label} ${d.value}`).join(activeLocale() === 'en' ? '; ' : ';')));
|
|
233
|
+
}, createElement('line', { x1: 0, y1: height - 18, x2: width, y2: height - 18, style: { stroke: 'var(--dsw-alias-border-l2)' } }), ...bars, ...labels)), createElement('span', { className: 'xy-visually-hidden' }, data.map((d) => `${d.label} ${d.inactive === true ? noScheduleText : d.value}`).join(activeLocale() === 'en' ? '; ' : ';')));
|
|
225
234
|
}
|
|
226
235
|
export const CARD_VIEWS = {
|
|
227
236
|
WishView,
|
package/lib/client/hooks.js
CHANGED
|
@@ -18,10 +18,18 @@ export function recentRangeDays(days) {
|
|
|
18
18
|
end: localYmd(now),
|
|
19
19
|
};
|
|
20
20
|
}
|
|
21
|
+
/** 窗口重新可见后的重取门槛(ms):等价 TanStack Query 的 staleTime——
|
|
22
|
+
* 数据在此年龄内视为新鲜,聚焦不重取;跨天翻篇则无视门槛一律重取。 */
|
|
23
|
+
const FOCUS_REFETCH_MIN_AGE_MS = 60_000;
|
|
21
24
|
/**
|
|
22
25
|
* 取数三态统一:挂载(或 deps 变化)即拉取,error 存本地化文案,
|
|
23
26
|
* reload 供错误态按钮与动作后刷新复用。path 支持函数形态(如日历按月取参);
|
|
24
27
|
* 内置序号守卫:快速翻页/连点时慢的旧响应不得覆盖新状态。
|
|
28
|
+
*
|
|
29
|
+
* 跨天/回焦自动新鲜化(习惯类应用的基本预期——凌晨挂着的今日页不能还是昨天):
|
|
30
|
+
* 监听 visibilitychange/focus,翻篇(本地日期变化)必重取,未翻篇仅当数据年龄
|
|
31
|
+
* 超过门槛才重取(有节流上限,不空转不定时器)。NN/g「系统应始终让用户知情」:
|
|
32
|
+
* 时间流逝导致的状态变化要在回到页面时即刻呈现。
|
|
25
33
|
*/
|
|
26
34
|
export function usePageData(path, deps = []) {
|
|
27
35
|
const [data, setData] = useState(undefined);
|
|
@@ -29,9 +37,13 @@ export function usePageData(path, deps = []) {
|
|
|
29
37
|
const seqRef = useRef(0);
|
|
30
38
|
const pathRef = useRef(path);
|
|
31
39
|
pathRef.current = path;
|
|
40
|
+
const lastLoadAtRef = useRef(0);
|
|
41
|
+
const lastLoadDayRef = useRef(undefined);
|
|
32
42
|
const load = useCallback(() => {
|
|
33
43
|
const seq = ++seqRef.current;
|
|
34
44
|
setError(undefined);
|
|
45
|
+
lastLoadAtRef.current = Date.now();
|
|
46
|
+
lastLoadDayRef.current = localYmd(new Date());
|
|
35
47
|
return getJson(typeof pathRef.current === 'function' ? pathRef.current() : pathRef.current)
|
|
36
48
|
.then((payload) => { if (seq === seqRef.current)
|
|
37
49
|
setData(payload); })
|
|
@@ -39,6 +51,23 @@ export function usePageData(path, deps = []) {
|
|
|
39
51
|
setError(describeError(e)); });
|
|
40
52
|
}, deps);
|
|
41
53
|
useEffect(() => { void load(); }, [load]);
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
const maybeReload = () => {
|
|
56
|
+
if (document.visibilityState !== 'visible')
|
|
57
|
+
return;
|
|
58
|
+
const today = localYmd(new Date());
|
|
59
|
+
// 翻篇必取;未翻篇按数据年龄节流(TanStack Query focus 重取的 staleTime 语义)
|
|
60
|
+
if (lastLoadDayRef.current !== today || Date.now() - lastLoadAtRef.current > FOCUS_REFETCH_MIN_AGE_MS) {
|
|
61
|
+
void load();
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
document.addEventListener('visibilitychange', maybeReload);
|
|
65
|
+
window.addEventListener('focus', maybeReload);
|
|
66
|
+
return () => {
|
|
67
|
+
document.removeEventListener('visibilitychange', maybeReload);
|
|
68
|
+
window.removeEventListener('focus', maybeReload);
|
|
69
|
+
};
|
|
70
|
+
}, [load]);
|
|
42
71
|
return { data, error, reload: load };
|
|
43
72
|
}
|
|
44
73
|
/**
|
package/lib/client/i18n.js
CHANGED
|
@@ -41,6 +41,7 @@ const ZH_DICT = {
|
|
|
41
41
|
'common.edit': '编辑',
|
|
42
42
|
'common.requestFailed': '请求失败',
|
|
43
43
|
'common.actionFailed': '操作失败',
|
|
44
|
+
'common.requestTimeout': '请求超时,请重试',
|
|
44
45
|
'common.dayUnit': '{n} 天',
|
|
45
46
|
'common.countUnit': '{n} 次',
|
|
46
47
|
'common.countUnitOne': '{n} 次',
|
|
@@ -86,18 +87,18 @@ const ZH_DICT = {
|
|
|
86
87
|
'action.checkinFuture': '提前打卡 {date}',
|
|
87
88
|
'action.checkinThisDay': '打卡此日',
|
|
88
89
|
'action.undoCheckin': '撤销',
|
|
89
|
-
'action.cancelCheckin': '取消打卡',
|
|
90
|
+
'action.cancelCheckin': '取消打卡',
|
|
91
|
+
'action.cancelCheckinAria': '撤销{date}的打卡', 'action.expand': '展开详情',
|
|
90
92
|
'action.collapse': '收起详情',
|
|
91
93
|
'action.createWish': '+ 新建愿望',
|
|
92
94
|
'action.createTask': '+ 新建任务',
|
|
93
95
|
'action.manageCategories': '分类管理',
|
|
94
96
|
'action.askAi': '让 AI 总结',
|
|
95
|
-
'today.notCheckinDay': '今天不是打卡日',
|
|
96
97
|
// --- 动作反馈 toast ---
|
|
97
98
|
'toast.claimed': '已领取「{name}」,去打卡吧',
|
|
98
99
|
'toast.checkinOk': '打卡成功',
|
|
99
100
|
'toast.undone': '打卡已撤销',
|
|
100
|
-
'toast.undoneAt': '已撤销
|
|
101
|
+
'toast.undoneAt': '已撤销{date}的打卡',
|
|
101
102
|
'toast.wishCreated': '已创建愿望「{title}」',
|
|
102
103
|
'toast.taskCreated': '已创建任务「{name}」',
|
|
103
104
|
'toast.deleted': '已删除「{name}」',
|
|
@@ -106,10 +107,10 @@ const ZH_DICT = {
|
|
|
106
107
|
'toast.categoryColorReset': '「{name}」配色已重置,跟随愿望默认色',
|
|
107
108
|
'toast.categoryDeleted': '已清除「{name}」的自定义颜色',
|
|
108
109
|
// --- 确认弹窗 ---
|
|
109
|
-
'confirm.futureCheckin': '「{name}」今天不是打卡日,提前打卡
|
|
110
|
+
'confirm.futureCheckin': '「{name}」今天不是打卡日,提前打卡{date}表示承诺当天完成。确认?',
|
|
110
111
|
'confirm.undoToday': '确定撤销「{name}」今天的打卡吗?进度将回退。',
|
|
111
|
-
'confirm.undoAt': '确定撤销「{name}」在
|
|
112
|
-
'confirm.futureAt': '「{name}
|
|
112
|
+
'confirm.undoAt': '确定撤销「{name}」在{date}的打卡吗?进度将回退。',
|
|
113
|
+
'confirm.futureAt': '「{name}」{date}不是打卡日,提前打卡表示承诺当天完成。确认?',
|
|
113
114
|
'confirm.deleteTask': '确定删除任务「{name}」吗?其打卡记录将一并删除,不可恢复。',
|
|
114
115
|
'confirm.deleteWish': '确定删除愿望「{name}」吗?下属任务与打卡记录将一并删除,不可恢复。',
|
|
115
116
|
'confirm.renameCategory': '把分类「{old}」下的全部愿望改名为「{new}」吗?(共 {count} 个)', 'confirm.deleteEmptyCategory': '删除空分类「{name}」的自定义颜色吗?',
|
|
@@ -257,18 +258,20 @@ const ZH_DICT = {
|
|
|
257
258
|
'micro.progress': '进度 {done}/{total} 完成',
|
|
258
259
|
// --- 打卡卡 ---
|
|
259
260
|
'checkin.success': '「{task}」打卡成功',
|
|
260
|
-
'checkin.cancelled': '已取消「{task}」{date}
|
|
261
|
+
'checkin.cancelled': '已取消「{task}」{date}的打卡',
|
|
261
262
|
// --- 图表卡 ---
|
|
262
263
|
'chart.noData': '暂无数据',
|
|
264
|
+
'chart.noSchedule': '无安排',
|
|
265
|
+
'chart.generatedAt': '生成于 {date}',
|
|
263
266
|
// --- 详情聚合视图 ---
|
|
264
267
|
'detail.grid.title': '打卡记录',
|
|
265
|
-
'detail.grid.summary': '近 {total}
|
|
268
|
+
'detail.grid.summary': '近 {total} 格:已打 {checked},未打 {missed},待打卡 {future}',
|
|
266
269
|
'detail.legend.checked': '已打卡',
|
|
267
270
|
'detail.legend.missed': '未打卡',
|
|
268
|
-
'detail.legend.future': '
|
|
271
|
+
'detail.legend.future': '待打卡',
|
|
269
272
|
'detail.grid.checked': '{date} 已打卡',
|
|
270
273
|
'detail.grid.missed': '{date} 未打卡',
|
|
271
|
-
'detail.grid.future': '{date}
|
|
274
|
+
'detail.grid.future': '{date} 待打卡',
|
|
272
275
|
'detail.next.title': '接下来的机会日:{dates}',
|
|
273
276
|
'detail.micro.title': '微行动进度',
|
|
274
277
|
'detail.micro.idle': '尚未开始微行动拆解(可在对话里说「帮我拆解」)。',
|
|
@@ -359,6 +362,7 @@ const EN_DICT = {
|
|
|
359
362
|
'common.edit': 'Edit',
|
|
360
363
|
'common.requestFailed': 'Request failed',
|
|
361
364
|
'common.actionFailed': 'Action failed',
|
|
365
|
+
'common.requestTimeout': 'Request timed out, please retry',
|
|
362
366
|
'common.dayUnit': '{n} days',
|
|
363
367
|
'common.countUnit': '{n} times',
|
|
364
368
|
'common.countUnitOne': '{n} time',
|
|
@@ -401,13 +405,13 @@ const EN_DICT = {
|
|
|
401
405
|
'action.checkinThisDay': 'Check in',
|
|
402
406
|
'action.undoCheckin': 'Undo',
|
|
403
407
|
'action.cancelCheckin': 'Undo check-in',
|
|
408
|
+
'action.cancelCheckinAria': 'Undo the check-in on {date}',
|
|
404
409
|
'action.expand': 'Details',
|
|
405
410
|
'action.collapse': 'Hide',
|
|
406
411
|
'action.createWish': '+ New wish',
|
|
407
412
|
'action.createTask': '+ New task',
|
|
408
413
|
'action.manageCategories': 'Categories',
|
|
409
414
|
'action.askAi': 'AI summary',
|
|
410
|
-
'today.notCheckinDay': 'Not a check-in day today',
|
|
411
415
|
'toast.claimed': 'Claimed “{name}”. Time to check in!',
|
|
412
416
|
'toast.checkinOk': 'Checked in',
|
|
413
417
|
'toast.undone': 'Check-in reverted',
|
|
@@ -566,14 +570,16 @@ const EN_DICT = {
|
|
|
566
570
|
'checkin.success': '“{task}” checked in',
|
|
567
571
|
'checkin.cancelled': 'Cancelled the check-in of “{task}” on {date}',
|
|
568
572
|
'chart.noData': 'No data yet',
|
|
573
|
+
'chart.noSchedule': 'Not scheduled',
|
|
574
|
+
'chart.generatedAt': 'Generated on {date}',
|
|
569
575
|
'detail.grid.title': 'Check-in record',
|
|
570
|
-
'detail.grid.summary': 'Last {total}
|
|
576
|
+
'detail.grid.summary': 'Last {total} cells: {checked} checked, {missed} missed, {future} pending',
|
|
571
577
|
'detail.legend.checked': 'Checked in',
|
|
572
578
|
'detail.legend.missed': 'Missed',
|
|
573
|
-
'detail.legend.future': '
|
|
579
|
+
'detail.legend.future': 'Pending today',
|
|
574
580
|
'detail.grid.checked': '{date} checked in',
|
|
575
581
|
'detail.grid.missed': '{date} missed',
|
|
576
|
-
'detail.grid.future': '{date}
|
|
582
|
+
'detail.grid.future': '{date} pending check-in',
|
|
577
583
|
'detail.next.title': 'Upcoming opportunity dates: {dates}',
|
|
578
584
|
'detail.micro.title': 'Micro-step progress',
|
|
579
585
|
'detail.micro.idle': 'No micro-step plan yet (ask “help me break it down” in chat).',
|
|
@@ -4,7 +4,7 @@ import { getJson, postAction } from '../api.js';
|
|
|
4
4
|
import { t } from '../i18n.js';
|
|
5
5
|
import { softConfirm, useActionGuard, usePageData, useStableScrollbar, localYmd } from '../hooks.js';
|
|
6
6
|
import { PageError, PageSkeleton, toast } from '../ui.js';
|
|
7
|
-
import { cycleLabel, dateSuffix, formatFriendlyDate, formatMonth } from './format.js';
|
|
7
|
+
import { cycleLabel, dateSuffix, formatFriendlyDate, formatMonth, formatShortDate } from './format.js';
|
|
8
8
|
function monthOf(offset) {
|
|
9
9
|
const base = new Date();
|
|
10
10
|
base.setDate(1);
|
|
@@ -66,7 +66,7 @@ export function CalendarPage() {
|
|
|
66
66
|
? t('toast.claimed', { name: taskName })
|
|
67
67
|
: action === 'checkin'
|
|
68
68
|
? t('toast.checkinOk') + dateSuffix(date)
|
|
69
|
-
: t('toast.undoneAt', { date }), 'ok');
|
|
69
|
+
: t('toast.undoneAt', { date: formatShortDate(date) }), 'ok');
|
|
70
70
|
// busy 窗口覆盖月历重取;之后仅当操作日仍是当前选中日才回填详情——
|
|
71
71
|
// 动作与快速连点另一日期竞争时,慢的旧响应不得覆盖新选中日的面板
|
|
72
72
|
return page.reload().then(() => {
|
|
@@ -77,7 +77,10 @@ export function CalendarPage() {
|
|
|
77
77
|
.then((dayAfter) => { if (seq === pickSeqRef.current) {
|
|
78
78
|
setDetail(dayAfter);
|
|
79
79
|
setPickState('idle');
|
|
80
|
-
} })
|
|
80
|
+
} })
|
|
81
|
+
// 动作已成功、仅详情回取失败:转面板错误态(重试即重新拾取),不静默吞掉也不误报动作失败
|
|
82
|
+
.catch(() => { if (seq === pickSeqRef.current)
|
|
83
|
+
setPickState('error'); });
|
|
81
84
|
});
|
|
82
85
|
}));
|
|
83
86
|
};
|
|
@@ -202,7 +205,10 @@ export function CalendarPage() {
|
|
|
202
205
|
...(task.wishName !== undefined ? [task.wishName] : []),
|
|
203
206
|
...(stateText !== '' ? [stateText] : []),
|
|
204
207
|
];
|
|
205
|
-
return createElement('li', { key: task.taskId, className: 'xy-grouprow' }, createElement('div', { className: 'xy-rowmain' }, createElement('span', { className: 'xy-rowtitle' }, task.name), createElement('span', { className: 'xy-meta' }, segments.join(' · '))),
|
|
208
|
+
return createElement('li', { key: task.taskId, className: 'xy-grouprow' }, createElement('div', { className: 'xy-rowmain' }, createElement('span', { className: 'xy-rowtitle' }, task.name), createElement('span', { className: 'xy-meta' }, segments.join(' · '))),
|
|
209
|
+
// 领取仅限今日:claim 无日期语义,claimDate 恒为领取当天——
|
|
210
|
+
// 过去/未来选中日的「领取」按钮会造出锚点与所见不符的任务
|
|
211
|
+
task.status === 'pending' && !task.checked && detail.date === data.today
|
|
206
212
|
? createElement('button', { className: 'xy-btn', disabled: busy, onClick: () => act('claim', task.taskId, task.name, detail.date) }, t('action.claim'))
|
|
207
213
|
: !task.checked && task.status === 'in_progress'
|
|
208
214
|
? createElement('button', { className: 'xy-btn xy-btn-primary', disabled: busy || !task.canCheckIn, onClick: () => act('checkin', task.taskId, task.name, detail.date) }, t('action.checkinThisDay'))
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
2
|
+
* 任务详情聚合视图:行内展开——打卡记录网格(截至今天的机会日末段 + 预勾)、
|
|
3
|
+
* 接下来机会日、微行动进度、状态机操作栏(领取/打卡/取消打卡/删除)与「让 AI 总结」降级入口。
|
|
4
4
|
* 数据经 GET /xingyuan/api/task-detail;动作直连 POST(用户点击即本人授权),动作后原位刷新
|
|
5
5
|
* 详情并回调上层列表刷新。
|
|
6
6
|
*/
|
|
@@ -9,7 +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
|
+
import { dateSuffix, formatShortDate } from './format.js';
|
|
13
13
|
/** 「让 AI 总结」提示词:触发者是人、产物直接可见,模板随界面语言切换(与 prompts.ts 总结分析模式同一结构诉求)。 */
|
|
14
14
|
function summaryPrompt(taskName) {
|
|
15
15
|
return activeLocale() === 'en'
|
|
@@ -41,6 +41,21 @@ function weekdayMon0(ymd) {
|
|
|
41
41
|
const date = new Date(Number(ymd.slice(0, 4)), Number(ymd.slice(5, 7)) - 1, Number(ymd.slice(8)), 12);
|
|
42
42
|
return Number.isNaN(date.getTime()) ? 0 : (date.getDay() + 6) % 7;
|
|
43
43
|
}
|
|
44
|
+
/**
|
|
45
|
+
* 撤销目标=网格内最近一条打卡(含未来预勾)。必须由客户端解析出具体日期并随请求显式携带:
|
|
46
|
+
* 服务端「不传日期=撤最近一次」的隐式口径下,弹框文案无法与实际撤销对象同源
|
|
47
|
+
* (今天已打卡 + 未来预勾并存时,弹框说撤今天、实际撤掉的是预勾日)。
|
|
48
|
+
*/
|
|
49
|
+
export function latestCheckedDate(grid) {
|
|
50
|
+
let latest;
|
|
51
|
+
for (const cell of grid) {
|
|
52
|
+
if (cell.state !== 'checked')
|
|
53
|
+
continue;
|
|
54
|
+
if (latest === undefined || cell.date > latest)
|
|
55
|
+
latest = cell.date;
|
|
56
|
+
}
|
|
57
|
+
return latest;
|
|
58
|
+
}
|
|
44
59
|
/**
|
|
45
60
|
* 任务详情面板:expanded 由调用方持有(TaskLine trailing 放 DetailToggle)。
|
|
46
61
|
* today 缺省取本地日期;onChanged 在任何写动作成功后回调(供列表 reload,
|
|
@@ -87,12 +102,14 @@ export function TaskDetailPanel(props) {
|
|
|
87
102
|
return null;
|
|
88
103
|
const today = props.today ?? localYmd(new Date());
|
|
89
104
|
const task = detail.task;
|
|
90
|
-
|
|
105
|
+
// 撤销目标=最近一条打卡(含预勾);只要有打卡记录撤销入口就可达(预勾也能在详情页撤销)
|
|
106
|
+
const undoTarget = latestCheckedDate(detail.grid);
|
|
91
107
|
// 网格可访问性:28 格逐格播报是读屏灾难——格子 aria-hidden,整体以一条
|
|
92
108
|
// 汇总 aria-label 暴露(role=img),逐日细节保留 title 供鼠标悬停。
|
|
93
109
|
// 视觉按周对齐(周一始,GitHub/Streaks 惯例):首行前置隐形占位,今日格加 accent 环,
|
|
94
110
|
// 连续坚持一眼可读;换月错位不再出现流式换行的参差断行。
|
|
95
|
-
|
|
111
|
+
// 服务端窗口恒 ≤28(GRID_DATES_LIMIT,§5.10 口径)——此处不再重复截尾,避免双份常量漂移
|
|
112
|
+
const recent = detail.grid;
|
|
96
113
|
const gridCells = [
|
|
97
114
|
...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
115
|
...recent.map((cell) => {
|
|
@@ -150,7 +167,9 @@ export function TaskDetailPanel(props) {
|
|
|
150
167
|
key: 'checkin', className: 'xy-btn xy-btn-primary', disabled: busy,
|
|
151
168
|
onClick: () => {
|
|
152
169
|
const future = task.nextOpportunityDate !== undefined && task.nextOpportunityDate > today ? task.nextOpportunityDate : undefined;
|
|
153
|
-
const submit = () => act('checkin', { taskId: task.taskId }, () =>
|
|
170
|
+
const submit = () => act('checkin', { taskId: task.taskId }, () =>
|
|
171
|
+
// 与今日页/日历/任务行同口径:toast 带实际勾选日期(预勾时不是今天)
|
|
172
|
+
translate('toast.checkinOk') + dateSuffix(future ?? today));
|
|
154
173
|
if (future === undefined) {
|
|
155
174
|
submit();
|
|
156
175
|
return;
|
|
@@ -160,13 +179,20 @@ export function TaskDetailPanel(props) {
|
|
|
160
179
|
},
|
|
161
180
|
}, t('action.checkin')));
|
|
162
181
|
}
|
|
163
|
-
if (
|
|
182
|
+
if (undoTarget !== undefined) {
|
|
164
183
|
ops.push(createElement('button', {
|
|
165
184
|
key: 'cancel', className: 'xy-btn', disabled: busy,
|
|
185
|
+
// 读屏标签携带目标日期:不开确认弹窗即知将撤销哪一天(含预勾场景)
|
|
186
|
+
'aria-label': t('action.cancelCheckinAria', { date: formatShortDate(undoTarget) }),
|
|
166
187
|
onClick: () => {
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
188
|
+
// 日期显式随请求携带,弹框/toast 与实际撤销对象同源;确认文案保留 ISO(§5.10 口径)
|
|
189
|
+
const message = undoTarget === today
|
|
190
|
+
? translate('confirm.undoToday', { name: task.name })
|
|
191
|
+
: translate('confirm.undoAt', { name: task.name, date: undoTarget });
|
|
192
|
+
void softConfirm(message).then((ok) => {
|
|
193
|
+
if (ok) {
|
|
194
|
+
act('cancel-checkin', { taskId: task.taskId, date: undoTarget }, () => translate('toast.undoneAt', { date: formatShortDate(undoTarget) }));
|
|
195
|
+
}
|
|
170
196
|
});
|
|
171
197
|
},
|
|
172
198
|
}, t('action.cancelCheckin')));
|
|
@@ -39,9 +39,10 @@ export function englishPlural(n) {
|
|
|
39
39
|
export function countKey(n, oneKey, manyKey) {
|
|
40
40
|
return englishPlural(n) ? manyKey : oneKey;
|
|
41
41
|
}
|
|
42
|
-
/** 打卡日期后缀(toast
|
|
42
|
+
/** 打卡日期后缀(toast/完成态用):入参 ISO,内部本地化为短日期(§5.10 界面禁裸奔 ISO);
|
|
43
|
+
* zh 全角括号 / en 半角括号由词典统一口径。 */
|
|
43
44
|
export function dateSuffix(date) {
|
|
44
|
-
return date !== undefined && date !== '' ? t('common.dateSuffix', { date }) : '';
|
|
45
|
+
return date !== undefined && date !== '' ? t('common.dateSuffix', { date: formatShortDate(date) }) : '';
|
|
45
46
|
}
|
|
46
47
|
/** 紧凑按钮内的本地化短日期:'2026-03-05' → 「3月5日」/「Mar 5」(解析失败原样回显)。 */
|
|
47
48
|
export function formatShortDate(ymd) {
|
|
@@ -55,6 +55,9 @@ export function MemoryPage() {
|
|
|
55
55
|
const moreSeqRef = useRef(0);
|
|
56
56
|
useEffect(() => {
|
|
57
57
|
moreSeqRef.current += 1;
|
|
58
|
+
// 使在途加载失效的同时必须复位按钮态:否则在途请求的 finally 因 seq 失配
|
|
59
|
+
// 跳过复位,loadingMore 永挂 true——「加载更多」自此点不动(P0 级交互死锁)
|
|
60
|
+
setLoadingMore(false);
|
|
58
61
|
setMore([]);
|
|
59
62
|
setLoadError(undefined);
|
|
60
63
|
}, [debounced]);
|
|
@@ -78,6 +81,8 @@ export function MemoryPage() {
|
|
|
78
81
|
/** 写操作后的全量刷新:追加页必须丢弃(offset 语义已失效),回到首屏。 */
|
|
79
82
|
const refreshAll = () => {
|
|
80
83
|
moreSeqRef.current += 1;
|
|
84
|
+
// 同 useEffect 的失效点:在途加载被弃时按钮态就地复位(防死锁)
|
|
85
|
+
setLoadingMore(false);
|
|
81
86
|
setMore([]);
|
|
82
87
|
setLoadError(undefined);
|
|
83
88
|
void page.reload();
|
|
@@ -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, formatFriendlyDate } from './format.js';
|
|
8
|
+
import { cycleLabel, dateSuffix, formatFriendlyDate, formatShortDate } from './format.js';
|
|
9
9
|
import { todayHintStore } from '../tab-hint.js';
|
|
10
10
|
export function TodayPage() {
|
|
11
11
|
const t = useXyT();
|
|
@@ -40,8 +40,9 @@ export function TodayPage() {
|
|
|
40
40
|
const rows = [...openItems, ...pendingItems].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}` : ''}`)), task.status === 'in_progress'
|
|
41
41
|
? createElement('button', {
|
|
42
42
|
className: 'xy-btn xy-btn-primary',
|
|
43
|
+
// planForDay 保证展示行必为未打卡的进行中任务,canCheckIn 恒 true——
|
|
44
|
+
// 不再有「disabled 但无解释」的死分支;disabled 仅服务动作在途的 busy 窗口
|
|
43
45
|
disabled: busy || !task.canCheckIn,
|
|
44
|
-
title: task.canCheckIn ? undefined : t('today.notCheckinDay'),
|
|
45
46
|
onClick: () => act('checkin', { taskId: task.taskId }, (p) => t('toast.checkinOk') + dateSuffix(typeof p.date === 'string' ? String(p.date) : undefined)),
|
|
46
47
|
}, t('action.checkin'))
|
|
47
48
|
: createElement('button', {
|
|
@@ -54,8 +55,10 @@ export function TodayPage() {
|
|
|
54
55
|
void softConfirm(t('confirm.undoToday', { name: task.name })).then((ok) => {
|
|
55
56
|
if (!ok)
|
|
56
57
|
return;
|
|
57
|
-
|
|
58
|
-
|
|
58
|
+
// 完成区行=今天的打卡:日期必须显式携带——服务端「不传日期=撤最近一次」
|
|
59
|
+
// 会误撤未来预勾(弹框却说撤今天)
|
|
60
|
+
act('cancel-checkin', { taskId: task.taskId, date: data.today }, (p) => typeof p.date === 'string'
|
|
61
|
+
? t('toast.undoneAt', { date: formatShortDate(String(p.date)) })
|
|
59
62
|
: t('toast.undone'));
|
|
60
63
|
});
|
|
61
64
|
} }, t('action.undoCheckin'))));
|