dsh-offwork 0.0.0-stage → 1.0.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/LICENSE +21 -0
- package/README.md +210 -2
- package/client.js +690 -0
- package/client.source.js +534 -0
- package/clock.js +103 -0
- package/cordis.patch.yml +4 -0
- package/icon.svg +1 -0
- package/index.js +198 -0
- package/locale/en.json +1 -0
- package/locale/zh.json +1 -0
- package/package.json +60 -4
- package/tests/clock.test.mjs +193 -0
- package/tests/host.test.mjs +172 -0
- package/tests/ui.test.mjs +75 -0
- package/tools/build.mjs +13 -0
- package/ui-utils.js +51 -0
package/client.js
ADDED
|
@@ -0,0 +1,690 @@
|
|
|
1
|
+
// Generated by tools/build.mjs; edit client.source.js, clock.js or ui-utils.js.
|
|
2
|
+
// Authored Client entry. Run node tools/build.mjs to inline the tested pure modules.
|
|
3
|
+
window.__ModuleLoader__.load({
|
|
4
|
+
id: 'dsh-offwork',
|
|
5
|
+
factory(require) {
|
|
6
|
+
const React = require('react');
|
|
7
|
+
const h = React.createElement;
|
|
8
|
+
// Pure calculation. Weekdays belong to the shift's START date, not the clock date.
|
|
9
|
+
// Keep helpers inside calculate: the client build embeds this exact function.
|
|
10
|
+
function calculate(settings, timestamp = Date.now()) {
|
|
11
|
+
const now = Number(timestamp);
|
|
12
|
+
if (!Number.isFinite(now)) throw new RangeError('Invalid timestamp');
|
|
13
|
+
const DAY = 86400000;
|
|
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',
|
|
17
|
+
});
|
|
18
|
+
const civil = time => {
|
|
19
|
+
const p = Object.fromEntries(formatter.formatToParts(time).map(p => [p.type, p.value]));
|
|
20
|
+
return Date.UTC(+p.year, +p.month - 1, +p.day, +p.hour, +p.minute, +p.second);
|
|
21
|
+
};
|
|
22
|
+
// Convert a timezone-local civil time to an absolute instant. At DST overlaps use
|
|
23
|
+
// 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);
|
|
28
|
+
if (exact.length) return Math.min(...exact);
|
|
29
|
+
const after = candidates.filter(time => civil(time) >= local);
|
|
30
|
+
return Math.min(...(after.length ? after : candidates));
|
|
31
|
+
};
|
|
32
|
+
const minute = text => {
|
|
33
|
+
if (typeof text !== 'string' || !/^(?:[01]\d|2[0-3]):[0-5]\d$/.test(text)) {
|
|
34
|
+
throw new RangeError('Shift times must be HH:mm');
|
|
35
|
+
}
|
|
36
|
+
const [h, m] = text.split(':').map(Number);
|
|
37
|
+
return h * 60 + m;
|
|
38
|
+
};
|
|
39
|
+
const start = minute(settings.start), end = minute(settings.end);
|
|
40
|
+
if (start === end) throw new RangeError('Shift must not be empty');
|
|
41
|
+
const overnight = end < start;
|
|
42
|
+
const duration = (end - start + 1440) % 1440;
|
|
43
|
+
let breakOffset = 0, breakDuration = 0;
|
|
44
|
+
if (settings.breakStart != null || settings.breakEnd != null) {
|
|
45
|
+
const bs = minute(settings.breakStart), be = minute(settings.breakEnd);
|
|
46
|
+
if (bs !== be) {
|
|
47
|
+
breakOffset = (bs - start + 1440) % 1440;
|
|
48
|
+
breakDuration = (be - bs + 1440) % 1440;
|
|
49
|
+
if (breakOffset + breakDuration > duration || breakDuration >= duration) {
|
|
50
|
+
throw new RangeError('Break must be inside the shift and leave paid work');
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
const localNow = civil(now);
|
|
55
|
+
const today = Math.floor(localNow / DAY) * DAY;
|
|
56
|
+
const minuteNow = (localNow - today) / 60000;
|
|
57
|
+
const workday = day => settings.weekdays.includes(new Date(day).getUTCDay());
|
|
58
|
+
let shiftDay = today;
|
|
59
|
+
if (overnight && minuteNow < start) {
|
|
60
|
+
// Retain yesterday's finished work shift until the next shift starts. If
|
|
61
|
+
// yesterday was off, a working today is 'before' (e.g. Monday daytime).
|
|
62
|
+
if (minuteNow < end || workday(today - DAY)) shiftDay -= DAY;
|
|
63
|
+
}
|
|
64
|
+
const isWorkday = workday(shiftDay);
|
|
65
|
+
const shiftStart = instant(shiftDay + start * 60000);
|
|
66
|
+
const shiftEnd = instant(shiftDay + (start + duration) * 60000);
|
|
67
|
+
const breakStart = breakDuration ? instant(shiftDay + (start + breakOffset) * 60000) : null;
|
|
68
|
+
const breakEnd = breakDuration ? instant(shiftDay + (start + breakOffset + breakDuration) * 60000) : null;
|
|
69
|
+
const breakMs = breakDuration ? breakEnd - breakStart : 0;
|
|
70
|
+
const totalMs = shiftEnd - shiftStart - breakMs;
|
|
71
|
+
if (totalMs <= 0 || breakMs < 0) throw new RangeError('Shift has no paid duration');
|
|
72
|
+
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';
|
|
77
|
+
// Decimal inputs become exact rational numbers before truncation. Never use
|
|
78
|
+
// 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');
|
|
81
|
+
const [mantissa, exponent = '0'] = String(number).toLowerCase().split('e');
|
|
82
|
+
const [integer, fraction = ''] = mantissa.split('.');
|
|
83
|
+
const scale = fraction.length - Number(exponent);
|
|
84
|
+
return scale >= 0 ? [BigInt(integer + fraction), 10n ** BigInt(scale)]
|
|
85
|
+
: [BigInt(integer + fraction) * 10n ** BigInt(-scale), 1n];
|
|
86
|
+
};
|
|
87
|
+
const payMode = settings.payMode === 'daily' ? 'daily' : 'monthly';
|
|
88
|
+
let [numerator, denominator] = rational(payMode === 'daily' ? settings.dailySalary : settings.monthlySalary);
|
|
89
|
+
if (payMode === 'monthly') {
|
|
90
|
+
const [daysN, daysD] = rational(settings.workdaysPerMonth);
|
|
91
|
+
if (daysN === 0n) throw new RangeError('Workdays per month must be positive');
|
|
92
|
+
numerator *= daysD;
|
|
93
|
+
denominator *= daysN;
|
|
94
|
+
}
|
|
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);
|
|
100
|
+
return {
|
|
101
|
+
status, remaining: isWorkday ? Math.max(0, Math.ceil((shiftEnd - now) / 1000)) : 0,
|
|
102
|
+
startRemaining: status === 'before' ? Math.max(0, Math.ceil((shiftStart - now) / 1000)) : 0,
|
|
103
|
+
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,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Small pure helpers shared by the browser artifact and its tests.
|
|
113
|
+
function formatDuration(seconds) {
|
|
114
|
+
if (!Number.isFinite(seconds) || seconds < 0) return '--:--:--';
|
|
115
|
+
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(':');
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function clampPosition(position, viewport, size) {
|
|
121
|
+
const width = Math.max(0, viewport.width), height = Math.max(0, viewport.height);
|
|
122
|
+
const edge = Math.min(12, width / 2, height / 2);
|
|
123
|
+
const maxX = Math.max(edge, width - size.width - edge);
|
|
124
|
+
const maxY = Math.max(edge, height - size.height - edge);
|
|
125
|
+
return {
|
|
126
|
+
x: Math.max(edge, Math.min(Number.isFinite(position?.x) ? position.x : maxX, maxX)),
|
|
127
|
+
y: Math.max(edge, Math.min(Number.isFinite(position?.y) ? position.y : 88, maxY)),
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function clampOpacity(value, fallback = 0.82) {
|
|
132
|
+
const number = Number(value);
|
|
133
|
+
if (!Number.isFinite(number)) return fallback;
|
|
134
|
+
return Math.min(1, Math.max(0.3, number));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function normalizeView(value) {
|
|
138
|
+
return {
|
|
139
|
+
mini: value?.mini !== false,
|
|
140
|
+
masked: value?.masked !== false,
|
|
141
|
+
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,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function decodeCommand(rpc) {
|
|
148
|
+
if (!rpc || rpc.ok !== true) throw new Error(rpc?.error?.message || 'RPC unavailable');
|
|
149
|
+
const result = rpc.value?.result;
|
|
150
|
+
if (!result || typeof result.text !== 'string') throw new Error('Command unavailable');
|
|
151
|
+
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');
|
|
155
|
+
if (!data.settings || typeof data.settings !== 'object') throw new Error('Missing settings');
|
|
156
|
+
return data;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function changedSettings(original, draft) {
|
|
160
|
+
return Object.fromEntries(Object.entries(draft)
|
|
161
|
+
.filter(([key, value]) => JSON.stringify(value) !== JSON.stringify(original[key])));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const messages = {
|
|
165
|
+
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:'只影响背景,不影响文字。',
|
|
179
|
+
},
|
|
180
|
+
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.',
|
|
194
|
+
},
|
|
195
|
+
};
|
|
196
|
+
const STYLE = `
|
|
197
|
+
.owc-v2{--owc-accent:color-mix(in srgb,var(--dsw-alias-brand-primary) 65%,#2563eb);pointer-events:auto;position:fixed;top:96px;right:24px;z-index:40;max-width:calc(100vw - 24px);max-height:calc(100dvh - 24px);border-radius:20px;color:var(--dsw-alias-label-primary);font:13px/1.45 -apple-system,BlinkMacSystemFont,'Segoe UI','PingFang SC',sans-serif;cursor:grab;touch-action:manipulation;user-select:none;-webkit-user-select:none}
|
|
198
|
+
.owc-v2:active{cursor:grabbing}
|
|
199
|
+
.owc-v2.owc-compact{touch-action:none;border-radius:18px}
|
|
200
|
+
.owc-v2:not(.owc-compact){width:296px}
|
|
201
|
+
.owc-v2 :is(button,input,select,label,a){cursor:pointer;user-select:auto;-webkit-user-select:auto}
|
|
202
|
+
.owc-v2 :is(input,select){user-select:text;-webkit-user-select:text}
|
|
203
|
+
.owc-glass{position:absolute;inset:0;border-radius:inherit;pointer-events:none;opacity:var(--owc-alpha,.82);background:color-mix(in srgb,var(--dsw-alias-bg-overlay) 97%,var(--owc-accent));-webkit-backdrop-filter:blur(18px);backdrop-filter:blur(18px);border:1px solid color-mix(in srgb,var(--dsw-alias-label-secondary) 18%,transparent);box-shadow:inset 0 1px 0 color-mix(in srgb,var(--dsw-alias-bg-base) 70%,transparent),0 8px 20px color-mix(in srgb,var(--dsw-alias-label-primary) 7%,transparent),0 14px 34px color-mix(in srgb,var(--owc-accent) 5%,transparent);transition:opacity .18s ease}
|
|
204
|
+
.owc-content{position:relative;border-radius:inherit;max-height:calc(100dvh - 24px);overflow:auto;overscroll-behavior:contain;scrollbar-width:thin}
|
|
205
|
+
.owc-v2 button{appearance:none;-webkit-appearance:none;border:0;margin:0;background:transparent;color:inherit;font:inherit;display:inline-flex;align-items:center;justify-content:center;gap:5px;padding:6px;min-height:28px;border-radius:10px;transition:background .15s ease,color .15s ease}
|
|
206
|
+
.owc-v2 button:hover{background:color-mix(in srgb,var(--dsw-alias-label-primary) 7%,transparent)}
|
|
207
|
+
.owc-v2 button:focus-visible,.owc-v2 :is(input,select):focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:1px}
|
|
208
|
+
.owc-v2 button:disabled{opacity:.45;cursor:not-allowed;background:transparent}
|
|
209
|
+
.owc-icon{color:var(--dsw-alias-label-secondary);min-width:28px}
|
|
210
|
+
.owc-text{font-size:11px;color:var(--dsw-alias-label-secondary);padding:4px 6px;min-height:24px;white-space:nowrap}
|
|
211
|
+
.owc-text:hover{color:var(--dsw-alias-label-primary)}
|
|
212
|
+
.owc-pill{display:flex;flex-direction:column;gap:8px;padding:12px 10px 9px 14px;min-width:168px}
|
|
213
|
+
.owc-pill-top>svg{color:var(--owc-accent);flex:none}
|
|
214
|
+
.owc-pill-top>.owc-icon{color:var(--dsw-alias-label-secondary)}
|
|
215
|
+
.owc-pill-top{display:flex;align-items:center;gap:7px;min-width:0}
|
|
216
|
+
.owc-grip{display:flex;align-items:baseline;gap:7px;touch-action:none;min-width:0}
|
|
217
|
+
.owc-pill-bottom{display:flex;align-items:center;justify-content:space-between;gap:8px;padding-top:6px;border-top:1px solid color-mix(in srgb,var(--dsw-alias-label-secondary) 12%,transparent)}
|
|
218
|
+
.owc-pill-actions{display:flex;align-items:center;gap:2px}
|
|
219
|
+
.owc-pill-status{font-size:10px;color:var(--dsw-alias-label-secondary);white-space:nowrap;letter-spacing:.02em}
|
|
220
|
+
.owc-pill-time{font-size:24px;font-weight:500;letter-spacing:-.5px;font-variant-numeric:tabular-nums;white-space:nowrap;line-height:1.25}
|
|
221
|
+
.owc-pill-money{font-size:13px;color:var(--dsw-alias-label-secondary);font-variant-numeric:tabular-nums;white-space:nowrap;min-width:46px}
|
|
222
|
+
.owc-dot{width:6px;height:6px;border-radius:50%;background:var(--dsw-alias-state-idle-primary);flex:none}
|
|
223
|
+
.owc-v2[data-status=working] .owc-dot{background:var(--dsw-alias-state-success-primary)}
|
|
224
|
+
.owc-v2[data-status=lunch] .owc-dot{background:var(--dsw-alias-state-warn-primary)}
|
|
225
|
+
.owc-v2[data-status=done] .owc-dot{background:var(--dsw-alias-brand-primary)}
|
|
226
|
+
.owc-v2[data-status=before] .owc-dot,.owc-v2[data-status=rest] .owc-dot{opacity:.6}
|
|
227
|
+
.owc-head{display:flex;align-items:center;gap:4px;padding:12px 12px 10px 18px;margin-bottom:12px;border-bottom:1px solid color-mix(in srgb,var(--dsw-alias-label-secondary) 12%,transparent)}
|
|
228
|
+
.owc-title{flex:1;min-width:0;font-size:11px;font-weight:600;letter-spacing:.02em;color:var(--dsw-alias-label-secondary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
229
|
+
.owc-main{padding:4px 17px 12px}
|
|
230
|
+
.owc-status{display:flex;align-items:center;gap:7px;font-size:11px;color:var(--dsw-alias-label-secondary);margin-bottom:1px}
|
|
231
|
+
.owc-time{font-size:34px;font-weight:350;line-height:1.28;letter-spacing:-1.2px;font-variant-numeric:tabular-nums}
|
|
232
|
+
.owc-progress{height:3px;border-radius:3px;overflow:hidden;background:color-mix(in srgb,var(--dsw-alias-label-primary) 9%,transparent);margin:10px 0 16px}
|
|
233
|
+
.owc-progress>div{height:100%;border-radius:3px;background:var(--owc-accent);transition:width .6s linear}
|
|
234
|
+
.owc-earn-row{display:flex;align-items:center;justify-content:space-between;gap:8px}
|
|
235
|
+
.owc-money{font-size:26px;font-weight:400;letter-spacing:-.6px;font-variant-numeric:tabular-nums;margin-top:1px}
|
|
236
|
+
.owc-muted{font-size:10.5px;color:var(--dsw-alias-label-secondary)}
|
|
237
|
+
.owc-stats{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-top:14px;padding-top:12px;border-top:1px solid color-mix(in srgb,var(--dsw-alias-label-primary) 8%,transparent)}
|
|
238
|
+
.owc-stats>div{padding:10px;border-radius:12px;background:color-mix(in srgb,var(--dsw-alias-label-secondary) 5%,transparent);border:1px solid color-mix(in srgb,var(--dsw-alias-label-secondary) 8%,transparent)}
|
|
239
|
+
.owc-stats span{display:block;font-size:10px;color:var(--dsw-alias-label-secondary)}
|
|
240
|
+
.owc-stats strong{display:block;margin-top:1px;font-size:13px;font-weight:500;font-variant-numeric:tabular-nums}
|
|
241
|
+
.owc-meta{margin-top:12px}
|
|
242
|
+
.owc-meta span{display:block;font-size:10.5px;line-height:1.7;color:var(--dsw-alias-label-secondary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
243
|
+
.owc-row-actions{display:flex;gap:4px;margin-top:8px;margin-left:-6px}
|
|
244
|
+
.owc-form{padding:2px 17px 15px}
|
|
245
|
+
.owc-v2.owc-editing{width:340px}
|
|
246
|
+
.owc-setting-group{min-width:0;margin:0 0 16px;padding:12px 12px 2px;border:1px solid color-mix(in srgb,var(--dsw-alias-label-secondary) 15%,transparent);border-radius:14px;background:color-mix(in srgb,var(--dsw-alias-bg-base) 24%,transparent)}
|
|
247
|
+
.owc-setting-group legend{padding:0 6px;font-size:12px;font-weight:600;color:var(--dsw-alias-label-primary)}
|
|
248
|
+
.owc-form :is(input[type=time],input[type=number],select){min-height:36px;border-radius:10px}
|
|
249
|
+
.owc-advanced{margin:0 0 12px;padding:10px 12px;border-radius:12px;background:color-mix(in srgb,var(--dsw-alias-label-secondary) 5%,transparent)}
|
|
250
|
+
.owc-advanced summary{cursor:pointer;font-size:12px;font-weight:500}
|
|
251
|
+
.owc-advanced[open] summary{margin-bottom:10px}
|
|
252
|
+
.owc-grip:focus-visible,.owc-advanced summary:focus-visible{outline:2px solid var(--owc-accent);outline-offset:3px}
|
|
253
|
+
.owc-field{display:block;margin-bottom:10px}
|
|
254
|
+
.owc-field>span{display:block;font-size:10.5px;color:var(--dsw-alias-label-secondary);margin-bottom:4px}
|
|
255
|
+
.owc-row2{display:grid;grid-template-columns:1fr 1fr;gap:10px}
|
|
256
|
+
.owc-v2 :is(input[type=text],input[type=time],input[type=number],select){width:100%;box-sizing:border-box;font:inherit;font-size:12px;padding:6px 8px;border-radius:9px;border:1px solid color-mix(in srgb,var(--dsw-alias-label-primary) 13%,transparent);background:color-mix(in srgb,var(--dsw-alias-bg-base) 55%,transparent);color:inherit;min-height:30px}
|
|
257
|
+
.owc-v2 select option{background:var(--dsw-alias-bg-overlay);color:var(--dsw-alias-label-primary)}
|
|
258
|
+
.owc-seg{display:flex;gap:3px;padding:3px;border-radius:11px;background:color-mix(in srgb,var(--dsw-alias-label-primary) 6%,transparent)}
|
|
259
|
+
.owc-seg button{flex:1;font-size:11.5px;min-height:26px;border-radius:8px}
|
|
260
|
+
.owc-seg button[aria-pressed=true]{background:color-mix(in srgb,var(--dsw-alias-bg-overlay) 92%,var(--dsw-alias-bg-base));box-shadow:0 1px 3px color-mix(in srgb,var(--dsw-alias-label-primary) 10%,transparent)}
|
|
261
|
+
.owc-days{display:flex;gap:3px}
|
|
262
|
+
.owc-days button{flex:1;min-height:26px;font-size:11px;border-radius:8px;background:color-mix(in srgb,var(--dsw-alias-label-primary) 5%,transparent)}
|
|
263
|
+
.owc-days button[aria-pressed=true]{background:color-mix(in srgb,var(--owc-accent) 12%,transparent);color:var(--owc-accent);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--owc-accent) 22%,transparent)}
|
|
264
|
+
.owc-check{display:flex;flex-direction:row;align-items:center;gap:7px;font-size:11.5px;margin:0 0 10px}
|
|
265
|
+
.owc-check input{appearance:auto;-webkit-appearance:auto;width:auto;accent-color:var(--dsw-alias-brand-primary)}
|
|
266
|
+
.owc-private{display:flex;align-items:center;justify-content:space-between;gap:6px;min-height:30px;padding:4px 6px 4px 10px;border-radius:9px;border:1px dashed color-mix(in srgb,var(--dsw-alias-label-primary) 16%,transparent);font-size:12px;color:var(--dsw-alias-label-secondary)}
|
|
267
|
+
.owc-preview{font-size:11px;color:var(--dsw-alias-label-secondary);padding:8px 10px;border-radius:10px;background:color-mix(in srgb,var(--dsw-alias-label-primary) 5%,transparent);margin:0 0 10px;font-variant-numeric:tabular-nums}
|
|
268
|
+
.owc-hint{font-size:10.5px;line-height:1.6;color:var(--dsw-alias-label-secondary);margin:0 0 10px}
|
|
269
|
+
.owc-range{width:100%;accent-color:var(--dsw-alias-brand-primary);margin:1px 0 2px}
|
|
270
|
+
.owc-save{display:flex;align-items:center;justify-content:flex-end;gap:6px;margin-top:12px}
|
|
271
|
+
.owc-v2 button.owc-primary{background:var(--owc-accent);color:#fff;padding:0 16px;min-height:36px;font-size:12px;font-weight:600;border-radius:12px}
|
|
272
|
+
.owc-v2 button.owc-primary:hover{background:color-mix(in srgb,var(--owc-accent) 88%,#000)}
|
|
273
|
+
.owc-v2 button.owc-primary:disabled{opacity:.5;color:#fff;background:var(--owc-accent)}
|
|
274
|
+
.owc-error,.owc-note{padding:0 17px 9px;margin:0;font-size:10.5px;line-height:1.6}
|
|
275
|
+
.owc-error{color:var(--dsw-alias-state-error-primary)}
|
|
276
|
+
.owc-note{color:var(--dsw-alias-label-secondary)}
|
|
277
|
+
@supports not ((backdrop-filter:blur(1px)) or (-webkit-backdrop-filter:blur(1px))){.owc-glass{opacity:1}}
|
|
278
|
+
@media(prefers-reduced-transparency:reduce),(prefers-contrast:more){.owc-glass{opacity:1;-webkit-backdrop-filter:none;backdrop-filter:none}}
|
|
279
|
+
@media(prefers-reduced-motion:reduce){.owc-v2 button,.owc-glass,.owc-progress>div{transition:none}}
|
|
280
|
+
@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
|
+
`;
|
|
282
|
+
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'}]],
|
|
291
|
+
};
|
|
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})));
|
|
296
|
+
|
|
297
|
+
return {
|
|
298
|
+
inject: ['slots', 'locale', 'remote', 'remote.commands'],
|
|
299
|
+
apply(ctx) {
|
|
300
|
+
for (const [lang, dict] of Object.entries(messages)) ctx.effect(() => ctx.locale.register('offwork-clock', lang, dict));
|
|
301
|
+
const viewKey = 'dsh.offwork-clock.view.v4';
|
|
302
|
+
const legacyKey = 'dsh.offwork-clock.view.v3';
|
|
303
|
+
|
|
304
|
+
function Clock(props) {
|
|
305
|
+
const locale = React.useSyncExternalStore(fn => ctx.locale.subscribe(fn), () => ctx.locale.getSnapshot());
|
|
306
|
+
const t = ctx.locale.bind('offwork-clock');
|
|
307
|
+
const sessions = props.useSessions(s => s);
|
|
308
|
+
const sessionIds = Array.isArray(sessions?.ids) ? sessions.ids : [];
|
|
309
|
+
const [sessionChoice, setSessionChoice] = React.useState('');
|
|
310
|
+
const sessionId = sessionIds.includes(sessionChoice) ? sessionChoice : sessionIds[0];
|
|
311
|
+
const [data, setData] = React.useState(null);
|
|
312
|
+
const [draft, setDraft] = React.useState(null);
|
|
313
|
+
const [editing, setEditing] = React.useState(false);
|
|
314
|
+
const [busy, setBusy] = React.useState(false);
|
|
315
|
+
const [error, setError] = React.useState('');
|
|
316
|
+
const [notice, setNotice] = React.useState('');
|
|
317
|
+
const [storageError, setStorageError] = React.useState(false);
|
|
318
|
+
const [now, setNow] = React.useState(Date.now());
|
|
319
|
+
const [view, setView] = React.useState(normalizeView(null));
|
|
320
|
+
const [ready, setReady] = React.useState(false);
|
|
321
|
+
const box = React.useRef(null);
|
|
322
|
+
const dragState = React.useRef(null);
|
|
323
|
+
const lastPosition = React.useRef(null);
|
|
324
|
+
const generation = React.useRef(0);
|
|
325
|
+
const requestBusy = React.useRef(false);
|
|
326
|
+
const focusReturn = React.useRef(null);
|
|
327
|
+
|
|
328
|
+
const settings = data?.settings;
|
|
329
|
+
const workingDraft = draft || settings;
|
|
330
|
+
const state = settings ? safeCalculate(settings) : null;
|
|
331
|
+
const preview = workingDraft ? safeCalculate(workingDraft) : null;
|
|
332
|
+
const safeState = state && !state.error ? state : null;
|
|
333
|
+
const collapsed = !!safeState && !editing && view.mini && !error && !state?.error;
|
|
334
|
+
const seconds = safeState ? (safeState.status === 'before' ? safeState.startRemaining : safeState.remaining) : NaN;
|
|
335
|
+
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);
|
|
340
|
+
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' } : {});
|
|
343
|
+
|
|
344
|
+
function safeCalculate(value) {
|
|
345
|
+
try { return calculate(value, now); }
|
|
346
|
+
catch (e) { return { error: e instanceof Error ? e.message : t('error') }; }
|
|
347
|
+
}
|
|
348
|
+
function errorText(e) { return e instanceof Error ? e.message : t('error'); }
|
|
349
|
+
function setPreference(patch) { setView(current => ({ ...current, ...patch })); }
|
|
350
|
+
|
|
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 }), []);
|
|
354
|
+
|
|
355
|
+
React.useEffect(() => {
|
|
356
|
+
try {
|
|
357
|
+
const saved = localStorage.getItem(viewKey);
|
|
358
|
+
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); }
|
|
361
|
+
setReady(true);
|
|
362
|
+
const sync = e => {
|
|
363
|
+
if (e.key !== viewKey) return;
|
|
364
|
+
try { setView(normalizeView(JSON.parse(e.newValue || 'null'))); } catch {}
|
|
365
|
+
};
|
|
366
|
+
window.addEventListener('storage', sync);
|
|
367
|
+
return () => window.removeEventListener('storage', sync);
|
|
368
|
+
}, []);
|
|
369
|
+
|
|
370
|
+
React.useEffect(() => {
|
|
371
|
+
if (!ready) return;
|
|
372
|
+
try { localStorage.setItem(viewKey, JSON.stringify(normalizeView(view))); }
|
|
373
|
+
catch { setStorageError(true); }
|
|
374
|
+
}, [ready, view]);
|
|
375
|
+
|
|
376
|
+
React.useEffect(() => {
|
|
377
|
+
if (sessionId) reload();
|
|
378
|
+
else setError(t('noSession'));
|
|
379
|
+
return () => { generation.current++; };
|
|
380
|
+
}, [sessionId]);
|
|
381
|
+
|
|
382
|
+
React.useEffect(() => {
|
|
383
|
+
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(); };
|
|
386
|
+
if (!document.hidden) tick();
|
|
387
|
+
document.addEventListener('visibilitychange', visibility);
|
|
388
|
+
return () => { clearTimeout(timer); document.removeEventListener('visibilitychange', visibility); };
|
|
389
|
+
}, []);
|
|
390
|
+
|
|
391
|
+
React.useEffect(() => {
|
|
392
|
+
const resize = () => setView(current => current.position ? { ...current, position: clamp(current.position) } : current);
|
|
393
|
+
const ro = typeof ResizeObserver === 'function' ? new ResizeObserver(resize) : null;
|
|
394
|
+
if (box.current) ro?.observe(box.current);
|
|
395
|
+
window.addEventListener('resize', resize);
|
|
396
|
+
return () => { ro?.disconnect(); window.removeEventListener('resize', resize); };
|
|
397
|
+
}, [clamp]);
|
|
398
|
+
|
|
399
|
+
React.useEffect(() => {
|
|
400
|
+
if (!editing) { focusReturn.current?.focus?.(); return; }
|
|
401
|
+
box.current?.querySelector('form input,form select')?.focus?.();
|
|
402
|
+
}, [editing]);
|
|
403
|
+
|
|
404
|
+
React.useEffect(() => () => { generation.current++; dragState.current = null; }, []);
|
|
405
|
+
|
|
406
|
+
async function request(action, patch) {
|
|
407
|
+
if (!sessionId) throw new Error(t('noSession'));
|
|
408
|
+
const rpc = await ctx.remote.commands.execute(sessionId, '/offwork-clock ' + action + (patch ? ' ' + JSON.stringify(patch) : ''), []);
|
|
409
|
+
return decodeCommand(rpc);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
async function reload(explicit = false) {
|
|
413
|
+
if (requestBusy.current) return;
|
|
414
|
+
const token = ++generation.current;
|
|
415
|
+
setBusy(true); setError('');
|
|
416
|
+
try {
|
|
417
|
+
const next = await request('get');
|
|
418
|
+
if (token !== generation.current) return;
|
|
419
|
+
setData(next);
|
|
420
|
+
if (!next.configured) { setDraft({ ...next.settings }); setEditing(true); }
|
|
421
|
+
if (explicit) setNotice(t('synced'));
|
|
422
|
+
} catch (e) { if (token === generation.current) setError(errorText(e)); }
|
|
423
|
+
finally { if (token === generation.current) setBusy(false); }
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
async function save(event) {
|
|
427
|
+
event.preventDefault();
|
|
428
|
+
if (requestBusy.current) return;
|
|
429
|
+
requestBusy.current = true; setBusy(true); setError('');
|
|
430
|
+
const token = ++generation.current;
|
|
431
|
+
try {
|
|
432
|
+
const next = await request('set', changedSettings(settings, draft));
|
|
433
|
+
if (token === generation.current) {
|
|
434
|
+
setData(next); setEditing(false); setDraft(null); setNotice(t('saved'));
|
|
435
|
+
}
|
|
436
|
+
} catch (e) { if (token === generation.current) setError(errorText(e)); }
|
|
437
|
+
finally { requestBusy.current = false; if (token === generation.current) setBusy(false); }
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function commitPosition(position) {
|
|
441
|
+
const next = clamp(position);
|
|
442
|
+
setPreference({ position: next });
|
|
443
|
+
lastPosition.current = next;
|
|
444
|
+
}
|
|
445
|
+
function dragDown(e) {
|
|
446
|
+
if (e.button !== 0) return;
|
|
447
|
+
if (e.target.closest?.('button,input,select,label,a,textarea,summary,[contenteditable]')) return;
|
|
448
|
+
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 {}
|
|
451
|
+
}
|
|
452
|
+
function dragMove(e) {
|
|
453
|
+
const d = dragState.current;
|
|
454
|
+
if (!d) return;
|
|
455
|
+
if (!d.moved && Math.abs(e.clientX - d.x0) + Math.abs(e.clientY - d.y0) < 4) return;
|
|
456
|
+
d.moved = true;
|
|
457
|
+
const next = clamp({ x: e.clientX - d.dx, y: e.clientY - d.dy });
|
|
458
|
+
lastPosition.current = next;
|
|
459
|
+
if (box.current) {
|
|
460
|
+
box.current.style.left = next.x + 'px';
|
|
461
|
+
box.current.style.top = next.y + 'px';
|
|
462
|
+
box.current.style.right = 'auto';
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
function dragEnd(e) {
|
|
466
|
+
const d = dragState.current;
|
|
467
|
+
if (!d) return;
|
|
468
|
+
dragState.current = null;
|
|
469
|
+
if (d.moved && lastPosition.current) commitPosition(lastPosition.current);
|
|
470
|
+
try { if (e.currentTarget.hasPointerCapture?.(e.pointerId)) e.currentTarget.releasePointerCapture(e.pointerId); } catch {}
|
|
471
|
+
}
|
|
472
|
+
function gripKeys(e) {
|
|
473
|
+
if (e.key === 'Home') { e.preventDefault(); setPreference({ position: null }); return; }
|
|
474
|
+
if (!['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(e.key)) return;
|
|
475
|
+
e.preventDefault();
|
|
476
|
+
const rect = box.current.getBoundingClientRect();
|
|
477
|
+
commitPosition({
|
|
478
|
+
x: rect.left + (e.key === 'ArrowRight' ? 10 : e.key === 'ArrowLeft' ? -10 : 0),
|
|
479
|
+
y: rect.top + (e.key === 'ArrowDown' ? 10 : e.key === 'ArrowUp' ? -10 : 0),
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function openEditor(e) {
|
|
484
|
+
focusReturn.current = e.currentTarget;
|
|
485
|
+
setDraft({ ...settings });
|
|
486
|
+
setEditing(true);
|
|
487
|
+
setError(''); setNotice('');
|
|
488
|
+
}
|
|
489
|
+
function closeEditor() {
|
|
490
|
+
if (!data?.configured) return;
|
|
491
|
+
setEditing(false); setDraft(null); setError('');
|
|
492
|
+
}
|
|
493
|
+
function update(key, value) { setDraft(current => ({ ...current, [key]: value })); }
|
|
494
|
+
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 };
|
|
499
|
+
const start = point(current.start);
|
|
500
|
+
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) };
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
|
|
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'))));
|
|
565
|
+
|
|
566
|
+
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'))));
|
|
573
|
+
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),
|
|
576
|
+
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'))));
|
|
595
|
+
};
|
|
596
|
+
|
|
597
|
+
const editor = () => {
|
|
598
|
+
if (!workingDraft) return null;
|
|
599
|
+
const payDaily = workingDraft.payMode === 'daily';
|
|
600
|
+
const salaryKey = payDaily ? 'dailySalary' : 'monthlySalary';
|
|
601
|
+
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')))
|
|
604
|
+
: numberInput(salaryKey);
|
|
605
|
+
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'];
|
|
607
|
+
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'))));
|
|
658
|
+
};
|
|
659
|
+
|
|
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())));
|
|
681
|
+
}
|
|
682
|
+
|
|
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));
|
|
687
|
+
},
|
|
688
|
+
};
|
|
689
|
+
},
|
|
690
|
+
});
|