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/CHANGELOG.md +27 -0
- package/README.md +77 -43
- package/assets/characters/NOTICE.md +7 -0
- package/assets/characters/whale-dress.webp +0 -0
- package/assets/characters/whale-ornate.webp +0 -0
- package/client.js +4410 -327
- package/client.source.js +3093 -278
- package/clock.js +81 -34
- package/companion.js +185 -0
- package/hydration-store.js +101 -0
- package/hydration.js +303 -0
- package/index.js +142 -66
- package/locale/en.json +6 -1
- package/locale/zh.json +6 -1
- package/package.json +27 -6
- package/pet-interactions.js +138 -0
- package/pet-surfaces.js +194 -0
- package/pet-view.js +272 -0
- package/tests/clock.test.mjs +76 -33
- package/tests/companion.test.mjs +86 -0
- package/tests/host.test.mjs +76 -27
- package/tests/hydration-extras.test.mjs +226 -0
- package/tests/hydration-store.test.mjs +287 -0
- package/tests/hydration.test.mjs +211 -0
- package/tests/pet-character.test.mjs +56 -0
- package/tests/pet-interactions.test.mjs +88 -0
- package/tests/pet-surfaces.test.mjs +128 -0
- package/tests/pet-view.test.mjs +48 -0
- package/tests/ui.test.mjs +98 -21
- package/tools/build.mjs +52 -7
- package/tools/check-package.mjs +105 -0
- package/ui-utils.js +37 -14
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,
|
|
9
|
-
|
|
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 = [
|
|
19
|
-
|
|
20
|
-
|
|
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),
|
|
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,
|
|
45
|
+
let breakOffset = 0,
|
|
46
|
+
breakDuration = 0;
|
|
37
47
|
if (settings.breakStart != null || settings.breakEnd != null) {
|
|
38
|
-
const bs = minute(settings.breakStart),
|
|
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
|
|
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
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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)
|
|
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
|
|
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(
|
|
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(
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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,
|
|
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,
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
+
}
|