dsh-offwork 1.1.2 → 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.
@@ -0,0 +1,1431 @@
1
+ export const COMPANION_APP_STYLE = `
2
+ .owc-extra{padding:0 16px 12px;font:inherit;color:inherit;cursor:default;user-select:text;touch-action:pan-y}
3
+ .owc-appearance .owc-extra{padding:0}.owc-extra *{box-sizing:border-box}.owc-extra h3{font-size:12px;font-weight:600;margin:14px 0 8px}.owc-extra p{margin:8px 0;line-height:1.65}.owc-extra .hub-muted,.owc-extra small{font-size:11px;opacity:.72}.owc-extra small{display:block}.owc-extra .hub-row{display:flex;gap:7px;align-items:center;flex-wrap:wrap;margin:9px 0}.owc-extra label{display:block;margin:10px 0}.owc-extra input:not([type=checkbox]),.owc-extra select,.owc-extra textarea{width:100%;padding:7px 9px;border:1px solid color-mix(in srgb,var(--owc-accent) 20%,transparent);border-radius:8px;background:var(--dsw-alias-bg-base,#f7f8fb);color:inherit;font:inherit}.owc-extra input[type=checkbox]{margin-right:8px}.owc-extra textarea{min-height:64px;resize:vertical}.owc-extra article{padding:10px 0;border-top:1px solid color-mix(in srgb,var(--owc-accent) 15%,transparent)}.owc-extra output{font-variant-numeric:tabular-nums}.owc-extra button{min-height:32px;padding:5px 10px;border-radius:8px}.owc-extra button[data-primary=true]{background:var(--owc-accent);color:white}.owc-extra details{margin:8px 0}.owc-extra summary{font-size:11px;cursor:pointer}.owc-extra .hub-error{color:#bd465a}.owc-extra .hub-preview{display:block;width:144px;height:192px;object-fit:contain;margin:8px auto;background:repeating-conic-gradient(#ddd 0% 25%,#f7f7f7 0% 50%) 50%/16px 16px;border-radius:10px}.owc-extra-feedback:empty{display:none}.owc-extra-feedback p{font-size:11px}.owc-extra [data-wrap-entry]{width:100%;text-align:left}.owc-wrap-entry{padding-top:12px;border-top:1px solid color-mix(in srgb,var(--owc-accent) 15%,transparent)}
4
+ .owc-v2 button.owc-pet-clock,.owc-v2 button.owc-pet-timer-row{cursor:pointer;color:inherit;min-height:28px;padding:4px 7px;border:0;border-radius:0;background:transparent}.owc-v2 button.owc-pet-clock{width:100%;justify-content:center}.owc-v2 button.owc-pet-timer-row:hover,.owc-v2 button.owc-pet-clock:hover{background:color-mix(in srgb,var(--owc-accent) 9%,transparent)}.owc-quick-actions{display:flex;gap:4px;align-items:center;padding:4px 6px;border-top:1px solid color-mix(in srgb,var(--owc-accent) 12%,transparent)}.owc-v2 .owc-quick-actions button{min-height:28px;padding:3px 7px;font-size:11px;background:transparent;border:0;display:flex;align-items:center;justify-content:center;gap:5px}.owc-quick-actions button:first-child{flex:1}.owc-quick-actions .owc-quick-more{font-size:18px;min-width:30px}.owc-v2 .owc-quick-actions button:hover{background:color-mix(in srgb,var(--owc-accent) 10%,transparent)}.owc-v2 .owc-quick-actions button:focus-visible,.owc-v2 button.owc-pet-timer-row:focus-visible,.owc-v2 button.owc-pet-clock:focus-visible{outline:2px solid var(--owc-accent);outline-offset:-2px}.owc-extra label.owc-check{display:flex;align-items:center;gap:8px;flex:1}.owc-extra .hub-row>.owc-check{margin:0}.owc-extra button.owc-link-row{width:100%;display:flex;align-items:center;justify-content:space-between;padding:9px 0;border:0;border-bottom:1px solid color-mix(in srgb,var(--owc-accent) 12%,transparent);border-radius:0;background:transparent;text-align:left}.owc-extra button.owc-link-row>span:last-child{opacity:.5;font-size:16px}.owc-extra button.owc-secondary{background:transparent;border:1px solid color-mix(in srgb,var(--owc-accent) 25%,transparent);padding:5px 10px}.owc-v2 .owc-quick-actions .owc-quick-more:only-child{flex:none;margin-left:auto;min-height:24px;padding:0 6px;font-size:16px}.owc-extra details>summary{font-weight:500}.owc-extra .owc-setting-links{margin:4px 0 12px}.owc-extra .owc-appearance-colors,.owc-extra .owc-appearance-custom{border-top:1px solid color-mix(in srgb,var(--owc-accent) 12%,transparent);padding-top:10px;margin-top:12px}.owc-v2 button.owc-inline-timer{width:100%;display:flex;align-items:center;gap:5px;min-height:28px;background:transparent;border:0;border-top:1px solid color-mix(in srgb,var(--owc-accent) 12%,transparent);border-radius:0;padding:4px 8px;font-size:11px}.owc-inline-timer span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;text-align:left}.owc-inline-timer strong{font-variant-numeric:tabular-nums}.owc-v2 button.owc-classic-mini-time{padding:0;min-height:24px;border:0;background:transparent;font:inherit;font-weight:600;cursor:pointer}.owc-v2 button.owc-classic-mini-time:hover{color:var(--owc-accent)}@media(pointer:coarse){.owc-v2 .owc-quick-actions button,.owc-v2 button.owc-pet-clock,.owc-v2 button.owc-pet-timer-row{min-height:40px}}
5
+ `;
6
+
7
+ export function useCompanionFeatures(React, input) {
8
+ const h = React.createElement;
9
+ const { useState, useRef, useEffect } = React;
10
+ const zh = !String(input.language || 'zh').startsWith('en');
11
+ const text = (cn, en) => (zh ? cn : en);
12
+ const [state, setState] = useState(defaultCompanionState);
13
+ const [ready, setReady] = useState(false),
14
+ [error, setError] = useState(''),
15
+ [notice, setNotice] = useState('');
16
+ const screen = input.screen;
17
+ const [busy, setBusy] = useState(false);
18
+ const [appearanceDraft, setAppearanceDraft] = useState(null);
19
+ const appearanceBase = useRef(null);
20
+ const [addingTimer, setAddingTimer] = useState(false);
21
+ const [tick, setTick] = useState(Date.now());
22
+ const [agentAccess, setAgentAccess] = useState(false);
23
+ const [backup, setBackup] = useState(null),
24
+ [restoreBefore, setRestoreBefore] = useState(null);
25
+ const [photo, setPhoto] = useState(null),
26
+ [crop, setCrop] = useState({ zoom: 1, x: 50, y: 50 }),
27
+ [photoPreview, setPhotoPreview] = useState('');
28
+ const [notes, setNotes] = useState(null),
29
+ [dirty, setDirty] = useState(false);
30
+ const [testReminder, setTestReminder] = useState(false);
31
+ const [soundReady, setSoundReady] = useState(false);
32
+ const latest = useRef(input);
33
+ latest.current = input;
34
+ const current = useRef(state);
35
+ current.current = state;
36
+ const runtime = useRef(null),
37
+ owner = useRef(null),
38
+ audio = useRef(null);
39
+ const lastInput = useRef(0),
40
+ alive = useRef(true);
41
+ if (!owner.current) owner.current = crypto.randomUUID();
42
+ const now = tick;
43
+ const events = ready ? companionEvents(state, input, now) : [];
44
+ const pending = companionPending(state, events, now);
45
+ const owned = pending.filter(
46
+ (e) =>
47
+ state.deliveries[e.id]?.owner === owner.current && state.deliveries[e.id]?.leaseUntil > now,
48
+ );
49
+ const active = testReminder
50
+ ? { id: 'test', kind: 'test', label: text('测试计时已结束', 'Test timer finished') }
51
+ : owned[0];
52
+ const showAlert = !!(active && !screen && !input.menuOpen && !document.hidden && !input.editing);
53
+
54
+ async function settingsCall(action, extra = {}) {
55
+ const response = await fetch(new URL('offwork-clock/settings', document.baseURI), {
56
+ method: action === 'get' ? 'GET' : 'POST',
57
+ credentials: 'same-origin',
58
+ cache: 'no-store',
59
+ headers: {
60
+ 'X-Offwork-Client': '1',
61
+ ...(action === 'get' ? {} : { 'Content-Type': 'application/json' }),
62
+ },
63
+ ...(action === 'get' ? {} : { body: JSON.stringify({ action, ...extra }) }),
64
+ });
65
+ const body = await response.json();
66
+ if (!response.ok || !body.ok) throw new Error(body.error || 'Settings unavailable');
67
+ return body;
68
+ }
69
+ async function unlockSound() {
70
+ const C = window.AudioContext || window.webkitAudioContext;
71
+ if (!C) throw new Error(text('浏览器不支持提示音', 'Audio is unavailable'));
72
+ if (!audio.current) audio.current = new C();
73
+ await audio.current.resume();
74
+ setSoundReady(audio.current.state === 'running');
75
+ }
76
+ function chime() {
77
+ if (audio.current?.state !== 'running') return false;
78
+ const ctx = audio.current,
79
+ osc = ctx.createOscillator(),
80
+ gain = ctx.createGain();
81
+ osc.connect(gain);
82
+ gain.connect(ctx.destination);
83
+ osc.frequency.value = 660;
84
+ gain.gain.setValueAtTime(0.0001, ctx.currentTime);
85
+ gain.gain.exponentialRampToValueAtTime(0.08, ctx.currentTime + 0.02);
86
+ gain.gain.exponentialRampToValueAtTime(0.0001, ctx.currentTime + 0.35);
87
+ osc.start();
88
+ osc.stop(ctx.currentTime + 0.4);
89
+ osc.onended = () => {
90
+ osc.disconnect();
91
+ gain.disconnect();
92
+ };
93
+ return true;
94
+ }
95
+ useEffect(() => {
96
+ alive.current = true;
97
+ const changed = () => {
98
+ lastInput.current = Date.now();
99
+ };
100
+ const unlock = () => {
101
+ if (current.current.preferences.sound) void unlockSound().catch(() => {});
102
+ };
103
+ document.addEventListener('input', changed);
104
+ document.addEventListener('pointerdown', unlock);
105
+ return () => {
106
+ alive.current = false;
107
+ document.removeEventListener('input', changed);
108
+ document.removeEventListener('pointerdown', unlock);
109
+ void audio.current?.close();
110
+ };
111
+ }, []);
112
+ useEffect(() => {
113
+ let store = createStateStore(
114
+ window.indexedDB,
115
+ COMPANION_DB,
116
+ defaultCompanionState,
117
+ validateCompanionState,
118
+ ),
119
+ closed = false,
120
+ running = false,
121
+ failed = false;
122
+ let channel;
123
+ try {
124
+ channel = new BroadcastChannel(COMPANION_DB);
125
+ } catch {}
126
+ const publish = (result) => {
127
+ if (!closed) {
128
+ setState(result);
129
+ setReady(true);
130
+ setTick(Date.now());
131
+ }
132
+ };
133
+ async function update(action) {
134
+ const result = await store.update((s) => companionTransition(s, action, Date.now()));
135
+ publish(result);
136
+ channel?.postMessage('changed');
137
+ return result;
138
+ }
139
+ async function poll() {
140
+ if (closed || running || failed) return;
141
+ running = true;
142
+ let notify = null;
143
+ try {
144
+ const result = await store.update((s) => {
145
+ const time = Date.now(),
146
+ signals = latest.current;
147
+ const nextEvents = companionEvents(s, signals, time);
148
+ let next = reconcileCompanion(s, nextEvents, time);
149
+ const available = companionPending(next, nextEvents, time);
150
+ const first = available[0];
151
+ const recentlyTyping = next.preferences.deferTyping && time - lastInput.current < 5000;
152
+ const linkedRunning =
153
+ next.preferences.linkedSession &&
154
+ signals.runningSessions?.includes(next.preferences.linkedSession);
155
+ const defer =
156
+ first &&
157
+ first.kind !== 'smoking' &&
158
+ first.kind !== 'timer' &&
159
+ (recentlyTyping || linkedRunning) &&
160
+ time - next.deliveries[first.id].createdAt < 120000;
161
+ if (!document.hidden && first && !signals.editing && !defer)
162
+ next = companionTransition(
163
+ next,
164
+ { type: 'claim', id: first.id, owner: owner.current },
165
+ time,
166
+ );
167
+ else next = companionTransition(next, { type: 'release', owner: owner.current }, time);
168
+ if (first && !defer && !signals.editing) {
169
+ const d = next.deliveries[first.id];
170
+ const foreground = !document.hidden && d.owner === owner.current;
171
+ const background =
172
+ document.hidden &&
173
+ d.leaseUntil <= time &&
174
+ next.preferences.notifications &&
175
+ window.Notification?.permission === 'granted';
176
+ if (!d.notifiedAt && (foreground || background)) {
177
+ next = companionTransition(next, { type: 'notify', id: first.id }, time);
178
+ notify = { event: first, background, preferences: next.preferences };
179
+ }
180
+ }
181
+ return next;
182
+ });
183
+ publish(result);
184
+ if (notify && !closed) {
185
+ if (notify.preferences.sound) chime();
186
+ if (notify.background) {
187
+ try {
188
+ const notification = new Notification(
189
+ text('下班搭子 · 到点了', 'Offwork · Time is up'),
190
+ { body: notify.event.label, tag: 'offwork:' + notify.event.id },
191
+ );
192
+ notification.onclick = () => {
193
+ window.focus();
194
+ setScreen('reminders');
195
+ notification.close();
196
+ };
197
+ } catch {
198
+ setNotice(
199
+ text(
200
+ '系统通知未能发送,回到页面可查看提醒',
201
+ 'System notification unavailable; reminders remain in the page',
202
+ ),
203
+ );
204
+ }
205
+ }
206
+ }
207
+ } catch {
208
+ failed = true;
209
+ setError(
210
+ text(
211
+ '本地数据暂不可用;自动提醒已暂停,请重试读取。',
212
+ 'Local storage unavailable. Automatic reminders paused; retry loading.',
213
+ ),
214
+ );
215
+ } finally {
216
+ running = false;
217
+ }
218
+ }
219
+ runtime.current = {
220
+ update,
221
+ poll,
222
+ retry: async () => {
223
+ store.close();
224
+ store = createStateStore(
225
+ window.indexedDB,
226
+ COMPANION_DB,
227
+ defaultCompanionState,
228
+ validateCompanionState,
229
+ );
230
+ failed = false;
231
+ setError('');
232
+ await poll();
233
+ },
234
+ read: () => store.update((s) => s),
235
+ };
236
+ const sync = () => {
237
+ void poll();
238
+ };
239
+ channel && (channel.onmessage = sync);
240
+ const interval = setInterval(sync, 1000);
241
+ sync();
242
+ document.addEventListener('visibilitychange', sync);
243
+ window.addEventListener('pageshow', sync);
244
+ window.addEventListener('focus', sync);
245
+ return () => {
246
+ closed = true;
247
+ clearInterval(interval);
248
+ document.removeEventListener('visibilitychange', sync);
249
+ window.removeEventListener('pageshow', sync);
250
+ window.removeEventListener('focus', sync);
251
+ channel?.close();
252
+ runtime.current = null;
253
+ void store
254
+ .update((s) =>
255
+ companionTransition(s, { type: 'release', owner: owner.current }, Date.now()),
256
+ )
257
+ .catch(() => {})
258
+ .finally(() => store.close());
259
+ };
260
+ }, []);
261
+
262
+ async function act(action) {
263
+ if (!runtime.current) return;
264
+ setBusy(true);
265
+ setError('');
266
+ try {
267
+ return await runtime.current.update(action);
268
+ } catch (e) {
269
+ setError(e.message);
270
+ } finally {
271
+ if (alive.current) setBusy(false);
272
+ }
273
+ }
274
+ async function execute(fn) {
275
+ setBusy(true);
276
+ setError('');
277
+ try {
278
+ return await fn();
279
+ } catch (e) {
280
+ setError(e.message);
281
+ } finally {
282
+ if (alive.current) setBusy(false);
283
+ }
284
+ }
285
+ function setScreen(next) {
286
+ latest.current.navigate(
287
+ next === 'settings' ? 'preferences' : next === 'timers' ? 'smoking' : next,
288
+ );
289
+ }
290
+ function open(next, event) {
291
+ latest.current.navigate(
292
+ next === 'settings' ? 'preferences' : next === 'timers' ? 'smoking' : next,
293
+ event,
294
+ );
295
+ }
296
+ function close() {
297
+ latest.current.navigate(null);
298
+ }
299
+ function discardDrafts() {
300
+ setNotes(null);
301
+ setDirty(false);
302
+ setPhoto(null);
303
+ setPhotoPreview('');
304
+ setAppearanceDraft(null);
305
+ appearanceBase.current = null;
306
+ setBackup(null);
307
+ setNotice('');
308
+ setError('');
309
+ }
310
+ function beginAppearance() {
311
+ const p = current.current.preferences;
312
+ const next = {
313
+ theme: p.theme,
314
+ cardScale: p.cardScale,
315
+ customEnabled: p.customEnabled,
316
+ image: p.image,
317
+ };
318
+ appearanceBase.current = next;
319
+ setAppearanceDraft(next);
320
+ setPhoto(null);
321
+ setError('');
322
+ }
323
+ function changeAppearance(patch) {
324
+ setAppearanceDraft((p) => ({ ...p, ...patch }));
325
+ }
326
+ async function applyAppearance() {
327
+ if (!appearanceDraft || busy) return false;
328
+ const patch = changedSettings(
329
+ appearanceBase.current || current.current.preferences,
330
+ appearanceDraft,
331
+ );
332
+ if (photo) {
333
+ if (!photoPreview) return false;
334
+ patch.image = photoPreview;
335
+ patch.customEnabled = true;
336
+ }
337
+ const saved = await act({ type: 'preferences', patch });
338
+ if (saved) {
339
+ setPhoto(null);
340
+ setAppearanceDraft(null);
341
+ appearanceBase.current = null;
342
+ }
343
+ return !!saved;
344
+ }
345
+ useEffect(() => {
346
+ if (screen === 'preferences')
347
+ void execute(async () => {
348
+ const r = await settingsCall('get');
349
+ setAgentAccess(r.agentAccess);
350
+ });
351
+ }, [screen]);
352
+ useEffect(() => {
353
+ if (!photo) {
354
+ setPhotoPreview('');
355
+ return;
356
+ }
357
+ try {
358
+ setPhotoPreview(cropCompanionImage(photo, crop));
359
+ } catch (e) {
360
+ setError(e.message);
361
+ }
362
+ }, [photo, crop]);
363
+
364
+ useEffect(() => {
365
+ const recovery = createCompanionRecoveryStore(window.indexedDB, createStateStore);
366
+ let live = true;
367
+ recovery
368
+ .update((s) => s)
369
+ .then((s) => {
370
+ if (live && s.bundle) setRestoreBefore(s.bundle);
371
+ })
372
+ .catch(() => {})
373
+ .finally(() => recovery.close());
374
+ return () => {
375
+ live = false;
376
+ };
377
+ }, []);
378
+
379
+ function download(name, value) {
380
+ const blob = new Blob([JSON.stringify(value, null, 2)], { type: 'application/json' }),
381
+ url = URL.createObjectURL(blob),
382
+ a = document.createElement('a');
383
+ a.href = url;
384
+ a.download = name;
385
+ a.click();
386
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
387
+ }
388
+ async function exportBackup() {
389
+ return execute(async () => {
390
+ const local = await runtime.current.read();
391
+ const waterStore = createHydrationStore(window.indexedDB),
392
+ smokeStore = createStateStore(
393
+ window.indexedDB,
394
+ SMOKING_TIMER_DB,
395
+ defaultCountdown,
396
+ validateCountdown,
397
+ );
398
+ let water, smoking;
399
+ try {
400
+ water = await waterStore.update((s) => s);
401
+ smoking = await smokeStore.update((s) => s);
402
+ } finally {
403
+ waterStore.close();
404
+ smokeStore.close();
405
+ }
406
+ // Browser data only; salary is an explicitly separate export below.
407
+ download('offwork-browser-backup.json', {
408
+ format: 'dsh-offwork-browser',
409
+ version: 1,
410
+ createdAt: Date.now(),
411
+ companion: local,
412
+ water,
413
+ smoking,
414
+ view: latest.current.view,
415
+ });
416
+ setNotice(
417
+ text(
418
+ '浏览器数据已导出;不包含薪资。文件含个人记录,请妥善保存。',
419
+ 'Browser data exported without salary. Keep the personal backup private.',
420
+ ),
421
+ );
422
+ });
423
+ }
424
+ async function inspectBackup(file) {
425
+ return execute(async () => {
426
+ if (!file || file.size > 3 * 1024 * 1024)
427
+ throw new Error(text('备份须小于3MB', 'Backup must be smaller than 3MB'));
428
+ const parsed = validateBrowserBackup(JSON.parse(await file.text()));
429
+ const w = createHydrationStore(window.indexedDB),
430
+ s = createStateStore(
431
+ window.indexedDB,
432
+ SMOKING_TIMER_DB,
433
+ defaultCountdown,
434
+ validateCountdown,
435
+ );
436
+ try {
437
+ const baseline = {
438
+ companion: await runtime.current.read(),
439
+ water: await w.update((x) => x),
440
+ smoking: await s.update((x) => x),
441
+ view: latest.current.view,
442
+ };
443
+ setBackup({ data: parsed, stamp: companionBackupStamp(baseline) });
444
+ } finally {
445
+ w.close();
446
+ s.close();
447
+ }
448
+ });
449
+ }
450
+ async function restoreBackup() {
451
+ return execute(async () => {
452
+ if (!backup) return;
453
+ // Capture rollback material before the first write. A partial failure stays
454
+ // visible and can be restored; never silently claim a cross-DB transaction.
455
+ const waterStore = createHydrationStore(window.indexedDB),
456
+ smokeStore = createStateStore(
457
+ window.indexedDB,
458
+ SMOKING_TIMER_DB,
459
+ defaultCountdown,
460
+ validateCountdown,
461
+ );
462
+ const recovery = createCompanionRecoveryStore(window.indexedDB, createStateStore);
463
+ try {
464
+ const before = {
465
+ companion: await runtime.current.read(),
466
+ water: await waterStore.update((s) => s),
467
+ smoking: await smokeStore.update((s) => s),
468
+ view: latest.current.view,
469
+ };
470
+ if (companionBackupStamp(before) !== backup.stamp)
471
+ throw new Error(
472
+ text(
473
+ '预览后数据已变化,请重新选择备份文件。',
474
+ 'Data changed after preview. Select the backup again.',
475
+ ),
476
+ );
477
+ const rollback = {
478
+ format: 'dsh-offwork-browser',
479
+ version: 1,
480
+ createdAt: Date.now(),
481
+ ...before,
482
+ };
483
+ await recovery.update((s) => ({ ...s, bundle: rollback }));
484
+ setRestoreBefore(rollback);
485
+ download('offwork-before-restore.json', rollback);
486
+ const original = backup.data;
487
+ await runtime.current.update({
488
+ type: 'restore',
489
+ value: original.companion,
490
+ base: before.companion.revision,
491
+ stamp: companionStateStamp(before.companion),
492
+ });
493
+ await waterStore.update((s) => {
494
+ if (companionWaterStamp(s) !== companionWaterStamp(before.water))
495
+ throw new Error('Water changed; restore interrupted');
496
+ return { ...original.water, claim: null, revision: s.revision };
497
+ });
498
+ await smokeStore.update((s) => {
499
+ if (s.revision !== before.smoking.revision)
500
+ throw new Error('Timer changed; restore interrupted');
501
+ return { ...original.smoking, revision: s.revision };
502
+ });
503
+ latest.current.setView(original.view);
504
+ latest.current.refreshLegacy();
505
+ setBackup(null);
506
+ setNotice(
507
+ text(
508
+ '已恢复,通知和声音需重新开启。恢复前备份已下载。',
509
+ 'Restored. Re-enable sound/notifications if wanted. A pre-restore backup was downloaded.',
510
+ ),
511
+ );
512
+ } catch (e) {
513
+ setError(
514
+ text(
515
+ '恢复没有完成。请检查下方错误;若已有部分写入,可从“上次恢复前备份”下载回退文件。',
516
+ 'Restore did not complete. If any data was written, use the pre-restore backup below to roll back.',
517
+ ) +
518
+ ' ' +
519
+ e.message,
520
+ );
521
+ } finally {
522
+ waterStore.close();
523
+ smokeStore.close();
524
+ recovery.close();
525
+ }
526
+ });
527
+ }
528
+ async function handleReminder(event, operation) {
529
+ if (event.kind === 'test') {
530
+ setTestReminder(false);
531
+ setScreen('settings');
532
+ setNotice(
533
+ text(
534
+ '测试完成;没有修改计时或记录。',
535
+ 'Test completed; no timers or records were changed.',
536
+ ),
537
+ );
538
+ return;
539
+ }
540
+ if (operation === 'dismiss' || operation === 'snooze')
541
+ return act({ type: operation, id: event.id });
542
+ if (event.kind === 'wrap') {
543
+ open('wrap');
544
+ return;
545
+ }
546
+ if (event.kind === 'water') {
547
+ const success = await latest.current.drink();
548
+ if (success) await act({ type: 'dismiss', id: event.id });
549
+ return;
550
+ }
551
+ let success;
552
+ if (event.kind === 'smoking') success = await latest.current.restartSmoking();
553
+ else {
554
+ const timer = current.current.timers.find((t) => t.id === event.timerId);
555
+ if (timer)
556
+ success = await act({
557
+ type: 'timer',
558
+ id: timer.id,
559
+ revision: timer.clock.revision,
560
+ operation: { type: 'restart' },
561
+ });
562
+ }
563
+ if (success) {
564
+ await act({
565
+ type: 'record',
566
+ event: { id: event.id, type: 'timer', at: Date.now(), label: event.label },
567
+ });
568
+ await act({ type: 'dismiss', id: event.id });
569
+ }
570
+ }
571
+ const button = (label, onClick, primary = false, extra = {}) =>
572
+ h(
573
+ 'button',
574
+ { type: 'button', onClick, disabled: busy, 'data-primary': primary, ...extra },
575
+ label,
576
+ );
577
+ const check = (label, checked, onChange) =>
578
+ h(
579
+ 'label',
580
+ { className: 'owc-check' },
581
+ h('input', {
582
+ type: 'checkbox',
583
+ checked,
584
+ disabled: busy,
585
+ onChange: (e) => onChange(e.target.checked),
586
+ }),
587
+ label,
588
+ );
589
+ const prefs = (patch) => act({ type: 'preferences', patch });
590
+ const reminderCard = (e, compact = false) =>
591
+ h(
592
+ 'article',
593
+ { key: e.id, 'data-reminder-kind': e.kind },
594
+ !compact && h('strong', null, e.label),
595
+ h(
596
+ 'p',
597
+ { className: 'hub-muted' },
598
+ e.kind === 'wrap'
599
+ ? text('留下一件明天要做的事,再结束今天。', 'Leave one next step for tomorrow.')
600
+ : e.kind === 'water'
601
+ ? text('喝点水,再继续。', 'Time for some water.')
602
+ : text('计时已结束。', 'Time is up.'),
603
+ ),
604
+ h(
605
+ 'div',
606
+ { className: 'hub-row' },
607
+ button(
608
+ e.kind === 'water'
609
+ ? text('喝了一杯', 'Drank water')
610
+ : e.kind === 'wrap'
611
+ ? text('收尾一下', 'Wrap up')
612
+ : text('重新计时', 'Restart'),
613
+ () => handleReminder(e, 'complete'),
614
+ true,
615
+ ),
616
+ button(text('稍后10分钟', 'Snooze 10m'), () => handleReminder(e, 'snooze')),
617
+ button(text('关闭', 'Dismiss'), () => handleReminder(e, 'dismiss')),
618
+ ),
619
+ );
620
+
621
+ function timerPanel() {
622
+ return h(
623
+ React.Fragment,
624
+ null,
625
+ state.timers.length > 0 && h('h3', null, text('其他计时', 'Other timers')),
626
+ state.timers.map((timer) => {
627
+ const view = countdownView(timer.clock, now);
628
+ const snoozedUntil =
629
+ state.deliveries['timer:' + timer.id + ':' + timer.clock.deadline]?.snoozeUntil || 0;
630
+ return h(
631
+ 'article',
632
+ { key: timer.id },
633
+ h('strong', null, timer.name),
634
+ ' · ',
635
+ h(
636
+ 'output',
637
+ null,
638
+ view.phase === 'idle'
639
+ ? timer.clock.minutes + text('分钟 · 未开始', ' min · Not started')
640
+ : view.phase === 'due' && snoozedUntil > now
641
+ ? text('稍后 ', 'Snoozed ') + formatDuration(Math.ceil((snoozedUntil - now) / 1000))
642
+ : formatDuration(Math.ceil(view.remaining / 1000)),
643
+ ),
644
+ h(
645
+ 'div',
646
+ { className: 'hub-row' },
647
+ button(
648
+ view.phase === 'running'
649
+ ? text('暂停', 'Pause')
650
+ : view.phase === 'paused'
651
+ ? text('继续', 'Resume')
652
+ : text('开始', 'Start'),
653
+ () =>
654
+ act({
655
+ type: 'timer',
656
+ id: timer.id,
657
+ revision: timer.clock.revision,
658
+ operation: {
659
+ type:
660
+ view.phase === 'running'
661
+ ? 'pause'
662
+ : view.phase === 'paused'
663
+ ? 'resume'
664
+ : 'start',
665
+ },
666
+ }),
667
+ true,
668
+ ),
669
+ h(
670
+ 'details',
671
+ null,
672
+ h('summary', null, text('更多', 'More')),
673
+ button(text('重新计时', 'Restart'), () =>
674
+ act({
675
+ type: 'timer',
676
+ id: timer.id,
677
+ revision: timer.clock.revision,
678
+ operation: { type: 'restart' },
679
+ }),
680
+ ),
681
+ button(text('停止', 'Stop'), () =>
682
+ act({
683
+ type: 'timer',
684
+ id: timer.id,
685
+ revision: timer.clock.revision,
686
+ operation: { type: 'stop' },
687
+ }),
688
+ ),
689
+ button(text('删除', 'Delete'), () => {
690
+ if (window.confirm(text('删除此计时?', 'Delete this timer?')))
691
+ void act({
692
+ type: 'timer',
693
+ id: timer.id,
694
+ revision: timer.clock.revision,
695
+ operation: { type: 'delete' },
696
+ });
697
+ }),
698
+ ),
699
+ ),
700
+ );
701
+ }),
702
+ !addingTimer &&
703
+ button(text('+ 添加其他计时', '+ Add another timer'), () => setAddingTimer(true)),
704
+ addingTimer &&
705
+ h(
706
+ 'form',
707
+ {
708
+ onSubmit: async (e) => {
709
+ e.preventDefault();
710
+ const form = e.currentTarget,
711
+ data = new FormData(form);
712
+ const result = await act({
713
+ type: 'add-timer',
714
+ id: crypto.randomUUID(),
715
+ name: String(data.get('name')),
716
+ minutes: Number(data.get('minutes')),
717
+ });
718
+ if (result) {
719
+ form.reset();
720
+ setAddingTimer(false);
721
+ }
722
+ },
723
+ },
724
+ h(
725
+ 'label',
726
+ null,
727
+ text('计时名称', 'Timer name'),
728
+ h('input', {
729
+ name: 'name',
730
+ maxLength: 40,
731
+ required: true,
732
+ placeholder: text('泡茶 / 起身 / 专注', 'Tea / stretch / focus'),
733
+ }),
734
+ ),
735
+ h(
736
+ 'label',
737
+ null,
738
+ text('时长(1–1440分钟)', 'Duration (1–1440 minutes)'),
739
+ h('input', {
740
+ name: 'minutes',
741
+ type: 'number',
742
+ min: 1,
743
+ max: 1440,
744
+ step: 1,
745
+ required: true,
746
+ defaultValue: 25,
747
+ }),
748
+ ),
749
+ h(
750
+ 'button',
751
+ { type: 'submit', disabled: busy || state.timers.length >= 8 },
752
+ text('添加计时', 'Add timer'),
753
+ ),
754
+ button(text('取消', 'Cancel'), () => setAddingTimer(false)),
755
+ ),
756
+ );
757
+ }
758
+ function wrapDraft() {
759
+ const date = input.shift?.shiftDate || new Date(now).toISOString().slice(0, 10);
760
+ return notes?.date === date
761
+ ? notes
762
+ : {
763
+ date,
764
+ done: state.journal[date]?.done || '',
765
+ next: state.journal[date]?.next || '',
766
+ base: state.journal[date]?.updatedAt || 0,
767
+ };
768
+ }
769
+ async function saveNotes(end = false) {
770
+ return execute(async () => {
771
+ const entry = wrapDraft();
772
+ const result = await runtime.current.update({ type: 'journal', ...entry, end });
773
+ setNotes({ ...entry, base: result.journal[entry.date].updatedAt });
774
+ setDirty(false);
775
+ setNotice(
776
+ end
777
+ ? text('今天收尾了,明天接着来。', 'All set for tomorrow.')
778
+ : text('草稿已保存', 'Draft saved'),
779
+ );
780
+ if (end) latest.current.react?.();
781
+ return result;
782
+ });
783
+ }
784
+ function wrapPanel() {
785
+ const entry = wrapDraft(),
786
+ date = entry.date;
787
+ const summary = companionDaySummary(state, date, input.timezone || 'UTC');
788
+ const previous = Object.entries(state.journal)
789
+ .filter(([key]) => key < date)
790
+ .sort(([a], [b]) => b.localeCompare(a))[0];
791
+ return h(
792
+ React.Fragment,
793
+ null,
794
+ h(
795
+ 'p',
796
+ null,
797
+ date,
798
+ ' · ',
799
+ state.journal[date]?.endedAt
800
+ ? text('已收尾', 'Wrapped up')
801
+ : text('今天的收尾', 'Today’s wrap-up'),
802
+ ),
803
+ previous?.[1].next &&
804
+ h(
805
+ 'article',
806
+ null,
807
+ h('strong', null, text('上次留给自己的事', 'Your previous next step')),
808
+ h('p', null, previous[1].next),
809
+ ),
810
+ h(
811
+ 'label',
812
+ null,
813
+ text('今天完成了什么(可跳过)', 'What did you finish? (optional)'),
814
+ h('textarea', {
815
+ value: entry.done,
816
+ 'aria-label': text('今天完成了什么(可跳过)', 'What did you finish? (optional)'),
817
+ maxLength: 500,
818
+ onChange: (e) => {
819
+ setNotes({ ...entry, done: e.target.value });
820
+ setDirty(true);
821
+ },
822
+ }),
823
+ ),
824
+ h(
825
+ 'label',
826
+ null,
827
+ text('明天第一件事(可跳过)', 'One next step for tomorrow (optional)'),
828
+ h('textarea', {
829
+ value: entry.next,
830
+ 'aria-label': text('明天第一件事(可跳过)', 'One next step for tomorrow (optional)'),
831
+ maxLength: 500,
832
+ onChange: (e) => {
833
+ setNotes({ ...entry, next: e.target.value });
834
+ setDirty(true);
835
+ },
836
+ }),
837
+ ),
838
+ h(
839
+ 'p',
840
+ { className: 'hub-muted' },
841
+ text(
842
+ `今天喝水 ${summary.water} 次 · 计时完成 ${summary.timers} 次`,
843
+ `Today: ${summary.water} drinks · ${summary.timers} timer acknowledgements`,
844
+ ),
845
+ ),
846
+ h(
847
+ 'div',
848
+ { className: 'hub-row' },
849
+ button(text('保存草稿', 'Save draft'), () => saveNotes(false)),
850
+ button(
851
+ state.journal[date]?.endedAt
852
+ ? text('更新收尾', 'Update wrap-up')
853
+ : text('结束今天', 'End today'),
854
+ () => saveNotes(true),
855
+ true,
856
+ ),
857
+ ),
858
+ !input.configured &&
859
+ h(
860
+ 'p',
861
+ { className: 'hub-muted' },
862
+ text(
863
+ '未配置作息也可写收尾;设置作息后可开启下班前提醒。',
864
+ 'You can write a wrap-up without a schedule. Configure one to enable pre-offwork reminders.',
865
+ ),
866
+ ),
867
+ h(
868
+ 'details',
869
+ null,
870
+ h('summary', null, text('最近收尾记录', 'Recent wrap-ups')),
871
+ Object.entries(state.journal)
872
+ .sort(([a], [b]) => b.localeCompare(a))
873
+ .slice(0, 14)
874
+ .map(([d, j]) =>
875
+ h(
876
+ 'article',
877
+ { key: d },
878
+ h('strong', null, d),
879
+ h('p', null, j.done || '—'),
880
+ h('small', null, text('下一步:', 'Next: '), j.next || '—'),
881
+ ),
882
+ ),
883
+ ),
884
+ );
885
+ }
886
+ function settingsPanel() {
887
+ const p = state.preferences;
888
+ const nav = (label, target) =>
889
+ button(
890
+ h(React.Fragment, null, h('span', null, label), h('span', { 'aria-hidden': true }, '›')),
891
+ (e) => open(target, e),
892
+ false,
893
+ { className: 'owc-link-row' },
894
+ );
895
+ const permission = window.Notification?.permission;
896
+ return h(
897
+ React.Fragment,
898
+ null,
899
+ h(
900
+ 'div',
901
+ { className: 'owc-setting-links' },
902
+ nav(text('作息与薪资', 'Schedule and salary'), 'schedule'),
903
+ nav(text('喝水间隔', 'Water interval'), 'water'),
904
+ nav(text('备份与恢复', 'Backup and restore'), 'data'),
905
+ ),
906
+ h('h3', null, text('提醒', 'Reminders')),
907
+ h(
908
+ 'div',
909
+ { className: 'hub-row' },
910
+ check(text('提示音', 'Sound'), p.sound, async (value) => {
911
+ if (!value) return prefs({ sound: false });
912
+ await execute(async () => {
913
+ await unlockSound();
914
+ await runtime.current.update({ type: 'preferences', patch: { sound: true } });
915
+ });
916
+ }),
917
+ button(text('试听', 'Play sample'), () =>
918
+ execute(async () => {
919
+ await unlockSound();
920
+ chime();
921
+ }),
922
+ ),
923
+ ),
924
+ check(
925
+ text('后台浏览器通知', 'Background browser notifications'),
926
+ p.notifications,
927
+ async (value) => {
928
+ if (!value) return prefs({ notifications: false });
929
+ await execute(async () => {
930
+ if (!window.Notification || !window.isSecureContext)
931
+ throw new Error(
932
+ text(
933
+ '需要HTTPS和支持通知的浏览器',
934
+ 'HTTPS and a browser with notifications are required',
935
+ ),
936
+ );
937
+ const result = await Notification.requestPermission();
938
+ if (result !== 'granted')
939
+ throw new Error(
940
+ text(
941
+ '通知权限未授予;页面提示仍可用',
942
+ 'Permission not granted; in-page reminders remain available',
943
+ ),
944
+ );
945
+ await runtime.current.update({ type: 'preferences', patch: { notifications: true } });
946
+ });
947
+ },
948
+ ),
949
+ p.notifications &&
950
+ permission === 'denied' &&
951
+ h(
952
+ 'small',
953
+ null,
954
+ text(
955
+ '浏览器已阻止通知,请在网站设置中重新允许。',
956
+ 'Notifications are blocked; allow them in site settings.',
957
+ ),
958
+ ),
959
+ p.quietUntil > now
960
+ ? h(
961
+ 'div',
962
+ { className: 'hub-row' },
963
+ h(
964
+ 'output',
965
+ null,
966
+ text('免打扰 ', 'Quiet ') + formatDuration(Math.ceil((p.quietUntil - now) / 1000)),
967
+ ),
968
+ button(text('结束免打扰', 'End quiet mode'), () => prefs({ quietUntil: 0 })),
969
+ )
970
+ : button(
971
+ text('全部免打扰30分钟', 'Quiet for 30m'),
972
+ () => prefs({ quietUntil: Date.now() + 1800000 }),
973
+ false,
974
+ { className: 'owc-secondary' },
975
+ ),
976
+ check(text('下班前提醒收尾', 'Remind me to wrap up'), p.wrapEnabled, (value) =>
977
+ prefs({ wrapEnabled: value }),
978
+ ),
979
+ p.wrapEnabled &&
980
+ h(
981
+ 'label',
982
+ null,
983
+ text('提前多久', 'How early'),
984
+ h(
985
+ 'select',
986
+ {
987
+ 'aria-label': text('提前多久', 'How early'),
988
+ value: p.leadMinutes,
989
+ onChange: (e) => prefs({ leadMinutes: Number(e.target.value) }),
990
+ },
991
+ [0, 5, 10, 15, 30, 60].map((n) =>
992
+ h('option', { key: n, value: n }, n + text('分钟', ' min')),
993
+ ),
994
+ ),
995
+ ),
996
+ h(
997
+ 'details',
998
+ null,
999
+ h('summary', null, text('高级设置', 'Advanced settings')),
1000
+ button(text('测试页面提示', 'Test in-page reminder'), () => {
1001
+ setTestReminder(true);
1002
+ setScreen(null);
1003
+ }),
1004
+ check(
1005
+ text(
1006
+ '输入时暂缓休息与收尾提示(最多2分钟)',
1007
+ 'Defer rest/wrap-up while typing (up to 2 min)',
1008
+ ),
1009
+ p.deferTyping,
1010
+ (value) => prefs({ deferTyping: value }),
1011
+ ),
1012
+ h(
1013
+ 'label',
1014
+ null,
1015
+ text(
1016
+ '等待这个会话本轮结束再提醒(最多2分钟)',
1017
+ 'Defer while this session runs (up to 2 min)',
1018
+ ),
1019
+ h(
1020
+ 'select',
1021
+ {
1022
+ 'aria-label': text('关联会话', 'Linked session'),
1023
+ value: p.linkedSession,
1024
+ onChange: (e) => prefs({ linkedSession: e.target.value }),
1025
+ },
1026
+ h('option', { value: '' }, text('不关联', 'No linked session')),
1027
+ (input.sessionIds || []).map((id) =>
1028
+ h('option', { key: id, value: id }, input.sessionTitles?.[id] || id.slice(0, 24)),
1029
+ ),
1030
+ ),
1031
+ ),
1032
+ h(
1033
+ 'small',
1034
+ null,
1035
+ text(
1036
+ '只读取运行状态,不读取会话内容。宿主未提供状态时按普通计时提醒。',
1037
+ 'Reads status only. Falls back to normal reminders when host status is unavailable.',
1038
+ ),
1039
+ ),
1040
+ check(
1041
+ text('允许代理工具读写作息与薪资', 'Allow agent tools to read/write schedule and salary'),
1042
+ agentAccess,
1043
+ async (allowed) =>
1044
+ execute(async () => {
1045
+ const r = await settingsCall('access', { allowed });
1046
+ setAgentAccess(r.agentAccess);
1047
+ }),
1048
+ ),
1049
+ h(
1050
+ 'small',
1051
+ null,
1052
+ text(
1053
+ '默认关闭。开启后,工具返回的数据可能进入会话记录。',
1054
+ 'Off by default. Tool results may enter conversation history.',
1055
+ ),
1056
+ ),
1057
+ ),
1058
+ );
1059
+ }
1060
+ function appearancePanel() {
1061
+ const p = appearanceDraft || state.preferences;
1062
+ return h(
1063
+ React.Fragment,
1064
+ null,
1065
+ h(
1066
+ 'details',
1067
+ { className: 'owc-appearance-colors' },
1068
+ h('summary', null, text('配色与卡片', 'Color and card size')),
1069
+ h(
1070
+ 'label',
1071
+ null,
1072
+ text('主题色', 'Accent theme'),
1073
+ h(
1074
+ 'select',
1075
+ {
1076
+ 'aria-label': text('主题色', 'Accent theme'),
1077
+ value: p.theme,
1078
+ onChange: (e) => changeAppearance({ theme: e.target.value }),
1079
+ },
1080
+ Object.keys(COMPANION_THEMES).map((k) =>
1081
+ h(
1082
+ 'option',
1083
+ { key: k, value: k },
1084
+ {
1085
+ host: text('跟随宿主', 'Follow host'),
1086
+ lavender: text('雾紫', 'Lavender'),
1087
+ ocean: text('海蓝', 'Ocean'),
1088
+ rose: text('玫瑰', 'Rose'),
1089
+ slate: text('石板灰', 'Slate'),
1090
+ }[k],
1091
+ ),
1092
+ ),
1093
+ ),
1094
+ ),
1095
+ h(
1096
+ 'label',
1097
+ null,
1098
+ text('卡片字号与宽度', 'Card text and width'),
1099
+ h(
1100
+ 'select',
1101
+ {
1102
+ 'aria-label': text('卡片字号与宽度', 'Card text and width'),
1103
+ value: p.cardScale,
1104
+ onChange: (e) => changeAppearance({ cardScale: Number(e.target.value) }),
1105
+ },
1106
+ [0.9, 1, 1.1, 1.2].map((v) =>
1107
+ h('option', { key: v, value: v }, Math.round(v * 100) + '%'),
1108
+ ),
1109
+ ),
1110
+ ),
1111
+ ),
1112
+ h(
1113
+ 'details',
1114
+ { className: 'owc-appearance-custom', open: photo ? true : undefined },
1115
+ h('summary', null, text('自定义人物', 'Custom character')),
1116
+ h(
1117
+ 'label',
1118
+ null,
1119
+ text('导入自己的形象(仅本浏览器)', 'Import your image (this browser only)'),
1120
+ h('input', {
1121
+ type: 'file',
1122
+ accept: 'image/png,image/webp,image/jpeg',
1123
+ onChange: (e) => {
1124
+ const f = e.target.files[0];
1125
+ e.target.value = '';
1126
+ void execute(async () => {
1127
+ setPhoto(await loadCompanionImage(f));
1128
+ setCrop({ zoom: 1, x: 50, y: 50 });
1129
+ latest.current.enableCharacter?.();
1130
+ });
1131
+ },
1132
+ }),
1133
+ ),
1134
+ photo &&
1135
+ photoPreview &&
1136
+ h('img', {
1137
+ className: 'hub-preview',
1138
+ src: photoPreview || p.image,
1139
+ alt: text('形象裁切预览', 'Character crop preview'),
1140
+ }),
1141
+ photo &&
1142
+ h(
1143
+ React.Fragment,
1144
+ null,
1145
+ ['zoom', 'x', 'y'].map((k) =>
1146
+ h(
1147
+ 'label',
1148
+ { key: k },
1149
+ {
1150
+ zoom: text('缩放', 'Zoom'),
1151
+ x: text('水平位置', 'Horizontal position'),
1152
+ y: text('垂直位置', 'Vertical position'),
1153
+ }[k],
1154
+ h('input', {
1155
+ type: 'range',
1156
+ min: k === 'zoom' ? 1 : 0,
1157
+ max: k === 'zoom' ? 3 : 100,
1158
+ step: k === 'zoom' ? 0.05 : 1,
1159
+ value: crop[k],
1160
+ onChange: (e) => setCrop({ ...crop, [k]: Number(e.target.value) }),
1161
+ }),
1162
+ ),
1163
+ ),
1164
+ h(
1165
+ 'div',
1166
+ { className: 'hub-row' },
1167
+ button(text('取消预览', 'Cancel preview'), () => setPhoto(null)),
1168
+ ),
1169
+ ),
1170
+ p.image &&
1171
+ check(text('使用自定义人物', 'Use custom character'), p.customEnabled, (v) =>
1172
+ changeAppearance({ customEnabled: v }),
1173
+ ),
1174
+ ),
1175
+ button(text('恢复内置外观和默认主题', 'Restore built-in appearance and theme'), () =>
1176
+ changeAppearance({ customEnabled: false, theme: 'host', cardScale: 1 }),
1177
+ ),
1178
+ h(
1179
+ 'small',
1180
+ null,
1181
+ text(
1182
+ '调整后点击下方“应用外观”。取消会保留原来的形象和配色。',
1183
+ 'Choose Apply appearance below to save. Cancel keeps your previous image and colors.',
1184
+ ),
1185
+ ),
1186
+ );
1187
+ }
1188
+ function dataPanel() {
1189
+ return h(
1190
+ React.Fragment,
1191
+ null,
1192
+ h(
1193
+ 'p',
1194
+ { className: 'hub-muted' },
1195
+ text(
1196
+ '浏览器备份包含喝水、抽烟、自定义计时、收尾笔记与外观,不含薪资。恢复会替换这些浏览器数据,并先下载恢复前备份。',
1197
+ 'Browser backups include water, smoking, timers, wrap-up notes and appearance, without salary. Restore replaces this data after downloading a pre-restore backup.',
1198
+ ),
1199
+ ),
1200
+ button(text('导出浏览器备份', 'Export browser backup'), exportBackup, true),
1201
+ h(
1202
+ 'label',
1203
+ null,
1204
+ text('预览要恢复的备份', 'Preview backup to restore'),
1205
+ h('input', {
1206
+ type: 'file',
1207
+ accept: '.json,application/json',
1208
+ onChange: (e) => {
1209
+ const f = e.target.files[0];
1210
+ e.target.value = '';
1211
+ void inspectBackup(f);
1212
+ },
1213
+ }),
1214
+ ),
1215
+ backup &&
1216
+ h(
1217
+ 'article',
1218
+ null,
1219
+ h('strong', null, text('恢复预览', 'Restore preview')),
1220
+ h(
1221
+ 'p',
1222
+ null,
1223
+ text(
1224
+ `喝水计数 ${backup.data.water.count};计时 ${backup.data.companion.timers.length};收尾 ${Object.keys(backup.data.companion.journal).length} 条。`,
1225
+ `Water count ${backup.data.water.count}; timers ${backup.data.companion.timers.length}; wrap-ups ${Object.keys(backup.data.companion.journal).length}.`,
1226
+ ),
1227
+ ),
1228
+ h(
1229
+ 'p',
1230
+ { className: 'hub-muted' },
1231
+ text(
1232
+ '请暂时停止其他标签页的操作。声音与系统通知在恢复后关闭。',
1233
+ 'Pause actions in other tabs. Sound and notifications will be disabled after restore.',
1234
+ ),
1235
+ ),
1236
+ button(text('确认替换并恢复', 'Confirm replacement and restore'), restoreBackup, true),
1237
+ button(text('取消', 'Cancel'), () => setBackup(null)),
1238
+ ),
1239
+ restoreBefore &&
1240
+ button(text('下载上次恢复前备份', 'Download pre-restore backup'), () =>
1241
+ download('offwork-before-restore.json', restoreBefore),
1242
+ ),
1243
+ h(
1244
+ 'article',
1245
+ null,
1246
+ h('strong', null, text('薪资单独导出', 'Separate salary export')),
1247
+ h(
1248
+ 'p',
1249
+ { className: 'hub-muted' },
1250
+ text(
1251
+ '此文件包含真实薪资,请自行保管。不会上传或发送到会话。',
1252
+ 'Contains salary. Store it privately; not uploaded or sent to a conversation.',
1253
+ ),
1254
+ ),
1255
+ button(text('导出作息与薪资', 'Export schedule and salary'), () =>
1256
+ execute(async () => {
1257
+ const r = await settingsCall('get');
1258
+ download('offwork-private-settings.json', {
1259
+ format: 'dsh-offwork-settings',
1260
+ version: 1,
1261
+ settings: r.settings,
1262
+ });
1263
+ }),
1264
+ ),
1265
+ h(
1266
+ 'label',
1267
+ null,
1268
+ text('恢复作息与薪资', 'Restore schedule and salary'),
1269
+ h('input', {
1270
+ type: 'file',
1271
+ accept: '.json,application/json',
1272
+ onChange: (e) => {
1273
+ const file = e.target.files[0];
1274
+ e.target.value = '';
1275
+ void execute(async () => {
1276
+ if (!file || file.size > 16384) throw new Error('Invalid settings file');
1277
+ const value = JSON.parse(await file.text());
1278
+ if (value.format !== 'dsh-offwork-settings' || value.version !== 1)
1279
+ throw new Error('Invalid settings file');
1280
+ if (
1281
+ !window.confirm(
1282
+ text(
1283
+ '将替换作息与薪资,是否继续?当前设置会先下载备份。',
1284
+ 'Replace schedule and salary? Current settings will be downloaded first.',
1285
+ ),
1286
+ )
1287
+ )
1288
+ return;
1289
+ const before = await settingsCall('get');
1290
+ download('offwork-settings-before-restore.json', {
1291
+ format: 'dsh-offwork-settings',
1292
+ version: 1,
1293
+ settings: before.settings,
1294
+ });
1295
+ await settingsCall('set', { settings: value.settings });
1296
+ latest.current.reload();
1297
+ setNotice(text('作息与薪资已恢复', 'Schedule and salary restored'));
1298
+ });
1299
+ },
1300
+ }),
1301
+ ),
1302
+ ),
1303
+ );
1304
+ }
1305
+ function render(page) {
1306
+ if (!ready)
1307
+ return h(
1308
+ 'div',
1309
+ { className: 'owc-extra' },
1310
+ feedback(),
1311
+ h('p', null, text('读取中…', 'Loading…')),
1312
+ );
1313
+ let content;
1314
+ if (page === 'timers') content = timerPanel();
1315
+ else if (page === 'wrap') content = wrapPanel();
1316
+ else if (page === 'settings') content = settingsPanel();
1317
+ else if (page === 'appearance') content = appearancePanel();
1318
+ else if (page === 'data') content = dataPanel();
1319
+ else if (page === 'alert') content = active && reminderCard(active, true);
1320
+ else if (page === 'wrap-entry') {
1321
+ const entry = wrapDraft();
1322
+ content = h(
1323
+ 'div',
1324
+ { className: 'owc-wrap-entry' },
1325
+ button(
1326
+ state.journal[entry.date]?.endedAt
1327
+ ? text('查看今日收尾', 'View today’s wrap-up')
1328
+ : input.shift?.status === 'done'
1329
+ ? text('收尾今天', 'Wrap up today')
1330
+ : text('记下明天第一件事', 'Leave a next step for tomorrow'),
1331
+ (e) => open('wrap', e),
1332
+ false,
1333
+ { 'data-wrap-entry': true },
1334
+ ),
1335
+ );
1336
+ } else if (page === 'reminders')
1337
+ content = h(
1338
+ React.Fragment,
1339
+ null,
1340
+ pending.length
1341
+ ? pending.map((e) => reminderCard(e))
1342
+ : h('p', null, text('目前没有待处理提醒', 'No pending reminders')),
1343
+ events
1344
+ .filter((e) => !pending.includes(e))
1345
+ .map((e) =>
1346
+ h(
1347
+ 'article',
1348
+ { key: e.id },
1349
+ h('strong', null, e.label),
1350
+ button(text('查看计时', 'View timer'), (ev) =>
1351
+ open(e.kind === 'water' ? 'home' : e.kind === 'wrap' ? 'wrap' : 'smoking', ev),
1352
+ ),
1353
+ ),
1354
+ ),
1355
+ );
1356
+ return h('div', { className: 'owc-extra', 'data-companion-content': page }, content);
1357
+ }
1358
+ function feedback() {
1359
+ return h(
1360
+ 'div',
1361
+ { className: 'owc-extra owc-extra-feedback' },
1362
+ error && h('p', { className: 'hub-error', role: 'alert' }, error),
1363
+ error && button(text('重试读取', 'Retry loading'), () => runtime.current?.retry()),
1364
+ notice && h('p', { role: 'status' }, notice),
1365
+ );
1366
+ }
1367
+ const appearanceDirty =
1368
+ !!photo ||
1369
+ !!(
1370
+ appearanceDraft &&
1371
+ appearanceBase.current &&
1372
+ JSON.stringify(appearanceDraft) !== JSON.stringify(appearanceBase.current)
1373
+ );
1374
+ const activeTimers = state.timers
1375
+ .filter((t) => ['running', 'paused'].includes(countdownView(t.clock, now).phase))
1376
+ .sort((a, b) => (a.clock.deadline || Infinity) - (b.clock.deadline || Infinity));
1377
+ const activeTimer = activeTimers[0]
1378
+ ? {
1379
+ name: activeTimers[0].name,
1380
+ ...countdownView(activeTimers[0].clock, now),
1381
+ count: activeTimers.length,
1382
+ }
1383
+ : null;
1384
+ return {
1385
+ state,
1386
+ ready,
1387
+ screen,
1388
+ render,
1389
+ feedback,
1390
+ busy,
1391
+ activeTimer,
1392
+ previewPreferences: appearanceDraft,
1393
+ dirty: dirty || appearanceDirty,
1394
+ discardDrafts,
1395
+ beginAppearance,
1396
+ applyAppearance,
1397
+ saveNotes,
1398
+ useBuiltin: () => {
1399
+ setPhoto(null);
1400
+ changeAppearance({ customEnabled: false });
1401
+ },
1402
+ previewImage:
1403
+ photoPreview ||
1404
+ ((appearanceDraft || state.preferences).customEnabled
1405
+ ? (appearanceDraft || state.preferences).image
1406
+ : null),
1407
+ hasCustom: !!(state.preferences.image || photo),
1408
+ selectCustom: () => changeAppearance({ customEnabled: true }),
1409
+ cropping: !!photo,
1410
+ alert: showAlert,
1411
+ alertTitle: active?.label,
1412
+ pendingCount: pending.length,
1413
+ dismissAlert: () => active && handleReminder(active, 'dismiss'),
1414
+ snoozeWater: () =>
1415
+ act({ type: 'snooze', id: 'water:' + hydrationView(input.water, now).eventId }),
1416
+ waterCanSnooze: !!state.deliveries['water:' + hydrationView(input.water, now).eventId],
1417
+ waterSnoozedUntil: input.waterReady
1418
+ ? state.deliveries['water:' + hydrationView(input.water, now).eventId]?.snoozeUntil || 0
1419
+ : 0,
1420
+ smokingSnoozedUntil: state.deliveries['smoking:' + input.smoking?.deadline]?.snoozeUntil || 0,
1421
+ act,
1422
+ open,
1423
+ quiet: state.preferences.quietUntil > now,
1424
+ customImage: state.preferences.customEnabled ? state.preferences.image : null,
1425
+ record: (event) =>
1426
+ runtime.current
1427
+ ?.update({ type: 'record', event })
1428
+ .catch(() => setError(text('记录未保存,请重试', 'Could not save record'))),
1429
+ suppressLegacyWater: true,
1430
+ };
1431
+ }