dsh-offwork 1.0.1 → 1.1.0

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 ADDED
@@ -0,0 +1,185 @@
1
+ // Presentation only: no clocks, storage, salary data or reminder side effects.
2
+ export function companionPresentation({
3
+ workStatus,
4
+ phase,
5
+ ownsPrompt = false,
6
+ ready = true,
7
+ failed = false,
8
+ feedback = null,
9
+ visible = true,
10
+ motion = true,
11
+ pageVisible = true,
12
+ }) {
13
+ if (!visible)
14
+ return { visible: false, pose: 'calm', message: null, announce: false, animate: false };
15
+ let pose = 'calm';
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);
20
+ if (!ready) {
21
+ pose = 'rest';
22
+ message = 'companionLoading';
23
+ } else if (failed) {
24
+ pose = 'offline';
25
+ message = 'companionUnavailable';
26
+ } else if (quiet) {
27
+ pose = 'quiet';
28
+ message = phase === 'quiet' ? 'companionQuiet' : 'companionPaused';
29
+ } else if (workStatus === 'lunch') {
30
+ pose = 'rest';
31
+ message = 'companionBreak';
32
+ } else if (workStatus === 'rest') {
33
+ pose = 'rest';
34
+ message = 'companionDayOff';
35
+ } else if (workStatus === 'done') {
36
+ pose = 'happy';
37
+ message = 'companionDone';
38
+ } else if (workStatus === 'before') {
39
+ pose = 'rest';
40
+ 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
+ }
46
+ let announce = false;
47
+ let animate = false;
48
+ if (ready && !failed && !(phase === 'due' && ownsPrompt) && feedback) {
49
+ message = feedback.key;
50
+ announce = true;
51
+ if (feedback.kind === 'drink' && !quiet && !resting) {
52
+ pose = 'happy';
53
+ animate = motion && pageVisible;
54
+ }
55
+ }
56
+ return { visible: true, pose, message, announce, animate };
57
+ }
58
+
59
+ // Original vector character. Its art is decorative; controls and status text
60
+ // provide the accessible names. Stable keys prevent the 1-second clock tick
61
+ // from replaying the reaction animation.
62
+ export function companionArtwork(h, presentation, size = 40, reaction = '') {
63
+ const { pose, animate } = presentation;
64
+ const asleep = ['rest', 'quiet'].includes(pose);
65
+ const path = (d, props = {}) => h('path', { d, ...props });
66
+ return h(
67
+ 'svg',
68
+ {
69
+ key: pose + ':' + reaction,
70
+ className: 'owc-companion-art',
71
+ viewBox: '0 0 64 64',
72
+ width: size,
73
+ height: size,
74
+ 'aria-hidden': 'true',
75
+ focusable: 'false',
76
+ 'data-pose': pose,
77
+ 'data-animate': animate,
78
+ },
79
+ h('ellipse', { cx: 32, cy: 58, rx: 19, ry: 3, className: 'owc-companion-shadow' }),
80
+ h(
81
+ 'g',
82
+ { className: 'owc-companion-body' },
83
+ path('M32 7C28 18 12 28 12 42C12 54 21 58 32 58S52 54 52 42C52 28 36 18 32 7Z', {
84
+ className: 'owc-companion-skin',
85
+ }),
86
+ path('M25 21C21 25 18 29 17 34', { className: 'owc-companion-shine' }),
87
+ h(
88
+ 'g',
89
+ { className: 'owc-companion-face' },
90
+ asleep
91
+ ? path('M21 38Q24 41 27 38M37 38Q40 41 43 38')
92
+ : h(
93
+ 'g',
94
+ null,
95
+ h('circle', { cx: 24, cy: 38, r: 1.7, fill: 'currentColor', stroke: 'none' }),
96
+ h('circle', { cx: 40, cy: 38, r: 1.7, fill: 'currentColor', stroke: 'none' }),
97
+ ),
98
+ path(
99
+ pose === 'happy'
100
+ ? 'M27 44Q32 51 37 44Z'
101
+ : pose === 'offline'
102
+ ? 'M29 46H35'
103
+ : 'M28 45Q32 49 36 45',
104
+ ),
105
+ ),
106
+ h(
107
+ 'g',
108
+ { className: 'owc-companion-cheeks' },
109
+ h('ellipse', { cx: 20, cy: 44, rx: 3, ry: 1.6 }),
110
+ h('ellipse', { cx: 44, cy: 44, rx: 3, ry: 1.6 }),
111
+ ),
112
+ pose === 'happy' && path('M12 44L7 38M52 44L57 38', { className: 'owc-companion-limbs' }),
113
+ pose === 'drink' &&
114
+ h(
115
+ 'g',
116
+ { className: 'owc-companion-cup' },
117
+ path('M43 41H55L53 54H45Z'),
118
+ path('M55 43H58Q62 47 55 49', { fill: 'none' }),
119
+ ),
120
+ pose === 'quiet' && path('M45 16H51L45 22H51', { className: 'owc-companion-limbs' }),
121
+ ),
122
+ );
123
+ }
124
+
125
+ export const COMPANION_MESSAGES = {
126
+ zh: {
127
+ companionName: '小歇',
128
+ companionLabel: '下班搭子',
129
+ companionOpen: '打开小歇的快捷操作',
130
+ companionShow: '显示陪伴角色',
131
+ companionMotion: '角色动作反馈',
132
+ companionPreferences: '立即生效,保存在本浏览器。隐藏角色后仍可使用计时和喝水操作。',
133
+ companionWorking: '你忙你的,我陪你记着时间。',
134
+ companionQuiet: '好,先安静陪着你。',
135
+ companionPaused: '本班先不提醒,需要时再叫我。',
136
+ companionBreak: '休息一下,回来再继续。',
137
+ companionBefore: '还没开工,先慢慢准备。',
138
+ companionDone: '这一班结束啦,辛苦了。',
139
+ companionDayOff: '今天歇一歇,按自己的节奏来。',
140
+ companionLoading: '稍等,我看看现在的状态。',
141
+ companionUnavailable: '暂时读不到提醒状态,可以重试读取。',
142
+ },
143
+ en: {
144
+ companionName: 'Xiaoxie',
145
+ companionLabel: 'Your workday companion',
146
+ companionOpen: 'Open companion quick actions',
147
+ companionShow: 'Show companion',
148
+ companionMotion: 'Character reactions',
149
+ companionPreferences:
150
+ 'Applies immediately in this browser. Timers and water actions work with the character hidden.',
151
+ companionWorking: 'Carry on. I’ll keep an eye on the time.',
152
+ companionQuiet: 'I’ll keep you company quietly.',
153
+ companionPaused: 'Reminders are paused. Resume whenever you like.',
154
+ companionBreak: 'Take a break. We can carry on afterward.',
155
+ companionBefore: 'No rush. Your shift hasn’t started yet.',
156
+ companionDone: 'That’s your shift done. Take a breather.',
157
+ companionDayOff: 'A day off. Enjoy your own pace.',
158
+ companionLoading: 'One moment while I check the status.',
159
+ companionUnavailable: 'I can’t read reminder status. You can retry loading.',
160
+ },
161
+ };
162
+
163
+ export const COMPANION_STYLE = `
164
+ .owc-sr-only{position:absolute!important;width:1px;height:1px;padding:0;overflow:hidden;clip-path:inset(50%);white-space:nowrap}
165
+ .owc-mini-feedback{margin:0;padding:0 14px 9px;max-width:260px;font-size:11px;line-height:1.6;overflow-wrap:anywhere}
166
+ .owc-companion{display:flex;align-items:center;gap:10px;padding:12px 17px 8px;cursor:default}
167
+ .owc-companion-copy{min-width:0;flex:1}
168
+ .owc-companion-name{display:flex;align-items:center;gap:6px;font-size:11px;color:var(--dsw-alias-label-secondary);font-weight:600}
169
+ .owc-companion-name span{font-size:10px;font-weight:400}
170
+ .owc-companion p{margin:5px 0 0;font-size:12px;line-height:1.65;overflow-wrap:anywhere}
171
+ .owc-water .owc-companion{padding:4px 0 8px;border-bottom:1px solid color-mix(in srgb,var(--owc-accent) 10%,transparent);margin-bottom:8px}
172
+ .owc-v2 button.owc-companion-trigger{padding:0;flex:none;min-width:36px;min-height:36px;border-radius:12px}
173
+ .owc-companion-art{flex:none;overflow:visible;color:var(--owc-accent);pointer-events:none}
174
+ .owc-companion-shadow{fill:currentColor;opacity:.12}
175
+ .owc-companion-skin{fill:color-mix(in srgb,var(--owc-accent) 22%,var(--dsw-alias-bg-overlay));stroke:currentColor;stroke-width:1.6;stroke-linejoin:round}
176
+ .owc-companion-shine{fill:none;stroke:white;stroke-width:2.5;stroke-linecap:round;opacity:.75}
177
+ .owc-companion-face{color:var(--dsw-alias-label-primary);fill:none;stroke:currentColor;stroke-width:1.6;stroke-linecap:round;stroke-linejoin:round}
178
+ .owc-companion-cheeks{fill:#ef8d96;opacity:.5}
179
+ .owc-companion-limbs{fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}
180
+ .owc-companion-cup{fill:var(--dsw-alias-bg-overlay);stroke:currentColor;stroke-width:1.6;stroke-linejoin:round}
181
+ .owc-companion-art[data-pose=offline]{opacity:.65}
182
+ .owc-companion-art[data-animate=true] .owc-companion-body{transform-origin:32px 52px;animation:owc-companion-thanks .65s ease-in-out 2}
183
+ @keyframes owc-companion-thanks{0%,100%{transform:translateY(0) rotate(0)}40%{transform:translateY(-2px) rotate(-3deg)}70%{transform:translateY(0) rotate(2deg)}}
184
+ @media(prefers-reduced-motion:reduce){.owc-companion-art *{animation:none!important;transition:none!important}}
185
+ `;
@@ -0,0 +1,101 @@
1
+ import { defaultHydration, validateHydration } from './hydration.js';
2
+
3
+ // A single IndexedDB read/write transaction is the cross-tab mutex. Neither
4
+ // notification delivery nor localStorage read-back is used as an atomic lock.
5
+ export function createHydrationStore(indexedDB, name = 'dsh.offwork-clock.hydration.v1') {
6
+ let database = null;
7
+ let opening = null;
8
+ let closed = false;
9
+ const open = () => {
10
+ if (closed) return Promise.reject(new Error('Hydration store closed'));
11
+ if (database) return Promise.resolve(database);
12
+ if (opening) return opening;
13
+ opening = new Promise((resolve, reject) => {
14
+ let abandoned = false;
15
+ const fail = (error) => {
16
+ abandoned = true;
17
+ reject(error);
18
+ };
19
+ if (!indexedDB) {
20
+ reject(new Error('Hydration storage unavailable'));
21
+ return;
22
+ }
23
+ const request = indexedDB.open(name, 1);
24
+ request.onupgradeneeded = () => request.result.createObjectStore('state');
25
+ request.onerror = () => fail(request.error ?? new Error('Hydration storage unavailable'));
26
+ request.onblocked = () => fail(new Error('Hydration storage blocked'));
27
+ request.onsuccess = () => {
28
+ if (closed || abandoned) {
29
+ request.result.close();
30
+ reject(new Error('Hydration store closed'));
31
+ return;
32
+ }
33
+ database = request.result;
34
+ database.onversionchange = () => {
35
+ database.close();
36
+ database = null;
37
+ opening = null;
38
+ };
39
+ database.onclose = () => {
40
+ database = null;
41
+ opening = null;
42
+ };
43
+ resolve(database);
44
+ };
45
+ }).catch((error) => {
46
+ opening = null;
47
+ throw error;
48
+ });
49
+ return opening;
50
+ };
51
+ return {
52
+ async update(reducer, { reset = false } = {}) {
53
+ const db = await open();
54
+ if (closed) throw new Error('Hydration store closed');
55
+ return new Promise((resolve, reject) => {
56
+ const transaction = db.transaction('state', 'readwrite');
57
+ const store = transaction.objectStore('state');
58
+ const request = store.get('current');
59
+ let output;
60
+ let failure;
61
+ request.onsuccess = () => {
62
+ try {
63
+ const current =
64
+ reset || request.result === undefined
65
+ ? defaultHydration()
66
+ : validateHydration(request.result);
67
+ const next = validateHydration(reducer(structuredClone(current)));
68
+ const changed = JSON.stringify(next) !== JSON.stringify(current);
69
+ const migrated =
70
+ request.result !== undefined && request.result.schema !== current.schema;
71
+ if (reset || changed || migrated) {
72
+ const previousRevision =
73
+ Number.isSafeInteger(request.result?.revision) && request.result.revision >= 0
74
+ ? request.result.revision
75
+ : current.revision;
76
+ next.revision = previousRevision + 1;
77
+ if (!Number.isSafeInteger(next.revision))
78
+ throw new Error('Hydration revision exhausted');
79
+ store.put(next, 'current');
80
+ }
81
+ output = next;
82
+ } catch (error) {
83
+ failure = error;
84
+ transaction.abort();
85
+ }
86
+ };
87
+ transaction.oncomplete = () => resolve(output);
88
+ transaction.onabort = () =>
89
+ reject(failure ?? transaction.error ?? new Error('Hydration transaction aborted'));
90
+ transaction.onerror = () => {
91
+ /* onabort reports the failure once. */
92
+ };
93
+ });
94
+ },
95
+ close() {
96
+ closed = true;
97
+ database?.close();
98
+ database = null;
99
+ },
100
+ };
101
+ }
package/hydration.js ADDED
@@ -0,0 +1,303 @@
1
+ // Pure hydration rules. Time is measured in paid-work milliseconds from calculate().
2
+ export const HYDRATION_SCHEMA = 2;
3
+ export const HYDRATION_ACTION_LIMIT = 64;
4
+ export const HYDRATION_LEASE_MS = 8000;
5
+
6
+ export function defaultHydration() {
7
+ return { schema: HYDRATION_SCHEMA, revision: 0, enabled: false, interval: 60, shift: null };
8
+ }
9
+
10
+ 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
+ if (
20
+ !keys(value, ['schema', 'revision', 'enabled', 'interval', 'shift']) ||
21
+ ![1, HYDRATION_SCHEMA].includes(value.schema) ||
22
+ !integer(value.revision) ||
23
+ typeof value.enabled !== 'boolean' ||
24
+ !integer(value.interval, 240) ||
25
+ value.interval < 5
26
+ ) {
27
+ throw new Error('Invalid hydration data');
28
+ }
29
+ 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) ||
47
+ 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');
82
+ }
83
+ }
84
+ return result;
85
+ }
86
+
87
+ export function hydrationContext(settings, clock) {
88
+ if (!settings || !clock || !Number.isFinite(clock.elapsed) || !Number.isFinite(clock.total))
89
+ return null;
90
+ 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)),
113
+ };
114
+ }
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);
193
+ 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;
198
+ };
199
+ if (action.type === 'reset') return defaultHydration();
200
+ if (action.type === 'preferences') {
201
+ const enabled = Object.hasOwn(action, 'enabled') ? action.enabled : next.enabled;
202
+ const interval = Object.hasOwn(action, 'interval') ? action.interval : next.interval;
203
+ if (
204
+ typeof enabled !== 'boolean' ||
205
+ !Number.isInteger(interval) ||
206
+ interval < 5 ||
207
+ interval > 240
208
+ )
209
+ throw new Error('Invalid reminder preferences');
210
+ const changed = next.enabled !== enabled || next.interval !== interval;
211
+ next.enabled = enabled;
212
+ next.interval = interval;
213
+ next = reconcileHydration(next, context);
214
+ if (!next.enabled && next.shift) {
215
+ next.shift.claim = null;
216
+ next.shift.quietUntil = 0;
217
+ }
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;
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);
229
+ 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');
242
+ if (
243
+ shift.recentActions.includes(action.actionId) ||
244
+ (action.eventId !== undefined && action.eventId !== view.eventId)
245
+ )
246
+ return next;
247
+ shift.recentActions = [
248
+ ...shift.recentActions.slice(-(HYDRATION_ACTION_LIMIT - 1)),
249
+ action.actionId,
250
+ ];
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
+ ) {
263
+ 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++;
271
+ } else if (action.type === 'claim' && view.phase === 'due') {
272
+ const claim = shift.claim;
273
+ 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;
290
+ restart();
291
+ } else if (
292
+ ['drink', 'snooze'].includes(action.type) &&
293
+ view.phase === 'due' &&
294
+ validClaim &&
295
+ action.eventId === view.eventId
296
+ ) {
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;
301
+ }
302
+ return next;
303
+ }
package/locale/en.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "meta": {
3
3
  "title": "Offwork Clock",
4
- "description": "A draggable countdown to clock-out with live estimated earnings and salary settings."
4
+ "description": "A Harness character companion with a clock-out countdown, estimated earnings, hydration reminders, quiet time and independent feature cards."
5
5
  }
