@starwish-ai/xingyuan-dsh 0.5.3 → 0.5.5
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 +211 -0
- package/README.en-US.md +1 -0
- package/README.md +1 -0
- package/lib/client/api.js +36 -4
- package/lib/client/cards.js +38 -25
- package/lib/client/chart-labels.js +107 -0
- package/lib/client/hooks.js +46 -0
- package/lib/client/i18n.js +225 -27
- package/lib/client/pages/calendar.js +33 -13
- package/lib/client/pages/color-swatch.js +3 -0
- package/lib/client/pages/detail.js +94 -18
- package/lib/client/pages/format.js +12 -2
- package/lib/client/pages/growth.js +37 -9
- package/lib/client/pages/memory.js +36 -19
- package/lib/client/pages/quick-create.js +26 -4
- package/lib/client/pages/settings.js +67 -9
- package/lib/client/pages/task-line.js +3 -4
- package/lib/client/pages/tasks.js +29 -28
- package/lib/client/pages/today.js +44 -19
- package/lib/client/pages/wishes.js +32 -18
- package/lib/client/styles.js +152 -62
- package/lib/client/ui.js +60 -2
- package/lib/client/view-state.js +24 -0
- package/lib/client.js +1044 -245
- package/lib/consistency-sweep.js +47 -0
- package/lib/growth.js +6 -17
- package/lib/index.js +15 -1
- package/lib/micro.js +10 -9
- package/lib/pref-policy.js +11 -0
- package/lib/pref-settings.js +3 -1
- package/lib/preset/charts.js +41 -28
- package/lib/preset/hitl.js +33 -6
- package/lib/preset/prompts.js +20 -14
- package/lib/preset/side.js +1 -0
- package/lib/preset/tools.js +72 -48
- package/lib/routes/api.js +120 -99
- package/lib/routes/errors.js +5 -2
- package/lib/routes/index.js +47 -41
- package/lib/routes/pages-html.js +66 -16
- package/lib/session-log-repair.js +20 -1
- package/lib/store.js +202 -43
- package/lib/types/client/api.d.ts +1 -1
- package/lib/types/client/chart-labels.d.ts +32 -0
- package/lib/types/client/hooks.d.ts +10 -0
- package/lib/types/client/i18n.d.ts +112 -16
- package/lib/types/client/pages/detail.d.ts +11 -2
- package/lib/types/client/pages/format.d.ts +10 -1
- package/lib/types/client/pages/quick-create.d.ts +4 -0
- package/lib/types/client/pages/settings.d.ts +3 -0
- package/lib/types/client/pages/types.d.ts +2 -1
- package/lib/types/client/styles.d.ts +7 -4
- package/lib/types/client/ui.d.ts +16 -1
- package/lib/types/client/view-state.d.ts +15 -0
- package/lib/types/consistency-sweep.d.ts +26 -0
- package/lib/types/events.d.ts +12 -0
- package/lib/types/pref-policy.d.ts +11 -0
- package/lib/types/preset/hitl.d.ts +7 -2
- package/lib/types/preset/tools.d.ts +3 -0
- package/lib/types/routes/api.d.ts +1 -1
- package/lib/types/routes/errors.d.ts +4 -3
- package/lib/types/routes/index.d.ts +3 -1
- package/lib/types/routes/pages-html.d.ts +5 -5
- package/lib/types/store.d.ts +48 -2
- package/lib/types/types.d.ts +3 -0
- package/lib/types.js +2 -0
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,217 @@ 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.5] - 2026-08-28
|
|
9
|
+
|
|
10
|
+
> **Note:** this release is a six-perspective quality pass (product, visual design,
|
|
11
|
+
> end user, dsh-platform conformance, architecture, UX) over every interaction,
|
|
12
|
+
> display and code path. It closes the failure side of the habit loop — an expired
|
|
13
|
+
> task used to be indistinguishable from an achieved one and was a dead end on the
|
|
14
|
+
> pages — and makes the plugin honest in English wherever it previously leaked
|
|
15
|
+
> built-in Chinese vocabulary. No data format changes; existing databases and
|
|
16
|
+
> recorded sessions replay as before.
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
- **Expired vs achieved, end to end**: task views carry `closedReason`; the Tasks
|
|
20
|
+
page splits the former "Closed" bucket into **Achieved** and **Expired** groups,
|
|
21
|
+
task rows say which one they are, and an expired task's detail panel explains the
|
|
22
|
+
state and offers an in-page **extend due date → restart** action (same store write
|
|
23
|
+
path as the chat-side `update_task`). An expired task is no longer a dead row with
|
|
24
|
+
a delete button.
|
|
25
|
+
- **Confirm card language** (`xingyuan-pref.confirmLang`, default Chinese): the
|
|
26
|
+
platform does not expose UI language to host-side plugins (verified against rc.2),
|
|
27
|
+
so the in-chat confirmation card's header, buttons and question text now follow
|
|
28
|
+
this explicit setting with full English strings; the Settings page gains a
|
|
29
|
+
"Confirm card language" select with optimistic save and write verification.
|
|
30
|
+
- **Built-in chart vocabulary localizes on the client**: chart cards map known
|
|
31
|
+
built-in Chinese titles, enum labels (weekdays, hour buckets, task statuses,
|
|
32
|
+
series names) and fixed subtitles to the interface language, while user data
|
|
33
|
+
(wish/task names) and unknown values replay verbatim. Coverage is locked by
|
|
34
|
+
`test/chart-labels.test.ts` against the real chart builder, so a new built-in
|
|
35
|
+
word without a mapping fails the build.
|
|
36
|
+
- **Level names localize**: the Growth page renders level names and rewards from
|
|
37
|
+
the interface language by level number (server strings remain the Chinese
|
|
38
|
+
fallback); locked to the server table by the same test.
|
|
39
|
+
- **Route contract compliance**: `/xingyuan` prefix registration is effect-scoped —
|
|
40
|
+
the disposer returned by `webServer.register` is no longer discarded, so hot
|
|
41
|
+
reload / re-activation can no longer die on a duplicate-prefix throw or leave a
|
|
42
|
+
stale handler serving a closed domain. A loader-level remount test with a
|
|
43
|
+
duplicate-throwing webServer stub pins this.
|
|
44
|
+
- New contract tests: `style-contract` (no `color-mix(` anywhere; semi-transparent
|
|
45
|
+
colors only inside the token zone; key semantic tokens must exist in both
|
|
46
|
+
themes), `chart-labels` (built-in chart vocabulary fully covered by the
|
|
47
|
+
localization maps, and client level names locked verbatim to the server table),
|
|
48
|
+
`sqlite-backend` (version-mismatch rejection, corrupt-global rejection,
|
|
49
|
+
write → cold-read persistence), plus a tool-suite audit pinning the 10-minute
|
|
50
|
+
HITL timeout on every confirmation-waiting tool and the "use real IDs from list
|
|
51
|
+
results" note on every ID parameter.
|
|
52
|
+
|
|
53
|
+
### Changed
|
|
54
|
+
- **System prompts no longer assert gated behavior**: six spots claimed the system
|
|
55
|
+
"will pop a confirmation card" unconditionally — wrong when the write-confirmation
|
|
56
|
+
setting is off (tool descriptions already hedged; the prompts now hedge the same
|
|
57
|
+
way, "删除始终弹出" stays asserted since deletes always confirm).
|
|
58
|
+
- **Memory overwrite asks once**: creating a memory with an existing key showed the
|
|
59
|
+
overwrite confirmation, then the server rejection re-asked the identical question;
|
|
60
|
+
the confirmed path now submits with `overwrite: true` directly.
|
|
61
|
+
- **Settings tab-visibility tells the truth**: memory-mode (non-loopback) and
|
|
62
|
+
read-only states get their own notices instead of silently grey controls, and
|
|
63
|
+
writes verify post-settle snapshot like the chat-preferences controls, toasting
|
|
64
|
+
on silent revert (`scope.set()` resolves even on failure).
|
|
65
|
+
- **Honest stale-data degradation**: when a refetch fails after a successful action
|
|
66
|
+
(or on focus refetch), pages keep the loaded data and show a "refresh failed,
|
|
67
|
+
data may be outdated" banner with inline retry instead of flipping to a full-page
|
|
68
|
+
error; Today's retry now reloads both endpoints at once.
|
|
69
|
+
- **Motion & touch discipline**: the growth bar drops its layout-animating `height`
|
|
70
|
+
transition and the toggle knob animates `transform` only; every purely decorative
|
|
71
|
+
hover is gated behind `(hover:hover) and (pointer:fine)` so touch screens no
|
|
72
|
+
longer stick hover tints; error toasts use a dedicated assertive `role=alert`
|
|
73
|
+
container; the confirm dialog locks body scroll; after deletes/undoes destroy the
|
|
74
|
+
triggered row, focus moves to the page title instead of falling to `<body>`.
|
|
75
|
+
- **Cross-tab state survives**: memory search query, calendar month offset and
|
|
76
|
+
expanded task details persist across view-tab switches via a module-scoped
|
|
77
|
+
snapshot store; view pages scroll back to top on mount.
|
|
78
|
+
- **Visual tokens**: new `--xyd-label-on-2` pair fixes secondary text on secondary
|
|
79
|
+
surfaces (≈4.3:1 → ≥5:1 in both themes) across the task-card preview strip, group
|
|
80
|
+
count pills, level chips, future grid cells and the growth chart axis ticks;
|
|
81
|
+
success/warn washes, calendar state fills,
|
|
82
|
+
shadows and hero colors are explicit themed token pairs (no more shared-alpha
|
|
83
|
+
washes that vanished on dark); calendar hover wash no longer paints over the
|
|
84
|
+
picked chip; 待打卡 chips gain the inset ring their legend dot always had;
|
|
85
|
+
standalone dark "complete" chip darkens to AA contrast; 11px CJK text raises to
|
|
86
|
+
12px; month-adjacent days brighten from 1.8:1 to readable-but-dim; the mock
|
|
87
|
+
generator itself is `color-mix`-free again.
|
|
88
|
+
- **Error codes completed end-to-end**: `not_claimed`, `no_opportunity_left`,
|
|
89
|
+
`no_checkins`, `title_too_long`, `name_too_long`, `once_today_only`,
|
|
90
|
+
`payload_too_large`, `bad_json_body`, `bad_coach_style`, `bad_interests` now flow
|
|
91
|
+
from store/routes to localized client strings (English users no longer receive
|
|
92
|
+
raw Chinese domain messages); HTTP status suffixes localize their brackets.
|
|
93
|
+
- **Dates**: hover tooltips on the check-in grid and calendar cells localize while
|
|
94
|
+
aria keeps ISO precision; memory timestamps render as medium dates; the growth
|
|
95
|
+
chart axis uses Intl ticks (Aug 27 / 8-27); the English "Commit {date}" button
|
|
96
|
+
becomes "Early check-in · {date}" so the button itself carries check-in semantics.
|
|
97
|
+
- **Writes stay in one place**: memory delete/clear and profile updates go through
|
|
98
|
+
store use cases (`deleteMemory`, `clearMemories`, `updateProfileGlobal`) shared by
|
|
99
|
+
tools and routes; profile fields clamp at the same limits on both faces (nickname
|
|
100
|
+
50 / occupation 100 / interests 20×50) — the tool path was previously unbounded
|
|
101
|
+
and injects into every system prompt turn.
|
|
102
|
+
- **Batch reads honor the store contract**: wish/task list paths use
|
|
103
|
+
`freshWishes` and a single shared checkin-count index instead of per-item
|
|
104
|
+
full-table rescans for the completed-day counts; the
|
|
105
|
+
chart builder computes its checkin index only for the one chart that consumes it;
|
|
106
|
+
semantically invalid dates (`2026-13-01`) are rejected with a stable `bad_date`
|
|
107
|
+
code instead of an unlocalizable RangeError; session-log repair sweeps its
|
|
108
|
+
`.xy-repair-*` atomic-write temp files; `./types` export resolves types and
|
|
109
|
+
runtime to the same module.
|
|
110
|
+
- **Copy honesty**: delete confirmations (chat and pages) now mention the
|
|
111
|
+
micro-step plan that the cascade removes; quick-create forms soft-confirm on an
|
|
112
|
+
exact duplicate name/title (the chat path dedupes via `check_similar_*`, the
|
|
113
|
+
page path previously did not); wish progress bars expose `role=progressbar`; the
|
|
114
|
+
four create tools declare the same 10-minute HITL timeout as their siblings;
|
|
115
|
+
task-related ID parameters all carry the "use real IDs from list results"
|
|
116
|
+
discipline.
|
|
117
|
+
|
|
118
|
+
### Fixed
|
|
119
|
+
- `start_micro_action` gates on a freshened task status, so a task that expired
|
|
120
|
+
since its last write can no longer start a breakdown.
|
|
121
|
+
- `create_wish_with_tasks` no longer over-claims atomicity in its pre-validation
|
|
122
|
+
comment (task-field failures are honestly reported; the wish and created tasks
|
|
123
|
+
are kept).
|
|
124
|
+
|
|
125
|
+
## [0.5.4] - 2026-08-28
|
|
126
|
+
|
|
127
|
+
> **Note:** trailing chart statistics no longer count future pre-checked days (see
|
|
128
|
+
> *Changed*). Chart cards generated by this version carry a "generated on" stamp so a
|
|
129
|
+
> stale card is distinguishable from current data; cards written by earlier versions
|
|
130
|
+
> replay unchanged, without the stamp.
|
|
131
|
+
|
|
132
|
+
**Charts and records now tell the truth about time.** Future pre-checked days were
|
|
133
|
+
counted by every trailing statistic as if they had already happened — a "how is this
|
|
134
|
+
week going" chart could be inflated by check-ins for days that have not occurred, and a
|
|
135
|
+
task's check-in grid could end up showing nothing but future days while today's check-in
|
|
136
|
+
was invisible. Undoing a check-in said "revert today's?" while actually reverting a
|
|
137
|
+
future pre-check, because the pages let the server fall back to "most recent record".
|
|
138
|
+
|
|
139
|
+
### Added
|
|
140
|
+
- **Chart snapshot stamp**: chart events carry `generatedAt` and the card renders a
|
|
141
|
+
localized "generated on" date; cards generated by older versions replay without it
|
|
142
|
+
(the field is optional and degrades honestly).
|
|
143
|
+
- **"No schedule" ≠ 0%**: in the daily completion-rate trend, days with no scheduled
|
|
144
|
+
opportunity render as an empty slot labelled "no schedule" (tooltip and screen reader
|
|
145
|
+
alike) instead of a 0% data point.
|
|
146
|
+
- The completion-rate chart annotates its subtitle with "including unclaimed tasks"
|
|
147
|
+
when pending tasks count toward the total, so the denominator is never a mystery.
|
|
148
|
+
- **Startup consistency sweep** (`src/consistency-sweep.ts`): check-ins whose task no
|
|
149
|
+
longer exists, tasks whose wish was deleted out from under them, and dangling
|
|
150
|
+
micro-action states are cleaned once per launch through the same cascade the delete
|
|
151
|
+
tools use. Idempotent, runs after activation without blocking it, and pinned by
|
|
152
|
+
`test/integrity.test.ts`.
|
|
153
|
+
- **Task-detail grid window**: the check-in grid shows the last 28 opportunity days up
|
|
154
|
+
to today, plus every checked day — future pre-checks stay visible (they are standing
|
|
155
|
+
commitments), and check-ins outside the opportunity series (deadline-free tasks) make
|
|
156
|
+
the grid their check-in history. Today is never squeezed out of the window, however
|
|
157
|
+
many future days are pre-checked; unchecked future days remain in the "upcoming"
|
|
158
|
+
preview line.
|
|
159
|
+
- **gzip content negotiation** for the standalone pages (`/xingyuan/today`, `/calendar`,
|
|
160
|
+
`/growth`) per RFC 9110 §12.5.3: absent header accepts any coding, an empty value or
|
|
161
|
+
an explicit opt-out gets plaintext, `Vary: Accept-Encoding` is always sent, and the
|
|
162
|
+
compressed template is cached per body.
|
|
163
|
+
- **Request timeout**: client fetches abort after 15 s with a localized message instead
|
|
164
|
+
of hanging forever.
|
|
165
|
+
- **Focus refetch**: page data refetches when the window regains focus if older than
|
|
166
|
+
60 s, and always when the local day has changed — overnight sessions no longer show
|
|
167
|
+
yesterday's "today".
|
|
168
|
+
- **Undo readability**: the detail panel's undo button announces the exact day it will
|
|
169
|
+
revert (screen-reader label), and its visibility no longer requires today to be the
|
|
170
|
+
checked day — a pre-check can be undone right where it was made.
|
|
171
|
+
|
|
172
|
+
### Fixed
|
|
173
|
+
- **The cancel dialog said "today" while a different day was reverted.** The today page
|
|
174
|
+
and the task detail panel posted the cancel without a date and the server falls back
|
|
175
|
+
to the most recent check-in — which a future pre-check satisfies. Every page now sends
|
|
176
|
+
the explicit date; the dialog, the toast and the reverted record all name the same day.
|
|
177
|
+
- `weekComparison` folded future pre-checks into weekday buckets as if they were in the
|
|
178
|
+
current week; the week window is now a closed interval from last Monday to today.
|
|
179
|
+
- Wish progress **rounded** instead of flooring: 249/250 displayed as 100% and could
|
|
180
|
+
archive the wish one day early.
|
|
181
|
+
- Checking in an expired (closed) task failed with a generic error; it now explains that
|
|
182
|
+
the deadline must be extended to revive the task before further check-ins.
|
|
183
|
+
- Concurrent writes to the global document (coach style, profile, category colors,
|
|
184
|
+
micro-action state) could lose updates under interleaving; all writers now serialize
|
|
185
|
+
through one promise chain, a failed writer no longer poisons the chain, and no-op
|
|
186
|
+
mutations skip the write entirely.
|
|
187
|
+
- The memory page could deadlock with a perpetual "loading more" spinner when an
|
|
188
|
+
in-flight request was superseded by a newer one (the reset ran on only one of two
|
|
189
|
+
completion paths).
|
|
190
|
+
- The calendar's post-action detail refetch swallowed failures; the panel now shows an
|
|
191
|
+
error state with a retry button instead of silently keeping stale content.
|
|
192
|
+
- A deadline-free one-shot task disappeared from the today page the moment it was
|
|
193
|
+
checked; the completed row now stays revocable for the rest of the day (it never
|
|
194
|
+
reappears the next day).
|
|
195
|
+
- `/xingyuan/api/day` and `/xingyuan/api/range` answered a malformed `date` with an
|
|
196
|
+
empty day list or an unlabelled `RangeError`; both now reject with a structured
|
|
197
|
+
`bad_date` error, consistent with the POST action surface.
|
|
198
|
+
- Creating a task with a name over 100 characters through a page action failed with a
|
|
199
|
+
bare validation message; the error now states the limit and the actual length.
|
|
200
|
+
- The claim button in the calendar is gated to the picked day being today, matching the
|
|
201
|
+
server-side anchor semantics.
|
|
202
|
+
- Display dates across cards, toasts and pages are localized (ISO strings remain only in
|
|
203
|
+
confirm-dialog copy and screen-reader strings, per the documented convention).
|
|
204
|
+
- The standalone growth page used `color-mix()`, which some shells render as an invalid
|
|
205
|
+
declaration; the darker tone is now precomputed in JS.
|
|
206
|
+
|
|
207
|
+
### Changed
|
|
208
|
+
- Trailing chart statistics (trend, daily rate, week comparison, by-category, ranking,
|
|
209
|
+
time-of-day, weekday activity) only count check-ins up to today; the calendar heatmap
|
|
210
|
+
deliberately keeps future pre-checks visible. Chart cards are snapshots of their
|
|
211
|
+
generation time — the coach guide tells the model to regenerate for current data.
|
|
212
|
+
- `cancel_check_in_task` parameter guidance now instructs the model to pass the date
|
|
213
|
+
whenever the user names a day (including "today"), so the "most recent" fallback is a
|
|
214
|
+
deliberate choice rather than an accident.
|
|
215
|
+
- zh copy no longer pads localized dates with spaces ("已撤销 8月29日 的打卡" →
|
|
216
|
+
"已撤销8月29日的打卡"), and the grid legend says "待打卡" where it previously said
|
|
217
|
+
"未来机会日" for what is now only ever today's pending slot.
|
|
218
|
+
|
|
8
219
|
## [0.5.3] - 2026-08-28
|
|
9
220
|
|
|
10
221
|
> **Note:** this is a patch release but it carries one breaking change — the `xingyuan`
|
package/README.en-US.md
CHANGED
|
@@ -43,6 +43,7 @@ Business data lives at `~/.dsh/xingyuan/xingyuan.sqlite` and survives uninstall
|
|
|
43
43
|
| Write confirmation | Whether create / check-in writes need confirmation (deletion always does) |
|
|
44
44
|
| Memory injection limit | Max memories injected per turn |
|
|
45
45
|
| Tab visibility | Three modes (follow session / always show / always hide) plus per-tab toggles; defaults to follow session |
|
|
46
|
+
| Confirm card language | Language of the in-chat confirmation card (Chinese / English; the platform does not expose UI language to plugins, defaults to Chinese) |
|
|
46
47
|
|
|
47
48
|
## Development
|
|
48
49
|
|
package/README.md
CHANGED
package/lib/client/api.js
CHANGED
|
@@ -24,10 +24,21 @@ const ERROR_KEY = {
|
|
|
24
24
|
already_claimed: 'err.already_claimed',
|
|
25
25
|
task_closed: 'err.task_closed',
|
|
26
26
|
due_past: 'err.due_past',
|
|
27
|
+
due_too_far: 'err.due_too_far',
|
|
27
28
|
bad_category_name: 'err.bad_category_name',
|
|
28
29
|
bad_color_key: 'err.bad_color_key',
|
|
29
30
|
bad_date: 'err.bad_date',
|
|
30
31
|
overwrite_required: 'err.overwrite_required',
|
|
32
|
+
not_claimed: 'err.not_claimed',
|
|
33
|
+
no_opportunity_left: 'err.no_opportunity_left',
|
|
34
|
+
no_checkins: 'err.no_checkins',
|
|
35
|
+
title_too_long: 'err.title_too_long',
|
|
36
|
+
name_too_long: 'err.name_too_long',
|
|
37
|
+
once_today_only: 'err.once_today_only',
|
|
38
|
+
payload_too_large: 'err.payload_too_large',
|
|
39
|
+
bad_json_body: 'err.bad_json_body',
|
|
40
|
+
bad_coach_style: 'err.bad_coach_style',
|
|
41
|
+
bad_interests: 'err.bad_interests',
|
|
31
42
|
};
|
|
32
43
|
/** 把任意抛错转成用户可读文案:ActionError 按键本地化;其余直出消息。 */
|
|
33
44
|
export function describeError(e) {
|
|
@@ -40,8 +51,27 @@ export function describeError(e) {
|
|
|
40
51
|
export async function postAction(path, body) {
|
|
41
52
|
return postJson(`/xingyuan/api/action/${path}`, body);
|
|
42
53
|
}
|
|
54
|
+
/** 请求超时:手动 AbortController——AbortSignal.timeout 为 Baseline 2024 "Newly available",
|
|
55
|
+
* 不满足 dsh 壳的保守浏览器矩阵(同 color-mix 禁令的判断口径),不用。 */
|
|
56
|
+
const REQUEST_TIMEOUT_MS = 15_000;
|
|
57
|
+
async function fetchWithTimeout(path, init) {
|
|
58
|
+
const controller = new AbortController();
|
|
59
|
+
const timer = window.setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
|
60
|
+
try {
|
|
61
|
+
return await fetch(path, { ...init, signal: controller.signal });
|
|
62
|
+
}
|
|
63
|
+
catch (e) {
|
|
64
|
+
// 超时中止统一转本地化文案;真实网络错误原样抛出(保留连接拒绝等语义)
|
|
65
|
+
if (e instanceof DOMException && e.name === 'AbortError')
|
|
66
|
+
throw new Error(t('common.requestTimeout'));
|
|
67
|
+
throw e;
|
|
68
|
+
}
|
|
69
|
+
finally {
|
|
70
|
+
window.clearTimeout(timer);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
43
73
|
export async function postJson(path, body) {
|
|
44
|
-
const response = await
|
|
74
|
+
const response = await fetchWithTimeout(path, {
|
|
45
75
|
method: 'POST',
|
|
46
76
|
headers: { 'content-type': 'application/json' },
|
|
47
77
|
body: JSON.stringify(body),
|
|
@@ -49,22 +79,24 @@ export async function postJson(path, body) {
|
|
|
49
79
|
return parsePayload(response, () => t('common.actionFailed'));
|
|
50
80
|
}
|
|
51
81
|
export async function getJson(path) {
|
|
52
|
-
const response = await
|
|
82
|
+
const response = await fetchWithTimeout(path, { headers: { accept: 'application/json' } });
|
|
53
83
|
return parsePayload(response, () => t('common.requestFailed'));
|
|
54
84
|
}
|
|
55
85
|
async function parsePayload(response, fallback) {
|
|
86
|
+
// HTTP 层错误的状态码后缀走本地化键(zh 全角 / en 半角括号),不硬编码全角括号
|
|
87
|
+
const withStatus = (message) => `${message}${t('common.httpStatus', { status: response.status })}`;
|
|
56
88
|
let payload;
|
|
57
89
|
try {
|
|
58
90
|
payload = await response.json();
|
|
59
91
|
}
|
|
60
92
|
catch {
|
|
61
|
-
throw new Error(
|
|
93
|
+
throw new Error(withStatus(fallback()));
|
|
62
94
|
}
|
|
63
95
|
if (!response.ok) {
|
|
64
96
|
if (typeof payload.code === 'string' && payload.code !== '') {
|
|
65
97
|
throw new ActionError(payload.code, payload.error ?? fallback(), payload.params);
|
|
66
98
|
}
|
|
67
|
-
throw new Error(payload.error
|
|
99
|
+
throw new Error(payload.error !== undefined ? withStatus(payload.error) : withStatus(fallback()));
|
|
68
100
|
}
|
|
69
101
|
return payload;
|
|
70
102
|
}
|
package/lib/client/cards.js
CHANGED
|
@@ -10,8 +10,9 @@ import { postAction, ActionError, describeError } from './api.js';
|
|
|
10
10
|
import { toast, toastError } from './ui.js';
|
|
11
11
|
import { localYmd, softConfirm } from './hooks.js';
|
|
12
12
|
import { useXyT, t, activeLocale } from './i18n.js';
|
|
13
|
+
import { localizeChartLabel, localizeChartSeries, localizeChartSubtitle, localizeChartTitle } from './chart-labels.js';
|
|
13
14
|
import { categoryVars } from '../category-color.js';
|
|
14
|
-
import { cycleLabel, durationText, dateSuffix } from './pages/format.js';
|
|
15
|
+
import { cycleLabel, durationText, dateSuffix, formatMediumDate, formatShortDate } from './pages/format.js';
|
|
15
16
|
function WishView(props) {
|
|
16
17
|
const t = useXyT();
|
|
17
18
|
const wish = props.node.data.data;
|
|
@@ -95,7 +96,7 @@ function TaskView(props) {
|
|
|
95
96
|
const showClaim = !deleted && phase === 'idle' && task.status === 'pending';
|
|
96
97
|
const showCheckIn = !deleted && phase !== 'done' && (task.status === 'in_progress' || phase === 'claimed');
|
|
97
98
|
const preview = event.opportunityPreview.length > 0
|
|
98
|
-
? t('task.upcoming', { dates: event.opportunityPreview.join(activeLocale() === 'en' ? ', ' : '、') })
|
|
99
|
+
? t('task.upcoming', { dates: event.opportunityPreview.map((date) => formatShortDate(date)).join(activeLocale() === 'en' ? ', ' : '、') })
|
|
99
100
|
: undefined;
|
|
100
101
|
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
102
|
? createElement('span', { className: 'xy-meta xy-actioned' },
|
|
@@ -105,7 +106,7 @@ function TaskView(props) {
|
|
|
105
106
|
? createElement('button', { className: 'xy-btn xy-btn-inline', disabled: busy, onClick: () => run('claim') }, t('action.claim'))
|
|
106
107
|
: null, showCheckIn
|
|
107
108
|
? 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
|
|
109
|
+
: 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
110
|
? createElement('div', { className: 'xy-preview' }, preview)
|
|
110
111
|
: null);
|
|
111
112
|
}
|
|
@@ -139,23 +140,31 @@ function CheckinView(props) {
|
|
|
139
140
|
const event = props.node.data.data;
|
|
140
141
|
const cancelled = event.op === 'cancelled';
|
|
141
142
|
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)}`));
|
|
143
|
+
? t('checkin.cancelled', { task: event.taskName, date: formatShortDate(event.date) })
|
|
144
|
+
: 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
145
|
}
|
|
145
146
|
function ChartView(props) {
|
|
146
147
|
const t = useXyT();
|
|
147
148
|
const event = props.node.data.data;
|
|
148
149
|
// 悬停柱下标(与成长页悬浮明细同一交互语法;null = 未悬停)
|
|
149
150
|
const [hoverIdx, setHoverIdx] = useState(null);
|
|
150
|
-
|
|
151
|
+
const title = localizeChartTitle(event);
|
|
152
|
+
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' }, title), event.subtitle !== undefined ? createElement('span', { className: 'xy-meta' }, localizeChartSubtitle(event.subtitle)) : null),
|
|
153
|
+
// 快照时点标注(事件溯源:卡片=当时的事实):无 generatedAt 的旧事件诚实降级不显示。
|
|
154
|
+
// 中格式带年份——回放的旧卡可能跨年,「生成于 3月5日」须能定位到哪一年。
|
|
155
|
+
// ISO → 本地日再格式化:直接 slice 会把 UTC 日期当本地日(UTC+8 零点后 8 小时内差一天)
|
|
156
|
+
event.generatedAt !== undefined
|
|
157
|
+
? createElement('div', { className: 'xy-meta' }, t('chart.generatedAt', { date: formatMediumDate(localYmd(new Date(event.generatedAt))) }))
|
|
158
|
+
: null, chartBody(event, title, t('chart.noData'), t('chart.noSchedule'), hoverIdx, setHoverIdx));
|
|
151
159
|
}
|
|
152
|
-
/** 图表卡主体:四类渲染路径(占比条/占比列表/热力格/柱图)颜色全部走主题变量,深浅色自适应。
|
|
153
|
-
|
|
160
|
+
/** 图表卡主体:四类渲染路径(占比条/占比列表/热力格/柱图)颜色全部走主题变量,深浅色自适应。
|
|
161
|
+
* 标签/序列名经 chart-labels 本地化(服务端内建中文词的展示层映射,未知值原样回显)。 */
|
|
162
|
+
function chartBody(event, title, noDataText, noScheduleText, hoverIdx, onHover) {
|
|
154
163
|
const { chartType, data } = event;
|
|
155
164
|
if (data.length === 0)
|
|
156
165
|
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}`;
|
|
166
|
+
const max = Math.max(...data.filter((d) => !d.inactive).map((d) => d.value), 1);
|
|
167
|
+
const labelValue = (d) => `${localizeChartLabel(d.label)}${activeLocale() === 'en' ? ': ' : ':'}${d.inactive === true ? noScheduleText : d.value}`;
|
|
159
168
|
if (chartType === 'arcbars') {
|
|
160
169
|
const ratio = Math.min(Math.max(data[0].ratio ?? 0, 0), 1);
|
|
161
170
|
return createElement('div', { className: 'xy-arcwrap' }, createElement('div', { className: 'xy-arcnum' }, `${Math.round(ratio * 100)}%`),
|
|
@@ -165,7 +174,7 @@ function chartBody(event, noDataText, hoverIdx, onHover) {
|
|
|
165
174
|
if (chartType === 'pie' || chartType === 'radar') {
|
|
166
175
|
// 占比分母守卫:总和为 0 时跳过百分比(避免 NaN%)
|
|
167
176
|
const sum = data.reduce((s, x) => s + x.value, 0);
|
|
168
|
-
return createElement('ul', { className: 'xy-rows' }, ...data.map((d, i) => createElement('li', { key: i, className: 'xy-row' }, createElement('span', null, d.label), createElement('span', { className: 'xy-rowval' }, `${d.value}${chartType === 'pie' && sum > 0 ? ` · ${Math.round((d.value / sum) * 100)}%` : ''}`))));
|
|
177
|
+
return createElement('ul', { className: 'xy-rows' }, ...data.map((d, i) => createElement('li', { key: i, className: 'xy-row' }, createElement('span', null, localizeChartLabel(d.label)), createElement('span', { className: 'xy-rowval' }, `${d.value}${chartType === 'pie' && sum > 0 ? ` · ${Math.round((d.value / sum) * 100)}%` : ''}`))));
|
|
169
178
|
}
|
|
170
179
|
if (chartType === 'heatmap') {
|
|
171
180
|
return createElement('div', null, createElement('div', { className: 'xy-heat', 'aria-hidden': 'true' }, ...data.map((d, i) => createElement('span', {
|
|
@@ -175,7 +184,7 @@ function chartBody(event, noDataText, hoverIdx, onHover) {
|
|
|
175
184
|
style: { opacity: String(0.25 + 0.75 * (d.value / max)) },
|
|
176
185
|
}))),
|
|
177
186
|
// 热力格纯视觉(title 不构成可访问名):数据以屏内隐藏文本整体提供给读屏
|
|
178
|
-
createElement('span', { className: 'xy-visually-hidden' }, data.map((d) => `${d.label} ${d.value}`).join(activeLocale() === 'en' ? '; ' : ';')));
|
|
187
|
+
createElement('span', { className: 'xy-visually-hidden' }, data.map((d) => `${localizeChartLabel(d.label)} ${d.value}`).join(activeLocale() === 'en' ? '; ' : ';')));
|
|
179
188
|
}
|
|
180
189
|
const seriesList = [...new Set(data.map((d) => d.series).filter((s) => s !== undefined))];
|
|
181
190
|
const seriesColor = (index) => index <= 0 ? 'var(--xyd-accent)' : 'var(--dsw-alias-label-secondary)';
|
|
@@ -187,19 +196,23 @@ function chartBody(event, noDataText, hoverIdx, onHover) {
|
|
|
187
196
|
const r = Math.min(2, h, w / 2);
|
|
188
197
|
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
198
|
};
|
|
190
|
-
const bars = data.
|
|
199
|
+
const bars = data.flatMap((d, i) => {
|
|
200
|
+
// 无安排日不画柱(缺失≠0):空槽由下标占位保留,柱序与日期轴不塌缩
|
|
201
|
+
if (d.inactive === true)
|
|
202
|
+
return [];
|
|
191
203
|
// 微值可见性下限:有值但按比例不足 2px 的柱给 2px,避免「有数据却看不见」
|
|
192
204
|
const rawH = Math.round((d.value / max) * (height - 24));
|
|
193
205
|
const h = d.value > 0 ? Math.max(rawH, 2) : rawH;
|
|
194
206
|
const seriesIdx = d.series !== undefined ? seriesList.indexOf(d.series) : -1;
|
|
195
|
-
return createElement('path', {
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
207
|
+
return [createElement('path', {
|
|
208
|
+
key: i,
|
|
209
|
+
d: topRoundedBar(i * step + 2, height - 18 - h, Math.max(step - 4, 2), h),
|
|
210
|
+
// 次要序列 opacity 0.8:0.55 曾把次要柱对卡底压到 2.1:1(非文字 3:1 下限之下)
|
|
211
|
+
style: { fill: seriesIdx > 0 ? seriesColor(seriesIdx) : 'var(--xyd-accent)', opacity: seriesIdx > 0 ? 0.8 : 0.85 },
|
|
212
|
+
onMouseEnter: () => onHover(i),
|
|
213
|
+
},
|
|
214
|
+
// 每根柱子带 <title>:原生悬停提示;精确数值另由上方悬浮明细条实时呈现
|
|
215
|
+
createElement('title', null, labelValue(d)))];
|
|
203
216
|
});
|
|
204
217
|
// 标签用原始下标定位:indexOf 会把重复标签(如分组系列的「周一」×2)全部钉到首个位置
|
|
205
218
|
const labelStep = data.length <= 10 ? 1 : Math.ceil(data.length / 8);
|
|
@@ -209,9 +222,9 @@ function chartBody(event, noDataText, hoverIdx, onHover) {
|
|
|
209
222
|
.map(({ d, i }) => createElement('text', {
|
|
210
223
|
key: `l${i}`, x: i * step + step / 2, y: height - 4,
|
|
211
224
|
fontSize: 11, textAnchor: 'middle', style: { fill: 'var(--dsw-alias-label-secondary)' },
|
|
212
|
-
}, d.label));
|
|
225
|
+
}, localizeChartLabel(d.label)));
|
|
213
226
|
const legend = seriesList.length > 1
|
|
214
|
-
? createElement('div', { className: 'xy-chart-legend' }, ...seriesList.map((name, idx) => createElement('span', { key: name }, createElement('i', { className: 'xy-dot', style: { background: seriesColor(idx), opacity: idx > 0 ? 0.
|
|
227
|
+
? createElement('div', { className: 'xy-chart-legend' }, ...seriesList.map((name, idx) => createElement('span', { key: name }, createElement('i', { className: 'xy-dot', style: { background: seriesColor(idx), opacity: idx > 0 ? 0.8 : 1 } }), localizeChartSeries(name))))
|
|
215
228
|
: null;
|
|
216
229
|
const hovered = hoverIdx !== null ? data[hoverIdx] : undefined;
|
|
217
230
|
return createElement('div', null, legend, createElement('div', { onMouseLeave: () => onHover(null) },
|
|
@@ -220,8 +233,8 @@ function chartBody(event, noDataText, hoverIdx, onHover) {
|
|
|
220
233
|
// 图表整体作为一张图暴露给读屏;逐条数值以屏内隐藏文本补充
|
|
221
234
|
createElement('svg', {
|
|
222
235
|
viewBox: `0 0 ${width} ${height}`, className: 'xy-svg',
|
|
223
|
-
role: 'img', 'aria-label':
|
|
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' ? '; ' : ';')));
|
|
236
|
+
role: 'img', 'aria-label': title,
|
|
237
|
+
}, 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) => `${localizeChartLabel(d.label)} ${d.inactive === true ? noScheduleText : d.value}`).join(activeLocale() === 'en' ? '; ' : ';')));
|
|
225
238
|
}
|
|
226
239
|
export const CARD_VIEWS = {
|
|
227
240
|
WishView,
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 图表卡词表的客户端本地化。
|
|
3
|
+
*
|
|
4
|
+
* 背景(终端用户评审 P1):服务端 charts.ts 的内建标题/副标题/枚举标签是中文权威源,
|
|
5
|
+
* en 用户会在图表卡上直接看到「周一 / 本周 / 未分类」。事件是 whole-value 冻结快照,
|
|
6
|
+
* 历史事件只有中文标签,因此本地化放在消费端:按 chartKey 与「已知中文标签 → 键」
|
|
7
|
+
* 的映射表渲染,未知值(模型自定义标题、用户数据标签如愿望名)原样回显——诚实降级。
|
|
8
|
+
*
|
|
9
|
+
* 一致性锁定:映射表的中文键必须与 charts.ts 内建词逐字一致,由 test/chart-labels.test.ts
|
|
10
|
+
* 用真实 buildChart 输出对拍(内建词改动而映射表未跟 → 测试红)。
|
|
11
|
+
*/
|
|
12
|
+
import { t } from './i18n.js';
|
|
13
|
+
/** 各 chartKey 的内建默认标题(与 charts.ts `params.title ?? '…'` 逐字同源)。 */
|
|
14
|
+
export const CHART_DEFAULT_TITLES = {
|
|
15
|
+
checkinTrend: '打卡趋势',
|
|
16
|
+
checkinCalendar: '打卡日历',
|
|
17
|
+
taskCompletionRate: '任务完成率',
|
|
18
|
+
checkinRateTrend: '打卡完成率',
|
|
19
|
+
weekComparison: '本周打卡对比',
|
|
20
|
+
taskStatus: '任务状态分布',
|
|
21
|
+
checkinByCategory: '分类打卡分布',
|
|
22
|
+
wishCategory: '愿望分类分布',
|
|
23
|
+
checkinRanking: '打卡排行',
|
|
24
|
+
taskDistribution: '任务分布',
|
|
25
|
+
wishProgress: '愿望进度',
|
|
26
|
+
wishAchievement: '愿望达成率',
|
|
27
|
+
continuousCheckin: '连续打卡',
|
|
28
|
+
checkinTimeDistribution: '打卡时间分布',
|
|
29
|
+
weeklyActivity: '周度活跃度',
|
|
30
|
+
};
|
|
31
|
+
/** 已知中文枚举标签 → 本地化键(weekComparison/weeklyActivity 星期、时段桶、状态、系列名等)。
|
|
32
|
+
* 导出供 test/chart-labels.test.ts 做「服务端内建词全覆盖」对拍锁定。 */
|
|
33
|
+
export const CHART_LABEL_KEYS = {
|
|
34
|
+
周一: 'chart.weekday.1',
|
|
35
|
+
周二: 'chart.weekday.2',
|
|
36
|
+
周三: 'chart.weekday.3',
|
|
37
|
+
周四: 'chart.weekday.4',
|
|
38
|
+
周五: 'chart.weekday.5',
|
|
39
|
+
周六: 'chart.weekday.6',
|
|
40
|
+
周日: 'chart.weekday.7',
|
|
41
|
+
'凌晨(0-5)': 'chart.hour.0',
|
|
42
|
+
'早晨(6-8)': 'chart.hour.1',
|
|
43
|
+
'上午(9-11)': 'chart.hour.2',
|
|
44
|
+
'中午(12-13)': 'chart.hour.3',
|
|
45
|
+
'下午(14-17)': 'chart.hour.4',
|
|
46
|
+
'傍晚(18-19)': 'chart.hour.5',
|
|
47
|
+
'夜间(20-23)': 'chart.hour.6',
|
|
48
|
+
待领取: 'task.status.pending',
|
|
49
|
+
进行中: 'task.status.in_progress',
|
|
50
|
+
已完结: 'task.status.closed',
|
|
51
|
+
未分类: 'chart.uncategorized',
|
|
52
|
+
未关联: 'chart.unlinked',
|
|
53
|
+
完成率: 'chart.label.completionRate',
|
|
54
|
+
达成率: 'chart.label.achievementRate',
|
|
55
|
+
'当前/最长': 'chart.label.currentVsBest',
|
|
56
|
+
};
|
|
57
|
+
export const CHART_SERIES_KEYS = {
|
|
58
|
+
本周: 'chart.series.this',
|
|
59
|
+
上周: 'chart.series.last',
|
|
60
|
+
};
|
|
61
|
+
/** 数据点标签本地化:已知中文枚举标签映射,其余(用户数据)原样回显。 */
|
|
62
|
+
export function localizeChartLabel(label) {
|
|
63
|
+
const key = CHART_LABEL_KEYS[label];
|
|
64
|
+
return key !== undefined ? t(key) : label;
|
|
65
|
+
}
|
|
66
|
+
/** 分组序列名本地化(图例与柱色对应关系按原字符串分组,故仅展示层替换)。 */
|
|
67
|
+
export function localizeChartSeries(series) {
|
|
68
|
+
const key = CHART_SERIES_KEYS[series];
|
|
69
|
+
return key !== undefined ? t(key) : series;
|
|
70
|
+
}
|
|
71
|
+
/** 标题本地化:内建默认标题按 chartKey 映射;模型自定义标题原样回显。 */
|
|
72
|
+
export function localizeChartTitle(event) {
|
|
73
|
+
const key = `chart.title.${event.chartKey}`;
|
|
74
|
+
if (event.title === CHART_DEFAULT_TITLES[event.chartKey])
|
|
75
|
+
return t(key);
|
|
76
|
+
return event.title;
|
|
77
|
+
}
|
|
78
|
+
/** 已知中文固定副标题 → 本地化键。 */
|
|
79
|
+
export const CHART_SUBTITLE_KEYS = {
|
|
80
|
+
最近一年: 'chart.subtitle.lastYear',
|
|
81
|
+
'按任务 TopN': 'chart.subtitle.byTaskTopN',
|
|
82
|
+
'按愿望 TopN': 'chart.subtitle.byWishTopN',
|
|
83
|
+
'按实际打卡时刻分桶;过滤范围为打卡日区间': 'chart.subtitle.byHourBucket',
|
|
84
|
+
'%(应打天数完成率)': 'chart.subtitle.progressPercent',
|
|
85
|
+
};
|
|
86
|
+
/**
|
|
87
|
+
* 副标题本地化:固定短语查表;含数字的模板(近 N 天 / A/B 天 / 当前 A 天 / 最长 B 天 /
|
|
88
|
+
* 含未领取任务)按正则重排——服务端模板变化时未知副标题原样回显(诚实降级)。
|
|
89
|
+
*/
|
|
90
|
+
export function localizeChartSubtitle(subtitle) {
|
|
91
|
+
const fixed = CHART_SUBTITLE_KEYS[subtitle];
|
|
92
|
+
if (fixed !== undefined)
|
|
93
|
+
return t(fixed);
|
|
94
|
+
let m = /^近 (\d+) 天$/.exec(subtitle);
|
|
95
|
+
if (m !== null)
|
|
96
|
+
return t('chart.subtitle.lastDays', { n: m[1] });
|
|
97
|
+
m = /^(\d+)\/(\d+) 天(含未领取任务)$/.exec(subtitle);
|
|
98
|
+
if (m !== null)
|
|
99
|
+
return t('chart.subtitle.daysInclPending', { done: m[1], due: m[2] });
|
|
100
|
+
m = /^(\d+)\/(\d+) 天$/.exec(subtitle);
|
|
101
|
+
if (m !== null)
|
|
102
|
+
return t('chart.subtitle.daysRatio', { done: m[1], due: m[2] });
|
|
103
|
+
m = /^当前 (\d+) 天 \/ 最长 (\d+) 天$/.exec(subtitle);
|
|
104
|
+
if (m !== null)
|
|
105
|
+
return t('chart.subtitle.streak', { current: m[1], best: m[2] });
|
|
106
|
+
return subtitle;
|
|
107
|
+
}
|