dsh-offwork 1.1.0 → 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.
Files changed (45) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/README.md +50 -32
  3. package/assets/animations/NOTICE.md +15 -0
  4. package/assets/animations/alpha-probe.webm +0 -0
  5. package/assets/animations/ornate-drink.webm +0 -0
  6. package/assets/animations/ornate-greet.webm +0 -0
  7. package/assets/animations/ornate-idle.webm +0 -0
  8. package/assets/animations/whale-drink.webm +0 -0
  9. package/assets/animations/whale-greet.webm +0 -0
  10. package/assets/characters/NOTICE.md +4 -0
  11. package/assets/characters/whale-actions.webp +0 -0
  12. package/assets/characters/whale-ornate-actions.webp +0 -0
  13. package/client.js +2970 -770
  14. package/client.source.js +1668 -444
  15. package/companion.js +6 -9
  16. package/countdown.js +83 -0
  17. package/hydration-store.js +55 -16
  18. package/hydration.js +231 -250
  19. package/index.js +2 -0
  20. package/locale/en.json +1 -1
  21. package/locale/zh.json +1 -1
  22. package/media-manifest.js +29 -0
  23. package/media-server.js +123 -0
  24. package/package.json +9 -2
  25. package/pet-interactions.js +78 -7
  26. package/pet-motion.js +111 -0
  27. package/pet-reminder.js +20 -0
  28. package/pet-surfaces.js +130 -6
  29. package/pet-video.js +380 -0
  30. package/pet-view.js +129 -13
  31. package/tests/classic-overview.test.mjs +77 -0
  32. package/tests/companion.test.mjs +12 -12
  33. package/tests/countdown.test.mjs +115 -0
  34. package/tests/host.test.mjs +6 -0
  35. package/tests/hydration-extras.test.mjs +168 -204
  36. package/tests/hydration-store.test.mjs +199 -213
  37. package/tests/hydration.test.mjs +116 -186
  38. package/tests/media-server.test.mjs +179 -0
  39. package/tests/pet-character.test.mjs +8 -2
  40. package/tests/pet-motion.test.mjs +139 -0
  41. package/tests/pet-reminder.test.mjs +71 -0
  42. package/tests/pet-video.test.mjs +503 -0
  43. package/tools/build.mjs +62 -13
  44. package/tools/check-package.mjs +19 -0
  45. package/ui-utils.js +51 -1
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
  }
package/index.js CHANGED
@@ -2,6 +2,7 @@ import { readFile, writeFile, rename, unlink, chmod } from 'node:fs/promises';
2
2
  import { randomUUID } from 'node:crypto';
3
3
  import { dirname, join, resolve } from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
+ import { registerMediaRoute } from './media-server.js';
5
6
 
6
7
  export const inject = ['commands', 'tools'];
7
8
 
@@ -200,6 +201,7 @@ const settingsSchema = {
200
201
  };
201
202
 
202
203
  export function apply(ctx) {
204
+ registerMediaRoute(ctx);
203
205
  const operate = createOperations();
204
206
  ctx.effect(() =>
205
207
  ctx.commands.register({
package/locale/en.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "meta": {
3
3
  "title": "Offwork Clock",
4
- "description": "A Harness character companion with a clock-out countdown, estimated earnings, hydration reminders, quiet time and independent feature cards."
4
+ "description": "A Harness character companion and compact widget with clock-out, all-day hydration and independent smoking timers, earnings, action previews and privacy controls."
5
5
  }
6
6
  }
package/locale/zh.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "meta": {
3
3
  "title": "下班计时器",
4
- "description": "Harness 页面陪伴角色、下班倒计时、实时收入和喝水提醒,支持鲸系少女外观、免打扰、独立功能卡片与隐私偏好。"
4
+ "description": "Harness 页面角色与紧凑小卡片,提供下班倒计时、全天喝水提醒、独立抽烟计时、收入与动作试播,支持简洁详情页和隐私偏好。"
5
5
  }
6
6
  }
@@ -0,0 +1,29 @@
1
+ // Generated by tools/build.mjs; edit the source modules, not this file.
2
+ export const MEDIA_ROUTE = "/offwork-clock/media";
3
+ export const MEDIA_ASSETS = Object.freeze({
4
+ "2b98a0d8f5ab3569254c57185f712ceae7081d081f3879a3bb48f62ae59ea616/ornate-greet.webm": {
5
+ "file": "assets/animations/ornate-greet.webm",
6
+ "size": 1419513,
7
+ "sha256": "2b98a0d8f5ab3569254c57185f712ceae7081d081f3879a3bb48f62ae59ea616"
8
+ },
9
+ "6149773dbf3b89062f649d54d990230caacdea084d22f93bd4867b0c25344b59/whale-greet.webm": {
10
+ "file": "assets/animations/whale-greet.webm",
11
+ "size": 1726801,
12
+ "sha256": "6149773dbf3b89062f649d54d990230caacdea084d22f93bd4867b0c25344b59"
13
+ },
14
+ "ac0e8be1731b24dfbc232114d08a771ea87512cef2c6f3949154a90c6413f1a7/ornate-drink.webm": {
15
+ "file": "assets/animations/ornate-drink.webm",
16
+ "size": 1746731,
17
+ "sha256": "ac0e8be1731b24dfbc232114d08a771ea87512cef2c6f3949154a90c6413f1a7"
18
+ },
19
+ "e6befff2ade25304f13c03e3ba30a19148cd7155c862f5a59cfdb31c8669e519/ornate-idle.webm": {
20
+ "file": "assets/animations/ornate-idle.webm",
21
+ "size": 890695,
22
+ "sha256": "e6befff2ade25304f13c03e3ba30a19148cd7155c862f5a59cfdb31c8669e519"
23
+ },
24
+ "f993c90acb9c4c6b9c4363986792790d4a38b2f5e3e4e589b0972d62d5a326f8/whale-drink.webm": {
25
+ "file": "assets/animations/whale-drink.webm",
26
+ "size": 1598173,
27
+ "sha256": "f993c90acb9c4c6b9c4363986792790d4a38b2f5e3e4e589b0972d62d5a326f8"
28
+ }
29
+ });