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/clock.js ADDED
@@ -0,0 +1,103 @@
1
+ // Pure calculation. Weekdays belong to the shift's START date, not the clock date.
2
+ // Keep helpers inside calculate: the client build embeds this exact function.
3
+ export function calculate(settings, timestamp = Date.now()) {
4
+ const now = Number(timestamp);
5
+ if (!Number.isFinite(now)) throw new RangeError('Invalid timestamp');
6
+ const DAY = 86400000;
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',
10
+ });
11
+ const civil = time => {
12
+ const p = Object.fromEntries(formatter.formatToParts(time).map(p => [p.type, p.value]));
13
+ return Date.UTC(+p.year, +p.month - 1, +p.day, +p.hour, +p.minute, +p.second);
14
+ };
15
+ // Convert a timezone-local civil time to an absolute instant. At DST overlaps use
16
+ // 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);
21
+ if (exact.length) return Math.min(...exact);
22
+ const after = candidates.filter(time => civil(time) >= local);
23
+ return Math.min(...(after.length ? after : candidates));
24
+ };
25
+ const minute = text => {
26
+ if (typeof text !== 'string' || !/^(?:[01]\d|2[0-3]):[0-5]\d$/.test(text)) {
27
+ throw new RangeError('Shift times must be HH:mm');
28
+ }
29
+ const [h, m] = text.split(':').map(Number);
30
+ return h * 60 + m;
31
+ };
32
+ const start = minute(settings.start), end = minute(settings.end);
33
+ if (start === end) throw new RangeError('Shift must not be empty');
34
+ const overnight = end < start;
35
+ const duration = (end - start + 1440) % 1440;
36
+ let breakOffset = 0, breakDuration = 0;
37
+ if (settings.breakStart != null || settings.breakEnd != null) {
38
+ const bs = minute(settings.breakStart), be = minute(settings.breakEnd);
39
+ if (bs !== be) {
40
+ breakOffset = (bs - start + 1440) % 1440;
41
+ breakDuration = (be - bs + 1440) % 1440;
42
+ if (breakOffset + breakDuration > duration || breakDuration >= duration) {
43
+ throw new RangeError('Break must be inside the shift and leave paid work');
44
+ }
45
+ }
46
+ }
47
+ const localNow = civil(now);
48
+ const today = Math.floor(localNow / DAY) * DAY;
49
+ const minuteNow = (localNow - today) / 60000;
50
+ const workday = day => settings.weekdays.includes(new Date(day).getUTCDay());
51
+ let shiftDay = today;
52
+ if (overnight && minuteNow < start) {
53
+ // Retain yesterday's finished work shift until the next shift starts. If
54
+ // yesterday was off, a working today is 'before' (e.g. Monday daytime).
55
+ if (minuteNow < end || workday(today - DAY)) shiftDay -= DAY;
56
+ }
57
+ const isWorkday = workday(shiftDay);
58
+ const shiftStart = instant(shiftDay + start * 60000);
59
+ const shiftEnd = instant(shiftDay + (start + duration) * 60000);
60
+ const breakStart = breakDuration ? instant(shiftDay + (start + breakOffset) * 60000) : null;
61
+ const breakEnd = breakDuration ? instant(shiftDay + (start + breakOffset + breakDuration) * 60000) : null;
62
+ const breakMs = breakDuration ? breakEnd - breakStart : 0;
63
+ const totalMs = shiftEnd - shiftStart - breakMs;
64
+ if (totalMs <= 0 || breakMs < 0) throw new RangeError('Shift has no paid duration');
65
+ 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';
70
+ // Decimal inputs become exact rational numbers before truncation. Never use
71
+ // 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');
74
+ const [mantissa, exponent = '0'] = String(number).toLowerCase().split('e');
75
+ const [integer, fraction = ''] = mantissa.split('.');
76
+ const scale = fraction.length - Number(exponent);
77
+ return scale >= 0 ? [BigInt(integer + fraction), 10n ** BigInt(scale)]
78
+ : [BigInt(integer + fraction) * 10n ** BigInt(-scale), 1n];
79
+ };
80
+ const payMode = settings.payMode === 'daily' ? 'daily' : 'monthly';
81
+ let [numerator, denominator] = rational(payMode === 'daily' ? settings.dailySalary : settings.monthlySalary);
82
+ if (payMode === 'monthly') {
83
+ const [daysN, daysD] = rational(settings.workdaysPerMonth);
84
+ if (daysN === 0n) throw new RangeError('Workdays per month must be positive');
85
+ numerator *= daysD;
86
+ denominator *= daysN;
87
+ }
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);
93
+ return {
94
+ status, remaining: isWorkday ? Math.max(0, Math.ceil((shiftEnd - now) / 1000)) : 0,
95
+ startRemaining: status === 'before' ? Math.max(0, Math.ceil((shiftStart - now) / 1000)) : 0,
96
+ 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,
102
+ };
103
+ }
@@ -0,0 +1,4 @@
1
+ - insert:
2
+ - id: offwork-clock
3
+ name: 'dsh-offwork'
4
+ config: {}
package/icon.svg ADDED
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="16" fill="#167a69"/><circle cx="29" cy="28" r="18" fill="none" stroke="#fff" stroke-width="4"/><path d="M29 17v12l8 5" fill="none" stroke="#fff" stroke-width="4" stroke-linecap="round"/><circle cx="46" cy="46" r="13" fill="#f6d376"/><path d="m40 40 6 7 6-7m-6 7v8m-6-7h12m-12 4h12" fill="none" stroke="#31534a" stroke-width="2.5"/></svg>
package/index.js ADDED
@@ -0,0 +1,198 @@
1
+ import { readFile, writeFile, rename, unlink, chmod } from 'node:fs/promises';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { dirname, join, resolve } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+
6
+ export const inject = ['commands', 'tools'];
7
+
8
+ export const DEFAULT_SETTINGS = Object.freeze({
9
+ payMode: 'monthly',
10
+ monthlySalary: 10000,
11
+ dailySalary: 400,
12
+ workdaysPerMonth: 21.75,
13
+ start: '09:00',
14
+ end: '18:00',
15
+ breakStart: '12:00',
16
+ breakEnd: '13:00',
17
+ weekdays: Object.freeze([1, 2, 3, 4, 5]),
18
+ timezone: 'Asia/Shanghai',
19
+ });
20
+
21
+ const dataFile = new URL('./data.json', import.meta.url);
22
+ const keys = Object.keys(DEFAULT_SETTINGS);
23
+ const isObject = value => value !== null && typeof value === 'object' && !Array.isArray(value);
24
+ const fail = error => ({ ok: false, error: error instanceof Error ? error.message : String(error) });
25
+
26
+ function validatePatch(value) {
27
+ if (!isObject(value)) throw new Error('settings 必须是 JSON 对象。');
28
+ for (const key of Object.keys(value)) {
29
+ if (!keys.includes(key)) throw new Error(`未知配置字段:${key}`);
30
+ }
31
+ }
32
+
33
+ export function validateSettings(value) {
34
+ validatePatch(value);
35
+ for (const key of keys) {
36
+ if (!Object.hasOwn(value, key)) throw new Error(`缺少配置字段:${key}`);
37
+ }
38
+ if (value.payMode !== 'monthly' && value.payMode !== 'daily') {
39
+ throw new Error('payMode 必须是 monthly 或 daily。');
40
+ }
41
+ if (typeof value.dailySalary !== 'number' || !Number.isFinite(value.dailySalary) || value.dailySalary < 0) {
42
+ throw new Error('dailySalary 必须是大于等于 0 的有限数字。');
43
+ }
44
+ if (typeof value.monthlySalary !== 'number' || !Number.isFinite(value.monthlySalary) || value.monthlySalary < 0) {
45
+ throw new Error('monthlySalary 必须是大于等于 0 的有限数字。');
46
+ }
47
+ if (typeof value.workdaysPerMonth !== 'number' || !Number.isFinite(value.workdaysPerMonth) || value.workdaysPerMonth <= 0) {
48
+ throw new Error('workdaysPerMonth 必须是大于 0 的有限数字。');
49
+ }
50
+ const minutes = key => {
51
+ if (typeof value[key] !== 'string' || !/^(?:[01]\d|2[0-3]):[0-5]\d$/.test(value[key])) {
52
+ throw new Error(`${key} 必须是 24 小时制 HH:mm。`);
53
+ }
54
+ const [hour, minute] = value[key].split(':').map(Number);
55
+ return hour * 60 + minute;
56
+ };
57
+ const start = minutes('start');
58
+ const end = minutes('end');
59
+ const breakStart = minutes('breakStart');
60
+ const breakEnd = minutes('breakEnd');
61
+ if (end === start) throw new Error('start 和 end 不能相同。');
62
+ const relative = minute => (minute - start + 1440) % 1440;
63
+ const duration = relative(end);
64
+ if (breakStart !== breakEnd) {
65
+ const from = relative(breakStart);
66
+ const to = relative(breakEnd);
67
+ if (from >= to || to > duration) {
68
+ throw new Error('午休必须按班次起点顺序落在班次内。');
69
+ }
70
+ if (to - from >= duration) throw new Error('午休不能占满整个班次。');
71
+ }
72
+ if (!Array.isArray(value.weekdays) || value.weekdays.length === 0 ||
73
+ value.weekdays.some(day => !Number.isInteger(day) || day < 0 || day > 6) ||
74
+ new Set(value.weekdays).size !== value.weekdays.length) {
75
+ throw new Error('weekdays 必须是非空、不重复的 0–6 整数数组(0 为周日)。');
76
+ }
77
+ if (typeof value.timezone !== 'string' || value.timezone.length === 0 || value.timezone.trim() !== value.timezone) {
78
+ throw new Error('timezone 必须是有效时区名称。');
79
+ }
80
+ try { new Intl.DateTimeFormat('en', { timeZone: value.timezone }); }
81
+ catch { throw new Error('timezone 必须是有效时区名称。'); }
82
+ return { ...value, weekdays: [...value.weekdays] };
83
+ }
84
+
85
+ // One local persistence owner, shared by the command and tool adapters.
86
+ // Reads never persist defaults. Writes are serialized and atomically replace a 0600 file.
87
+ export function createOperations(dataFileArg = dataFile) {
88
+ const target = dataFileArg instanceof URL ? fileURLToPath(dataFileArg) : resolve(dataFileArg);
89
+ let queue = Promise.resolve();
90
+ async function read() {
91
+ let text;
92
+ try { text = await readFile(target, 'utf8'); }
93
+ catch (error) {
94
+ if (error.code === 'ENOENT') return { settings: validateSettings(DEFAULT_SETTINGS), configured: false };
95
+ throw new Error('无法读取本机配置。');
96
+ }
97
+ try {
98
+ const stored = JSON.parse(text);
99
+ validatePatch(stored);
100
+ // Only the newly introduced fields are optional in legacy files.
101
+ const settings = validateSettings({ payMode: DEFAULT_SETTINGS.payMode, dailySalary: DEFAULT_SETTINGS.dailySalary, ...stored });
102
+ return { settings, configured: true };
103
+ } catch { throw new Error('本机配置损坏或无效;请检查插件目录 data.json。'); }
104
+ }
105
+ async function write(settings) {
106
+ const temp = join(dirname(target), `.data-${randomUUID()}.tmp`);
107
+ try {
108
+ await writeFile(temp, `${JSON.stringify(settings, null, 2)}\n`, { mode: 0o600, flag: 'wx' });
109
+ await chmod(temp, 0o600);
110
+ await rename(temp, target);
111
+ } catch {
112
+ throw new Error('无法保存本机配置。');
113
+ } finally {
114
+ await unlink(temp).catch(() => {});
115
+ }
116
+ }
117
+ return function operate(action, patch) {
118
+ const operation = queue.then(async () => {
119
+ try {
120
+ if (action !== 'get' && action !== 'set') throw new Error('action 必须是 get 或 set。');
121
+ if (action === 'get' && patch !== undefined) throw new Error('get 不接受 settings。');
122
+ if (action === 'set') validatePatch(patch);
123
+ const current = await read();
124
+ if (action === 'get') return { ok: true, ...current };
125
+ const settings = validateSettings({ ...current.settings, ...patch });
126
+ await write(settings);
127
+ return { ok: true, settings, configured: true };
128
+ } catch (error) { return fail(error); }
129
+ });
130
+ queue = operation.then(() => {}, () => {});
131
+ return operation;
132
+ };
133
+ }
134
+
135
+ const settingsSchema = {
136
+ type: 'object', additionalProperties: false,
137
+ description: '要合并保存的配置字段。时间使用 HH:mm。',
138
+ properties: {
139
+ payMode: { type: 'string', enum: ['monthly', 'daily'], description: '计薪方式,默认 monthly(月薪);daily 为日薪。' },
140
+ dailySalary: { type: 'number', description: '日薪,有限数字且 >= 0,默认 400。' },
141
+ monthlySalary: { type: 'number', description: '月薪,有限数字且 >= 0。' },
142
+ workdaysPerMonth: { type: 'number', description: '每月工作日数,有限数字且 > 0。' },
143
+ start: { type: 'string', description: '上班时间 HH:mm。' },
144
+ end: { type: 'string', description: '下班时间 HH:mm;早于上班表示次日,不能与上班相同。' },
145
+ breakStart: { type: 'string', description: '休息开始 HH:mm。' },
146
+ breakEnd: { type: 'string', description: '休息结束 HH:mm;与开始相同即关闭,否则按班次顺序在班次内,且不能占满班次。' },
147
+ weekdays: { type: 'array', items: { type: 'integer', enum: [0, 1, 2, 3, 4, 5, 6] }, description: '非空、不重复;0 为周日,1 为周一。' },
148
+ timezone: { type: 'string', description: '有效时区,例如 Asia/Shanghai。' },
149
+ },
150
+ };
151
+
152
+ export function apply(ctx) {
153
+ const operate = createOperations();
154
+ ctx.effect(() => ctx.commands.register({
155
+ name: 'offwork-clock',
156
+ description: '读取或保存本机下班时钟配置:get 或 set JSON。',
157
+ input: { hint: 'get | set {"monthlySalary":10000}' },
158
+ recordInput: false,
159
+ async handler({ rawInput }) {
160
+ let result;
161
+ try {
162
+ const match = /^(get|set)(?:\s+([\s\S]*))?$/.exec(rawInput.trim());
163
+ if (!match) throw new Error('用法:/offwork-clock get 或 /offwork-clock set JSON');
164
+ const [, action, json] = match;
165
+ if (action === 'get' && json !== undefined) throw new Error('get 不接受参数。');
166
+ if (action === 'set' && !json?.trim()) throw new Error('set 需要 JSON 配置对象。');
167
+ result = await operate(action, action === 'set' ? JSON.parse(json) : undefined);
168
+ } catch (error) { result = fail(error); }
169
+ return { kind: result.ok ? 'success' : 'error', text: JSON.stringify(result) };
170
+ },
171
+ }));
172
+ ctx.effect(() => ctx.tools.register({
173
+ name: 'offwork_clock',
174
+ description: '读取或更新本机下班时钟的工资与班次配置。get 返回配置及 configured(是否已有保存文件);set 合并保存。工资仅存本机,但调用及结果会进入会话记录。',
175
+ parameters: {
176
+ type: 'object', additionalProperties: false, required: ['action'],
177
+ properties: {
178
+ action: { type: 'string', enum: ['get', 'set'], description: 'get 读取;set 更新。' },
179
+ settings: settingsSchema,
180
+ },
181
+ },
182
+ output: {
183
+ schema: {
184
+ oneOf: [
185
+ { type: 'object', additionalProperties: false, required: ['ok', 'settings', 'configured'], properties: { ok: { type: 'boolean', const: true }, settings: { ...settingsSchema, required: keys }, configured: { type: 'boolean' } } },
186
+ { type: 'object', additionalProperties: false, required: ['ok', 'error'], properties: { ok: { type: 'boolean', const: false }, error: { type: 'string' } } },
187
+ ],
188
+ },
189
+ render: (_args, value) => [{ type: 'text', text: JSON.stringify(value) }],
190
+ },
191
+ async execute(args) {
192
+ if (!isObject(args) || Object.keys(args).some(key => key !== 'action' && key !== 'settings')) {
193
+ return fail('参数必须是仅含 action 和可选 settings 的对象。');
194
+ }
195
+ return operate(args.action, args.settings);
196
+ },
197
+ }));
198
+ }
package/locale/en.json ADDED
@@ -0,0 +1 @@
1
+ {"meta":{"title":"Offwork Clock","description":"A draggable countdown to clock-out with live estimated earnings and salary settings."}}
package/locale/zh.json ADDED
@@ -0,0 +1 @@
1
+ {"meta":{"title":"下班计时器","description":"可拖动的下班倒计时与工资悬浮窗,支持月薪/日薪、夜班、工作日、可选休息扣除、金额隐藏与位置记忆。"}}
package/package.json CHANGED
@@ -1,6 +1,62 @@
1
1
  {
2
2
  "name": "dsh-offwork",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.0.0",
4
+ "description": "下班倒计时与实时工资悬浮窗:可拖动、可调透明度、工资可隐藏。A draggable countdown and salary overlay for DeepSeek Harness.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "now",
8
+ "repository": { "type": "git", "url": "git+https://github.com/SnowBeatRain/dsh-offwork.git" },
9
+ "homepage": "https://github.com/SnowBeatRain/dsh-offwork#readme",
10
+ "bugs": { "url": "https://github.com/SnowBeatRain/dsh-offwork/issues" },
11
+ "keywords": [
12
+ "deepseek-harness",
13
+ "dsh-plugin",
14
+ "dsh-widget",
15
+ "countdown",
16
+ "salary",
17
+ "offwork",
18
+ "overlay"
19
+ ],
20
+ "engines": {
21
+ "dsh": ">=0.2.1-rc.5 <0.3.0-0"
22
+ },
23
+ "exports": {
24
+ ".": "./index.js",
25
+ "./client": "./client.js",
26
+ "./locale/*.json": "./locale/*.json",
27
+ "./icon": "./icon.svg",
28
+ "./package.json": "./package.json"
29
+ },
30
+ "files": [
31
+ "index.js",
32
+ "client.js",
33
+ "client.source.js",
34
+ "clock.js",
35
+ "ui-utils.js",
36
+ "cordis.patch.yml",
37
+ "locale/*.json",
38
+ "icon.svg",
39
+ "LICENSE",
40
+ "README.md",
41
+ "tests/*.mjs",
42
+ "tools/build.mjs"
43
+ ],
44
+ "scripts": {
45
+ "build": "node tools/build.mjs",
46
+ "test": "node tools/build.mjs --check && node --test tests/*.mjs",
47
+ "prepublishOnly": "npm run build && npm test"
48
+ },
49
+ "dsh": {
50
+ "bundle": {
51
+ "patch": "./cordis.patch.yml"
52
+ },
53
+ "client": {
54
+ "platform": "web",
55
+ "immediately": true,
56
+ "inject": [
57
+ "@deepseek-ai/dsh-client-ui-conversation",
58
+ "@deepseek-ai/dsh-api-remotes"
59
+ ]
60
+ }
61
+ }
62
+ }
@@ -0,0 +1,193 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { calculate } from '../clock.js';
4
+
5
+ const base = {
6
+ monthlySalary: 10000, workdaysPerMonth: 20,
7
+ start: '09:00', end: '18:00', breakStart: '12:00', breakEnd: '13:00',
8
+ weekdays: [1, 2, 3, 4, 5], timezone: 'Asia/Shanghai',
9
+ };
10
+ const at = (time, patch = {}) => calculate({ ...base, ...patch }, Date.parse(time));
11
+ const night = { start: '22:00', end: '06:00', breakStart: '02:00', breakEnd: '03:00' };
12
+ const utc = { timezone: 'UTC' };
13
+
14
+ for (const [time, status, progress] of [
15
+ ['08:00', 'before', 0], ['09:00', 'working', 0], ['11:00', 'working', 0.25],
16
+ ['12:00', 'lunch', 0.375], ['12:59', 'lunch', 0.375],
17
+ ['13:00', 'working', 0.375], ['17:00', 'working', 0.875], ['18:00', 'done', 1], ['23:59', 'done', 1],
18
+ ]) test(`same-day boundary ${time}: ${status}`, () => {
19
+ const result = at(`2026-11-02T${time}:00+08:00`);
20
+ assert.equal(result.status, status);
21
+ assert.equal(result.progress, progress);
22
+ });
23
+
24
+ test('before has both actual end countdown and start countdown', () => {
25
+ const result = at('2026-11-02T08:00:00+08:00');
26
+ assert.equal(result.remaining, 36000);
27
+ assert.equal(result.startRemaining, 3600);
28
+ });
29
+ test('lunch freezes pay but not the real end countdown', () => {
30
+ const first = at('2026-11-02T12:00:00+08:00');
31
+ const second = at('2026-11-02T12:30:00+08:00');
32
+ assert.equal(first.earned, second.earned);
33
+ assert.equal(first.remaining - second.remaining, 1800);
34
+ assert.equal(second.startRemaining, 0);
35
+ });
36
+ test('equal break endpoints mean no break even outside the shift', () => {
37
+ const result = at('2026-11-02T13:30:00+08:00', { breakStart: '00:00', breakEnd: '00:00' });
38
+ assert.equal(result.progress, 0.5);
39
+ assert.equal(result.total, 9 * 3600);
40
+ assert.equal(result.breakStart, null);
41
+ });
42
+ test('omitted break is optional', () => {
43
+ const result = at('2026-11-02T13:30:00+08:00', { breakStart: undefined, breakEnd: undefined });
44
+ assert.equal(result.progress, 0.5);
45
+ });
46
+ test('weekend is rest, not a completed salaried shift', () => {
47
+ const result = at('2026-11-07T20:00:00+08:00');
48
+ assert.equal(result.status, 'rest');
49
+ assert.equal(result.earned, 0);
50
+ assert.equal(result.progress, 0);
51
+ assert.equal(result.remaining, 0);
52
+ });
53
+ test('night early hours belong to previous start date', () => {
54
+ const result = at('2026-11-03T01:00:00+08:00', night);
55
+ assert.equal(result.shiftDate, '2026-11-02');
56
+ assert.equal(result.date, '2026-11-03');
57
+ assert.equal(result.status, 'working');
58
+ assert.equal(result.elapsed, 3 * 3600);
59
+ assert.equal(result.remaining, 5 * 3600);
60
+ });
61
+ test('night break after midnight is normalized into the shift', () => {
62
+ const result = at('2026-11-03T02:30:00+08:00', night);
63
+ assert.equal(result.status, 'lunch');
64
+ assert.equal(result.elapsed, 4 * 3600);
65
+ assert.equal(result.total, 7 * 3600);
66
+ });
67
+ test('break spanning midnight is normalized', () => {
68
+ const result = at('2026-11-03T00:15:00+08:00', { ...night, breakStart: '23:30', breakEnd: '00:30' });
69
+ assert.equal(result.status, 'lunch');
70
+ assert.equal(result.elapsed, 1.5 * 3600);
71
+ });
72
+ test('Saturday early night shift uses working Friday', () => {
73
+ const result = at('2026-11-07T04:00:00+08:00', night);
74
+ assert.equal(result.status, 'working');
75
+ assert.equal(result.shiftDate, '2026-11-06');
76
+ });
77
+ test('night completed status retained through the following daytime', () => {
78
+ for (const time of ['06:00', '12:00', '21:59']) {
79
+ const result = at(`2026-11-03T${time}:00+08:00`, night);
80
+ assert.equal(result.status, 'done');
81
+ assert.equal(result.shiftDate, '2026-11-02');
82
+ assert.equal(result.earned, result.daily);
83
+ }
84
+ });
85
+ test('new night shift starts exactly at 22:00', () => {
86
+ const result = at('2026-11-03T22:00:00+08:00', night);
87
+ assert.equal(result.status, 'working');
88
+ assert.equal(result.shiftDate, '2026-11-03');
89
+ assert.equal(result.progress, 0);
90
+ });
91
+ test('Monday early hours still belong to nonworking Sunday', () => {
92
+ assert.equal(at('2026-11-02T04:00:00+08:00', night).status, 'rest');
93
+ });
94
+ test('Monday daytime is before when Sunday did not work', () => {
95
+ for (const time of ['06:00', '12:00', '21:59']) {
96
+ const result = at(`2026-11-02T${time}:00+08:00`, night);
97
+ assert.equal(result.status, 'before');
98
+ assert.equal(result.shiftDate, '2026-11-02');
99
+ assert.ok(result.startRemaining > 0);
100
+ }
101
+ });
102
+ test('Saturday daytime retains Friday done; Saturday night is rest', () => {
103
+ assert.equal(at('2026-11-07T12:00:00+08:00', night).status, 'done');
104
+ assert.equal(at('2026-11-07T22:00:00+08:00', night).status, 'rest');
105
+ });
106
+ test('configured Sunday workday overrides weekday assumptions', () => {
107
+ const result = at('2026-11-08T10:00:00+08:00', { weekdays: [0] });
108
+ assert.equal(result.status, 'working');
109
+ });
110
+ test('daily salary ignores monthly fields and truncates cents', () => {
111
+ const result = at('2026-11-02T18:00:00+08:00', {
112
+ payMode: 'daily', dailySalary: 123.459, monthlySalary: undefined, workdaysPerMonth: undefined,
113
+ });
114
+ assert.equal(result.payMode, 'daily');
115
+ assert.equal(result.daily, 123.45);
116
+ assert.equal(result.earned, 123.45);
117
+ });
118
+ test('decimal division does not lose a cent at completion', () => {
119
+ const result = at('2026-11-02T18:00:00+08:00', { monthlySalary: 0.29, workdaysPerMonth: 1 });
120
+ assert.equal(result.dailyCents, 29);
121
+ assert.equal(result.earnedCents, 29);
122
+ });
123
+ test('hourly and partial salary truncate rather than round', () => {
124
+ const result = at('2026-11-02T10:00:00+08:00', { payMode: 'daily', dailySalary: 100.01 });
125
+ assert.equal(result.earned, 12.5);
126
+ assert.equal(result.hourly, 12.5);
127
+ });
128
+ test('zero salary remains finite', () => {
129
+ const result = at('2026-11-02T10:00:00+08:00', { monthlySalary: 0 });
130
+ assert.equal(result.earned, 0);
131
+ assert.equal(result.hourly, 0);
132
+ });
133
+ test('scientific notation salaries use exact decimal arithmetic', () => {
134
+ assert.equal(at('2026-11-02T18:00:00+08:00', { payMode: 'daily', dailySalary: 1e-7 }).daily, 0);
135
+ assert.equal(at('2026-11-02T18:00:00+08:00', { payMode: 'daily', dailySalary: 1e7 }).daily, 1e7);
136
+ });
137
+ test('absolute timestamp and configured timezone determine local date and status', () => {
138
+ const timestamp = '2026-11-02T01:00:00Z';
139
+ assert.equal(at(timestamp).status, 'working');
140
+ assert.equal(at(timestamp, utc).status, 'before');
141
+ const la = at(timestamp, { timezone: 'America/Los_Angeles' });
142
+ assert.equal(la.status, 'rest');
143
+ assert.equal(la.date, '2026-11-01');
144
+ });
145
+ test('fractional-zone offset is handled independently of system timezone', () => {
146
+ const result = at('2026-11-02T03:15:00Z', { timezone: 'Asia/Kathmandu' });
147
+ assert.equal(result.status, 'working');
148
+ assert.equal(result.progress, 0);
149
+ });
150
+ test('DST spring night countdown measures real elapsed seconds', () => {
151
+ const patch = { ...night, timezone: 'America/New_York', weekdays: [6], breakStart: '00:00', breakEnd: '00:00' };
152
+ const result = at('2026-03-08T06:00:00Z', patch);
153
+ assert.equal(result.shiftDate, '2026-03-07');
154
+ assert.equal(result.total, 7 * 3600);
155
+ assert.equal(result.elapsed, 3 * 3600);
156
+ assert.equal(result.remaining, 4 * 3600);
157
+ });
158
+ test('DST fall night countdown includes the repeated hour', () => {
159
+ const patch = { ...night, timezone: 'America/New_York', weekdays: [6], breakStart: '00:00', breakEnd: '00:00' };
160
+ const result = at('2026-11-01T06:30:00Z', patch);
161
+ assert.equal(result.total, 9 * 3600);
162
+ assert.equal(result.elapsed, 4.5 * 3600);
163
+ assert.equal(result.remaining, 4.5 * 3600);
164
+ });
165
+ test('subsecond countdown rounds upwards and salary remains truncated', () => {
166
+ const result = at('2026-11-02T17:59:59.999+08:00');
167
+ assert.equal(result.remaining, 1);
168
+ assert.equal(result.earned, 499.99);
169
+ });
170
+ for (const [label, patch, begin] of [
171
+ ['monthly lunch', {}, '2026-11-02T00:00:00+08:00'],
172
+ ['daily no lunch', { payMode: 'daily', dailySalary: 109.99, breakStart: '00:00', breakEnd: '00:00' }, '2026-11-02T00:00:00+08:00'],
173
+ ['night cross-midnight lunch', { ...night, breakStart: '23:30', breakEnd: '00:30' }, '2026-11-02T22:00:00+08:00'],
174
+ ['decimal cents', { monthlySalary: 0.29, workdaysPerMonth: 1 }, '2026-11-02T00:00:00+08:00'],
175
+ ]) test(`whole-day minute grid: monotone and bounded (${label})`, () => {
176
+ let previous = 0;
177
+ const start = Date.parse(begin);
178
+ // Stop before the next civil day / shift resets its earnings.
179
+ const last = 1439;
180
+ for (let minute = 0; minute <= last; minute++) {
181
+ const result = calculate({ ...base, ...patch }, start + minute * 60000);
182
+ assert.ok(result.earnedCents >= previous, `${label} minute ${minute} regressed`);
183
+ assert.ok(result.earnedCents <= result.dailyCents);
184
+ assert.ok(result.progress >= 0 && result.progress <= 1);
185
+ assert.equal(Number.isInteger(result.earnedCents), true);
186
+ previous = result.earnedCents;
187
+ }
188
+ assert.equal(previous, calculate({ ...base, ...patch }, start).dailyCents);
189
+ });
190
+ test('invalid empty shift or external break fails explicitly', () => {
191
+ assert.throws(() => at('2026-11-02T10:00:00Z', { start: '09:00', end: '09:00' }), RangeError);
192
+ assert.throws(() => at('2026-11-02T10:00:00Z', { breakStart: '18:00', breakEnd: '19:00' }), RangeError);
193
+ });