@tom2012/dsh-pomodoro-overlay 0.0.0-stage → 0.1.2
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 +127 -2
- package/client/client.js +236 -0
- package/cordis.patch.yml +4 -0
- package/docs/architecture.md +84 -0
- package/docs/design.md +72 -0
- package/docs/verification.md +102 -0
- package/lib/index.js +28 -0
- package/package.json +77 -4
- package/src/browser-store.js +251 -0
- package/src/chat-geometry.js +49 -0
- package/src/client.js +235 -0
- package/src/font.js +18 -0
- package/src/settings.js +329 -0
- package/src/styles.js +230 -0
- package/src/timer-core.js +254 -0
- package/tests/browser-store.test.js +362 -0
- package/tests/chat-geometry.test.js +84 -0
- package/tests/client-bundle.test.js +106 -0
- package/tests/font.test.js +21 -0
- package/tests/host-config.test.js +67 -0
- package/tests/timer-core.test.js +205 -0
- package/tools/build.mjs +37 -0
- package/tools/check-package.mjs +50 -0
package/src/styles.js
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
export const CSS = String.raw`
|
|
2
|
+
.dpo-overlay {
|
|
3
|
+
position: fixed;
|
|
4
|
+
z-index: 1;
|
|
5
|
+
box-sizing: border-box;
|
|
6
|
+
display: grid;
|
|
7
|
+
place-items: center;
|
|
8
|
+
overflow: hidden;
|
|
9
|
+
pointer-events: none;
|
|
10
|
+
container-type: inline-size;
|
|
11
|
+
container-name: dpo;
|
|
12
|
+
color: var(--dsw-alias-label-primary);
|
|
13
|
+
font-family: inherit;
|
|
14
|
+
}
|
|
15
|
+
.dpo-overlay[hidden] { display: none !important; }
|
|
16
|
+
.dpo-number {
|
|
17
|
+
font-family: var(--ds-font-family-code, ui-monospace, "SFMono-Regular", Menlo, Monaco, Consolas, monospace);
|
|
18
|
+
font-size: 120px;
|
|
19
|
+
font-weight: 600;
|
|
20
|
+
font-variant-numeric: tabular-nums lining-nums;
|
|
21
|
+
letter-spacing: -0.065em;
|
|
22
|
+
line-height: 1;
|
|
23
|
+
white-space: nowrap;
|
|
24
|
+
user-select: none;
|
|
25
|
+
transform: translateX(-0.0325em);
|
|
26
|
+
transition: color 160ms ease-out, opacity 160ms ease-out;
|
|
27
|
+
}
|
|
28
|
+
.dpo-button {
|
|
29
|
+
box-sizing: border-box;
|
|
30
|
+
min-width: 44px;
|
|
31
|
+
min-height: 44px;
|
|
32
|
+
border: 0;
|
|
33
|
+
border-radius: 8px;
|
|
34
|
+
padding: 0 12px;
|
|
35
|
+
background: transparent;
|
|
36
|
+
color: var(--dsw-alias-label-secondary);
|
|
37
|
+
font: inherit;
|
|
38
|
+
font-size: 12px;
|
|
39
|
+
line-height: 20px;
|
|
40
|
+
cursor: pointer;
|
|
41
|
+
transition: background-color 120ms ease-out, color 120ms ease-out;
|
|
42
|
+
}
|
|
43
|
+
.dpo-button:hover:not(:disabled) {
|
|
44
|
+
background: var(--dsw-alias-interactive-bg-hover);
|
|
45
|
+
color: var(--dsw-alias-label-primary);
|
|
46
|
+
}
|
|
47
|
+
.dpo-button:active:not(:disabled) {
|
|
48
|
+
background: var(--dsw-alias-interactive-bg-active, var(--dsw-alias-interactive-bg-hover));
|
|
49
|
+
}
|
|
50
|
+
.dpo-button:focus-visible,
|
|
51
|
+
.dpo-input:focus-visible,
|
|
52
|
+
.dpo-color-input:focus-visible,
|
|
53
|
+
.dpo-range:focus-visible {
|
|
54
|
+
outline: 2px solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary));
|
|
55
|
+
outline-offset: 2px;
|
|
56
|
+
}
|
|
57
|
+
.dpo-button:disabled,
|
|
58
|
+
.dpo-input:disabled {
|
|
59
|
+
opacity: 0.55;
|
|
60
|
+
cursor: default;
|
|
61
|
+
}
|
|
62
|
+
.dpo-sr-only {
|
|
63
|
+
position: absolute !important;
|
|
64
|
+
width: 1px !important;
|
|
65
|
+
height: 1px !important;
|
|
66
|
+
padding: 0 !important;
|
|
67
|
+
margin: -1px !important;
|
|
68
|
+
overflow: hidden !important;
|
|
69
|
+
clip: rect(0, 0, 0, 0) !important;
|
|
70
|
+
white-space: nowrap !important;
|
|
71
|
+
border: 0 !important;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
.dpo-settings {
|
|
75
|
+
box-sizing: border-box;
|
|
76
|
+
max-width: 680px;
|
|
77
|
+
display: flex;
|
|
78
|
+
flex-direction: column;
|
|
79
|
+
gap: 16px;
|
|
80
|
+
color: var(--dsw-alias-label-primary);
|
|
81
|
+
font-size: 14px;
|
|
82
|
+
line-height: 22px;
|
|
83
|
+
}
|
|
84
|
+
.dpo-settings h2,
|
|
85
|
+
.dpo-settings h3,
|
|
86
|
+
.dpo-settings p { margin: 0; }
|
|
87
|
+
.dpo-settings h2 { font-size: 18px; line-height: 28px; font-weight: 550; }
|
|
88
|
+
.dpo-settings h3 { font-size: 14px; line-height: 22px; font-weight: 500; }
|
|
89
|
+
.dpo-description { color: var(--dsw-alias-label-secondary); }
|
|
90
|
+
.dpo-hint { color: var(--dsw-alias-label-tertiary); font-size: 12px; line-height: 18px; }
|
|
91
|
+
.dpo-form {
|
|
92
|
+
display: flex;
|
|
93
|
+
flex-direction: column;
|
|
94
|
+
gap: 16px;
|
|
95
|
+
}
|
|
96
|
+
.dpo-phase-card {
|
|
97
|
+
box-sizing: border-box;
|
|
98
|
+
min-width: 0;
|
|
99
|
+
margin: 0;
|
|
100
|
+
display: flex;
|
|
101
|
+
flex-direction: column;
|
|
102
|
+
gap: 16px;
|
|
103
|
+
border: 1px solid var(--dsw-alias-settings-card-stroke, var(--dsw-alias-border-l2));
|
|
104
|
+
border-radius: var(--dsw-radius-xl, 16px);
|
|
105
|
+
padding: 16px;
|
|
106
|
+
background: var(--dsw-alias-settings-card-fill, var(--dsw-alias-bg-base));
|
|
107
|
+
}
|
|
108
|
+
.dpo-phase-card > legend {
|
|
109
|
+
padding: 0 4px;
|
|
110
|
+
font-size: 14px;
|
|
111
|
+
font-weight: 600;
|
|
112
|
+
}
|
|
113
|
+
.dpo-field {
|
|
114
|
+
display: grid;
|
|
115
|
+
grid-template-columns: minmax(0, 1fr) minmax(120px, 160px);
|
|
116
|
+
align-items: center;
|
|
117
|
+
gap: 16px;
|
|
118
|
+
}
|
|
119
|
+
.dpo-field-copy { display: flex; flex-direction: column; gap: 2px; }
|
|
120
|
+
.dpo-field-copy label { color: var(--dsw-alias-label-primary); font-weight: 500; }
|
|
121
|
+
.dpo-input {
|
|
122
|
+
box-sizing: border-box;
|
|
123
|
+
width: 100%;
|
|
124
|
+
min-height: 44px;
|
|
125
|
+
border: 1px solid var(--dsw-alias-border-l3);
|
|
126
|
+
border-radius: var(--dsw-radius-md, 8px);
|
|
127
|
+
padding: 8px 12px;
|
|
128
|
+
background: var(--dsw-alias-bg-base);
|
|
129
|
+
color: var(--dsw-alias-label-primary);
|
|
130
|
+
font: inherit;
|
|
131
|
+
font-variant-numeric: tabular-nums;
|
|
132
|
+
}
|
|
133
|
+
.dpo-font-control { min-width: 0; }
|
|
134
|
+
.dpo-color-control {
|
|
135
|
+
min-height: 44px;
|
|
136
|
+
display: flex;
|
|
137
|
+
align-items: center;
|
|
138
|
+
gap: 12px;
|
|
139
|
+
}
|
|
140
|
+
.dpo-color-input {
|
|
141
|
+
box-sizing: border-box;
|
|
142
|
+
width: 52px;
|
|
143
|
+
height: 44px;
|
|
144
|
+
border: 1px solid var(--dsw-alias-border-l3);
|
|
145
|
+
border-radius: var(--dsw-radius-md, 8px);
|
|
146
|
+
padding: 4px;
|
|
147
|
+
background: var(--dsw-alias-bg-base);
|
|
148
|
+
cursor: pointer;
|
|
149
|
+
}
|
|
150
|
+
.dpo-color-value {
|
|
151
|
+
color: var(--dsw-alias-label-secondary);
|
|
152
|
+
font-family: var(--ds-font-family-code, ui-monospace, monospace);
|
|
153
|
+
font-size: 12px;
|
|
154
|
+
}
|
|
155
|
+
.dpo-range-control {
|
|
156
|
+
min-height: 44px;
|
|
157
|
+
display: grid;
|
|
158
|
+
grid-template-columns: minmax(80px, 1fr) 48px;
|
|
159
|
+
align-items: center;
|
|
160
|
+
gap: 12px;
|
|
161
|
+
}
|
|
162
|
+
.dpo-range {
|
|
163
|
+
width: 100%;
|
|
164
|
+
min-height: 44px;
|
|
165
|
+
margin: 0;
|
|
166
|
+
accent-color: var(--dsw-alias-brand-primary);
|
|
167
|
+
cursor: pointer;
|
|
168
|
+
}
|
|
169
|
+
.dpo-output {
|
|
170
|
+
text-align: right;
|
|
171
|
+
font-variant-numeric: tabular-nums;
|
|
172
|
+
color: var(--dsw-alias-label-secondary);
|
|
173
|
+
}
|
|
174
|
+
.dpo-preview {
|
|
175
|
+
min-height: 88px;
|
|
176
|
+
display: flex;
|
|
177
|
+
flex-direction: column;
|
|
178
|
+
align-items: center;
|
|
179
|
+
justify-content: center;
|
|
180
|
+
gap: 4px;
|
|
181
|
+
overflow: hidden;
|
|
182
|
+
border: 1px dashed var(--dsw-alias-border-l3);
|
|
183
|
+
border-radius: var(--dsw-radius-lg, 12px);
|
|
184
|
+
background: var(--dsw-alias-bg-base);
|
|
185
|
+
}
|
|
186
|
+
.dpo-preview-number {
|
|
187
|
+
color: var(--dpo-preview-color);
|
|
188
|
+
opacity: var(--dpo-preview-opacity);
|
|
189
|
+
font-family: var(--dpo-preview-font, var(--ds-font-family-code, ui-monospace, "SFMono-Regular", Menlo, Monaco, Consolas, monospace));
|
|
190
|
+
font-size: 42px;
|
|
191
|
+
font-weight: 600;
|
|
192
|
+
font-variant-numeric: tabular-nums lining-nums;
|
|
193
|
+
line-height: 1;
|
|
194
|
+
letter-spacing: -0.065em;
|
|
195
|
+
user-select: none;
|
|
196
|
+
}
|
|
197
|
+
.dpo-actions { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
|
|
198
|
+
.dpo-settings .dpo-button { border: 1px solid var(--dsw-alias-border-l3); }
|
|
199
|
+
.dpo-settings .dpo-primary {
|
|
200
|
+
border-color: transparent;
|
|
201
|
+
background: var(--dsw-alias-brand-primary);
|
|
202
|
+
color: var(--dsw-alias-label-primary-inverted, #fff);
|
|
203
|
+
}
|
|
204
|
+
.dpo-settings .dpo-primary:hover:not(:disabled) {
|
|
205
|
+
background: color-mix(in srgb, var(--dsw-alias-brand-primary) 88%, var(--dsw-alias-label-primary));
|
|
206
|
+
color: var(--dsw-alias-label-primary-inverted, #fff);
|
|
207
|
+
}
|
|
208
|
+
.dpo-error { color: var(--dsw-alias-state-error-primary); font-size: 13px; line-height: 20px; }
|
|
209
|
+
.dpo-success { color: var(--dsw-alias-state-success-primary); font-size: 13px; line-height: 20px; }
|
|
210
|
+
.dpo-warning { color: var(--dsw-alias-state-warn-label); font-size: 13px; line-height: 20px; }
|
|
211
|
+
.dpo-current {
|
|
212
|
+
display: flex;
|
|
213
|
+
flex-wrap: wrap;
|
|
214
|
+
gap: 8px 16px;
|
|
215
|
+
color: var(--dsw-alias-label-secondary);
|
|
216
|
+
font-size: 13px;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
@media (max-width: 520px) {
|
|
220
|
+
.dpo-field { grid-template-columns: 1fr; gap: 8px; }
|
|
221
|
+
.dpo-actions .dpo-button { flex: 1 1 120px; }
|
|
222
|
+
}
|
|
223
|
+
@media (pointer: coarse) {
|
|
224
|
+
.dpo-button, .dpo-input, .dpo-range { min-height: 48px; }
|
|
225
|
+
.dpo-color-input { height: 48px; }
|
|
226
|
+
}
|
|
227
|
+
@media (prefers-reduced-motion: reduce) {
|
|
228
|
+
.dpo-number, .dpo-button { transition: none; }
|
|
229
|
+
}
|
|
230
|
+
`
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
export const FOCUS = 'focus'
|
|
2
|
+
export const BREAK = 'break'
|
|
3
|
+
export const STATE_VERSION = 1
|
|
4
|
+
export const MIN_MINUTES = 1
|
|
5
|
+
export const MAX_MINUTES = 240
|
|
6
|
+
export const MINUTE_MS = 60_000
|
|
7
|
+
|
|
8
|
+
export const DEFAULT_CONFIG = Object.freeze({
|
|
9
|
+
focusMinutes: 25,
|
|
10
|
+
breakMinutes: 5,
|
|
11
|
+
focusColor: '#9ca3af',
|
|
12
|
+
breakColor: '#ef4444',
|
|
13
|
+
focusOpacity: 0.1,
|
|
14
|
+
breakOpacity: 0.3,
|
|
15
|
+
fontFamily: '等线',
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
function numeric(value) {
|
|
19
|
+
if (typeof value === 'number') return value
|
|
20
|
+
if (typeof value === 'string' && value.trim() !== '') return Number(value)
|
|
21
|
+
return Number.NaN
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function isValidMinutes(value) {
|
|
25
|
+
const parsed = numeric(value)
|
|
26
|
+
return Number.isInteger(parsed) && parsed >= MIN_MINUTES && parsed <= MAX_MINUTES
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function isValidColor(value) {
|
|
30
|
+
return typeof value === 'string' && /^#[0-9a-f]{6}$/i.test(value)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function isValidOpacity(value) {
|
|
34
|
+
const parsed = numeric(value)
|
|
35
|
+
return Number.isFinite(parsed) && parsed >= 0 && parsed <= 1
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function isValidFontFamily(value) {
|
|
39
|
+
return typeof value === 'string' && value.length <= 80
|
|
40
|
+
&& /^[\p{L}\p{N}][\p{L}\p{N} _.-]*$/u.test(value)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Strict validation for an explicit settings save. */
|
|
44
|
+
export function parseConfig(value) {
|
|
45
|
+
if (!isValidMinutes(value?.focusMinutes) || !isValidMinutes(value?.breakMinutes)) {
|
|
46
|
+
throw new RangeError(`Durations must be integers from ${MIN_MINUTES} to ${MAX_MINUTES} minutes`)
|
|
47
|
+
}
|
|
48
|
+
const parsed = {
|
|
49
|
+
focusMinutes: numeric(value.focusMinutes),
|
|
50
|
+
breakMinutes: numeric(value.breakMinutes),
|
|
51
|
+
focusColor: value.focusColor === undefined ? DEFAULT_CONFIG.focusColor : value.focusColor,
|
|
52
|
+
breakColor: value.breakColor === undefined ? DEFAULT_CONFIG.breakColor : value.breakColor,
|
|
53
|
+
focusOpacity: value.focusOpacity === undefined ? DEFAULT_CONFIG.focusOpacity : value.focusOpacity,
|
|
54
|
+
breakOpacity: value.breakOpacity === undefined ? DEFAULT_CONFIG.breakOpacity : value.breakOpacity,
|
|
55
|
+
fontFamily: value.fontFamily === undefined ? DEFAULT_CONFIG.fontFamily : value.fontFamily,
|
|
56
|
+
}
|
|
57
|
+
if (!isValidColor(parsed.focusColor) || !isValidColor(parsed.breakColor)) {
|
|
58
|
+
throw new TypeError('Colors must use six-digit hex format (#rrggbb)')
|
|
59
|
+
}
|
|
60
|
+
if (!isValidOpacity(parsed.focusOpacity) || !isValidOpacity(parsed.breakOpacity)) {
|
|
61
|
+
throw new RangeError('Opacity values must be between 0 and 1')
|
|
62
|
+
}
|
|
63
|
+
if (!isValidFontFamily(parsed.fontFamily)) {
|
|
64
|
+
throw new TypeError('Font family must be a single installed font name')
|
|
65
|
+
}
|
|
66
|
+
parsed.focusOpacity = numeric(parsed.focusOpacity)
|
|
67
|
+
parsed.breakOpacity = numeric(parsed.breakOpacity)
|
|
68
|
+
return parsed
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Non-throwing recovery for a browser-stored snapshot. */
|
|
72
|
+
export function sanitizeConfig(value) {
|
|
73
|
+
return {
|
|
74
|
+
focusMinutes: isValidMinutes(value?.focusMinutes)
|
|
75
|
+
? numeric(value.focusMinutes)
|
|
76
|
+
: DEFAULT_CONFIG.focusMinutes,
|
|
77
|
+
breakMinutes: isValidMinutes(value?.breakMinutes)
|
|
78
|
+
? numeric(value.breakMinutes)
|
|
79
|
+
: DEFAULT_CONFIG.breakMinutes,
|
|
80
|
+
focusColor: isValidColor(value?.focusColor) ? value.focusColor : DEFAULT_CONFIG.focusColor,
|
|
81
|
+
breakColor: isValidColor(value?.breakColor) ? value.breakColor : DEFAULT_CONFIG.breakColor,
|
|
82
|
+
focusOpacity: isValidOpacity(value?.focusOpacity)
|
|
83
|
+
? numeric(value.focusOpacity)
|
|
84
|
+
: DEFAULT_CONFIG.focusOpacity,
|
|
85
|
+
breakOpacity: isValidOpacity(value?.breakOpacity)
|
|
86
|
+
? numeric(value.breakOpacity)
|
|
87
|
+
: DEFAULT_CONFIG.breakOpacity,
|
|
88
|
+
fontFamily: isValidFontFamily(value?.fontFamily) ? value.fontFamily : DEFAULT_CONFIG.fontFamily,
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function configFromJSON(text) {
|
|
93
|
+
if (typeof text !== 'string' || text === '') return { ...DEFAULT_CONFIG }
|
|
94
|
+
try {
|
|
95
|
+
return sanitizeConfig(JSON.parse(text))
|
|
96
|
+
} catch {
|
|
97
|
+
return { ...DEFAULT_CONFIG }
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function otherPhase(phase) {
|
|
102
|
+
if (phase === FOCUS) return BREAK
|
|
103
|
+
if (phase === BREAK) return FOCUS
|
|
104
|
+
throw new TypeError(`Unknown Pomodoro phase: ${String(phase)}`)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function durationForPhase(phase, config) {
|
|
108
|
+
otherPhase(phase)
|
|
109
|
+
const valid = parseConfig(config)
|
|
110
|
+
return (phase === FOCUS ? valid.focusMinutes : valid.breakMinutes) * MINUTE_MS
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function assertFinite(value, label) {
|
|
114
|
+
if (!Number.isFinite(value)) throw new TypeError(`${label} must be finite`)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function createRunningState(phase, config, now, durationOverride) {
|
|
118
|
+
otherPhase(phase)
|
|
119
|
+
assertFinite(now, 'now')
|
|
120
|
+
const durationMs = durationOverride ?? durationForPhase(phase, config)
|
|
121
|
+
assertFinite(durationMs, 'durationMs')
|
|
122
|
+
if (durationMs <= 0) throw new RangeError('durationMs must be positive')
|
|
123
|
+
return {
|
|
124
|
+
version: STATE_VERSION,
|
|
125
|
+
phase,
|
|
126
|
+
running: true,
|
|
127
|
+
startedAt: now,
|
|
128
|
+
endsAt: now + durationMs,
|
|
129
|
+
durationMs,
|
|
130
|
+
remainingMs: null,
|
|
131
|
+
updatedAt: now,
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function isValidState(value) {
|
|
136
|
+
if (!value || value.version !== STATE_VERSION) return false
|
|
137
|
+
if (value.phase !== FOCUS && value.phase !== BREAK) return false
|
|
138
|
+
if (typeof value.running !== 'boolean') return false
|
|
139
|
+
if (!Number.isFinite(value.startedAt) || !Number.isFinite(value.updatedAt)) return false
|
|
140
|
+
if (!Number.isFinite(value.durationMs) || value.durationMs <= 0) return false
|
|
141
|
+
if (value.running) {
|
|
142
|
+
return Number.isFinite(value.endsAt)
|
|
143
|
+
&& value.endsAt >= 0
|
|
144
|
+
&& value.endsAt - value.startedAt === value.durationMs
|
|
145
|
+
&& value.remainingMs === null
|
|
146
|
+
}
|
|
147
|
+
return value.endsAt === null
|
|
148
|
+
&& Number.isFinite(value.remainingMs)
|
|
149
|
+
&& value.remainingMs >= 0
|
|
150
|
+
&& value.remainingMs <= value.durationMs
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function stateFromJSON(text) {
|
|
154
|
+
if (typeof text !== 'string' || text === '') return null
|
|
155
|
+
try {
|
|
156
|
+
const value = JSON.parse(text)
|
|
157
|
+
return isValidState(value) ? value : null
|
|
158
|
+
} catch {
|
|
159
|
+
return null
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Advance a running state to the phase that should be active at `now`.
|
|
165
|
+
* Expired boundaries retain their original timestamps, so reopening after a
|
|
166
|
+
* long sleep lands on the correct wall-clock phase rather than restarting.
|
|
167
|
+
*/
|
|
168
|
+
export function settleState(state, config, now) {
|
|
169
|
+
if (!isValidState(state)) throw new TypeError('Invalid Pomodoro state')
|
|
170
|
+
const valid = parseConfig(config)
|
|
171
|
+
assertFinite(now, 'now')
|
|
172
|
+
if (!state.running || now < state.endsAt) return state
|
|
173
|
+
|
|
174
|
+
const next = { ...state, updatedAt: now }
|
|
175
|
+
let transitions = 0
|
|
176
|
+
const cycleMs = (valid.focusMinutes + valid.breakMinutes) * MINUTE_MS
|
|
177
|
+
|
|
178
|
+
while (now >= next.endsAt) {
|
|
179
|
+
const boundary = next.endsAt
|
|
180
|
+
next.phase = otherPhase(next.phase)
|
|
181
|
+
next.startedAt = boundary
|
|
182
|
+
next.durationMs = (next.phase === FOCUS ? valid.focusMinutes : valid.breakMinutes) * MINUTE_MS
|
|
183
|
+
next.endsAt = boundary + next.durationMs
|
|
184
|
+
next.remainingMs = null
|
|
185
|
+
next.running = true
|
|
186
|
+
transitions += 1
|
|
187
|
+
|
|
188
|
+
// After two transitions both durations come from the current config.
|
|
189
|
+
// Skip complete cycles arithmetically instead of iterating once per minute.
|
|
190
|
+
if (transitions >= 2) {
|
|
191
|
+
const cycles = Math.floor((now - next.startedAt) / cycleMs)
|
|
192
|
+
if (cycles > 0) {
|
|
193
|
+
next.startedAt += cycles * cycleMs
|
|
194
|
+
next.endsAt += cycles * cycleMs
|
|
195
|
+
transitions += cycles * 2
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return next
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export function remainingAt(state, now) {
|
|
204
|
+
if (!isValidState(state)) throw new TypeError('Invalid Pomodoro state')
|
|
205
|
+
assertFinite(now, 'now')
|
|
206
|
+
if (!state.running) return state.remainingMs
|
|
207
|
+
// A backwards wall-clock adjustment must not show more than a full phase.
|
|
208
|
+
return Math.min(state.durationMs, Math.max(0, state.endsAt - now))
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export function pauseState(state, config, now) {
|
|
212
|
+
const settled = settleState(state, config, now)
|
|
213
|
+
if (!settled.running) return settled
|
|
214
|
+
return {
|
|
215
|
+
...settled,
|
|
216
|
+
running: false,
|
|
217
|
+
endsAt: null,
|
|
218
|
+
remainingMs: remainingAt(settled, now),
|
|
219
|
+
updatedAt: now,
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export function resumeState(state, config, now) {
|
|
224
|
+
if (!isValidState(state)) throw new TypeError('Invalid Pomodoro state')
|
|
225
|
+
assertFinite(now, 'now')
|
|
226
|
+
if (state.running) return state
|
|
227
|
+
const elapsed = Math.max(0, state.durationMs - state.remainingMs)
|
|
228
|
+
const resumed = {
|
|
229
|
+
...state,
|
|
230
|
+
running: true,
|
|
231
|
+
startedAt: now - elapsed,
|
|
232
|
+
endsAt: now + state.remainingMs,
|
|
233
|
+
remainingMs: null,
|
|
234
|
+
updatedAt: now,
|
|
235
|
+
}
|
|
236
|
+
return settleState(resumed, config, now)
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export function skipState(state, config, now) {
|
|
240
|
+
if (!isValidState(state)) throw new TypeError('Invalid Pomodoro state')
|
|
241
|
+
return createRunningState(otherPhase(state.phase), config, now)
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export function resetState(config, now) {
|
|
245
|
+
return createRunningState(FOCUS, config, now)
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export function formatRemaining(milliseconds) {
|
|
249
|
+
assertFinite(milliseconds, 'milliseconds')
|
|
250
|
+
const totalSeconds = Math.max(0, Math.ceil(milliseconds / 1000))
|
|
251
|
+
const minutes = Math.floor(totalSeconds / 60)
|
|
252
|
+
const seconds = totalSeconds % 60
|
|
253
|
+
return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`
|
|
254
|
+
}
|