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.
- package/CHANGELOG.md +39 -0
- package/README.md +48 -26
- package/ROADMAP.md +70 -0
- package/client.js +5066 -1099
- package/client.source.js +1988 -541
- package/companion-app.js +1431 -0
- package/companion-image.js +59 -0
- package/companion-state.js +424 -0
- package/companion.js +6 -9
- package/countdown.js +83 -0
- package/docs/RELEASE-1.2.2.md +20 -0
- package/hydration-store.js +55 -16
- package/hydration.js +231 -250
- package/icon.svg +28 -1
- package/index.js +59 -6
- package/locale/en.json +1 -1
- package/locale/zh.json +1 -1
- package/media-manifest.js +29 -0
- package/media-server.js +123 -0
- package/package.json +13 -3
- package/pet-interactions.js +121 -17
- package/pet-motion.js +2 -1
- package/pet-reminder.js +20 -0
- package/pet-surfaces.js +130 -6
- package/pet-video.js +221 -72
- package/pet-view.js +71 -4
- package/settings-api.js +126 -0
- package/tests/classic-overview.test.mjs +77 -0
- package/tests/companion-state.test.mjs +267 -0
- package/tests/companion.test.mjs +12 -12
- package/tests/countdown.test.mjs +115 -0
- package/tests/host.test.mjs +26 -13
- package/tests/hydration-extras.test.mjs +168 -204
- package/tests/hydration-store.test.mjs +199 -213
- package/tests/hydration.test.mjs +116 -186
- package/tests/media-server.test.mjs +179 -0
- package/tests/pet-interactions.test.mjs +19 -0
- package/tests/pet-reminder.test.mjs +71 -0
- package/tests/pet-video.test.mjs +171 -5
- package/tests/settings-api.test.mjs +130 -0
- package/tests/ui.test.mjs +4 -1
- package/tools/build.mjs +45 -16
- package/tools/check-package.mjs +12 -0
- package/ui-utils.js +44 -0
package/hydration-store.js
CHANGED
|
@@ -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(
|
|
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 = () =>
|
|
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
|
|
57
|
-
|
|
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
|
-
|
|
83
|
+
const apply = (stored, legacy, imported = false) => {
|
|
62
84
|
try {
|
|
63
|
-
const current =
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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(
|
|
74
|
-
?
|
|
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
|
-
|
|
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 {
|
|
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
|
-
!
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
!
|
|
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 (
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
!
|
|
44
|
-
!
|
|
45
|
-
!
|
|
46
|
-
!
|
|
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
|
-
!
|
|
49
|
-
!
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
156
|
+
next.dueAt = next.enabled ? now + remaining : 0;
|
|
157
|
+
return validateHydration(next);
|
|
85
158
|
}
|
|
86
159
|
|
|
87
|
-
export function
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
195
|
-
|
|
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 =
|
|
213
|
+
const changed = enabled !== next.enabled || interval !== next.interval;
|
|
211
214
|
next.enabled = enabled;
|
|
212
215
|
next.interval = interval;
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
next.
|
|
216
|
-
next.shift.quietUntil = 0;
|
|
216
|
+
if (!enabled) {
|
|
217
|
+
next.claim = null;
|
|
218
|
+
next.quietUntil = 0;
|
|
217
219
|
}
|
|
218
|
-
if (changed)
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
-
|
|
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
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
if (action.type === '
|
|
236
|
-
if (
|
|
237
|
-
|
|
238
|
-
|
|
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
|
-
|
|
238
|
+
next.recentActions.includes(action.actionId) ||
|
|
244
239
|
(action.eventId !== undefined && action.eventId !== view.eventId)
|
|
245
240
|
)
|
|
246
|
-
return
|
|
247
|
-
|
|
248
|
-
...
|
|
241
|
+
return finish();
|
|
242
|
+
next.recentActions = [
|
|
243
|
+
...next.recentActions.slice(-(HYDRATION_ACTION_LIMIT - 1)),
|
|
249
244
|
action.actionId,
|
|
250
245
|
];
|
|
251
|
-
|
|
252
|
-
if (next.enabled && !
|
|
253
|
-
else
|
|
254
|
-
|
|
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
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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
|
-
|
|
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
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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')
|
|
298
|
-
|
|
299
|
-
|
|
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
|
|
283
|
+
return finish();
|
|
303
284
|
}
|