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
@@ -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'));
package/hydration.js CHANGED
@@ -1,200 +1,203 @@
1
- // Pure hydration rules. Time is measured in paid-work milliseconds from calculate().
2
- export const HYDRATION_SCHEMA = 2;
1
+ import { calculate } from './clock.js';
2
+
3
+ // Hydration uses wall-clock deadlines, independently of salary and work schedules.
4
+ export const HYDRATION_SCHEMA = 3;
3
5
  export const HYDRATION_ACTION_LIMIT = 64;
4
6
  export const HYDRATION_LEASE_MS = 8000;
7
+ const hydrationInteger = (number, max = Number.MAX_SAFE_INTEGER) =>
8
+ Number.isSafeInteger(number) && number >= 0 && number <= max;
9
+ const hydrationText = (value, max = 1024) =>
10
+ typeof value === 'string' && value.length > 0 && value.length <= max;
11
+ const hydrationKeys = (value, keys) =>
12
+ value &&
13
+ typeof value === 'object' &&
14
+ !Array.isArray(value) &&
15
+ Object.keys(value).every((key) => keys.includes(key));
16
+ const hydrationClaim = (claim) =>
17
+ claim === null ||
18
+ (hydrationKeys(claim, ['owner', 'until', 'sequence']) &&
19
+ hydrationText(claim.owner) &&
20
+ hydrationInteger(claim.until) &&
21
+ hydrationInteger(claim.sequence));
22
+ const hydrationActions = (actions) =>
23
+ Array.isArray(actions) &&
24
+ actions.length <= HYDRATION_ACTION_LIMIT &&
25
+ actions.every((id) => hydrationText(id, 128)) &&
26
+ new Set(actions).size === actions.length;
5
27
 
6
28
  export function defaultHydration() {
7
- return { schema: HYDRATION_SCHEMA, revision: 0, enabled: false, interval: 60, shift: null };
29
+ return {
30
+ schema: HYDRATION_SCHEMA,
31
+ revision: 0,
32
+ enabled: false,
33
+ interval: 60,
34
+ count: 0,
35
+ paused: false,
36
+ dueAt: 0,
37
+ sequence: 0,
38
+ claim: null,
39
+ quietUntil: 0,
40
+ recentActions: [],
41
+ };
8
42
  }
