dsh-offwork 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/clock.js CHANGED
@@ -5,37 +5,48 @@ export function calculate(settings, timestamp = Date.now()) {
5
5
  if (!Number.isFinite(now)) throw new RangeError('Invalid timestamp');
6
6
  const DAY = 86400000;
7
7
  const formatter = new Intl.DateTimeFormat('en-CA', {
8
- timeZone: settings.timezone, year: 'numeric', month: '2-digit', day: '2-digit',
9
- hour: '2-digit', minute: '2-digit', second: '2-digit', hourCycle: 'h23',
8
+ timeZone: settings.timezone,
9
+ year: 'numeric',
10
+ month: '2-digit',
11
+ day: '2-digit',
12
+ hour: '2-digit',
13
+ minute: '2-digit',
14
+ second: '2-digit',
15
+ hourCycle: 'h23',
10
16
  });
11
- const civil = time => {
12
- const p = Object.fromEntries(formatter.formatToParts(time).map(p => [p.type, p.value]));
17
+ const civil = (time) => {
18
+ const p = Object.fromEntries(formatter.formatToParts(time).map((p) => [p.type, p.value]));
13
19
  return Date.UTC(+p.year, +p.month - 1, +p.day, +p.hour, +p.minute, +p.second);
14
20
  };
15
21
  // Convert a timezone-local civil time to an absolute instant. At DST overlaps use
16
22
  // the earlier occurrence; at gaps move forward by the gap (Temporal-compatible).
17
- const instant = local => {
18
- const offsets = [...new Set([-DAY, 0, DAY].map(delta => civil(local + delta) - (local + delta)))];
19
- const candidates = offsets.map(offset => local - offset);
20
- const exact = candidates.filter(time => civil(time) === local);
23
+ const instant = (local) => {
24
+ const offsets = [
25
+ ...new Set([-DAY, 0, DAY].map((delta) => civil(local + delta) - (local + delta))),
26
+ ];
27
+ const candidates = offsets.map((offset) => local - offset);
28
+ const exact = candidates.filter((time) => civil(time) === local);
21
29
  if (exact.length) return Math.min(...exact);
22
- const after = candidates.filter(time => civil(time) >= local);
30
+ const after = candidates.filter((time) => civil(time) >= local);
23
31
  return Math.min(...(after.length ? after : candidates));
24
32
  };
25
- const minute = text => {
33
+ const minute = (text) => {
26
34
  if (typeof text !== 'string' || !/^(?:[01]\d|2[0-3]):[0-5]\d$/.test(text)) {
27
35
  throw new RangeError('Shift times must be HH:mm');
28
36
  }
29
37
  const [h, m] = text.split(':').map(Number);
30
38
  return h * 60 + m;
31
39
  };
32
- const start = minute(settings.start), end = minute(settings.end);
40
+ const start = minute(settings.start),
41
+ end = minute(settings.end);
33
42
  if (start === end) throw new RangeError('Shift must not be empty');
34
43
  const overnight = end < start;
35
44
  const duration = (end - start + 1440) % 1440;
36
- let breakOffset = 0, breakDuration = 0;
45
+ let breakOffset = 0,
46
+ breakDuration = 0;
37
47
  if (settings.breakStart != null || settings.breakEnd != null) {
38
- const bs = minute(settings.breakStart), be = minute(settings.breakEnd);
48
+ const bs = minute(settings.breakStart),
49
+ be = minute(settings.breakEnd);
39
50
  if (bs !== be) {
40
51
  breakOffset = (bs - start + 1440) % 1440;
41
52
  breakDuration = (be - bs + 1440) % 1440;
@@ -47,7 +58,7 @@ export function calculate(settings, timestamp = Date.now()) {
47
58
  const localNow = civil(now);
48
59
  const today = Math.floor(localNow / DAY) * DAY;
49
60
  const minuteNow = (localNow - today) / 60000;
50
- const workday = day => settings.weekdays.includes(new Date(day).getUTCDay());
61
+ const workday = (day) => settings.weekdays.includes(new Date(day).getUTCDay());
51
62
  let shiftDay = today;
52
63
  if (overnight && minuteNow < start) {
53
64
  // Retain yesterday's finished work shift until the next shift starts. If
@@ -58,46 +69,82 @@ export function calculate(settings, timestamp = Date.now()) {
58
69
  const shiftStart = instant(shiftDay + start * 60000);
59
70
  const shiftEnd = instant(shiftDay + (start + duration) * 60000);
60
71
  const breakStart = breakDuration ? instant(shiftDay + (start + breakOffset) * 60000) : null;
61
- const breakEnd = breakDuration ? instant(shiftDay + (start + breakOffset + breakDuration) * 60000) : null;
72
+ const breakEnd = breakDuration
73
+ ? instant(shiftDay + (start + breakOffset + breakDuration) * 60000)
74
+ : null;
62
75
  const breakMs = breakDuration ? breakEnd - breakStart : 0;
63
76
  const totalMs = shiftEnd - shiftStart - breakMs;
64
77
  if (totalMs <= 0 || breakMs < 0) throw new RangeError('Shift has no paid duration');
65
78
  const clampedNow = Math.max(shiftStart, Math.min(now, shiftEnd));
66
- const elapsedMs = isWorkday ? Math.max(0, Math.min(totalMs,
67
- clampedNow - shiftStart - (breakDuration ? Math.max(0, Math.min(clampedNow, breakEnd) - breakStart) : 0))) : 0;
68
- const status = !isWorkday ? 'rest' : now < shiftStart ? 'before' : now >= shiftEnd ? 'done'
69
- : breakDuration && now >= breakStart && now < breakEnd ? 'lunch' : 'working';
79
+ const elapsedMs = isWorkday
80
+ ? Math.max(
81
+ 0,
82
+ Math.min(
83
+ totalMs,
84
+ clampedNow -
85
+ shiftStart -
86
+ (breakDuration ? Math.max(0, Math.min(clampedNow, breakEnd) - breakStart) : 0),
87
+ ),
88
+ )
89
+ : 0;
90
+ const status = !isWorkday
91
+ ? 'rest'
92
+ : now < shiftStart
93
+ ? 'before'
94
+ : now >= shiftEnd
95
+ ? 'done'
96
+ : breakDuration && now >= breakStart && now < breakEnd
97
+ ? 'lunch'
98
+ : 'working';
70
99
  // Decimal inputs become exact rational numbers before truncation. Never use
71
100
  // floor((salary / days * progress) * 100): binary rounding loses whole cents.
72
- const rational = number => {
73
- if (typeof number !== 'number' || !Number.isFinite(number) || number < 0) throw new RangeError('Invalid salary');
101
+ const rational = (number) => {
102
+ if (typeof number !== 'number' || !Number.isFinite(number) || number < 0)
103
+ throw new RangeError('Invalid salary');
74
104
  const [mantissa, exponent = '0'] = String(number).toLowerCase().split('e');
75
105
  const [integer, fraction = ''] = mantissa.split('.');
76
106
  const scale = fraction.length - Number(exponent);
77
- return scale >= 0 ? [BigInt(integer + fraction), 10n ** BigInt(scale)]
107
+ return scale >= 0
108
+ ? [BigInt(integer + fraction), 10n ** BigInt(scale)]
78
109
  : [BigInt(integer + fraction) * 10n ** BigInt(-scale), 1n];
79
110
  };
80
111
  const payMode = settings.payMode === 'daily' ? 'daily' : 'monthly';
81
- let [numerator, denominator] = rational(payMode === 'daily' ? settings.dailySalary : settings.monthlySalary);
112
+ let [numerator, denominator] = rational(
113
+ payMode === 'daily' ? settings.dailySalary : settings.monthlySalary,
114
+ );
82
115
  if (payMode === 'monthly') {
83
116
  const [daysN, daysD] = rational(settings.workdaysPerMonth);
84
117
  if (daysN === 0n) throw new RangeError('Workdays per month must be positive');
85
118
  numerator *= daysD;
86
119
  denominator *= daysN;
87
120
  }
88
- const dailyCents = Number(numerator * 100n / denominator);
89
- const earnedCents = Math.min(dailyCents, Number(numerator * 100n * BigInt(Math.floor(elapsedMs)) /
90
- (denominator * BigInt(totalMs))));
91
- const hourlyCents = Number(numerator * 100n * 3600000n / (denominator * BigInt(totalMs)));
92
- const dateString = day => new Date(day).toISOString().slice(0, 10);
121
+ const dailyCents = Number((numerator * 100n) / denominator);
122
+ const earnedCents = Math.min(
123
+ dailyCents,
124
+ Number((numerator * 100n * BigInt(Math.floor(elapsedMs))) / (denominator * BigInt(totalMs))),
125
+ );
126
+ const hourlyCents = Number((numerator * 100n * 3600000n) / (denominator * BigInt(totalMs)));
127
+ const dateString = (day) => new Date(day).toISOString().slice(0, 10);
93
128
  return {
94
- status, remaining: isWorkday ? Math.max(0, Math.ceil((shiftEnd - now) / 1000)) : 0,
129
+ status,
130
+ remaining: isWorkday ? Math.max(0, Math.ceil((shiftEnd - now) / 1000)) : 0,
95
131
  startRemaining: status === 'before' ? Math.max(0, Math.ceil((shiftStart - now) / 1000)) : 0,
96
132
  progress: elapsedMs / totalMs,
97
- earned: earnedCents / 100, daily: dailyCents / 100, hourly: hourlyCents / 100,
98
- date: dateString(today), shiftDate: dateString(shiftDay),
99
- earnedCents, dailyCents, payMode, isWorkday, overnight,
100
- shiftStart, shiftEnd, breakStart, breakEnd,
101
- elapsed: elapsedMs / 1000, total: totalMs / 1000,
133
+ earned: earnedCents / 100,
134
+ daily: dailyCents / 100,
135
+ hourly: hourlyCents / 100,
136
+ date: dateString(today),
137
+ shiftDate: dateString(shiftDay),
138
+ earnedCents,
139
+ dailyCents,
140
+ payMode,
141
+ isWorkday,
142
+ overnight,
143
+ shiftStart,
144
+ shiftEnd,
145
+ breakStart,
146
+ breakEnd,
147
+ elapsed: elapsedMs / 1000,
148
+ total: totalMs / 1000,
102
149
  };
103
150
  }
package/companion.js ADDED
@@ -0,0 +1,185 @@
1
+ // Presentation only: no clocks, storage, salary data or reminder side effects.
2
+ export function companionPresentation({
3
+ workStatus,
4
+ phase,
5
+ ownsPrompt = false,
6
+ ready = true,
7
+ failed = false,
8
+ feedback = null,
9
+ visible = true,
10
+ motion = true,
11
+ pageVisible = true,
12
+ }) {
13
+ if (!visible)
14
+ return { visible: false, pose: 'calm', message: null, announce: false, animate: false };
15
+ let pose = 'calm';
16
+ let message = 'companionWorking';
17
+ const quiet =
18
+ ['quiet', 'paused'].includes(phase) && !['before', 'done', 'rest'].includes(workStatus);
19
+ const resting = ['lunch', 'rest', 'done', 'before'].includes(workStatus);
20
+ if (!ready) {
21
+ pose = 'rest';
22
+ message = 'companionLoading';
23
+ } else if (failed) {
24
+ pose = 'offline';
25
+ message = 'companionUnavailable';
26
+ } else if (quiet) {
27
+ pose = 'quiet';
28
+ message = phase === 'quiet' ? 'companionQuiet' : 'companionPaused';
29
+ } else if (workStatus === 'lunch') {
30
+ pose = 'rest';
31
+ message = 'companionBreak';
32
+ } else if (workStatus === 'rest') {
33
+ pose = 'rest';
34
+ message = 'companionDayOff';
35
+ } else if (workStatus === 'done') {
36
+ pose = 'happy';
37
+ message = 'companionDone';
38
+ } else if (workStatus === 'before') {
39
+ pose = 'rest';
40
+ message = 'companionBefore';
41
+ } else if (phase === 'due' && ownsPrompt) {
42
+ // The existing leased reminder is the only source of the due message.
43
+ pose = 'drink';
44
+ message = null;
45
+ }
46
+ let announce = false;
47
+ let animate = false;
48
+ if (ready && !failed && !(phase === 'due' && ownsPrompt) && feedback) {
49
+ message = feedback.key;
50
+ announce = true;
51
+ if (feedback.kind === 'drink' && !quiet && !resting) {
52
+ pose = 'happy';
53
+ animate = motion && pageVisible;
54
+ }
55
+ }
56
+ return { visible: true, pose, message, announce, animate };
57
+ }
58
+
59
+ // Original vector character. Its art is decorative; controls and status text
60
+ // provide the accessible names. Stable keys prevent the 1-second clock tick
61
+ // from replaying the reaction animation.
62
+ export function companionArtwork(h, presentation, size = 40, reaction = '') {
63
+ const { pose, animate } = presentation;
64
+ const asleep = ['rest', 'quiet'].includes(pose);
65
+ const path = (d, props = {}) => h('path', { d, ...props });
66
+ return h(
67
+ 'svg',
68
+ {
69
+ key: pose + ':' + reaction,
70
+ className: 'owc-companion-art',
71
+ viewBox: '0 0 64 64',
72
+ width: size,
73
+ height: size,
74
+ 'aria-hidden': 'true',
75
+ focusable: 'false',
76
+ 'data-pose': pose,
77
+ 'data-animate': animate,
78
+ },
79
+ h('ellipse', { cx: 32, cy: 58, rx: 19, ry: 3, className: 'owc-companion-shadow' }),
80
+ h(
81
+ 'g',
82
+ { className: 'owc-companion-body' },
83
+ path('M32 7C28 18 12 28 12 42C12 54 21 58 32 58S52 54 52 42C52 28 36 18 32 7Z', {
84
+ className: 'owc-companion-skin',
85
+ }),
86
+ path('M25 21C21 25 18 29 17 34', { className: 'owc-companion-shine' }),
87
+ h(
88
+ 'g',
89
+ { className: 'owc-companion-face' },
90
+ asleep
91
+ ? path('M21 38Q24 41 27 38M37 38Q40 41 43 38')
92
+ : h(
93
+ 'g',
94
+ null,
95
+ h('circle', { cx: 24, cy: 38, r: 1.7, fill: 'currentColor', stroke: 'none' }),
96
+ h('circle', { cx: 40, cy: 38, r: 1.7, fill: 'currentColor', stroke: 'none' }),
97
+ ),
98
+ path(
99
+ pose === 'happy'
100
+ ? 'M27 44Q32 51 37 44Z'
101
+ : pose === 'offline'
102
+ ? 'M29 46H35'
103
+ : 'M28 45Q32 49 36 45',
104
+ ),
105
+ ),
106
+ h(
107
+ 'g',
108
+ { className: 'owc-companion-cheeks' },
109
+ h('ellipse', { cx: 20, cy: 44, rx: 3, ry: 1.6 }),
110
+ h('ellipse', { cx: 44, cy: 44, rx: 3, ry: 1.6 }),
111
+ ),
112
+ pose === 'happy' && path('M12 44L7 38M52 44L57 38', { className: 'owc-companion-limbs' }),
113
+ pose === 'drink' &&
114
+ h(
115
+ 'g',
116
+ { className: 'owc-companion-cup' },
117
+ path('M43 41H55L53 54H45Z'),
118
+ path('M55 43H58Q62 47 55 49', { fill: 'none' }),
119
+ ),
120
+ pose === 'quiet' && path('M45 16H51L45 22H51', { className: 'owc-companion-limbs' }),
121
+ ),
122
+ );
123
+ }
124
+
125
+ export const COMPANION_MESSAGES = {
126
+ zh: {
127
+ companionName: '小歇',
128
+ companionLabel: '下班搭子',
129
+ companionOpen: '打开小歇的快捷操作',
130
+ companionShow: '显示陪伴角色',
131
+ companionMotion: '角色动作反馈',
132
+ companionPreferences: '立即生效,保存在本浏览器。隐藏角色后仍可使用计时和喝水操作。',
133
+ companionWorking: '你忙你的,我陪你记着时间。',
134
+ companionQuiet: '好,先安静陪着你。',
135
+ companionPaused: '本班先不提醒,需要时再叫我。',
136
+ companionBreak: '休息一下,回来再继续。',
137
+ companionBefore: '还没开工,先慢慢准备。',
138
+ companionDone: '这一班结束啦,辛苦了。',
139
+ companionDayOff: '今天歇一歇,按自己的节奏来。',
140
+ companionLoading: '稍等,我看看现在的状态。',
141
+ companionUnavailable: '暂时读不到提醒状态,可以重试读取。',
142
+ },
143
+ en: {
144
+ companionName: 'Xiaoxie',
145
+ companionLabel: 'Your workday companion',
146
+ companionOpen: 'Open companion quick actions',
147
+ companionShow: 'Show companion',
148
+ companionMotion: 'Character reactions',
149
+ companionPreferences:
150
+ 'Applies immediately in this browser. Timers and water actions work with the character hidden.',
151
+ companionWorking: 'Carry on. I’ll keep an eye on the time.',
152
+ companionQuiet: 'I’ll keep you company quietly.',
153
+ companionPaused: 'Reminders are paused. Resume whenever you like.',
154
+ companionBreak: 'Take a break. We can carry on afterward.',
155
+ companionBefore: 'No rush. Your shift hasn’t started yet.',
156
+ companionDone: 'That’s your shift done. Take a breather.',
157
+ companionDayOff: 'A day off. Enjoy your own pace.',
158
+ companionLoading: 'One moment while I check the status.',
159
+ companionUnavailable: 'I can’t read reminder status. You can retry loading.',
160
+ },
161
+ };
162
+
163
+ export const COMPANION_STYLE = `
164
+ .owc-sr-only{position:absolute!important;width:1px;height:1px;padding:0;overflow:hidden;clip-path:inset(50%);white-space:nowrap}
165
+ .owc-mini-feedback{margin:0;padding:0 14px 9px;max-width:260px;font-size:11px;line-height:1.6;overflow-wrap:anywhere}
166
+ .owc-companion{display:flex;align-items:center;gap:10px;padding:12px 17px 8px;cursor:default}
167
+ .owc-companion-copy{min-width:0;flex:1}
168
+ .owc-companion-name{display:flex;align-items:center;gap:6px;font-size:11px;color:var(--dsw-alias-label-secondary);font-weight:600}
169
+ .owc-companion-name span{font-size:10px;font-weight:400}
170
+ .owc-companion p{margin:5px 0 0;font-size:12px;line-height:1.65;overflow-wrap:anywhere}
171
+ .owc-water .owc-companion{padding:4px 0 8px;border-bottom:1px solid color-mix(in srgb,var(--owc-accent) 10%,transparent);margin-bottom:8px}
172
+ .owc-v2 button.owc-companion-trigger{padding:0;flex:none;min-width:36px;min-height:36px;border-radius:12px}
173
+ .owc-companion-art{flex:none;overflow:visible;color:var(--owc-accent);pointer-events:none}
174
+ .owc-companion-shadow{fill:currentColor;opacity:.12}
175
+ .owc-companion-skin{fill:color-mix(in srgb,var(--owc-accent) 22%,var(--dsw-alias-bg-overlay));stroke:currentColor;stroke-width:1.6;stroke-linejoin:round}
176
+ .owc-companion-shine{fill:none;stroke:white;stroke-width:2.5;stroke-linecap:round;opacity:.75}
177
+ .owc-companion-face{color:var(--dsw-alias-label-primary);fill:none;stroke:currentColor;stroke-width:1.6;stroke-linecap:round;stroke-linejoin:round}
178
+ .owc-companion-cheeks{fill:#ef8d96;opacity:.5}
179
+ .owc-companion-limbs{fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}
180
+ .owc-companion-cup{fill:var(--dsw-alias-bg-overlay);stroke:currentColor;stroke-width:1.6;stroke-linejoin:round}
181
+ .owc-companion-art[data-pose=offline]{opacity:.65}
182
+ .owc-companion-art[data-animate=true] .owc-companion-body{transform-origin:32px 52px;animation:owc-companion-thanks .65s ease-in-out 2}
183
+ @keyframes owc-companion-thanks{0%,100%{transform:translateY(0) rotate(0)}40%{transform:translateY(-2px) rotate(-3deg)}70%{transform:translateY(0) rotate(2deg)}}
184
+ @media(prefers-reduced-motion:reduce){.owc-companion-art *{animation:none!important;transition:none!important}}
185
+ `;
@@ -0,0 +1,101 @@
1
+ import { defaultHydration, validateHydration } from './hydration.js';
2
+
3
+ // A single IndexedDB read/write transaction is the cross-tab mutex. Neither
4
+ // notification delivery nor localStorage read-back is used as an atomic lock.
5
+ export function createHydrationStore(indexedDB, name = 'dsh.offwork-clock.hydration.v1') {
6
+ let database = null;
7
+ let opening = null;
8
+ let closed = false;
9
+ const open = () => {
10
+ if (closed) return Promise.reject(new Error('Hydration store closed'));
11
+ if (database) return Promise.resolve(database);
12
+ if (opening) return opening;
13
+ opening = new Promise((resolve, reject) => {
14
+ let abandoned = false;
15
+ const fail = (error) => {
16
+ abandoned = true;
17
+ reject(error);
18
+ };
19
+ if (!indexedDB) {
20
+ reject(new Error('Hydration storage unavailable'));
21
+ return;
22
+ }
23
+ const request = indexedDB.open(name, 1);
24
+ request.onupgradeneeded = () => request.result.createObjectStore('state');
25
+ request.onerror = () => fail(request.error ?? new Error('Hydration storage unavailable'));
26
+ request.onblocked = () => fail(new Error('Hydration storage blocked'));
27
+ request.onsuccess = () => {
28
+ if (closed || abandoned) {
29
+ request.result.close();
30
+ reject(new Error('Hydration store closed'));
31
+ return;
32
+ }
33
+ database = request.result;
34
+ database.onversionchange = () => {
35
+ database.close();
36
+ database = null;
37
+ opening = null;
38
+ };
39
+ database.onclose = () => {
40
+ database = null;
41
+ opening = null;
42
+ };
43
+ resolve(database);
44
+ };
45
+ }).catch((error) => {
46
+ opening = null;
47
+ throw error;
48
+ });
49
+ return opening;
50
+ };
51
+ return {
52
+ async update(reducer, { reset = false } = {}) {
53
+ const db = await open();
54
+ if (closed) throw new Error('Hydration store closed');
55
+ return new Promise((resolve, reject) => {
56
+ const transaction = db.transaction('state', 'readwrite');
57
+ const store = transaction.objectStore('state');
58
+ const request = store.get('current');
59
+ let output;
60
+ let failure;
61
+ request.onsuccess = () => {
62
+ try {
63
+ const current =
64
+ reset || request.result === undefined
65
+ ? defaultHydration()
66
+ : validateHydration(request.result);
67
+ const next = validateHydration(reducer(structuredClone(current)));
68
+ const changed = JSON.stringify(next) !== JSON.stringify(current);
69
+ const migrated =
70
+ request.result !== undefined && request.result.schema !== current.schema;
71
+ if (reset || changed || migrated) {
72
+ const previousRevision =
73
+ Number.isSafeInteger(request.result?.revision) && request.result.revision >= 0
74
+ ? request.result.revision
75
+ : current.revision;
76
+ next.revision = previousRevision + 1;
77
+ if (!Number.isSafeInteger(next.revision))
78
+ throw new Error('Hydration revision exhausted');
79
+ store.put(next, 'current');
80
+ }
81
+ output = next;
82
+ } catch (error) {
83
+ failure = error;
84
+ transaction.abort();
85
+ }
86
+ };
87
+ transaction.oncomplete = () => resolve(output);
88
+ transaction.onabort = () =>
89
+ reject(failure ?? transaction.error ?? new Error('Hydration transaction aborted'));
90
+ transaction.onerror = () => {
91
+ /* onabort reports the failure once. */
92
+ };
93
+ });
94
+ },
95
+ close() {
96
+ closed = true;
97
+ database?.close();
98
+ database = null;
99
+ },
100
+ };
101
+ }