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/CHANGELOG.md +12 -0
- package/README.md +36 -21
- package/client.js +2558 -822
- package/client.source.js +1592 -432
- package/companion.js +6 -9
- package/countdown.js +83 -0
- package/hydration-store.js +55 -16
- package/hydration.js +231 -250
- package/index.js +2 -0
- 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 +6 -2
- package/pet-interactions.js +80 -9
- package/pet-motion.js +2 -1
- package/pet-reminder.js +20 -0
- package/pet-surfaces.js +129 -5
- package/pet-video.js +221 -72
- package/pet-view.js +69 -4
- package/tests/classic-overview.test.mjs +77 -0
- package/tests/companion.test.mjs +12 -12
- package/tests/countdown.test.mjs +115 -0
- package/tests/host.test.mjs +6 -0
- 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-reminder.test.mjs +71 -0
- package/tests/pet-video.test.mjs +171 -5
- package/tools/build.mjs +39 -16
- package/tools/check-package.mjs +8 -0
- package/ui-utils.js +44 -0
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
|
|
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
|
+
}
|
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'));
|