dsh-offwork 1.1.1 → 1.1.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.
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
+ }
@@ -1,8 +1,22 @@
1
- import { defaultHydration, validateHydration } from './hydration.js';
1
+ import { defaultHydration, validateHydration, migrateHydration } from './hydration.js';
2
2
 
3
3
  // A single IndexedDB read/write transaction is the cross-tab mutex. Neither
4
4
  // notification delivery nor localStorage read-back is used as an atomic lock.
5
- export function createHydrationStore(indexedDB, name = 'dsh.offwork-clock.hydration.v1') {
5
+ export function createHydrationStore(
6
+ indexedDB,
7
+ name = 'dsh.offwork-clock.hydration.v1',
8
+ now = () => Date.now(),
9
+ ) {
10
+ return createStateStore(indexedDB, name, defaultHydration, validateHydration, {
11
+ version: 2,
12
+ storeName: 'wall-clock',
13
+ legacyStore: 'state',
14
+ migrate: (value) => migrateHydration(value, now()),
15
+ });
16
+ }
17
+
18
+ export function createStateStore(indexedDB, name, initial, validate, options = {}) {
19
+ const storeName = options.storeName || 'state';
6
20
  let database = null;
7
21
  let opening = null;
8
22
  let closed = false;
@@ -20,8 +34,11 @@ export function createHydrationStore(indexedDB, name = 'dsh.offwork-clock.hydrat
20
34
  reject(new Error('Hydration storage unavailable'));
21
35
  return;
22
36
  }
23
- const request = indexedDB.open(name, 1);
24
- request.onupgradeneeded = () => request.result.createObjectStore('state');
37
+ const request = indexedDB.open(name, options.version || 1);
38
+ request.onupgradeneeded = () => {
39
+ if (!request.result.objectStoreNames.contains(storeName))
40
+ request.result.createObjectStore(storeName);
41
+ };
25
42
  request.onerror = () => fail(request.error ?? new Error('Hydration storage unavailable'));
26
43
  request.onblocked = () => fail(new Error('Hydration storage blocked'));
27
44
  request.onsuccess = () => {
@@ -53,37 +70,59 @@ export function createHydrationStore(indexedDB, name = 'dsh.offwork-clock.hydrat
53
70
  const db = await open();
54
71
  if (closed) throw new Error('Hydration store closed');
55
72
  return new Promise((resolve, reject) => {
56
- const transaction = db.transaction('state', 'readwrite');
57
- const store = transaction.objectStore('state');
73
+ const hasLegacy =
74
+ !!options.legacyStore && db.objectStoreNames.contains(options.legacyStore);
75
+ const transaction = db.transaction(
76
+ hasLegacy ? [storeName, options.legacyStore] : storeName,
77
+ 'readwrite',
78
+ );
79
+ const store = transaction.objectStore(storeName);
58
80
  const request = store.get('current');
59
81
  let output;
60
82
  let failure;
61
- request.onsuccess = () => {
83
+ const apply = (stored, legacy, imported = false) => {
62
84
  try {
63
- const current =
64
- reset || request.result === undefined
65
- ? defaultHydration()
66
- : validateHydration(request.result);
67
- const next = validateHydration(reducer(structuredClone(current)));
85
+ const current = reset
86
+ ? initial()
87
+ : stored !== undefined
88
+ ? validate(stored)
89
+ : imported
90
+ ? validate(options.migrate(legacy))
91
+ : initial();
92
+ const next = validate(reducer(structuredClone(current)));
68
93
  const changed = JSON.stringify(next) !== JSON.stringify(current);
69
- const migrated =
70
- request.result !== undefined && request.result.schema !== current.schema;
94
+ const migrated = imported || (stored !== undefined && stored.schema !== current.schema);
71
95
  if (reset || changed || migrated) {
72
96
  const previousRevision =
73
- Number.isSafeInteger(request.result?.revision) && request.result.revision >= 0
74
- ? request.result.revision
97
+ Number.isSafeInteger(stored?.revision) && stored.revision >= 0
98
+ ? stored.revision
75
99
  : current.revision;
76
100
  next.revision = previousRevision + 1;
77
101
  if (!Number.isSafeInteger(next.revision))
78
102
  throw new Error('Hydration revision exhausted');
79
103
  store.put(next, 'current');
80
104
  }
105
+ if (imported || (reset && hasLegacy)) store.put(true, 'legacy-imported');
81
106
  output = next;
82
107
  } catch (error) {
83
108
  failure = error;
84
109
  transaction.abort();
85
110
  }
86
111
  };
112
+ request.onsuccess = () => {
113
+ if (hasLegacy) {
114
+ const marker = store.get('legacy-imported');
115
+ marker.onsuccess = () => {
116
+ if (!reset && request.result === undefined && marker.result !== true) {
117
+ const legacy = transaction.objectStore(options.legacyStore).get('current');
118
+ legacy.onsuccess = () => apply(undefined, legacy.result, true);
119
+ } else {
120
+ if (marker.result !== true) store.put(true, 'legacy-imported');
121
+ apply(request.result);
122
+ }
123
+ };
124
+ } else apply(request.result);
125
+ };
87
126
  transaction.oncomplete = () => resolve(output);
88
127
  transaction.onabort = () =>
89
128
  reject(failure ?? transaction.error ?? new Error('Hydration transaction aborted'));