6
6
  }
package/locale/zh.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "meta": {
3
3
  "title": "下班计时器",
4
- "description": "可拖动的下班倒计时与工资悬浮窗,支持月薪/日薪、夜班、工作日、可选休息扣除、金额隐藏与位置记忆。"
4
+ "description": "Harness 页面陪伴角色、下班倒计时、实时收入和喝水提醒,支持鲸系少女外观、免打扰、独立功能卡片与隐私偏好。"
5
5
  }
6
6
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-offwork",
3
- "version": "1.0.1",
4
- "description": "下班倒计时与实时工资悬浮窗:可拖动、可调透明度、工资可隐藏。A draggable countdown and salary overlay for DeepSeek Harness.",
3
+ "version": "1.1.0",
4
+ "description": "Harness 页面陪伴角色、下班倒计时、实时收入与喝水提醒。Character companion, clock-out countdown, earnings and hydration reminders for DeepSeek Harness.",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "author": "now",
@@ -38,9 +38,16 @@
38
38
  "client.source.js",
39
39
  "clock.js",
40
40
  "ui-utils.js",
41
+ "companion.js",
42
+ "pet-view.js",
43
+ "pet-interactions.js",
44
+ "pet-surfaces.js",
45
+ "hydration.js",
46
+ "hydration-store.js",
41
47
  "cordis.patch.yml",
42
48
  "locale/*.json",
43
49
  "icon.svg",
50
+ "assets/characters/*",
44
51
  "LICENSE",
45
52
  "README.md",
46
53
  "CHANGELOG.md",
@@ -70,6 +77,7 @@
70
77
  }
71
78
  },
72
79
  "devDependencies": {
80
+ "fake-indexeddb": "6.2.7",
73
81
  "prettier": "3.9.10"
74
82
  }
75
83
  }