dsh-offwork 1.1.1 → 1.2.2

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.
Files changed (44) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.md +48 -26
  3. package/ROADMAP.md +70 -0
  4. package/client.js +5066 -1099
  5. package/client.source.js +1988 -541
  6. package/companion-app.js +1431 -0
  7. package/companion-image.js +59 -0
  8. package/companion-state.js +424 -0
  9. package/companion.js +6 -9
  10. package/countdown.js +83 -0
  11. package/docs/RELEASE-1.2.2.md +20 -0
  12. package/hydration-store.js +55 -16
  13. package/hydration.js +231 -250
  14. package/icon.svg +28 -1
  15. package/index.js +59 -6
  16. package/locale/en.json +1 -1
  17. package/locale/zh.json +1 -1
  18. package/media-manifest.js +29 -0
  19. package/media-server.js +123 -0
  20. package/package.json +13 -3
  21. package/pet-interactions.js +121 -17
  22. package/pet-motion.js +2 -1
  23. package/pet-reminder.js +20 -0
  24. package/pet-surfaces.js +130 -6
  25. package/pet-video.js +221 -72
  26. package/pet-view.js +71 -4
  27. package/settings-api.js +126 -0
  28. package/tests/classic-overview.test.mjs +77 -0
  29. package/tests/companion-state.test.mjs +267 -0
  30. package/tests/companion.test.mjs +12 -12
  31. package/tests/countdown.test.mjs +115 -0
  32. package/tests/host.test.mjs +26 -13
  33. package/tests/hydration-extras.test.mjs +168 -204
  34. package/tests/hydration-store.test.mjs +199 -213
  35. package/tests/hydration.test.mjs +116 -186
  36. package/tests/media-server.test.mjs +179 -0
  37. package/tests/pet-interactions.test.mjs +19 -0
  38. package/tests/pet-reminder.test.mjs +71 -0
  39. package/tests/pet-video.test.mjs +171 -5
  40. package/tests/settings-api.test.mjs +130 -0
  41. package/tests/ui.test.mjs +4 -1
  42. package/tools/build.mjs +45 -16
  43. package/tools/check-package.mjs +12 -0
  44. package/ui-utils.js +44 -0
