dsh-offwork 1.0.0 → 1.0.1

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/client.js CHANGED
@@ -12,37 +12,48 @@ function calculate(settings, timestamp = Date.now()) {
12
12
  if (!Number.isFinite(now)) throw new RangeError('Invalid timestamp');
13
13
  const DAY = 86400000;
14
14
  const formatter = new Intl.DateTimeFormat('en-CA', {
15
- timeZone: settings.timezone, year: 'numeric', month: '2-digit', day: '2-digit',
16
- hour: '2-digit', minute: '2-digit', second: '2-digit', hourCycle: 'h23',
15
+ timeZone: settings.timezone,
16
+ year: 'numeric',
17
+ month: '2-digit',
18
+ day: '2-digit',
19
+ hour: '2-digit',
20
+ minute: '2-digit',
21
+ second: '2-digit',
22
+ hourCycle: 'h23',
17
23
  });
18
- const civil = time => {
19
- const p = Object.fromEntries(formatter.formatToParts(time).map(p => [p.type, p.value]));
24
+ const civil = (time) => {
25
+ const p = Object.fromEntries(formatter.formatToParts(time).map((p) => [p.type, p.value]));
20
26
  return Date.UTC(+p.year, +p.month - 1, +p.day, +p.hour, +p.minute, +p.second);
21
27
  };
22
28
  // Convert a timezone-local civil time to an absolute instant. At DST overlaps use
23
29
  // the earlier occurrence; at gaps move forward by the gap (Temporal-compatible).
24
- const instant = local => {
25
- const offsets = [...new Set([-DAY, 0, DAY].map(delta => civil(local + delta) - (local + delta)))];
26
- const candidates = offsets.map(offset => local - offset);
27
- const exact = candidates.filter(time => civil(time) === local);
30
+ const instant = (local) => {
31
+ const offsets = [
32
+ ...new Set([-DAY, 0, DAY].map((delta) => civil(local + delta) - (local + delta))),
33
+ ];
34
+ const candidates = offsets.map((offset) => local - offset);
35
+ const exact = candidates.filter((time) => civil(time) === local);
28
36
  if (exact.length) return Math.min(...exact);
29
- const after = candidates.filter(time => civil(time) >= local);
37
+ const after = candidates.filter((time) => civil(time) >= local);
30
38
  return Math.min(...(after.length ? after : candidates));
31
39
  };
32
- const minute = text => {
40
+ const minute = (text) => {
33
41
  if (typeof text !== 'string' || !/^(?:[01]\d|2[0-3]):[0-5]\d$/.test(text)) {
34
42
  throw new RangeError('Shift times must be HH:mm');
35
43
  }
36
44
  const [h, m] = text.split(':').map(Number);
37
45
  return h * 60 + m;
38
46
  };
39
- const start = minute(settings.start), end = minute(settings.end);
47
+ const start = minute(settings.start),
48
+ end = minute(settings.end);
40
49
  if (start === end) throw new RangeError('Shift must not be empty');
41
50
  const overnight = end < start;
42
51
  const duration = (end - start + 1440) % 1440;
43
- let breakOffset = 0, breakDuration = 0;
52
+ let breakOffset = 0,
53
+ breakDuration = 0;
44
54
  if (settings.breakStart != null || settings.breakEnd != null) {
45
- const bs = minute(settings.breakStart), be = minute(settings.breakEnd);
55
+ const bs = minute(settings.breakStart),
56
+ be = minute(settings.breakEnd);
46
57
  if (bs !== be) {
47
58
  breakOffset = (bs - start + 1440) % 1440;
48
59
  breakDuration = (be - bs + 1440) % 1440;
@@ -54,7 +65,7 @@ function calculate(settings, timestamp = Date.now()) {
54
65
  const localNow = civil(now);
55
66
  const today = Math.floor(localNow / DAY) * DAY;
56
67
  const minuteNow = (localNow - today) / 60000;
57
- const workday = day => settings.weekdays.includes(new Date(day).getUTCDay());
68
+ const workday = (day) => settings.weekdays.includes(new Date(day).getUTCDay());
58
69
  let shiftDay = today;
59
70
  if (overnight && minuteNow < start) {
60
71
  // Retain yesterday's finished work shift until the next shift starts. If
@@ -65,47 +76,83 @@ function calculate(settings, timestamp = Date.now()) {
65
76
  const shiftStart = instant(shiftDay + start * 60000);
66
77
  const shiftEnd = instant(shiftDay + (start + duration) * 60000);
67
78
  const breakStart = breakDuration ? instant(shiftDay + (start + breakOffset) * 60000) : null;
68
- const breakEnd = breakDuration ? instant(shiftDay + (start + breakOffset + breakDuration) * 60000) : null;
79
+ const breakEnd = breakDuration
80
+ ? instant(shiftDay + (start + breakOffset + breakDuration) * 60000)
81
+ : null;
69
82
  const breakMs = breakDuration ? breakEnd - breakStart : 0;
70
83
  const totalMs = shiftEnd - shiftStart - breakMs;
71
84
  if (totalMs <= 0 || breakMs < 0) throw new RangeError('Shift has no paid duration');
72
85
  const clampedNow = Math.max(shiftStart, Math.min(now, shiftEnd));
73
- const elapsedMs = isWorkday ? Math.max(0, Math.min(totalMs,
74
- clampedNow - shiftStart - (breakDuration ? Math.max(0, Math.min(clampedNow, breakEnd) - breakStart) : 0))) : 0;
75
- const status = !isWorkday ? 'rest' : now < shiftStart ? 'before' : now >= shiftEnd ? 'done'
76
- : breakDuration && now >= breakStart && now < breakEnd ? 'lunch' : 'working';
86
+ const elapsedMs = isWorkday
87
+ ? Math.max(
88
+ 0,
89
+ Math.min(
90
+ totalMs,
91
+ clampedNow -
92
+ shiftStart -
93
+ (breakDuration ? Math.max(0, Math.min(clampedNow, breakEnd) - breakStart) : 0),
94
+ ),
95
+ )
96
+ : 0;
97
+ const status = !isWorkday
98
+ ? 'rest'
99
+ : now < shiftStart
100
+ ? 'before'
101
+ : now >= shiftEnd
102
+ ? 'done'
103
+ : breakDuration && now >= breakStart && now < breakEnd
104
+ ? 'lunch'
105
+ : 'working';
77
106
  // Decimal inputs become exact rational numbers before truncation. Never use
78
107
  // floor((salary / days * progress) * 100): binary rounding loses whole cents.
79
- const rational = number => {
80
- if (typeof number !== 'number' || !Number.isFinite(number) || number < 0) throw new RangeError('Invalid salary');
108
+ const rational = (number) => {
109
+ if (typeof number !== 'number' || !Number.isFinite(number) || number < 0)
110
+ throw new RangeError('Invalid salary');
81
111
  const [mantissa, exponent = '0'] = String(number).toLowerCase().split('e');
82
112
  const [integer, fraction = ''] = mantissa.split('.');
83
113
  const scale = fraction.length - Number(exponent);
84
- return scale >= 0 ? [BigInt(integer + fraction), 10n ** BigInt(scale)]
114
+ return scale >= 0
115
+ ? [BigInt(integer + fraction), 10n ** BigInt(scale)]
85
116
  : [BigInt(integer + fraction) * 10n ** BigInt(-scale), 1n];
86
117
  };
87
118
  const payMode = settings.payMode === 'daily' ? 'daily' : 'monthly';
88
- let [numerator, denominator] = rational(payMode === 'daily' ? settings.dailySalary : settings.monthlySalary);
119
+ let [numerator, denominator] = rational(
120
+ payMode === 'daily' ? settings.dailySalary : settings.monthlySalary,
121
+ );
89
122
  if (payMode === 'monthly') {
90
123
  const [daysN, daysD] = rational(settings.workdaysPerMonth);
91
124
  if (daysN === 0n) throw new RangeError('Workdays per month must be positive');
92
125
  numerator *= daysD;
93
126
  denominator *= daysN;
94
127
  }
95
- const dailyCents = Number(numerator * 100n / denominator);
96
- const earnedCents = Math.min(dailyCents, Number(numerator * 100n * BigInt(Math.floor(elapsedMs)) /
97
- (denominator * BigInt(totalMs))));
98
- const hourlyCents = Number(numerator * 100n * 3600000n / (denominator * BigInt(totalMs)));
99
- const dateString = day => new Date(day).toISOString().slice(0, 10);
128
+ const dailyCents = Number((numerator * 100n) / denominator);
129
+ const earnedCents = Math.min(
130
+ dailyCents,
131
+ Number((numerator * 100n * BigInt(Math.floor(elapsedMs))) / (denominator * BigInt(totalMs))),
132
+ );
133
+ const hourlyCents = Number((numerator * 100n * 3600000n) / (denominator * BigInt(totalMs)));
134
+ const dateString = (day) => new Date(day).toISOString().slice(0, 10);
100
135
  return {
101
- status, remaining: isWorkday ? Math.max(0, Math.ceil((shiftEnd - now) / 1000)) : 0,
136
+ status,
137
+ remaining: isWorkday ? Math.max(0, Math.ceil((shiftEnd - now) / 1000)) : 0,
102
138
  startRemaining: status === 'before' ? Math.max(0, Math.ceil((shiftStart - now) / 1000)) : 0,
103
139
  progress: elapsedMs / totalMs,
104
- earned: earnedCents / 100, daily: dailyCents / 100, hourly: hourlyCents / 100,
105
- date: dateString(today), shiftDate: dateString(shiftDay),
106
- earnedCents, dailyCents, payMode, isWorkday, overnight,
107
- shiftStart, shiftEnd, breakStart, breakEnd,
108
- elapsed: elapsedMs / 1000, total: totalMs / 1000,
140
+ earned: earnedCents / 100,
141
+ daily: dailyCents / 100,
142
+ hourly: hourlyCents / 100,
143
+ date: dateString(today),
144
+ shiftDate: dateString(shiftDay),
145
+ earnedCents,
146
+ dailyCents,
147
+ payMode,
148
+ isWorkday,
149
+ overnight,
150
+ shiftStart,
151
+ shiftEnd,
152
+ breakStart,
153
+ breakEnd,
154
+ elapsed: elapsedMs / 1000,
155
+ total: totalMs / 1000,
109
156
  };
110
157
  }
111
158
 
@@ -113,12 +160,14 @@ function calculate(settings, timestamp = Date.now()) {
113
160
  function formatDuration(seconds) {
114
161
  if (!Number.isFinite(seconds) || seconds < 0) return '--:--:--';
115
162
  const n = Math.ceil(seconds);
116
- return [Math.floor(n / 3600), Math.floor(n % 3600 / 60), n % 60]
117
- .map(part => String(part).padStart(2, '0')).join(':');
163
+ return [Math.floor(n / 3600), Math.floor((n % 3600) / 60), n % 60]
164
+ .map((part) => String(part).padStart(2, '0'))
165
+ .join(':');
118
166
  }
119
167
 
120
168
  function clampPosition(position, viewport, size) {
121
- const width = Math.max(0, viewport.width), height = Math.max(0, viewport.height);
169
+ const width = Math.max(0, viewport.width),
170
+ height = Math.max(0, viewport.height);
122
171
  const edge = Math.min(12, width / 2, height / 2);
123
172
  const maxX = Math.max(edge, width - size.width - edge);
124
173
  const maxY = Math.max(edge, height - size.height - edge);
@@ -139,8 +188,10 @@ function normalizeView(value) {
139
188
  mini: value?.mini !== false,
140
189
  masked: value?.masked !== false,
141
190
  opacity: clampOpacity(value?.opacity),
142
- position: Number.isFinite(value?.position?.x) && Number.isFinite(value?.position?.y)
143
- ? { x: value.position.x, y: value.position.y } : null,
191
+ position:
192
+ Number.isFinite(value?.position?.x) && Number.isFinite(value?.position?.y)
193
+ ? { x: value.position.x, y: value.position.y }
194
+ : null,
144
195
  };
145
196
  }
146
197
 
@@ -149,48 +200,146 @@ function decodeCommand(rpc) {
149
200
  const result = rpc.value?.result;
150
201
  if (!result || typeof result.text !== 'string') throw new Error('Command unavailable');
151
202
  let data;
152
- try { data = JSON.parse(result.text); }
153
- catch { throw new Error('Invalid command response'); }
154
- if (result.kind !== 'success' || data?.ok !== true) throw new Error(data?.error || 'Request failed');
203
+ try {
204
+ data = JSON.parse(result.text);
205
+ } catch {
206
+ throw new Error('Invalid command response');
207
+ }
208
+ if (result.kind !== 'success' || data?.ok !== true)
209
+ throw new Error(data?.error || 'Request failed');
155
210
  if (!data.settings || typeof data.settings !== 'object') throw new Error('Missing settings');
156
211
  return data;
157
212
  }
158
213
 
159
214
  function changedSettings(original, draft) {
160
- return Object.fromEntries(Object.entries(draft)
161
- .filter(([key, value]) => JSON.stringify(value) !== JSON.stringify(original[key])));
215
+ return Object.fromEntries(
216
+ Object.entries(draft).filter(
217
+ ([key, value]) => JSON.stringify(value) !== JSON.stringify(original[key]),
218
+ ),
219
+ );
162
220
  }
163
221
 
164
222
  const messages = {
165
223
  zh: {
166
- title:'下班计时器', working:'距离下班', before:'距离开工', lunch:'午休中', done:'本班已完成', rest:'今天休息',
167
- earned:'本班已赚 · 税前', daily:'日薪', hourly:'有效时薪', progress:'计薪进度',
168
- settings:'设置', collapse:'收起为小条', expand:'展开', hide:'隐藏工资', show:'显示工资',
169
- save:'保存', cancel:'取消', monthlySalary:'税前月薪(元)', dailySalary:'税前日薪(元)',
170
- workdaysPerMonth:'月计薪天数', start:'上班时间', end:'下班时间', breakStart:'休息开始', breakEnd:'休息结束',
171
- timezone:'时区', weekdays:'班次开始日', payMode:'计薪方式', monthly:'月薪', dailyMode:'日薪',
172
- loading:'正在读取设置…', retry:'重新读取', noSession:'请先打开或创建一个会话。', error:'操作失败,请重试。',
173
- saved:'设置已保存', days:'日一二三四五六', privacy:'配置存本机;读写结果进入所选会话记录。',
174
- drag:'按住可拖动;方向键微调,Home 复位', setup:'先设好你的作息', setupHint:'示例数值不是你的收入,保存后才开始计时。',
175
- breakEnabled:'扣除无薪休息', noBreak:'不扣休息', nextDay:'次日', preview:'折算',
176
- salaryGroup:'薪资', scheduleGroup:'作息', appearanceGroup:'外观', advanced:'高级设置', sessionHint:'仅用于记录配置读写操作,不是账本,不影响工资计算。', session:'操作记录会话', masked:'工资已隐藏', reset:'复位位置', synced:'已读取最新配置',
177
- refresh:'读取最新配置', busy:'保存中…', storage:'浏览器禁止本地存储;位置与透明度本次有效。',
178
- shiftDate:'班次', opacity:'背景透明度', opacityHint:'只影响背景,不影响文字。',
224
+ title: '下班计时器',
225
+ working: '距离下班',
226
+ before: '距离开工',
227
+ lunch: '午休中',
228
+ done: '本班已完成',
229
+ rest: '今天休息',
230
+ earned: '本班已赚 · 税前',
231
+ daily: '日薪',
232
+ hourly: '有效时薪',
233
+ progress: '计薪进度',
234
+ settings: '设置',
235
+ collapse: '收起为小条',
236
+ expand: '展开',
237
+ hide: '隐藏工资',
238
+ show: '显示工资',
239
+ save: '保存',
240
+ cancel: '取消',
241
+ monthlySalary: '税前月薪(元)',
242
+ dailySalary: '税前日薪(元)',
243
+ workdaysPerMonth: '月计薪天数',
244
+ start: '上班时间',
245
+ end: '下班时间',
246
+ breakStart: '休息开始',
247
+ breakEnd: '休息结束',
248
+ timezone: '时区',
249
+ weekdays: '班次开始日',
250
+ payMode: '计薪方式',
251
+ monthly: '月薪',
252
+ dailyMode: '日薪',
253
+ loading: '正在读取设置…',
254
+ retry: '重新读取',
255
+ noSession: '请先打开或创建一个会话。',
256
+ error: '操作失败,请重试。',
257
+ saved: '设置已保存',
258
+ days: '日一二三四五六',
259
+ privacy: '配置保存在 Harness 所在机器;读写结果进入所选会话记录。',
260
+ drag: '按住可拖动;方向键微调,Home 复位',
261
+ setup: '先设好你的作息',
262
+ setupHint: '示例数值不是你的收入,保存后才开始计时。',
263
+ breakEnabled: '扣除无薪休息',
264
+ noBreak: '不扣休息',
265
+ nextDay: '次日',
266
+ preview: '折算',
267
+ salaryGroup: '薪资',
268
+ scheduleGroup: '作息',
269
+ appearanceGroup: '外观',
270
+ advanced: '高级设置',
271
+ sessionHint: '仅用于记录配置读写操作,不是账本,不影响工资计算。',
272
+ session: '操作记录会话',
273
+ masked: '工资已隐藏',
274
+ reset: '复位位置',
275
+ synced: '已读取最新配置',
276
+ refresh: '读取最新配置',
277
+ busy: '保存中…',
278
+ storage: '浏览器禁止本地存储;位置与透明度本次有效。',
279
+ shiftDate: '班次',
280
+ opacity: '背景透明度',
281
+ opacityHint: '只影响背景,不影响文字。',
179
282
  },
180
283
  en: {
181
- title:'Offwork Clock', working:'Until clock-out', before:'Until work starts', lunch:'On break', done:'Shift complete', rest:'Day off',
182
- earned:'Earned this shift · gross', daily:'Shift pay', hourly:'Paid-hour rate', progress:'Paid progress',
183
- settings:'Settings', collapse:'Minimize to strip', expand:'Expand', hide:'Hide salary', show:'Show salary',
184
- save:'Save', cancel:'Cancel', monthlySalary:'Monthly gross (CNY)', dailySalary:'Daily gross (CNY)',
185
- workdaysPerMonth:'Paid days / month', start:'Shift starts', end:'Shift ends', breakStart:'Break starts', breakEnd:'Break ends',
186
- timezone:'Time zone', weekdays:'Shift starting days', payMode:'Pay basis', monthly:'Monthly', dailyMode:'Daily',
187
- loading:'Loading settings…', retry:'Reload', noSession:'Open or create a session first.', error:'Operation failed. Please retry.',
188
- saved:'Settings saved', days:'SMTWTFS', privacy:'Settings stay local; results enter the selected session log.',
189
- drag:'Drag to move; arrow keys to adjust; Home resets', setup:'Set up your schedule', setupHint:'These are example values, not your income. Save to start.',
190
- breakEnabled:'Deduct unpaid break', noBreak:'no break', nextDay:'next day', preview:'Preview',
191
- salaryGroup:'Salary', scheduleGroup:'Schedule', appearanceGroup:'Appearance', advanced:'Advanced', sessionHint:'Logs configuration operations only; not a ledger and does not affect pay calculations.', session:'Action log session', masked:'Salary hidden', reset:'Reset position', synced:'Latest settings loaded',
192
- refresh:'Reload latest settings', busy:'Saving…', storage:'Local storage is blocked; position and opacity last only for this visit.',
193
- shiftDate:'Shift', opacity:'Surface opacity', opacityHint:'Affects the background only.',
284
+ title: 'Offwork Clock',
285
+ working: 'Until clock-out',
286
+ before: 'Until work starts',
287
+ lunch: 'On break',
288
+ done: 'Shift complete',
289
+ rest: 'Day off',
290
+ earned: 'Earned this shift · gross',
291
+ daily: 'Shift pay',
292
+ hourly: 'Paid-hour rate',
293
+ progress: 'Paid progress',
294
+ settings: 'Settings',
295
+ collapse: 'Minimize to strip',
296
+ expand: 'Expand',
297
+ hide: 'Hide salary',
298
+ show: 'Show salary',
299
+ save: 'Save',
300
+ cancel: 'Cancel',
301
+ monthlySalary: 'Monthly gross (CNY)',
302
+ dailySalary: 'Daily gross (CNY)',
303
+ workdaysPerMonth: 'Paid days / month',
304
+ start: 'Shift starts',
305
+ end: 'Shift ends',
306
+ breakStart: 'Break starts',
307
+ breakEnd: 'Break ends',
308
+ timezone: 'Time zone',
309
+ weekdays: 'Shift starting days',
310
+ payMode: 'Pay basis',
311
+ monthly: 'Monthly',
312
+ dailyMode: 'Daily',
313
+ loading: 'Loading settings…',
314
+ retry: 'Reload',
315
+ noSession: 'Open or create a session first.',
316
+ error: 'Operation failed. Please retry.',
317
+ saved: 'Settings saved',
318
+ days: 'SMTWTFS',
319
+ privacy: 'Settings are saved on the Harness host; results enter the selected session log.',
320
+ drag: 'Drag to move; arrow keys to adjust; Home resets',
321
+ setup: 'Set up your schedule',
322
+ setupHint: 'These are example values, not your income. Save to start.',
323
+ breakEnabled: 'Deduct unpaid break',
324
+ noBreak: 'no break',
325
+ nextDay: 'next day',
326
+ preview: 'Preview',
327
+ salaryGroup: 'Salary',
328
+ scheduleGroup: 'Schedule',
329
+ appearanceGroup: 'Appearance',
330
+ advanced: 'Advanced',
331
+ sessionHint:
332
+ 'Logs configuration operations only; not a ledger and does not affect pay calculations.',
333
+ session: 'Action log session',
334
+ masked: 'Salary hidden',
335
+ reset: 'Reset position',
336
+ synced: 'Latest settings loaded',
337
+ refresh: 'Reload latest settings',
338
+ busy: 'Saving…',
339
+ storage: 'Local storage is blocked; position and opacity last only for this visit.',
340
+ shiftDate: 'Shift',
341
+ opacity: 'Surface opacity',
342
+ opacityHint: 'Affects the background only.',
194
343
  },
195
344
  };
196
345
  const STYLE = `
@@ -280,31 +429,65 @@ function changedSettings(original, draft) {
280
429
  @media(max-width:420px){.owc-pill{min-width:136px;padding:8px 8px 7px 12px}.owc-pill-status{display:none}.owc-pill-time{font-size:22px}.owc-main,.owc-form{padding-left:14px;padding-right:14px}.owc-time{font-size:31px}}
281
430
  `;
282
431
  const ICONS = {
283
- eye: [['path', {d:'M1.7 8S4.3 3.9 8 3.9 14.3 8 14.3 8 11.7 12.1 8 12.1 1.7 8 1.7 8Z'}], ['circle', {cx:8, cy:8, r:2.05}]],
284
- eyeOff: [['path', {d:'M1.7 8S4.3 3.9 8 3.9 14.3 8 14.3 8 11.7 12.1 8 12.1 1.7 8 1.7 8Z'}], ['circle', {cx:8, cy:8, r:2.05}], ['path', {d:'M3 3l10 10'}]],
285
- sliders: [['path', {d:'M2.2 4.6h3.4M9.2 4.6h4.6M2.2 11.4h5.4M11.4 11.4h2.4'}], ['circle', {cx:7.3, cy:4.6, r:1.55}], ['circle', {cx:9.4, cy:11.4, r:1.55}]],
286
- alarm: [['circle', {cx:8, cy:9, r:4.5}], ['path', {d:'M8 6.5V9l1.7 1M2 4l2-2M12 2l2 2M5 13l-1 2M11 13l1 2'}]],
287
- chevronUp: [['path', {d:'M3.7 9.8L8 5.5l4.3 4.3'}]],
288
- chevronDown: [['path', {d:'M3.7 6.2L8 10.5l4.3-4.3'}]],
289
- close: [['path', {d:'M4.3 4.3l7.4 7.4M11.7 4.3l-7.4 7.4'}]],
290
- refresh: [['path', {d:'M13.1 8a5.1 5.1 0 1 1-1.5-3.6'}], ['path', {d:'M13.1 3v3.2H9.9'}]],
432
+ eye: [
433
+ ['path', { d: 'M1.7 8S4.3 3.9 8 3.9 14.3 8 14.3 8 11.7 12.1 8 12.1 1.7 8 1.7 8Z' }],
434
+ ['circle', { cx: 8, cy: 8, r: 2.05 }],
435
+ ],
436
+ eyeOff: [
437
+ ['path', { d: 'M1.7 8S4.3 3.9 8 3.9 14.3 8 14.3 8 11.7 12.1 8 12.1 1.7 8 1.7 8Z' }],
438
+ ['circle', { cx: 8, cy: 8, r: 2.05 }],
439
+ ['path', { d: 'M3 3l10 10' }],
440
+ ],
441
+ sliders: [
442
+ ['path', { d: 'M2.2 4.6h3.4M9.2 4.6h4.6M2.2 11.4h5.4M11.4 11.4h2.4' }],
443
+ ['circle', { cx: 7.3, cy: 4.6, r: 1.55 }],
444
+ ['circle', { cx: 9.4, cy: 11.4, r: 1.55 }],
445
+ ],
446
+ alarm: [
447
+ ['circle', { cx: 8, cy: 9, r: 4.5 }],
448
+ ['path', { d: 'M8 6.5V9l1.7 1M2 4l2-2M12 2l2 2M5 13l-1 2M11 13l1 2' }],
449
+ ],
450
+ chevronUp: [['path', { d: 'M3.7 9.8L8 5.5l4.3 4.3' }]],
451
+ chevronDown: [['path', { d: 'M3.7 6.2L8 10.5l4.3-4.3' }]],
452
+ close: [['path', { d: 'M4.3 4.3l7.4 7.4M11.7 4.3l-7.4 7.4' }]],
453
+ refresh: [
454
+ ['path', { d: 'M13.1 8a5.1 5.1 0 1 1-1.5-3.6' }],
455
+ ['path', { d: 'M13.1 3v3.2H9.9' }],
456
+ ],
291
457
  };
292
- const iconSvg = (name, size = 15) => h('svg', {
293
- width: size, height: size, viewBox: '0 0 16 16', fill: 'none', stroke: 'currentColor',
294
- strokeWidth: 1.55, strokeLinecap: 'round', strokeLinejoin: 'round', 'aria-hidden': 'true', focusable: 'false',
295
- }, ICONS[name].map(([tag, attrs], index) => h(tag, {key: index, ...attrs})));
458
+ const iconSvg = (name, size = 15) =>
459
+ h(
460
+ 'svg',
461
+ {
462
+ width: size,
463
+ height: size,
464
+ viewBox: '0 0 16 16',
465
+ fill: 'none',
466
+ stroke: 'currentColor',
467
+ strokeWidth: 1.55,
468
+ strokeLinecap: 'round',
469
+ strokeLinejoin: 'round',
470
+ 'aria-hidden': 'true',
471
+ focusable: 'false',
472
+ },
473
+ ICONS[name].map(([tag, attrs], index) => h(tag, { key: index, ...attrs })),
474
+ );
296
475
 
297
476
  return {
298
477
  inject: ['slots', 'locale', 'remote', 'remote.commands'],
299
478
  apply(ctx) {
300
- for (const [lang, dict] of Object.entries(messages)) ctx.effect(() => ctx.locale.register('offwork-clock', lang, dict));
479
+ for (const [lang, dict] of Object.entries(messages))
480
+ ctx.effect(() => ctx.locale.register('offwork-clock', lang, dict));
301
481
  const viewKey = 'dsh.offwork-clock.view.v4';
302
482
  const legacyKey = 'dsh.offwork-clock.view.v3';
303
483
 
304
484
  function Clock(props) {
305
- const locale = React.useSyncExternalStore(fn => ctx.locale.subscribe(fn), () => ctx.locale.getSnapshot());
485
+ const locale = React.useSyncExternalStore(
486
+ (fn) => ctx.locale.subscribe(fn),
487
+ () => ctx.locale.getSnapshot(),
488
+ );
306
489
  const t = ctx.locale.bind('offwork-clock');
307
- const sessions = props.useSessions(s => s);
490
+ const sessions = props.useSessions((s) => s);
308
491
  const sessionIds = Array.isArray(sessions?.ids) ? sessions.ids : [];
309
492
  const [sessionChoice, setSessionChoice] = React.useState('');
310
493
  const sessionId = sessionIds.includes(sessionChoice) ? sessionChoice : sessionIds[0];
@@ -331,37 +514,76 @@ function changedSettings(original, draft) {
331
514
  const preview = workingDraft ? safeCalculate(workingDraft) : null;
332
515
  const safeState = state && !state.error ? state : null;
333
516
  const collapsed = !!safeState && !editing && view.mini && !error && !state?.error;
334
- const seconds = safeState ? (safeState.status === 'before' ? safeState.startRemaining : safeState.remaining) : NaN;
517
+ const seconds = safeState
518
+ ? safeState.status === 'before'
519
+ ? safeState.startRemaining
520
+ : safeState.remaining
521
+ : NaN;
335
522
  const duration = formatDuration(seconds);
336
- const formatMoney = React.useMemo(() => new Intl.NumberFormat(locale.active, {
337
- style: 'currency', currency: 'CNY', minimumFractionDigits: 2, maximumFractionDigits: 2,
338
- }), [locale.active]);
339
- const money = value => view.masked ? '••••' : formatMoney.format(value);
523
+ const formatMoney = React.useMemo(
524
+ () =>
525
+ new Intl.NumberFormat(locale.active, {
526
+ style: 'currency',
527
+ currency: 'CNY',
528
+ minimumFractionDigits: 2,
529
+ maximumFractionDigits: 2,
530
+ }),
531
+ [locale.active],
532
+ );
533
+ const money = (value) => (view.masked ? '••••' : formatMoney.format(value));
340
534
  const moneyText = safeState ? money(safeState.earned) : '—';
341
- const cardStyle = Object.assign({ '--owc-alpha': String(view.opacity) },
342
- view.position ? { left: view.position.x, top: view.position.y, right: 'auto' } : {});
535
+ const cardStyle = Object.assign(
536
+ { '--owc-alpha': String(view.opacity) },
537
+ view.position ? { left: view.position.x, top: view.position.y, right: 'auto' } : {},
538
+ );
343
539
 
344
540
  function safeCalculate(value) {
345
- try { return calculate(value, now); }
346
- catch (e) { return { error: e instanceof Error ? e.message : t('error') }; }
541
+ try {
542
+ return calculate(value, now);
543
+ } catch (e) {
544
+ return { error: e instanceof Error ? e.message : t('error') };
545
+ }
546
+ }
547
+ function errorText(e) {
548
+ return e instanceof Error ? e.message : t('error');
549
+ }
550
+ function setPreference(patch) {
551
+ setView((current) => ({ ...current, ...patch }));
347
552
  }
348
- function errorText(e) { return e instanceof Error ? e.message : t('error'); }
349
- function setPreference(patch) { setView(current => ({ ...current, ...patch })); }
350
553
 
351
- const clamp = React.useCallback(position => clampPosition(position,
352
- { width: window.innerWidth, height: window.innerHeight },
353
- { width: box.current?.offsetWidth || 300, height: box.current?.offsetHeight || 200 }), []);
554
+ const clamp = React.useCallback(
555
+ (position) =>
556
+ clampPosition(
557
+ position,
558
+ { width: window.innerWidth, height: window.innerHeight },
559
+ {
560
+ width: box.current?.offsetWidth || 300,
561
+ height: box.current?.offsetHeight || 200,
562
+ },
563
+ ),
564
+ [],
565
+ );
354
566
 
355
567
  React.useEffect(() => {
356
568
  try {
357
569
  const saved = localStorage.getItem(viewKey);
358
570
  const legacy = !saved ? JSON.parse(localStorage.getItem(legacyKey) || 'null') : null;
359
- setView(normalizeView(saved ? JSON.parse(saved) : { position: legacy?.position ?? null, masked: legacy?.masked ?? true }));
360
- } catch { setStorageError(true); }
571
+ setView(
572
+ normalizeView(
573
+ saved
574
+ ? JSON.parse(saved)
575
+ : { position: legacy?.position ?? null, masked: legacy?.masked ?? true },
576
+ ),
577
+ );
578
+ } catch {
579
+ setStorageError(true);
580
+ }
361
581
  setReady(true);
362
- const sync = e => {
582
+ const sync = (e) => {
363
583
  if (e.key !== viewKey) return;
364
- try { setView(normalizeView(JSON.parse(e.newValue || 'null'))); } catch {}
584
+ try {
585
+ setView(normalizeView(JSON.parse(e.newValue || 'null')));
586
+ } catch {}
365
587
  };
366
588
  window.addEventListener('storage', sync);
367
589
  return () => window.removeEventListener('storage', sync);
@@ -369,72 +591,122 @@ function changedSettings(original, draft) {
369
591
 
370
592
  React.useEffect(() => {
371
593
  if (!ready) return;
372
- try { localStorage.setItem(viewKey, JSON.stringify(normalizeView(view))); }
373
- catch { setStorageError(true); }
594
+ try {
595
+ localStorage.setItem(viewKey, JSON.stringify(normalizeView(view)));
596
+ } catch {
597
+ setStorageError(true);
598
+ }
374
599
  }, [ready, view]);
375
600
 
376
601
  React.useEffect(() => {
377
602
  if (sessionId) reload();
378
603
  else setError(t('noSession'));
379
- return () => { generation.current++; };
604
+ return () => {
605
+ generation.current++;
606
+ };
380
607
  }, [sessionId]);
381
608
 
382
609
  React.useEffect(() => {
383
610
  let timer;
384
- const tick = () => { setNow(Date.now()); timer = setTimeout(tick, 1000 - Date.now() % 1000); };
385
- const visibility = () => { clearTimeout(timer); setNow(Date.now()); if (!document.hidden) tick(); };
611
+ const tick = () => {
612
+ setNow(Date.now());
613
+ timer = setTimeout(tick, 1000 - (Date.now() % 1000));
614
+ };
615
+ const visibility = () => {
616
+ clearTimeout(timer);
617
+ setNow(Date.now());
618
+ if (!document.hidden) tick();
619
+ };
386
620
  if (!document.hidden) tick();
387
621
  document.addEventListener('visibilitychange', visibility);
388
- return () => { clearTimeout(timer); document.removeEventListener('visibilitychange', visibility); };
622
+ return () => {
623
+ clearTimeout(timer);
624
+ document.removeEventListener('visibilitychange', visibility);
625
+ };
389
626
  }, []);
390
627
 
391
628
  React.useEffect(() => {
392
- const resize = () => setView(current => current.position ? { ...current, position: clamp(current.position) } : current);
629
+ const resize = () =>
630
+ setView((current) =>
631
+ current.position ? { ...current, position: clamp(current.position) } : current,
632
+ );
393
633
  const ro = typeof ResizeObserver === 'function' ? new ResizeObserver(resize) : null;
394
634
  if (box.current) ro?.observe(box.current);
395
635
  window.addEventListener('resize', resize);
396
- return () => { ro?.disconnect(); window.removeEventListener('resize', resize); };
636
+ return () => {
637
+ ro?.disconnect();
638
+ window.removeEventListener('resize', resize);
639
+ };
397
640
  }, [clamp]);
398
641
 
399
642
  React.useEffect(() => {
400
- if (!editing) { focusReturn.current?.focus?.(); return; }
643
+ if (!editing) {
644
+ focusReturn.current?.focus?.();
645
+ return;
646
+ }
401
647
  box.current?.querySelector('form input,form select')?.focus?.();
402
648
  }, [editing]);
403
649
 
404
- React.useEffect(() => () => { generation.current++; dragState.current = null; }, []);
650
+ React.useEffect(
651
+ () => () => {
652
+ generation.current++;
653
+ dragState.current = null;
654
+ },
655
+ [],
656
+ );
405
657
 
406
658
  async function request(action, patch) {
407
659
  if (!sessionId) throw new Error(t('noSession'));
408
- const rpc = await ctx.remote.commands.execute(sessionId, '/offwork-clock ' + action + (patch ? ' ' + JSON.stringify(patch) : ''), []);
660
+ const rpc = await ctx.remote.commands.execute(
661
+ sessionId,
662
+ '/offwork-clock ' + action + (patch ? ' ' + JSON.stringify(patch) : ''),
663
+ [],
664
+ );
409
665
  return decodeCommand(rpc);
410
666
  }
411
667
 
412
668
  async function reload(explicit = false) {
413
669
  if (requestBusy.current) return;
414
670
  const token = ++generation.current;
415
- setBusy(true); setError('');
671
+ setBusy(true);
672
+ setError('');
416
673
  try {
417
674
  const next = await request('get');
418
675
  if (token !== generation.current) return;
419
676
  setData(next);
420
- if (!next.configured) { setDraft({ ...next.settings }); setEditing(true); }
677
+ if (!next.configured) {
678
+ setDraft({ ...next.settings });
679
+ setEditing(true);
680
+ }
421
681
  if (explicit) setNotice(t('synced'));
422
- } catch (e) { if (token === generation.current) setError(errorText(e)); }
423
- finally { if (token === generation.current) setBusy(false); }
682
+ } catch (e) {
683
+ if (token === generation.current) setError(errorText(e));
684
+ } finally {
685
+ if (token === generation.current) setBusy(false);
686
+ }
424
687
  }
425
688
 
426
689
  async function save(event) {
427
690
  event.preventDefault();
428
691
  if (requestBusy.current) return;
429
- requestBusy.current = true; setBusy(true); setError('');
692
+ requestBusy.current = true;
693
+ setBusy(true);
694
+ setError('');
430
695
  const token = ++generation.current;
431
696
  try {
432
697
  const next = await request('set', changedSettings(settings, draft));
433
698
  if (token === generation.current) {
434
- setData(next); setEditing(false); setDraft(null); setNotice(t('saved'));
699
+ setData(next);
700
+ setEditing(false);
701
+ setDraft(null);
702
+ setNotice(t('saved'));
435
703
  }
436
- } catch (e) { if (token === generation.current) setError(errorText(e)); }
437
- finally { requestBusy.current = false; if (token === generation.current) setBusy(false); }
704
+ } catch (e) {
705
+ if (token === generation.current) setError(errorText(e));
706
+ } finally {
707
+ requestBusy.current = false;
708
+ if (token === generation.current) setBusy(false);
709
+ }
438
710
  }
439
711
 
440
712
  function commitPosition(position) {
@@ -444,10 +716,21 @@ function changedSettings(original, draft) {
444
716
  }
445
717
  function dragDown(e) {
446
718
  if (e.button !== 0) return;
447
- if (e.target.closest?.('button,input,select,label,a,textarea,summary,[contenteditable]')) return;
719
+ if (
720
+ e.target.closest?.('button,input,select,label,a,textarea,summary,[contenteditable]')
721
+ )
722
+ return;
448
723
  const rect = box.current.getBoundingClientRect();
449
- dragState.current = { dx: e.clientX - rect.left, dy: e.clientY - rect.top, x0: e.clientX, y0: e.clientY, moved: false };
450
- try { e.currentTarget.setPointerCapture?.(e.pointerId); } catch {}
724
+ dragState.current = {
725
+ dx: e.clientX - rect.left,
726
+ dy: e.clientY - rect.top,
727
+ x0: e.clientX,
728
+ y0: e.clientY,
729
+ moved: false,
730
+ };
731
+ try {
732
+ e.currentTarget.setPointerCapture?.(e.pointerId);
733
+ } catch {}
451
734
  }
452
735
  function dragMove(e) {
453
736
  const d = dragState.current;
@@ -467,10 +750,17 @@ function changedSettings(original, draft) {
467
750
  if (!d) return;
468
751
  dragState.current = null;
469
752
  if (d.moved && lastPosition.current) commitPosition(lastPosition.current);
470
- try { if (e.currentTarget.hasPointerCapture?.(e.pointerId)) e.currentTarget.releasePointerCapture(e.pointerId); } catch {}
753
+ try {
754
+ if (e.currentTarget.hasPointerCapture?.(e.pointerId))
755
+ e.currentTarget.releasePointerCapture(e.pointerId);
756
+ } catch {}
471
757
  }
472
758
  function gripKeys(e) {
473
- if (e.key === 'Home') { e.preventDefault(); setPreference({ position: null }); return; }
759
+ if (e.key === 'Home') {
760
+ e.preventDefault();
761
+ setPreference({ position: null });
762
+ return;
763
+ }
474
764
  if (!['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(e.key)) return;
475
765
  e.preventDefault();
476
766
  const rect = box.current.getBoundingClientRect();
@@ -484,114 +774,324 @@ function changedSettings(original, draft) {
484
774
  focusReturn.current = e.currentTarget;
485
775
  setDraft({ ...settings });
486
776
  setEditing(true);
487
- setError(''); setNotice('');
777
+ setError('');
778
+ setNotice('');
488
779
  }
489
780
  function closeEditor() {
490
781
  if (!data?.configured) return;
491
- setEditing(false); setDraft(null); setError('');
782
+ setEditing(false);
783
+ setDraft(null);
784
+ setError('');
785
+ }
786
+ function update(key, value) {
787
+ setDraft((current) => ({ ...current, [key]: value }));
492
788
  }
493
- function update(key, value) { setDraft(current => ({ ...current, [key]: value })); }
494
789
  function toggleBreak(e) {
495
- const point = text => +text.slice(0, 2) * 60 + +text.slice(3);
496
- const label = minutes => String(Math.floor(minutes / 60)).padStart(2, '0') + ':' + String(minutes % 60).padStart(2, '0');
497
- setDraft(current => {
498
- if (!e.target.checked) return { ...current, breakStart: current.start, breakEnd: current.start };
790
+ const point = (text) => +text.slice(0, 2) * 60 + +text.slice(3);
791
+ const label = (minutes) =>
792
+ String(Math.floor(minutes / 60)).padStart(2, '0') +
793
+ ':' +
794
+ String(minutes % 60).padStart(2, '0');
795
+ setDraft((current) => {
796
+ if (!e.target.checked)
797
+ return { ...current, breakStart: current.start, breakEnd: current.start };
499
798
  const start = point(current.start);
500
799
  const span = (point(current.end) - start + 1440) % 1440;
501
- return { ...current, breakStart: current.start, breakEnd: label((start + Math.max(1, Math.min(60, Math.floor(span / 2)))) % 1440) };
800
+ return {
801
+ ...current,
802
+ breakStart: current.start,
803
+ breakEnd: label((start + Math.max(1, Math.min(60, Math.floor(span / 2)))) % 1440),
804
+ };
502
805
  });
503
806
  }
504
807
 
505
- const label = (text, control) => h('label', { className: 'owc-field' }, h('span', null, text), control);
506
- const numberInput = key => h('input', {
507
- name: key, type: 'number', step: 'any', min: key === 'workdaysPerMonth' ? '0.01' : '0',
508
- value: workingDraft?.[key] ?? '', disabled: busy,
509
- onChange: e => update(key, e.target.value === '' ? '' : e.target.valueAsNumber),
510
- });
511
- const timeInput = key => h('input', {
512
- name: key, type: 'time', required: true, value: workingDraft?.[key] ?? '', disabled: busy,
513
- onChange: e => update(key, e.target.value),
514
- });
515
- const sessionSelect = () => h('select', {
516
- value: sessionId || '', disabled: busy,
517
- onChange: e => setSessionChoice(e.target.value),
518
- }, sessionIds.length
519
- ? sessionIds.map(id => h('option', { key: id, value: id }, sessions?.byId?.[id]?.title || String(id).slice(0, 12)))
520
- : h('option', { value: '' }, t('noSession')));
521
- const salaryToggle = () => h('button', {
522
- type: 'button', className: 'owc-text', 'aria-pressed': view.masked,
523
- title: t(view.masked ? 'show' : 'hide'),
524
- onClick: () => setPreference({ masked: !view.masked }),
525
- }, iconSvg(view.masked ? 'eyeOff' : 'eye', 13), t(view.masked ? 'show' : 'hide'));
526
-
527
- const strip = () => h('div', { className: 'owc-pill' },
528
- h('div', { className: 'owc-pill-top' },
529
- iconSvg('alarm', 16),
530
- h('span', { className: 'owc-grip', tabIndex: 0, title: t('drag'), onKeyDown: gripKeys },
531
- safeState.status !== 'done' && h('span', { className: 'owc-pill-status' }, t(safeState.status)),
532
- h('span', { className: 'owc-pill-time' }, duration)),
533
- h('button', {
534
- type: 'button', className: 'owc-icon', style: { marginLeft: 'auto' },
535
- title: t('expand'), 'aria-label': t('expand'),
536
- onClick: () => setPreference({ mini: false }),
537
- }, iconSvg('chevronUp', 14))),
538
- h('div', { className: 'owc-pill-bottom' },
539
- h('span', { className: 'owc-pill-money', 'aria-label': view.masked ? t('masked') : moneyText }, moneyText),
540
- h('div', { className: 'owc-pill-actions' },
541
- h('button', {
542
- type: 'button', className: 'owc-icon',
543
- title: t(view.masked ? 'show' : 'hide'), 'aria-label': t(view.masked ? 'show' : 'hide'),
544
- onClick: () => setPreference({ masked: !view.masked }),
545
- }, iconSvg(view.masked ? 'eyeOff' : 'eye', 14)))));
546
-
547
- const panelHead = () => h('div', { className: 'owc-head' },
548
- h('span', {
549
- className: 'owc-title', tabIndex: 0, title: t('drag'), onKeyDown: gripKeys,
550
- }, editing ? t(data?.configured ? 'settings' : 'setup') : t('title')),
551
- editing
552
- ? (data?.configured && h('button', {
553
- type: 'button', className: 'owc-icon', title: t('cancel'), 'aria-label': t('cancel'),
554
- onClick: closeEditor, disabled: busy,
555
- }, iconSvg('close')))
556
- : h(React.Fragment, null,
557
- h('button', {
558
- type: 'button', className: 'owc-icon', title: t('settings'), 'aria-label': t('settings'),
559
- onClick: openEditor, disabled: busy,
560
- }, iconSvg('sliders')),
561
- h('button', {
562
- type: 'button', className: 'owc-icon', title: t('collapse'), 'aria-label': t('collapse'),
563
- onClick: () => setPreference({ mini: true }),
564
- }, iconSvg('chevronDown'))));
808
+ const label = (text, control) =>
809
+ h('label', { className: 'owc-field' }, h('span', null, text), control);
810
+ const numberInput = (key) =>
811
+ h('input', {
812
+ name: key,
813
+ type: 'number',
814
+ step: 'any',
815
+ min: key === 'workdaysPerMonth' ? '0.01' : '0',
816
+ value: workingDraft?.[key] ?? '',
817
+ disabled: busy,
818
+ onChange: (e) => update(key, e.target.value === '' ? '' : e.target.valueAsNumber),
819
+ });
820
+ const timeInput = (key) =>
821
+ h('input', {
822
+ name: key,
823
+ type: 'time',
824
+ required: true,
825
+ value: workingDraft?.[key] ?? '',
826
+ disabled: busy,
827
+ onChange: (e) => update(key, e.target.value),
828
+ });
829
+ const sessionSelect = () =>
830
+ h(
831
+ 'select',
832
+ {
833
+ value: sessionId || '',
834
+ disabled: busy,
835
+ onChange: (e) => setSessionChoice(e.target.value),
836
+ },
837
+ sessionIds.length
838
+ ? sessionIds.map((id) =>
839
+ h(
840
+ 'option',
841
+ { key: id, value: id },
842
+ sessions?.byId?.[id]?.title || String(id).slice(0, 12),
843
+ ),
844
+ )
845
+ : h('option', { value: '' }, t('noSession')),
846
+ );
847
+ const salaryToggle = () =>
848
+ h(
849
+ 'button',
850
+ {
851
+ type: 'button',
852
+ className: 'owc-text',
853
+ 'aria-pressed': view.masked,
854
+ title: t(view.masked ? 'show' : 'hide'),
855
+ onClick: () => setPreference({ masked: !view.masked }),
856
+ },
857
+ iconSvg(view.masked ? 'eyeOff' : 'eye', 13),
858
+ t(view.masked ? 'show' : 'hide'),
859
+ );
860
+
861
+ const strip = () =>
862
+ h(
863
+ 'div',
864
+ { className: 'owc-pill' },
865
+ h(
866
+ 'div',
867
+ { className: 'owc-pill-top' },
868
+ iconSvg('alarm', 16),
869
+ h(
870
+ 'span',
871
+ { className: 'owc-grip', tabIndex: 0, title: t('drag'), onKeyDown: gripKeys },
872
+ safeState.status !== 'done' &&
873
+ h('span', { className: 'owc-pill-status' }, t(safeState.status)),
874
+ h('span', { className: 'owc-pill-time' }, duration),
875
+ ),
876
+ h(
877
+ 'button',
878
+ {
879
+ type: 'button',
880
+ className: 'owc-icon',
881
+ style: { marginLeft: 'auto' },
882
+ title: t('expand'),
883
+ 'aria-label': t('expand'),
884
+ onClick: () => setPreference({ mini: false }),
885
+ },
886
+ iconSvg('chevronUp', 14),
887
+ ),
888
+ ),
889
+ h(
890
+ 'div',
891
+ { className: 'owc-pill-bottom' },
892
+ h(
893
+ 'span',
894
+ {
895
+ className: 'owc-pill-money',
896
+ 'aria-label': view.masked ? t('masked') : moneyText,
897
+ },
898
+ moneyText,
899
+ ),
900
+ h(
901
+ 'div',
902
+ { className: 'owc-pill-actions' },
903
+ h(
904
+ 'button',
905
+ {
906
+ type: 'button',
907
+ className: 'owc-icon',
908
+ title: t(view.masked ? 'show' : 'hide'),
909
+ 'aria-label': t(view.masked ? 'show' : 'hide'),
910
+ onClick: () => setPreference({ masked: !view.masked }),
911
+ },
912
+ iconSvg(view.masked ? 'eyeOff' : 'eye', 14),
913
+ ),
914
+ ),
915
+ ),
916
+ );
917
+
918
+ const panelHead = () =>
919
+ h(
920
+ 'div',
921
+ { className: 'owc-head' },
922
+ h(
923
+ 'span',
924
+ {
925
+ className: 'owc-title',
926
+ tabIndex: 0,
927
+ title: t('drag'),
928
+ onKeyDown: gripKeys,
929
+ },
930
+ editing ? t(data?.configured ? 'settings' : 'setup') : t('title'),
931
+ ),
932
+ editing
933
+ ? data?.configured &&
934
+ h(
935
+ 'button',
936
+ {
937
+ type: 'button',
938
+ className: 'owc-icon',
939
+ title: t('cancel'),
940
+ 'aria-label': t('cancel'),
941
+ onClick: closeEditor,
942
+ disabled: busy,
943
+ },
944
+ iconSvg('close'),
945
+ )
946
+ : h(
947
+ React.Fragment,
948
+ null,
949
+ h(
950
+ 'button',
951
+ {
952
+ type: 'button',
953
+ className: 'owc-icon',
954
+ title: t('settings'),
955
+ 'aria-label': t('settings'),
956
+ onClick: openEditor,
957
+ disabled: busy,
958
+ },
959
+ iconSvg('sliders'),
960
+ ),
961
+ h(
962
+ 'button',
963
+ {
964
+ type: 'button',
965
+ className: 'owc-icon',
966
+ title: t('collapse'),
967
+ 'aria-label': t('collapse'),
968
+ onClick: () => setPreference({ mini: true }),
969
+ },
970
+ iconSvg('chevronDown'),
971
+ ),
972
+ ),
973
+ );
565
974
 
566
975
  const panelBody = () => {
567
- if (!safeState) return h('div', { className: 'owc-main' },
568
- h('div', { className: 'owc-status' }, h('span', { className: 'owc-dot' }), t('loading')),
569
- !sessionId && h('p', { className: 'owc-hint' }, t('noSession')),
570
- h('div', { className: 'owc-row-actions' },
571
- h('button', { type: 'button', className: 'owc-text', onClick: () => reload(true), disabled: busy || !sessionId },
572
- iconSvg('refresh', 12), t('retry'))));
976
+ if (!safeState)
977
+ return h(
978
+ 'div',
979
+ { className: 'owc-main' },
980
+ h(
981
+ 'div',
982
+ { className: 'owc-status' },
983
+ h('span', { className: 'owc-dot' }),
984
+ t('loading'),
985
+ ),
986
+ !sessionId && h('p', { className: 'owc-hint' }, t('noSession')),
987
+ h(
988
+ 'div',
989
+ { className: 'owc-row-actions' },
990
+ h(
991
+ 'button',
992
+ {
993
+ type: 'button',
994
+ className: 'owc-text',
995
+ onClick: () => reload(true),
996
+ disabled: busy || !sessionId,
997
+ },
998
+ iconSvg('refresh', 12),
999
+ t('retry'),
1000
+ ),
1001
+ ),
1002
+ );
573
1003
  const status = safeState.status === 'done' ? '' : t(safeState.status);
574
- return h('div', { className: 'owc-main' },
575
- status && h('div', { className: 'owc-status' }, h('span', { className: 'owc-dot' }), status),
1004
+ return h(
1005
+ 'div',
1006
+ { className: 'owc-main' },
1007
+ status &&
1008
+ h('div', { className: 'owc-status' }, h('span', { className: 'owc-dot' }), status),
576
1009
  h('div', { className: 'owc-time', 'aria-label': status + ' ' + duration }, duration),
577
- h('div', {
578
- className: 'owc-progress', role: 'progressbar', 'aria-label': t('progress'),
579
- 'aria-valuenow': Math.floor(safeState.progress * 100), 'aria-valuemin': 0, 'aria-valuemax': 100,
580
- }, h('div', { style: { width: safeState.progress * 100 + '%' } })),
581
- h('div', { className: 'owc-earn-row' },
582
- h('span', { className: 'owc-muted' }, t('earned')), salaryToggle()),
583
- h('div', { className: 'owc-money', 'aria-label': view.masked ? t('masked') : moneyText }, moneyText),
584
- h('div', { className: 'owc-stats' },
585
- h('div', null, h('span', null, t('daily')), h('strong', null, money(safeState.daily))),
586
- h('div', null, h('span', null, t('hourly')), h('strong', null, money(safeState.hourly)))),
587
- h('div', { className: 'owc-meta' },
588
- h('span', null, settings.start + ' — ' + settings.end + (settings.end < settings.start ? ' +1' : '') +
589
- ' · ' + (settings.breakStart === settings.breakEnd ? t('noBreak') : settings.breakStart + '–' + settings.breakEnd)),
590
- h('span', null, t('shiftDate') + ' ' + safeState.shiftDate + ' · ' + settings.timezone)),
591
- h('div', { className: 'owc-row-actions' },
592
- h('button', { type: 'button', className: 'owc-text', onClick: () => reload(true), disabled: busy },
593
- iconSvg('refresh', 12), t('refresh')),
594
- h('button', { type: 'button', className: 'owc-text', onClick: () => setPreference({ position: null }) }, t('reset'))));
1010
+ h(
1011
+ 'div',
1012
+ {
1013
+ className: 'owc-progress',
1014
+ role: 'progressbar',
1015
+ 'aria-label': t('progress'),
1016
+ 'aria-valuenow': Math.floor(safeState.progress * 100),
1017
+ 'aria-valuemin': 0,
1018
+ 'aria-valuemax': 100,
1019
+ },
1020
+ h('div', { style: { width: safeState.progress * 100 + '%' } }),
1021
+ ),
1022
+ h(
1023
+ 'div',
1024
+ { className: 'owc-earn-row' },
1025
+ h('span', { className: 'owc-muted' }, t('earned')),
1026
+ salaryToggle(),
1027
+ ),
1028
+ h(
1029
+ 'div',
1030
+ { className: 'owc-money', 'aria-label': view.masked ? t('masked') : moneyText },
1031
+ moneyText,
1032
+ ),
1033
+ h(
1034
+ 'div',
1035
+ { className: 'owc-stats' },
1036
+ h(
1037
+ 'div',
1038
+ null,
1039
+ h('span', null, t('daily')),
1040
+ h('strong', null, money(safeState.daily)),
1041
+ ),
1042
+ h(
1043
+ 'div',
1044
+ null,
1045
+ h('span', null, t('hourly')),
1046
+ h('strong', null, money(safeState.hourly)),
1047
+ ),
1048
+ ),
1049
+ h(
1050
+ 'div',
1051
+ { className: 'owc-meta' },
1052
+ h(
1053
+ 'span',
1054
+ null,
1055
+ settings.start +
1056
+ ' — ' +
1057
+ settings.end +
1058
+ (settings.end < settings.start ? ' +1' : '') +
1059
+ ' · ' +
1060
+ (settings.breakStart === settings.breakEnd
1061
+ ? t('noBreak')
1062
+ : settings.breakStart + '–' + settings.breakEnd),
1063
+ ),
1064
+ h(
1065
+ 'span',
1066
+ null,
1067
+ t('shiftDate') + ' ' + safeState.shiftDate + ' · ' + settings.timezone,
1068
+ ),
1069
+ ),
1070
+ h(
1071
+ 'div',
1072
+ { className: 'owc-row-actions' },
1073
+ h(
1074
+ 'button',
1075
+ {
1076
+ type: 'button',
1077
+ className: 'owc-text',
1078
+ onClick: () => reload(true),
1079
+ disabled: busy,
1080
+ },
1081
+ iconSvg('refresh', 12),
1082
+ t('refresh'),
1083
+ ),
1084
+ h(
1085
+ 'button',
1086
+ {
1087
+ type: 'button',
1088
+ className: 'owc-text',
1089
+ onClick: () => setPreference({ position: null }),
1090
+ },
1091
+ t('reset'),
1092
+ ),
1093
+ ),
1094
+ );
595
1095
  };
596
1096
 
597
1097
  const editor = () => {
@@ -599,91 +1099,293 @@ function changedSettings(original, draft) {
599
1099
  const payDaily = workingDraft.payMode === 'daily';
600
1100
  const salaryKey = payDaily ? 'dailySalary' : 'monthlySalary';
601
1101
  const salaryControl = view.masked
602
- ? h('div', { className: 'owc-private' }, h('span', null, '••••'),
603
- h('button', { type: 'button', className: 'owc-text', onClick: () => setPreference({ masked: false }) }, t('show')))
1102
+ ? h(
1103
+ 'div',
1104
+ { className: 'owc-private' },
1105
+ h('span', null, '••••'),
1106
+ h(
1107
+ 'button',
1108
+ {
1109
+ type: 'button',
1110
+ className: 'owc-text',
1111
+ 'aria-label': t('show'),
1112
+ onClick: () => setPreference({ masked: false }),
1113
+ },
1114
+ t('show'),
1115
+ ),
1116
+ )
604
1117
  : numberInput(salaryKey);
605
1118
  const breakOn = workingDraft.breakStart !== workingDraft.breakEnd;
606
- const commonZones = ['Asia/Shanghai', 'Asia/Hong_Kong', 'Asia/Taipei', 'Asia/Singapore', 'Asia/Tokyo', 'Asia/Seoul', 'Asia/Kolkata', 'Europe/London', 'Europe/Paris', 'America/New_York', 'America/Los_Angeles', 'Australia/Sydney', 'UTC'];
1119
+ const commonZones = [
1120
+ 'Asia/Shanghai',
1121
+ 'Asia/Hong_Kong',
1122
+ 'Asia/Taipei',
1123
+ 'Asia/Singapore',
1124
+ 'Asia/Tokyo',
1125
+ 'Asia/Seoul',
1126
+ 'Asia/Kolkata',
1127
+ 'Europe/London',
1128
+ 'Europe/Paris',
1129
+ 'America/New_York',
1130
+ 'America/Los_Angeles',
1131
+ 'Australia/Sydney',
1132
+ 'UTC',
1133
+ ];
607
1134
  let allZones = [];
608
- try { allZones = Intl.supportedValuesOf('timeZone'); } catch {}
609
- const zones = [...new Set([workingDraft.timezone, ...commonZones, ...allZones].filter(Boolean))];
610
- return h('form', {
611
- className: 'owc-form', onSubmit: save,
612
- onKeyDown: e => { if (e.key === 'Escape') { e.preventDefault(); closeEditor(); } },
613
- },
614
- !data?.configured && h('p', { className: 'owc-hint' }, t('setupHint')),
615
- h('fieldset', { className: 'owc-setting-group' }, h('legend', null, t('salaryGroup')),
616
- label(t('payMode'), h('div', { className: 'owc-seg' },
617
- h('button', { type: 'button', disabled: busy, 'aria-pressed': !payDaily, onClick: () => update('payMode', 'monthly') }, t('monthly')),
618
- h('button', { type: 'button', disabled: busy, 'aria-pressed': payDaily, onClick: () => update('payMode', 'daily') }, t('dailyMode')))),
619
- h('div', { className: 'owc-row2' },
620
- label(t(salaryKey), salaryControl),
621
- !payDaily && label(t('workdaysPerMonth'), numberInput('workdaysPerMonth')))),
622
- h('fieldset', { className: 'owc-setting-group' }, h('legend', null, t('scheduleGroup')),
623
- h('div', { className: 'owc-row2' }, label(t('start'), timeInput('start')), label(t('end'), timeInput('end'))),
624
- workingDraft.end < workingDraft.start && h('p', { className: 'owc-hint' }, t('end') + ' · ' + t('nextDay')),
625
- h('label', { className: 'owc-check' },
626
- h('input', { type: 'checkbox', checked: breakOn, disabled: busy, onChange: toggleBreak }), t('breakEnabled')),
627
- breakOn && h('div', { className: 'owc-row2' }, label(t('breakStart'), timeInput('breakStart')), label(t('breakEnd'), timeInput('breakEnd'))),
628
- label(t('weekdays'), h('div', { className: 'owc-days' }, [0, 1, 2, 3, 4, 5, 6].map(day => h('button', {
629
- key: day, type: 'button', disabled: busy, 'aria-pressed': workingDraft.weekdays.includes(day),
630
- 'aria-label': t('weekdays') + ' ' + t('days')[day],
631
- onClick: () => update('weekdays', workingDraft.weekdays.includes(day)
632
- ? workingDraft.weekdays.filter(d => d !== day)
633
- : [...workingDraft.weekdays, day].sort((a, b) => a - b)),
634
- }, t('days')[day])))),
635
- label(t('timezone'), h('select', {
636
- name: 'timezone', value: workingDraft.timezone ?? '', disabled: busy,
637
- onChange: e => update('timezone', e.target.value),
638
- }, zones.map(zone => h('option', { key: zone, value: zone }, zone))))),
639
- h('fieldset', { className: 'owc-setting-group' }, h('legend', null, t('appearanceGroup')),
640
- label(t('opacity'), h(React.Fragment, null,
641
- h('input', {
642
- className: 'owc-range', type: 'range', min: '30', max: '100', step: '5',
643
- value: String(Math.round(view.opacity * 100)), disabled: busy, 'aria-label': t('opacity'),
644
- onChange: e => setPreference({ opacity: e.target.valueAsNumber / 100 }),
645
- }),
646
- h('span', { className: 'owc-hint', style: { margin: 0 } }, Math.round(view.opacity * 100) + '% · ' + t('opacityHint'))))),
647
- preview && !preview.error && h('div', { className: 'owc-preview' },
648
- t('preview') + ' · ' + t('daily') + ' ' + money(preview.daily) + ' · ' + t('hourly') + ' ' + money(preview.hourly)),
649
- preview?.error && h('p', { className: 'owc-error', role: 'alert', style: { padding: 0 } }, preview.error),
650
- h('details', { className: 'owc-advanced' },
651
- h('summary', null, t('advanced')),
652
- h('p', { className: 'owc-hint' }, t('sessionHint')),
653
- label(t('session'), sessionSelect())),
654
- h('p', { className: 'owc-hint' }, t('privacy')),
655
- h('div', { className: 'owc-save' },
656
- data?.configured && h('button', { type: 'button', className: 'owc-text', onClick: closeEditor, disabled: busy }, t('cancel')),
657
- h('button', { type: 'submit', className: 'owc-primary', disabled: busy || !!preview?.error }, t(busy ? 'busy' : 'save'))));
1135
+ try {
1136
+ allZones = Intl.supportedValuesOf('timeZone');
1137
+ } catch {}
1138
+ const zones = [
1139
+ ...new Set([workingDraft.timezone, ...commonZones, ...allZones].filter(Boolean)),
1140
+ ];
1141
+ return h(
1142
+ 'form',
1143
+ {
1144
+ className: 'owc-form',
1145
+ onSubmit: save,
1146
+ onKeyDown: (e) => {
1147
+ if (e.key === 'Escape') {
1148
+ e.preventDefault();
1149
+ closeEditor();
1150
+ }
1151
+ },
1152
+ },
1153
+ !data?.configured && h('p', { className: 'owc-hint' }, t('setupHint')),
1154
+ h(
1155
+ 'fieldset',
1156
+ { className: 'owc-setting-group' },
1157
+ h('legend', null, t('salaryGroup')),
1158
+ label(
1159
+ t('payMode'),
1160
+ h(
1161
+ 'div',
1162
+ { className: 'owc-seg' },
1163
+ h(
1164
+ 'button',
1165
+ {
1166
+ type: 'button',
1167
+ disabled: busy,
1168
+ 'aria-pressed': !payDaily,
1169
+ 'aria-label': t('monthly'),
1170
+ onClick: () => update('payMode', 'monthly'),
1171
+ },
1172
+ t('monthly'),
1173
+ ),
1174
+ h(
1175
+ 'button',
1176
+ {
1177
+ type: 'button',
1178
+ disabled: busy,
1179
+ 'aria-pressed': payDaily,
1180
+ 'aria-label': t('dailyMode'),
1181
+ onClick: () => update('payMode', 'daily'),
1182
+ },
1183
+ t('dailyMode'),
1184
+ ),
1185
+ ),
1186
+ ),
1187
+ h(
1188
+ 'div',
1189
+ { className: 'owc-row2' },
1190
+ label(t(salaryKey), salaryControl),
1191
+ !payDaily && label(t('workdaysPerMonth'), numberInput('workdaysPerMonth')),
1192
+ ),
1193
+ ),
1194
+ h(
1195
+ 'fieldset',
1196
+ { className: 'owc-setting-group' },
1197
+ h('legend', null, t('scheduleGroup')),
1198
+ h(
1199
+ 'div',
1200
+ { className: 'owc-row2' },
1201
+ label(t('start'), timeInput('start')),
1202
+ label(t('end'), timeInput('end')),
1203
+ ),
1204
+ workingDraft.end < workingDraft.start &&
1205
+ h('p', { className: 'owc-hint' }, t('end') + ' · ' + t('nextDay')),
1206
+ h(
1207
+ 'label',
1208
+ { className: 'owc-check' },
1209
+ h('input', {
1210
+ type: 'checkbox',
1211
+ checked: breakOn,
1212
+ disabled: busy,
1213
+ onChange: toggleBreak,
1214
+ }),
1215
+ t('breakEnabled'),
1216
+ ),
1217
+ breakOn &&
1218
+ h(
1219
+ 'div',
1220
+ { className: 'owc-row2' },
1221
+ label(t('breakStart'), timeInput('breakStart')),
1222
+ label(t('breakEnd'), timeInput('breakEnd')),
1223
+ ),
1224
+ label(
1225
+ t('weekdays'),
1226
+ h(
1227
+ 'div',
1228
+ { className: 'owc-days' },
1229
+ [0, 1, 2, 3, 4, 5, 6].map((day) =>
1230
+ h(
1231
+ 'button',
1232
+ {
1233
+ key: day,
1234
+ type: 'button',
1235
+ disabled: busy,
1236
+ 'aria-pressed': workingDraft.weekdays.includes(day),
1237
+ 'aria-label': t('weekdays') + ' ' + t('days')[day],
1238
+ onClick: () =>
1239
+ update(
1240
+ 'weekdays',
1241
+ workingDraft.weekdays.includes(day)
1242
+ ? workingDraft.weekdays.filter((d) => d !== day)
1243
+ : [...workingDraft.weekdays, day].sort((a, b) => a - b),
1244
+ ),
1245
+ },
1246
+ t('days')[day],
1247
+ ),
1248
+ ),
1249
+ ),
1250
+ ),
1251
+ label(
1252
+ t('timezone'),
1253
+ h(
1254
+ 'select',
1255
+ {
1256
+ name: 'timezone',
1257
+ value: workingDraft.timezone ?? '',
1258
+ disabled: busy,
1259
+ onChange: (e) => update('timezone', e.target.value),
1260
+ },
1261
+ zones.map((zone) => h('option', { key: zone, value: zone }, zone)),
1262
+ ),
1263
+ ),
1264
+ ),
1265
+ h(
1266
+ 'fieldset',
1267
+ { className: 'owc-setting-group' },
1268
+ h('legend', null, t('appearanceGroup')),
1269
+ label(
1270
+ t('opacity'),
1271
+ h(
1272
+ React.Fragment,
1273
+ null,
1274
+ h('input', {
1275
+ className: 'owc-range',
1276
+ type: 'range',
1277
+ min: '30',
1278
+ max: '100',
1279
+ step: '1',
1280
+ value: String(Math.round(view.opacity * 100)),
1281
+ disabled: busy,
1282
+ 'aria-label': t('opacity'),
1283
+ onChange: (e) => setPreference({ opacity: e.target.valueAsNumber / 100 }),
1284
+ }),
1285
+ h(
1286
+ 'span',
1287
+ { className: 'owc-hint', style: { margin: 0 } },
1288
+ Math.round(view.opacity * 100) + '% · ' + t('opacityHint'),
1289
+ ),
1290
+ ),
1291
+ ),
1292
+ ),
1293
+ preview &&
1294
+ !preview.error &&
1295
+ h(
1296
+ 'div',
1297
+ { className: 'owc-preview' },
1298
+ t('preview') +
1299
+ ' · ' +
1300
+ t('daily') +
1301
+ ' ' +
1302
+ money(preview.daily) +
1303
+ ' · ' +
1304
+ t('hourly') +
1305
+ ' ' +
1306
+ money(preview.hourly),
1307
+ ),
1308
+ preview?.error &&
1309
+ h(
1310
+ 'p',
1311
+ { className: 'owc-error', role: 'alert', style: { padding: 0 } },
1312
+ preview.error,
1313
+ ),
1314
+ h(
1315
+ 'details',
1316
+ { className: 'owc-advanced' },
1317
+ h('summary', null, t('advanced')),
1318
+ h('p', { className: 'owc-hint' }, t('sessionHint')),
1319
+ label(t('session'), sessionSelect()),
1320
+ ),
1321
+ h('p', { className: 'owc-hint' }, t('privacy')),
1322
+ h(
1323
+ 'div',
1324
+ { className: 'owc-save' },
1325
+ data?.configured &&
1326
+ h(
1327
+ 'button',
1328
+ { type: 'button', className: 'owc-text', onClick: closeEditor, disabled: busy },
1329
+ t('cancel'),
1330
+ ),
1331
+ h(
1332
+ 'button',
1333
+ { type: 'submit', className: 'owc-primary', disabled: busy || !!preview?.error },
1334
+ t(busy ? 'busy' : 'save'),
1335
+ ),
1336
+ ),
1337
+ );
658
1338
  };
659
1339
 
660
- const notices = () => h(React.Fragment, null,
661
- error && h('p', { className: 'owc-error', role: 'alert' }, error),
662
- state?.error && h('p', { className: 'owc-error', role: 'alert' }, state.error),
663
- notice && h('p', { className: 'owc-note', role: 'status' }, notice),
664
- storageError && h('p', { className: 'owc-note' }, t('storage')));
665
-
666
- return h('section', {
667
- ref: box,
668
- className: 'owc-v2' + (collapsed ? ' owc-compact' : '') + (editing ? ' owc-editing' : ''),
669
- 'aria-label': t('title'), 'data-version': '1.0.0', 'data-status': safeState?.status || 'setup',
670
- style: cardStyle,
671
- onPointerDown: dragDown, onPointerMove: dragMove, onPointerUp: dragEnd,
672
- onPointerCancel: dragEnd, onLostPointerCapture: dragEnd,
673
- },
674
- h('style', null, STYLE),
675
- h('div', { className: 'owc-glass', 'aria-hidden': 'true' }),
676
- h('div', { className: 'owc-content' },
677
- collapsed ? strip() : h(React.Fragment, null,
678
- panelHead(),
679
- editing ? editor() : panelBody(),
680
- notices())));
1340
+ const notices = () =>
1341
+ h(
1342
+ React.Fragment,
1343
+ null,
1344
+ error && h('p', { className: 'owc-error', role: 'alert' }, error),
1345
+ state?.error && h('p', { className: 'owc-error', role: 'alert' }, state.error),
1346
+ notice && h('p', { className: 'owc-note', role: 'status' }, notice),
1347
+ storageError && h('p', { className: 'owc-note' }, t('storage')),
1348
+ );
1349
+
1350
+ return h(
1351
+ 'section',
1352
+ {
1353
+ ref: box,
1354
+ className:
1355
+ 'owc-v2' + (collapsed ? ' owc-compact' : '') + (editing ? ' owc-editing' : ''),
1356
+ 'aria-label': t('title'),
1357
+ 'data-version': '1.0.1',
1358
+ 'data-status': safeState?.status || 'setup',
1359
+ style: cardStyle,
1360
+ onPointerDown: dragDown,
1361
+ onPointerMove: dragMove,
1362
+ onPointerUp: dragEnd,
1363
+ onPointerCancel: dragEnd,
1364
+ onLostPointerCapture: dragEnd,
1365
+ },
1366
+ h('style', null, STYLE),
1367
+ h('div', { className: 'owc-glass', 'aria-hidden': 'true' }),
1368
+ h(
1369
+ 'div',
1370
+ { className: 'owc-content' },
1371
+ collapsed
1372
+ ? strip()
1373
+ : h(React.Fragment, null, panelHead(), editing ? editor() : panelBody(), notices()),
1374
+ ),
1375
+ );
681
1376
  }
682
1377
 
683
- ctx.slots.inject('shell.overlay', () => ctx.slots.register({
684
- name: 'shell.overlay', id: 'offwork-clock', order: 900,
685
- label: () => ctx.locale.bind('offwork-clock')('title'),
686
- }, Clock));
1378
+ ctx.slots.inject('shell.overlay', () =>
1379
+ ctx.slots.register(
1380
+ {
1381
+ name: 'shell.overlay',
1382
+ id: 'offwork-clock',
1383
+ order: 900,
1384
+ label: () => ctx.locale.bind('offwork-clock')('title'),
1385
+ },
1386
+ Clock,
1387
+ ),
1388
+ );
687
1389
  },
688
1390
  };
689
1391
  },