9
-
10
43
  export function validateHydration(value) {
11
- const integer = (number, max = Number.MAX_SAFE_INTEGER) =>
12
- Number.isSafeInteger(number) && number >= 0 && number <= max;
13
- const text = (string) => typeof string === 'string' && string.length > 0 && string.length <= 1024;
14
- const keys = (object, allowed) =>
15
- object &&
16
- typeof object === 'object' &&
17
- !Array.isArray(object) &&
18
- Object.keys(object).every((key) => allowed.includes(key));
19
44
  if (
20
- !keys(value, ['schema', 'revision', 'enabled', 'interval', 'shift']) ||
21
- ![1, HYDRATION_SCHEMA].includes(value.schema) ||
22
- !integer(value.revision) ||
45
+ !hydrationKeys(value, [
46
+ 'schema',
47
+ 'revision',
48
+ 'enabled',
49
+ 'interval',
50
+ 'count',
51
+ 'paused',
52
+ 'dueAt',
53
+ 'sequence',
54
+ 'claim',
55
+ 'quietUntil',
56
+ 'recentActions',
57
+ ]) ||
58
+ value.schema !== HYDRATION_SCHEMA ||
59
+ !hydrationInteger(value.revision) ||
23
60
  typeof value.enabled !== 'boolean' ||
24
- !integer(value.interval, 240) ||
25
- value.interval < 5
26
- ) {
61
+ !hydrationInteger(value.interval, 240) ||
62
+ value.interval < 5 ||
63
+ !hydrationInteger(value.count, 1000000) ||
64
+ typeof value.paused !== 'boolean' ||
65
+ !hydrationInteger(value.dueAt) ||
66
+ !hydrationInteger(value.sequence) ||
67
+ !hydrationInteger(value.quietUntil) ||
68
+ !hydrationClaim(value.claim) ||
69
+ !hydrationActions(value.recentActions)
70
+ )
27
71
  throw new Error('Invalid hydration data');
28
- }
72
+ return structuredClone(value);
73
+ }
74
+
75
+ function validateLegacyHydration(value) {
76
+ if (
77
+ !hydrationKeys(value, ['schema', 'revision', 'enabled', 'interval', 'shift']) ||
78
+ ![1, 2].includes(value.schema) ||
79
+ !hydrationInteger(value.revision) ||
80
+ typeof value.enabled !== 'boolean' ||
81
+ !hydrationInteger(value.interval, 240) ||
82
+ value.interval < 5
83
+ )
84
+ throw new Error('Invalid legacy hydration data');
29
85
  const shift = value.shift;
30
- if (shift !== null) {
31
- if (
32
- !keys(shift, [
33
- 'id',
34
- 'schedule',
35
- 'config',
36
- 'count',
37
- 'paused',
38
- 'due',
39
- 'sequence',
40
- 'claim',
41
- ...(value.schema === 2 ? ['quietUntil', 'recentActions'] : []),
42
- ]) ||
43
- !text(shift.id) ||
44
- !text(shift.schedule) ||
45
- !text(shift.config) ||
46
- !integer(shift.count, 1000000) ||
86
+ if (
87
+ shift !== null &&
88
+ (!hydrationKeys(shift, [
89
+ 'id',
90
+ 'schedule',
91
+ 'config',
92
+ 'count',
93
+ 'paused',
94
+ 'due',
95
+ 'sequence',
96
+ 'claim',
97
+ ...(value.schema === 2 ? ['quietUntil', 'recentActions'] : []),
98
+ ]) ||
99
+ !hydrationText(shift.id) ||
100
+ !hydrationText(shift.schedule) ||
101
+ !hydrationText(shift.config) ||
102
+ !hydrationInteger(shift.count, 1000000) ||
47
103
  typeof shift.paused !== 'boolean' ||
48
- !integer(shift.sequence) ||
49
- !integer(shift.due, 172800000)
50
- ) {
51
- throw new Error('Invalid hydration shift');
52
- }
53
- if (
54
- value.schema === 2 &&
55
- (!integer(shift.quietUntil) ||
56
- !Array.isArray(shift.recentActions) ||
57
- shift.recentActions.length > HYDRATION_ACTION_LIMIT ||
58
- !shift.recentActions.every((id) => text(id) && id.length <= 128) ||
59
- new Set(shift.recentActions).size !== shift.recentActions.length)
60
- )
61
- throw new Error('Invalid hydration actions');
62
- if (
63
- shift.claim !== null &&
64
- (!keys(shift.claim, ['owner', 'until', 'sequence']) ||
65
- !text(shift.claim.owner) ||
66
- !integer(shift.claim.until) ||
67
- !integer(shift.claim.sequence))
68
- ) {
69
- throw new Error('Invalid hydration claim');
70
- }
71
- }
72
- const result = structuredClone(value);
73
- if (result.schema === 1) {
74
- result.schema = HYDRATION_SCHEMA;
75
- if (result.shift) {
76
- result.shift.quietUntil = 0;
77
- result.shift.recentActions = [];
78
- result.shift.claim = null;
79
- result.shift.sequence++;
80
- if (!Number.isSafeInteger(result.shift.sequence))
81
- throw new Error('Invalid hydration sequence');
104
+ !hydrationInteger(shift.due, 172800000) ||
105
+ !hydrationInteger(shift.sequence) ||
106
+ !hydrationClaim(shift.claim) ||
107
+ (value.schema === 2 &&
108
+ (!hydrationInteger(shift.quietUntil) || !hydrationActions(shift.recentActions))))
109
+ )
110
+ throw new Error('Invalid legacy hydration record');
111
+ return structuredClone(value);
112
+ }
113
+
114
+ // Invoked only inside the one-time, two-store migration transaction. The original
115
+ // work-based record stays untouched in its old object store for preservation.
116
+ export function migrateHydration(value, now) {
117
+ if (!hydrationInteger(now)) throw new Error('Invalid hydration migration time');
118
+ if (value === undefined) return defaultHydration();
119
+ const old = validateLegacyHydration(value),
120
+ next = defaultHydration(),
121
+ shift = old.shift;
122
+ next.revision = old.revision;
123
+ next.enabled = old.enabled;
124
+ next.interval = old.interval;
125
+ let remaining = next.interval * 60000;
126
+ if (shift) {
127
+ next.count = shift.count;
128
+ next.paused = shift.paused;
129
+ next.sequence = shift.sequence + 1;
130
+ next.quietUntil = old.schema === 2 && shift.quietUntil > now ? shift.quietUntil : 0;
131
+ next.recentActions = old.schema === 2 ? shift.recentActions : [];
132
+ // A matching retained shift supplies the remaining paid minutes. Convert
133
+ // that remainder once; never consult work settings again after migration.
134
+ try {
135
+ const [timezone, start, end, breakStart, breakEnd, weekdays] = JSON.parse(shift.config);
136
+ const clock = calculate(
137
+ { timezone, start, end, breakStart, breakEnd, weekdays, payMode: 'daily', dailySalary: 1 },
138
+ now,
139
+ );
140
+ const id = JSON.stringify([timezone, clock.shiftDate]);
141
+ const schedule = JSON.stringify([
142
+ start,
143
+ end,
144
+ breakStart,
145
+ breakEnd,
146
+ [...weekdays].sort(),
147
+ clock.shiftStart,
148
+ clock.shiftEnd,
149
+ ]);
150
+ if (id === shift.id && schedule === shift.schedule && !shift.paused)
151
+ remaining = Math.max(0, shift.due - Math.floor(clock.elapsed * 1000));
152
+ } catch {
153
+ /* Older/unknown schedule text: start one full configured interval. */
82
154
  }
83
155
  }
84
- return result;
156
+ next.dueAt = next.enabled ? now + remaining : 0;
157
+ return validateHydration(next);
85
158
  }
86
159
 
87
- export function hydrationContext(settings, clock) {
88
- if (!settings || !clock || !Number.isFinite(clock.elapsed) || !Number.isFinite(clock.total))
89
- return null;
160
+ export function reconcileHydration(state, now) {
161
+ const next = validateHydration(state);
162
+ if (!hydrationInteger(now)) throw new Error('Invalid reminder time');
163
+ if (next.enabled && next.dueAt === 0) next.dueAt = now + next.interval * 60000;
164
+ if (next.quietUntil <= now) next.quietUntil = 0;
165
+ if (!next.enabled || next.paused || next.quietUntil > now) next.claim = null;
166
+ return validateHydration(next);
167
+ }
168
+ export function hydrationView(state, now) {
169
+ const remaining = state.dueAt ? Math.max(0, state.dueAt - now) : state.interval * 60000;
170
+ const quietRemaining = state.enabled && !state.paused ? Math.max(0, state.quietUntil - now) : 0;
171
+ const phase = !state.enabled
172
+ ? 'off'
173
+ : state.paused
174
+ ? 'paused'
175
+ : quietRemaining > 0
176
+ ? 'quiet'
177
+ : remaining === 0
178
+ ? 'due'
179
+ : 'countdown';
90
180
  return {
91
- id: JSON.stringify([settings.timezone, clock.shiftDate]),
92
- config: JSON.stringify([
93
- settings.timezone,
94
- settings.start,
95
- settings.end,
96
- settings.breakStart,
97
- settings.breakEnd,
98
- [...settings.weekdays].sort(),
99
- ]),
100
- schedule: JSON.stringify([
101
- settings.start,
102
- settings.end,
103
- settings.breakStart,
104
- settings.breakEnd,
105
- [...settings.weekdays].sort(),
106
- clock.shiftStart,
107
- clock.shiftEnd,
108
- ]),
109
- status: clock.status,
110
- end: clock.shiftEnd,
111
- elapsed: Math.max(0, Math.floor(clock.elapsed * 1000)),
112
- total: Math.max(0, Math.floor(clock.total * 1000)),
181
+ phase,
182
+ count: state.count,
183
+ remaining,
184
+ quietRemaining,
185
+ eventId:
186
+ state.enabled && state.dueAt
187
+ ? JSON.stringify(['wall-clock', state.sequence, state.dueAt])
188
+ : null,
113
189
  };
114
190
  }
115
-
116
- export function reconcileHydration(state, context, manual = false) {
117
- const next = structuredClone(state);
118
- if (!context) return next;
119
- if (!next.enabled && !manual) {
120
- if (next.shift) next.shift.claim = null;
121
- return next;
122
- }
123
- if (next.shift?.id !== context.id) {
124
- // Do not discard the last shift's count on a day off. New working shifts
125
- // are initialized once the user opens the page, never backfilled.
126
- if (!['before', 'working', 'lunch'].includes(context.status)) return next;
127
- next.shift = {
128
- id: context.id,
129
- schedule: context.schedule,
130
- config: context.config,
131
- count: 0,
132
- paused: false,
133
- due: context.elapsed + next.interval * 60000,
134
- sequence: 0,
135
- claim: null,
136
- quietUntil: 0,
137
- recentActions: [],
138
- };
139
- } else if (next.shift.schedule !== context.schedule) {
140
- next.shift.schedule = context.schedule;
141
- next.shift.config = context.config;
142
- next.shift.due = context.elapsed + next.interval * 60000;
143
- next.shift.sequence++;
144
- next.shift.claim = null;
145
- next.shift.quietUntil = Math.min(next.shift.quietUntil, context.end);
146
- }
147
- if (next.shift && (!next.enabled || context.status !== 'working')) next.shift.claim = null;
148
- return next;
149
- }
150
-
151
- export function hydrationView(state, context, now = 0) {
152
- const same =
153
- context && state.shift?.id === context.id && state.shift.schedule === context.schedule;
154
- const shift = same ? state.shift : null;
155
- const count = shift?.count ?? 0;
156
- const remaining = shift ? Math.max(0, shift.due - context.elapsed) : state.interval * 60000;
157
- const eventId = shift
158
- ? JSON.stringify([shift.id, shift.schedule, shift.sequence, shift.due])
159
- : null;
160
- const quietRemaining =
161
- shift && ['working', 'lunch'].includes(context.status)
162
- ? Math.max(0, Math.min(shift.quietUntil, context.end) - now)
163
- : 0;
164
- let phase = 'waiting';
165
- if (!state.enabled) phase = 'off';
166
- else if (shift?.paused) phase = 'paused';
167
- else if (quietRemaining > 0) phase = 'quiet';
168
- else if (context?.status === 'lunch') phase = 'break';
169
- else if (shift && context.status === 'working') {
170
- phase = remaining === 0 ? 'due' : shift.due >= context.total ? 'finished' : 'countdown';
171
- }
172
- return { phase, count, remaining, eventId, quietRemaining };
173
- }
174
-
175
- export function transitionHydration(state, context, action, now, owner) {
176
- if (!Number.isSafeInteger(now) || now < 0) throw new Error('Invalid reminder time');
177
- // Reconfiguration requires an explicit compare-and-set grant obtained before
178
- // reading/saving Host settings. A stale tab may release its lease or disable
179
- // reminders, but cannot silently restore its previous schedule.
180
- if (
181
- context &&
182
- state.shift &&
183
- (state.shift.config !== context.config ||
184
- (state.shift.id === context.id && state.shift.schedule !== context.schedule)) &&
185
- action.baseSchedule !== state.shift.schedule
186
- )
187
- context = null;
188
- const manual =
189
- action.type === 'manual-drink' &&
190
- action.shiftId === context?.id &&
191
- ['working', 'lunch'].includes(context?.status);
192
- let next = reconcileHydration(state, context, manual);
191
+ export function transitionHydration(state, action, now, owner) {
192
+ let next = reconcileHydration(state, now);
193
+ const finish = () => validateHydration(next);
194
+ const invalidate = () => {
195
+ next.sequence++;
196
+ next.claim = null;
197
+ };
193
198
  const restart = () => {
194
- if (!next.shift || !context || next.shift.id !== context.id) return;
195
- next.shift.due = context.elapsed + next.interval * 60000;
196
- next.shift.sequence++;
197
- next.shift.claim = null;
199
+ next.dueAt = now + next.interval * 60000;
200
+ invalidate();
198
201
  };
199
202
  if (action.type === 'reset') return defaultHydration();
200
203
  if (action.type === 'preferences') {
@@ -207,86 +210,65 @@ export function transitionHydration(state, context, action, now, owner) {
207
210
  interval > 240
208
211
  )
209
212
  throw new Error('Invalid reminder preferences');
210
- const changed = next.enabled !== enabled || next.interval !== interval;
213
+ const changed = enabled !== next.enabled || interval !== next.interval;
211
214
  next.enabled = enabled;
212
215
  next.interval = interval;
213
- next = reconcileHydration(next, context);
214
- if (!next.enabled && next.shift) {
215
- next.shift.claim = null;
216
- next.shift.quietUntil = 0;
216
+ if (!enabled) {
217
+ next.claim = null;
218
+ next.quietUntil = 0;
217
219
  }
218
- if (changed) restart();
219
- return next;
220
- }
221
- const shift = next.shift;
222
- if (action.type === 'release') {
223
- if (shift?.claim?.owner === owner) shift.claim = null;
224
- return next;
220
+ if (changed) {
221
+ if (enabled) restart();
222
+ else invalidate();
223
+ }
224
+ return finish();
225
225
  }
226
- if (!shift || !context || shift.id !== context.id) return next;
227
- if (shift.quietUntil && (now >= shift.quietUntil || now >= context.end)) shift.quietUntil = 0;
228
- const view = hydrationView(next, context, now);
226
+ const view = hydrationView(next, now);
229
227
  const validClaim =
230
- shift.claim &&
231
- shift.claim.owner === owner &&
232
- shift.claim.sequence === shift.sequence &&
233
- shift.claim.until > now &&
234
- shift.claim.until <= now + HYDRATION_LEASE_MS;
235
- if (action.type === 'manual-drink' && manual) {
236
- if (
237
- typeof action.actionId !== 'string' ||
238
- !action.actionId.length ||
239
- action.actionId.length > 128
240
- )
241
- throw new Error('Invalid hydration action id');
228
+ next.claim &&
229
+ next.claim.owner === owner &&
230
+ next.claim.sequence === next.sequence &&
231
+ next.claim.until > now &&
232
+ next.claim.until <= now + HYDRATION_LEASE_MS;
233
+ if (action.type === 'release') {
234
+ if (next.claim?.owner === owner) next.claim = null;
235
+ } else if (action.type === 'manual-drink') {
236
+ if (!hydrationText(action.actionId, 128)) throw new Error('Invalid hydration action id');
242
237
  if (
243
- shift.recentActions.includes(action.actionId) ||
238
+ next.recentActions.includes(action.actionId) ||
244
239
  (action.eventId !== undefined && action.eventId !== view.eventId)
245
240
  )
246
- return next;
247
- shift.recentActions = [
248
- ...shift.recentActions.slice(-(HYDRATION_ACTION_LIMIT - 1)),
241
+ return finish();
242
+ next.recentActions = [
243
+ ...next.recentActions.slice(-(HYDRATION_ACTION_LIMIT - 1)),
249
244
  action.actionId,
250
245
  ];
251
- shift.count = Math.min(1000000, shift.count + 1);
252
- if (next.enabled && !shift.paused) restart();
253
- else {
254
- shift.sequence++;
255
- shift.claim = null;
256
- }
257
- } else if (
258
- action.type === 'quiet' &&
259
- next.enabled &&
260
- !shift.paused &&
261
- ['working', 'lunch'].includes(context.status)
262
- ) {
246
+ next.count = Math.min(1000000, next.count + 1);
247
+ if (next.enabled && !next.paused) restart();
248
+ else invalidate();
249
+ } else if (action.type === 'quiet' && next.enabled && !next.paused) {
263
250
  if (![30, 60].includes(action.minutes)) throw new Error('Invalid quiet duration');
264
- shift.quietUntil = Math.min(now + action.minutes * 60000, context.end);
265
- shift.claim = null;
266
- shift.sequence++;
267
- } else if (action.type === 'end-quiet' && shift.quietUntil) {
268
- shift.quietUntil = 0;
269
- shift.claim = null;
270
- shift.sequence++;
251
+ next.quietUntil = now + action.minutes * 60000;
252
+ invalidate();
253
+ } else if (action.type === 'end-quiet' && next.quietUntil) {
254
+ next.quietUntil = 0;
255
+ invalidate();
271
256
  } else if (action.type === 'claim' && view.phase === 'due') {
272
- const claim = shift.claim;
257
+ if (!hydrationText(owner)) throw new Error('Invalid reminder owner');
273
258
  if (
274
- !claim ||
275
- claim.owner === owner ||
276
- claim.until <= now ||
277
- claim.until > now + HYDRATION_LEASE_MS
278
- ) {
279
- shift.claim = { owner, until: now + HYDRATION_LEASE_MS, sequence: shift.sequence };
280
- }
281
- } else if (action.type === 'clear-count') {
282
- shift.count = 0;
283
- } else if (action.type === 'pause' && next.enabled && !shift.paused) {
284
- shift.paused = true;
285
- shift.quietUntil = 0;
286
- shift.claim = null;
287
- shift.sequence++;
288
- } else if (action.type === 'resume' && next.enabled && shift.paused) {
289
- shift.paused = false;
259
+ !next.claim ||
260
+ next.claim.owner === owner ||
261
+ next.claim.until <= now ||
262
+ next.claim.until > now + HYDRATION_LEASE_MS
263
+ )
264
+ next.claim = { owner, until: now + HYDRATION_LEASE_MS, sequence: next.sequence };
265
+ } else if (action.type === 'clear-count') next.count = 0;
266
+ else if (action.type === 'pause' && next.enabled && !next.paused) {
267
+ next.paused = true;
268
+ next.quietUntil = 0;
269
+ invalidate();
270
+ } else if (action.type === 'resume' && next.enabled && next.paused) {
271
+ next.paused = false;
290
272
  restart();
291
273
  } else if (
292
274
  ['drink', 'snooze'].includes(action.type) &&
@@ -294,10 +276,9 @@ export function transitionHydration(state, context, action, now, owner) {
294
276
  validClaim &&
295
277
  action.eventId === view.eventId
296
278
  ) {
297
- if (action.type === 'drink') shift.count = Math.min(1000000, shift.count + 1);
298
- shift.due = context.elapsed + (action.type === 'snooze' ? 10 : next.interval) * 60000;
299
- shift.sequence++;
300
- shift.claim = null;
279
+ if (action.type === 'drink') next.count = Math.min(1000000, next.count + 1);
280
+ next.dueAt = now + (action.type === 'snooze' ? 10 : next.interval) * 60000;
281
+ invalidate();
301
282
  }
302
- return next;
283
+ return finish();
303
284
  }