@@ -0,0 +1,59 @@
1
+ // Browser-only image intake: rasterize before persistence so imported SVG/URLs
2
+ // never execute and backups contain a bounded, self-contained PNG.
3
+ export async function loadCompanionImage(file, scope = window) {
4
+ if (
5
+ !file ||
6
+ !['image/png', 'image/webp', 'image/jpeg'].includes(file.type) ||
7
+ file.size > 5 * 1024 * 1024
8
+ )
9
+ throw new Error('请选择5MB以内的PNG、WebP或JPEG / Choose a PNG, WebP or JPEG under 5MB');
10
+ const magic = new Uint8Array(await file.slice(0, 24).arrayBuffer());
11
+ const signature =
12
+ file.type === 'image/png'
13
+ ? magic.length >= 24 && [137, 80, 78, 71, 13, 10, 26, 10].every((v, i) => magic[i] === v)
14
+ : file.type === 'image/webp'
15
+ ? String.fromCharCode(...magic.slice(0, 4)) === 'RIFF' &&
16
+ String.fromCharCode(...magic.slice(8, 12)) === 'WEBP'
17
+ : magic[0] === 255 && magic[1] === 216 && magic[2] === 255;
18
+ if (!signature) throw new Error('图片内容与格式不符 / Image signature does not match its format');
19
+ if (file.type === 'image/png') {
20
+ const header = new DataView(magic.buffer);
21
+ if (header.getUint32(16) * header.getUint32(20) > 24e6)
22
+ throw new Error('图片尺寸过大 / Image dimensions too large');
23
+ }
24
+ const url = scope.URL.createObjectURL(file);
25
+ try {
26
+ const image = new scope.Image();
27
+ image.src = url;
28
+ await image.decode();
29
+ if (
30
+ !image.naturalWidth ||
31
+ !image.naturalHeight ||
32
+ image.naturalWidth * image.naturalHeight > 24e6
33
+ )
34
+ throw new Error('图片尺寸过大 / Image dimensions too large');
35
+ return image;
36
+ } finally {
37
+ scope.URL.revokeObjectURL(url);
38
+ }
39
+ }
40
+ export function cropCompanionImage(image, { zoom = 1, x = 50, y = 50 } = {}, doc = document) {
41
+ const canvas = doc.createElement('canvas');
42
+ canvas.width = 384;
43
+ canvas.height = 512;
44
+ const ctx = canvas.getContext('2d');
45
+ const base = Math.max(canvas.width / image.naturalWidth, canvas.height / image.naturalHeight);
46
+ const scale = base * Math.max(1, Math.min(3, zoom));
47
+ const width = image.naturalWidth * scale,
48
+ height = image.naturalHeight * scale;
49
+ ctx.drawImage(
50
+ image,
51
+ (-(width - canvas.width) * Math.max(0, Math.min(100, x))) / 100,
52
+ (-(height - canvas.height) * Math.max(0, Math.min(100, y))) / 100,
53
+ width,
54
+ height,
55
+ );
56
+ const data = canvas.toDataURL('image/png');
57
+ if (data.length > 1500000) throw new Error('图片过大,请缩小后重试 / Image is too large');
58
+ return data;
59
+ }
@@ -0,0 +1,424 @@
1
+ import {
2
+ defaultCountdown,
3
+ validateCountdown,
4
+ countdownView,
5
+ transitionCountdown,
6
+ } from './countdown.js';
7
+ import { hydrationView, validateHydration } from './hydration.js';
8
+ import { normalizeView } from './ui-utils.js';
9
+
10
+ // Shared companion state. All updates run inside createStateStore transactions.
11
+ export const COMPANION_DB = 'dsh.offwork-clock.companion.v1';
12
+ export const COMPANION_THEMES = {
13
+ host: '',
14
+ lavender: '#7779bc',
15
+ ocean: '#397fbd',
16
+ rose: '#b96c89',
17
+ slate: '#65748b',
18
+ };
19
+ export function defaultCompanionState() {
20
+ return {
21
+ schema: 1,
22
+ revision: 0,
23
+ preferences: {
24
+ sound: false,
25
+ notifications: false,
26
+ quietUntil: 0,
27
+ wrapEnabled: false,
28
+ leadMinutes: 10,
29
+ deferTyping: true,
30
+ linkedSession: '',
31
+ theme: 'host',
32
+ cardScale: 1,
33
+ customEnabled: false,
34
+ image: null,
35
+ },
36
+ timers: [],
37
+ deliveries: {},
38
+ journal: {},
39
+ history: [],
40
+ };
41
+ }
42
+ function companionAssert(condition) {
43
+ if (!condition) throw new Error('Invalid companion data / 搭子数据格式无效');
44
+ }
45
+ const companionTime = (n) => Number.isSafeInteger(n) && n >= 0;
46
+ const companionText = (s, max) => typeof s === 'string' && s.length <= max;
47
+ export function validateCompanionState(value) {
48
+ companionAssert(value?.schema === 1 && companionTime(value.revision));
49
+ const p = value.preferences;
50
+ companionAssert(
51
+ p && Object.keys(p).every((k) => Object.hasOwn(defaultCompanionState().preferences, k)),
52
+ );
53
+ companionAssert(
54
+ p &&
55
+ ['sound', 'notifications', 'wrapEnabled', 'deferTyping', 'customEnabled'].every(
56
+ (k) => typeof p[k] === 'boolean',
57
+ ),
58
+ );
59
+ companionAssert(
60
+ companionTime(p.quietUntil) &&
61
+ Number.isInteger(p.leadMinutes) &&
62
+ p.leadMinutes >= 0 &&
63
+ p.leadMinutes <= 60,
64
+ );
65
+ companionAssert(companionText(p.linkedSession, 200) && Object.hasOwn(COMPANION_THEMES, p.theme));
66
+ companionAssert([0.9, 1, 1.1, 1.2].includes(p.cardScale));
67
+ companionAssert(
68
+ p.image === null ||
69
+ (typeof p.image === 'string' &&
70
+ p.image.length <= 1500000 &&
71
+ /^data:image\/png;base64,[A-Za-z0-9+/]+=*$/.test(p.image)),
72
+ );
73
+ companionAssert(Array.isArray(value.timers) && value.timers.length <= 8);
74
+ const ids = new Set();
75
+ for (const t of value.timers) {
76
+ companionAssert(
77
+ companionText(t.id, 80) &&
78
+ /^[a-zA-Z0-9_-]+$/.test(t.id) &&
79
+ !ids.has(t.id) &&
80
+ companionText(t.name, 40) &&
81
+ t.name.trim().length > 0,
82
+ );
83
+ ids.add(t.id);
84
+ validateCountdown(t.clock);
85
+ }
86
+ companionAssert(
87
+ value.deliveries &&
88
+ typeof value.deliveries === 'object' &&
89
+ !Array.isArray(value.deliveries) &&
90
+ Object.keys(value.deliveries).length <= 128,
91
+ );
92
+ for (const [id, d] of Object.entries(value.deliveries)) {
93
+ companionAssert(
94
+ companionText(id, 250) && !['__proto__', 'constructor', 'prototype'].includes(id),
95
+ );
96
+ companionAssert(
97
+ d &&
98
+ ['dueAt', 'createdAt', 'snoozeUntil', 'leaseUntil', 'notifiedAt'].every((k) =>
99
+ companionTime(d[k]),
100
+ ) &&
101
+ typeof d.dismissed === 'boolean' &&
102
+ companionText(d.owner, 100),
103
+ );
104
+ }
105
+ companionAssert(
106
+ value.journal &&
107
+ typeof value.journal === 'object' &&
108
+ !Array.isArray(value.journal) &&
109
+ Object.keys(value.journal).length <= 120,
110
+ );
111
+ for (const [date, j] of Object.entries(value.journal)) {
112
+ companionAssert(
113
+ /^\d{4}-\d{2}-\d{2}$/.test(date) &&
114
+ companionText(j.done, 500) &&
115
+ companionText(j.next, 500) &&
116
+ companionTime(j.endedAt) &&
117
+ companionTime(j.updatedAt),
118
+ );
119
+ }
120
+ companionAssert(Array.isArray(value.history) && value.history.length <= 1500);
121
+ for (const e of value.history)
122
+ companionAssert(
123
+ companionText(e.id, 250) &&
124
+ companionText(e.label, 80) &&
125
+ ['water', 'timer', 'wrap'].includes(e.type) &&
126
+ companionTime(e.at),
127
+ );
128
+ return structuredClone({
129
+ schema: 1,
130
+ revision: value.revision,
131
+ preferences: p,
132
+ timers: value.timers,
133
+ deliveries: value.deliveries,
134
+ journal: value.journal,
135
+ history: value.history,
136
+ });
137
+ }
138
+ export function companionEvents(state, input, now) {
139
+ const events = [];
140
+ if (input.smokingReady && countdownView(input.smoking, now).phase === 'due')
141
+ events.push({
142
+ id: 'smoking:' + input.smoking.deadline,
143
+ kind: 'smoking',
144
+ dueAt: input.smoking.deadline,
145
+ label: input.smokingLabel,
146
+ });
147
+ const waterClaim = input.water?.claim;
148
+ const waterOwnedElsewhere =
149
+ input.waterOwner &&
150
+ waterClaim?.owner !== input.waterOwner &&
151
+ waterClaim?.sequence === input.water?.sequence &&
152
+ waterClaim?.until > now &&
153
+ waterClaim.until <= now + 8000;
154
+ if (input.waterReady && !waterOwnedElsewhere && hydrationView(input.water, now).phase === 'due') {
155
+ const view = hydrationView(input.water, now);
156
+ events.push({
157
+ id: 'water:' + view.eventId,
158
+ kind: 'water',
159
+ eventId: view.eventId,
160
+ dueAt: input.water.dueAt,
161
+ label: input.waterLabel,
162
+ });
163
+ }
164
+ for (const timer of state.timers)
165
+ if (countdownView(timer.clock, now).phase === 'due')
166
+ events.push({
167
+ id: 'timer:' + timer.id + ':' + timer.clock.deadline,
168
+ kind: 'timer',
169
+ timerId: timer.id,
170
+ dueAt: timer.clock.deadline,
171
+ label: timer.name,
172
+ });
173
+ const shift = input.shift;
174
+ if (
175
+ state.preferences.wrapEnabled &&
176
+ shift?.isWorkday &&
177
+ now >= shift.shiftStart &&
178
+ !state.journal[shift.shiftDate]?.endedAt
179
+ ) {
180
+ const dueAt = shift.shiftEnd - state.preferences.leadMinutes * 60000;
181
+ if (now >= dueAt)
182
+ events.push({
183
+ id: 'wrap:' + shift.shiftDate + ':' + shift.shiftEnd,
184
+ kind: 'wrap',
185
+ date: shift.shiftDate,
186
+ dueAt,
187
+ label: input.wrapLabel,
188
+ });
189
+ }
190
+ return events.sort((a, b) => a.dueAt - b.dueAt);
191
+ }
192
+ export function companionPending(state, events, now) {
193
+ if (state.preferences.quietUntil > now) return [];
194
+ return events.filter((e) => {
195
+ const d = state.deliveries[e.id];
196
+ return d && !d.dismissed && d.snoozeUntil <= now;
197
+ });
198
+ }
199
+ export function reconcileCompanion(state, events, now) {
200
+ const next = validateCompanionState(state);
201
+ const active = new Set(events.map((e) => e.id));
202
+ for (const event of events)
203
+ if (!Object.hasOwn(next.deliveries, event.id))
204
+ next.deliveries[event.id] = {
205
+ dueAt: event.dueAt,
206
+ createdAt: now,
207
+ snoozeUntil: 0,
208
+ dismissed: false,
209
+ owner: '',
210
+ leaseUntil: 0,
211
+ notifiedAt: 0,
212
+ };
213
+ const records = Object.entries(next.deliveries).sort((a, b) => b[1].createdAt - a[1].createdAt);
214
+ next.deliveries = Object.fromEntries(
215
+ records
216
+ .filter(([id, d], i) => active.has(id) || (i < 100 && now - d.createdAt < 90 * 86400000))
217
+ .slice(0, 128),
218
+ );
219
+ next.history = next.history.filter((e) => now - e.at < 90 * 86400000).slice(-1500);
220
+ return next;
221
+ }
222
+ export function companionTransition(value, action, now) {
223
+ const state = validateCompanionState(value);
224
+ companionAssert(companionTime(now));
225
+ const record = () => {
226
+ const d = state.deliveries[action.id];
227
+ if (!d) throw new Error('Reminder changed / 提醒已变化');
228
+ return d;
229
+ };
230
+ switch (action.type) {
231
+ case 'preferences':
232
+ companionAssert(
233
+ action.patch && Object.keys(action.patch).every((k) => Object.hasOwn(state.preferences, k)),
234
+ );
235
+ Object.assign(state.preferences, action.patch);
236
+ break;
237
+ case 'dismiss': {
238
+ const d = record();
239
+ d.dismissed = true;
240
+ d.owner = '';
241
+ d.leaseUntil = 0;
242
+ break;
243
+ }
244
+ case 'snooze': {
245
+ const d = record();
246
+ d.snoozeUntil = now + 10 * 60000;
247
+ d.dismissed = false;
248
+ d.owner = '';
249
+ d.leaseUntil = 0;
250
+ d.notifiedAt = 0;
251
+ break;
252
+ }
253
+ case 'claim': {
254
+ const d = record();
255
+ if (
256
+ !d.dismissed &&
257
+ d.snoozeUntil <= now &&
258
+ state.preferences.quietUntil <= now &&
259
+ (d.owner === action.owner || d.leaseUntil <= now)
260
+ ) {
261
+ d.owner = action.owner;
262
+ d.leaseUntil = now + 8000;
263
+ }
264
+ break;
265
+ }
266
+ case 'notify': {
267
+ const d = record();
268
+ if (
269
+ !d.notifiedAt &&
270
+ !d.dismissed &&
271
+ d.snoozeUntil <= now &&
272
+ state.preferences.quietUntil <= now
273
+ )
274
+ d.notifiedAt = now;
275
+ break;
276
+ }
277
+ case 'release':
278
+ for (const d of Object.values(state.deliveries))
279
+ if (d.owner === action.owner) {
280
+ d.owner = '';
281
+ d.leaseUntil = 0;
282
+ }
283
+ break;
284
+ case 'add-timer': {
285
+ if (state.timers.length >= 8) throw new Error('最多保存8个计时 / Up to 8 timers');
286
+ if (state.timers.some((t) => t.id === action.id)) break;
287
+ state.timers.push({
288
+ id: action.id,
289
+ name: action.name.trim(),
290
+ clock: { ...defaultCountdown(), minutes: action.minutes },
291
+ });
292
+ break;
293
+ }
294
+ case 'timer': {
295
+ const timer = state.timers.find((t) => t.id === action.id);
296
+ if (!timer || timer.clock.revision !== action.revision)
297
+ throw new Error('Timer changed / 计时已在其他页面改变,请重试');
298
+ if (action.operation.type === 'delete') {
299
+ state.timers = state.timers.filter((t) => t !== timer);
300
+ break;
301
+ }
302
+ timer.clock = transitionCountdown(timer.clock, action.operation, now);
303
+ timer.clock.revision++;
304
+ break;
305
+ }
306
+ case 'journal': {
307
+ const previous = state.journal[action.date];
308
+ if ((previous?.updatedAt || 0) !== action.base)
309
+ throw new Error('Notes changed in another tab / 另一页面已修改收尾记录,请重新打开');
310
+ state.journal[action.date] = {
311
+ done: action.done,
312
+ next: action.next,
313
+ endedAt: action.end ? now : previous?.endedAt || 0,
314
+ updatedAt: Math.max(now, (previous?.updatedAt || 0) + 1),
315
+ };
316
+ state.journal = Object.fromEntries(
317
+ Object.entries(state.journal)
318
+ .sort(([a], [b]) => b.localeCompare(a))
319
+ .slice(0, 120),
320
+ );
321
+ if (action.end && !state.history.some((e) => e.id === 'wrap:' + action.date))
322
+ state.history.push({ id: 'wrap:' + action.date, type: 'wrap', at: now, label: 'Wrap-up' });
323
+ break;
324
+ }
325
+ case 'record':
326
+ if (!state.history.some((e) => e.id === action.event.id)) state.history.push(action.event);
327
+ state.history = state.history.slice(-1500);
328
+ break;
329
+ case 'restore': {
330
+ const restored = validateCompanionState(action.value);
331
+ if (
332
+ state.revision !== action.base &&
333
+ (!action.stamp || companionStateStamp(state) !== action.stamp)
334
+ )
335
+ throw new Error('Data changed; preview the backup again / 数据已变化,请重新预览备份');
336
+ restored.revision = state.revision;
337
+ restored.preferences.sound = false;
338
+ restored.preferences.notifications = false;
339
+ for (const d of Object.values(restored.deliveries)) {
340
+ d.owner = '';
341
+ d.leaseUntil = 0;
342
+ }
343
+ return restored;
344
+ }
345
+ default:
346
+ throw new Error('Unknown companion action');
347
+ }
348
+ return validateCompanionState(state);
349
+ }
350
+ export function companionDaySummary(state, date, timezone) {
351
+ const formatter = new Intl.DateTimeFormat('en-CA', {
352
+ timeZone: timezone,
353
+ year: 'numeric',
354
+ month: '2-digit',
355
+ day: '2-digit',
356
+ });
357
+ const localDate = (at) => {
358
+ const p = Object.fromEntries(formatter.formatToParts(at).map((p) => [p.type, p.value]));
359
+ return `${p.year}-${p.month}-${p.day}`;
360
+ };
361
+ const events = state.history.filter((e) => localDate(e.at) === date);
362
+ return {
363
+ water: events.filter((e) => e.type === 'water').length,
364
+ timers: events.filter((e) => e.type === 'timer').length,
365
+ };
366
+ }
367
+
368
+ export function validateBrowserBackup(value) {
369
+ companionAssert(
370
+ value?.format === 'dsh-offwork-browser' &&
371
+ value.version === 1 &&
372
+ companionTime(value.createdAt),
373
+ );
374
+ return {
375
+ format: value.format,
376
+ version: 1,
377
+ createdAt: value.createdAt,
378
+ companion: validateCompanionState(value.companion),
379
+ water: validateHydration(value.water),
380
+ smoking: validateCountdown(value.smoking),
381
+ view: normalizeView(value.view),
382
+ };
383
+ }
384
+ // Ignore transient leases and revisions; user-visible changes invalidate a restore preview.
385
+ export function companionStateStamp(c) {
386
+ return JSON.stringify({
387
+ preferences: c.preferences,
388
+ timers: c.timers,
389
+ journal: c.journal,
390
+ history: c.history,
391
+ choices: Object.entries(c.deliveries)
392
+ .filter(([, d]) => d.dismissed || d.snoozeUntil)
393
+ .sort(([a], [b]) => a.localeCompare(b))
394
+ .map(([id, d]) => [id, d.dismissed, d.snoozeUntil]),
395
+ });
396
+ }
397
+ export function companionWaterStamp(w) {
398
+ const { claim, revision, ...value } = w;
399
+ return JSON.stringify(value);
400
+ }
401
+ export function companionBackupStamp(value) {
402
+ const { revision: smokeRevision, ...smoking } = value.smoking;
403
+ return JSON.stringify({
404
+ companion: companionStateStamp(value.companion),
405
+ water: companionWaterStamp(value.water),
406
+ smoking,
407
+ view: value.view,
408
+ });
409
+ }
410
+ export function createCompanionRecoveryStore(indexedDB, createStore) {
411
+ return createStore(
412
+ indexedDB,
413
+ 'dsh.offwork-clock.restore.v1',
414
+ () => ({ schema: 1, revision: 0, bundle: null }),
415
+ (value) => {
416
+ companionAssert(value?.schema === 1 && companionTime(value.revision));
417
+ return {
418
+ schema: 1,
419
+ revision: value.revision,
420
+ bundle: value.bundle === null ? null : validateBrowserBackup(value.bundle),
421
+ };
422
+ },
423
+ );
424
+ }
package/companion.js CHANGED
@@ -14,9 +14,7 @@ export function companionPresentation({
14
14
  return { visible: false, pose: 'calm', message: null, announce: false, animate: false };
15
15
  let pose = 'calm';
16
16
  let message = 'companionWorking';
17
- const quiet =
18
- ['quiet', 'paused'].includes(phase) && !['before', 'done', 'rest'].includes(workStatus);
19
- const resting = ['lunch', 'rest', 'done', 'before'].includes(workStatus);
17
+ const quiet = ['quiet', 'paused'].includes(phase);
20
18
  if (!ready) {
21
19
  pose = 'rest';
22
20
  message = 'companionLoading';
@@ -26,6 +24,9 @@ export function companionPresentation({
26
24
  } else if (quiet) {
27
25
  pose = 'quiet';
28
26
  message = phase === 'quiet' ? 'companionQuiet' : 'companionPaused';
27
+ } else if (phase === 'due' && ownsPrompt) {
28
+ pose = 'drink';
29
+ message = null;
29
30
  } else if (workStatus === 'lunch') {
30
31
  pose = 'rest';
31
32
  message = 'companionBreak';
@@ -38,17 +39,13 @@ export function companionPresentation({
38
39
  } else if (workStatus === 'before') {
39
40
  pose = 'rest';
40
41
  message = 'companionBefore';
41
- } else if (phase === 'due' && ownsPrompt) {
42
- // The existing leased reminder is the only source of the due message.
43
- pose = 'drink';
44
- message = null;
45
42
  }
46
43
  let announce = false;
47
44
  let animate = false;
48
45
  if (ready && !failed && !(phase === 'due' && ownsPrompt) && feedback) {
49
46
  message = feedback.key;
50
47
  announce = true;
51
- if (feedback.kind === 'drink' && !quiet && !resting) {
48
+ if (feedback.kind === 'drink' && !quiet) {
52
49
  pose = 'happy';
53
50
  animate = motion && pageVisible;
54
51
  }
@@ -132,7 +129,7 @@ export const COMPANION_MESSAGES = {
132
129
  companionPreferences: '立即生效,保存在本浏览器。隐藏角色后仍可使用计时和喝水操作。',
133
130
  companionWorking: '你忙你的,我陪你记着时间。',
134
131
  companionQuiet: '好,先安静陪着你。',
135
- companionPaused: '本班先不提醒,需要时再叫我。',
132
+ companionPaused: '提醒先暂停,需要时再叫我。',
136
133
  companionBreak: '休息一下,回来再继续。',
137
134
  companionBefore: '还没开工,先慢慢准备。',
138
135
  companionDone: '这一班结束啦,辛苦了。',
package/countdown.js ADDED
@@ -0,0 +1,83 @@
1
+ // A user-started wall-clock countdown. No hydration or salary state is involved.
2
+ export const SMOKING_TIMER_DB = 'dsh.offwork-clock.smoking.v1';
3
+ export const COUNTDOWN_INTERVALS = [
4
+ 1, 5, 10, 15, 20, 30, 45, 60, 90, 120, 180, 240, 480, 720, 1440,
5
+ ];
6
+ export function defaultCountdown() {
7
+ return { schema: 1, revision: 0, minutes: 60, status: 'idle', deadline: 0, remaining: 0 };
8
+ }
9
+ export function validateCountdown(value) {
10
+ const integer = (n) => Number.isSafeInteger(n) && n >= 0;
11
+ if (
12
+ !value ||
13
+ value.schema !== 1 ||
14
+ !integer(value.revision) ||
15
+ !Number.isInteger(value.minutes) ||
16
+ value.minutes < 1 ||
17
+ value.minutes > 1440 ||
18
+ !['idle', 'running', 'paused'].includes(value.status) ||
19
+ !integer(value.deadline) ||
20
+ !integer(value.remaining) ||
21
+ value.remaining > 86400000 ||
22
+ (value.status === 'running'
23
+ ? value.deadline === 0 || value.remaining !== 0
24
+ : value.deadline !== 0) ||
25
+ (value.status === 'idle' && value.remaining !== 0) ||
26
+ (value.status === 'paused' && value.remaining === 0)
27
+ )
28
+ throw new Error('Invalid countdown state');
29
+ return {
30
+ schema: 1,
31
+ revision: value.revision,
32
+ minutes: value.minutes,
33
+ status: value.status,
34
+ deadline: value.deadline,
35
+ remaining: value.remaining,
36
+ };
37
+ }
38
+ export function countdownView(value, now) {
39
+ const remaining =
40
+ value.status === 'running' ? Math.max(0, value.deadline - now) : value.remaining;
41
+ return { remaining, phase: value.status === 'running' && remaining === 0 ? 'due' : value.status };
42
+ }
43
+ export function transitionCountdown(value, action, now) {
44
+ const state = validateCountdown(value);
45
+ if (!Number.isSafeInteger(now) || now < 0) throw new Error('Invalid countdown time');
46
+ const current = countdownView(state, now);
47
+ switch (action.type) {
48
+ case 'interval':
49
+ state.minutes = action.minutes;
50
+ break;
51
+ case 'start':
52
+ if (current.phase !== 'idle' && current.phase !== 'due') return state;
53
+ state.status = 'running';
54
+ state.deadline = now + state.minutes * 60000;
55
+ state.remaining = 0;
56
+ break;
57
+ case 'restart':
58
+ state.status = 'running';
59
+ state.deadline = now + state.minutes * 60000;
60
+ state.remaining = 0;
61
+ break;
62
+ case 'pause':
63
+ if (current.phase !== 'running') return state;
64
+ state.status = 'paused';
65
+ state.remaining = Math.min(86400000, current.remaining);
66
+ state.deadline = 0;
67
+ break;
68
+ case 'resume':
69
+ if (current.phase !== 'paused') return state;
70
+ state.status = 'running';
71
+ state.deadline = now + state.remaining;
72
+ state.remaining = 0;
73
+ break;
74
+ case 'stop':
75
+ state.status = 'idle';
76
+ state.deadline = 0;
77
+ state.remaining = 0;
78
+ break;
79
+ default:
80
+ throw new Error('Unknown countdown action');
81
+ }
82
+ return validateCountdown(state);
83
+ }
@@ -0,0 +1,20 @@
1
+ # 1.2.2:页面收拢与信息层级
2
+
3
+ 本地候选版,尚未发布到 GitHub/npm。
4
+
5
+ ## 主要调整
6
+
7
+ - 下班卡片只保留计时、班次详情、收尾入口和收入明细。刷新与作息设置不再作为并列文字按钮,设置入口放到卡片右上角。
8
+ - 卡片标题会跟随状态显示“上班倒计时 / 下班倒计时 / 本班已完成 / 今天休息”。
9
+ - 外观页的角色选择在主区域,大小、透明度和动作收进“显示与动作”;预览高度缩短,主题、卡片尺寸和自定义人物统一通过底部“应用”保存。
10
+ - 设置页按“入口 / 提醒 / 高级”分层:作息薪资、喝水间隔、备份恢复是清晰的行入口;提示音、通知、免打扰和下班提醒集中展示;测试提示与代理权限折叠在高级设置。
11
+ - “全部免打扰30分钟”使用明确的按钮样式;链接箭头不再混入按钮辅助名称。
12
+ - 人物卡片不再长期显示“喝了一杯”。只有喝水提醒到期时才显示快捷记录;平时从喝水卡片操作。
13
+ - 自定义计时运行中仍显示在人物脚下,点击回到计时卡片。
14
+
15
+ ## 验证
16
+
17
+ - 187项Node测试通过,格式、生成物、语法和打包检查通过。
18
+ - 隔离Harness Chromium回归:计时/喝水/收尾/外观/备份、未保存保护、到点稍后、非宠物模式、快捷入口、320/390/640视口和触屏深色主题。
19
+ - 实际 xxy 环境加载 `data-version=1.2.2`,设置接口 HTTP 200,页面无运行错误。
20
+ - 旧薪资、喝水、抽烟、计时和外观数据沿用;不清理站点数据。