@kumbatio/energy-system 0.2.1 → 0.4.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/README.md +173 -6
- package/dist/defer.d.ts +40 -0
- package/dist/defer.d.ts.map +1 -0
- package/dist/defer.js +173 -0
- package/dist/defer.js.map +1 -0
- package/dist/engine.d.ts +13 -1
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +58 -9
- package/dist/engine.js.map +1 -1
- package/dist/gate.d.ts +84 -0
- package/dist/gate.d.ts.map +1 -0
- package/dist/gate.js +224 -0
- package/dist/gate.js.map +1 -0
- package/dist/index.d.ts +12 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -2
- package/dist/index.js.map +1 -1
- package/dist/metrics.d.ts.map +1 -1
- package/dist/metrics.js +3 -1
- package/dist/metrics.js.map +1 -1
- package/dist/presence.d.ts +56 -0
- package/dist/presence.d.ts.map +1 -0
- package/dist/presence.js +122 -0
- package/dist/presence.js.map +1 -0
- package/dist/react.d.ts +48 -1
- package/dist/react.d.ts.map +1 -1
- package/dist/react.js +105 -6
- package/dist/react.js.map +1 -1
- package/dist/session.d.ts +67 -0
- package/dist/session.d.ts.map +1 -0
- package/dist/session.js +197 -0
- package/dist/session.js.map +1 -0
- package/dist/strategies.d.ts +15 -0
- package/dist/strategies.d.ts.map +1 -1
- package/dist/strategies.js +45 -2
- package/dist/strategies.js.map +1 -1
- package/dist/types.d.ts +18 -2
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +28 -2
- package/dist/types.js.map +1 -1
- package/package.json +2 -2
- package/src/energy.css +62 -0
package/README.md
CHANGED
|
@@ -30,10 +30,19 @@ This library gives applications a structured way to adapt to energy state instea
|
|
|
30
30
|
- UI visibility
|
|
31
31
|
- Notification filtering
|
|
32
32
|
- Task complexity guidance
|
|
33
|
+
- Interaction forgiveness (undo windows, destructive-action confirmation)
|
|
34
|
+
- Energy-aware deferral ordering
|
|
35
|
+
- **Presence annotation**: declare which energy levels a component/view belongs
|
|
36
|
+
to (`defineEnergyPresence`, `presenceAtOrAbove`, `<EnergyGate>`, `data-energy-min`)
|
|
37
|
+
- **Focus sessions**: time-boxed suppression windows with auto-expiry and break nudges
|
|
38
|
+
- **Notification gate**: a runtime that enforces `NotificationConfig`
|
|
39
|
+
(threshold, batching, defer-not-drop) instead of leaving it as guidance
|
|
40
|
+
- **Deferral presets**: pure "not now" (`snooze`) computations with
|
|
41
|
+
energy-aware default ordering
|
|
33
42
|
- DOM adapter (`data-energy-level` + CSS variables)
|
|
34
|
-
- React provider, hooks, and headless render
|
|
43
|
+
- React provider, hooks, and headless render components
|
|
35
44
|
- Persistence adapters (`localStorage`, in-memory)
|
|
36
|
-
- Deterministic clock support for testing/simulation
|
|
45
|
+
- Deterministic clock and timer support for testing/simulation
|
|
37
46
|
- Optional external persistence observation (`observe`)
|
|
38
47
|
- Derived metrics helper (`getEnergyMetrics`)
|
|
39
48
|
- Compatibility helpers for non-native external level models
|
|
@@ -103,6 +112,133 @@ export function App() {
|
|
|
103
112
|
}
|
|
104
113
|
```
|
|
105
114
|
|
|
115
|
+
## Presence annotation (which energy states does this element belong to?)
|
|
116
|
+
|
|
117
|
+
Every component/view can declare the energy levels it participates in. The
|
|
118
|
+
declaration is a plain typed object — one presence (`'visible' | 'muted' |
|
|
119
|
+
'hidden'`) per level — so the same annotation drives React, the engine, or
|
|
120
|
+
plain CSS.
|
|
121
|
+
|
|
122
|
+
```ts
|
|
123
|
+
import {
|
|
124
|
+
defineEnergyPresence,
|
|
125
|
+
presenceAtOrAbove,
|
|
126
|
+
presenceAtOrBelow,
|
|
127
|
+
createPresenceStrategy,
|
|
128
|
+
resolveEnergyPresence,
|
|
129
|
+
} from '@kumbatio/energy-system'
|
|
130
|
+
|
|
131
|
+
// Explicit map: hide the AI chat at 50 and below, mute it at 75
|
|
132
|
+
const aiChatPresence = defineEnergyPresence({
|
|
133
|
+
default: 'visible',
|
|
134
|
+
75: 'muted',
|
|
135
|
+
50: 'hidden',
|
|
136
|
+
25: 'hidden',
|
|
137
|
+
0: 'hidden',
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
// Shorthands
|
|
141
|
+
const composerTools = presenceAtOrAbove(50) // hidden at 25 and 0
|
|
142
|
+
const recoveryHint = presenceAtOrBelow(25) // low-energy-only affordance
|
|
143
|
+
|
|
144
|
+
// Resolve anywhere
|
|
145
|
+
resolveEnergyPresence(aiChatPresence, 50) // 'hidden'
|
|
146
|
+
|
|
147
|
+
// Or lift into a strategy and resolve through the engine
|
|
148
|
+
const aiChat = createPresenceStrategy('ai-chat', aiChatPresence)
|
|
149
|
+
engine.resolve(aiChat) // 'visible' | 'muted' | 'hidden'
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
In React, `<EnergyGate>` applies a presence declaration to a subtree:
|
|
153
|
+
|
|
154
|
+
```tsx
|
|
155
|
+
import { EnergyGate } from '@kumbatio/energy-system/react'
|
|
156
|
+
|
|
157
|
+
// Shorthand: needs at least 75 energy
|
|
158
|
+
<EnergyGate min={75}>
|
|
159
|
+
<AiChatPanel />
|
|
160
|
+
</EnergyGate>
|
|
161
|
+
|
|
162
|
+
// Full map; function children receive the resolved presence so 'muted'
|
|
163
|
+
// can style itself
|
|
164
|
+
<EnergyGate presence={aiChatPresence} fallback={<QuietPlaceholder />}>
|
|
165
|
+
{(presence) => <AiChatPanel muted={presence === 'muted'} />}
|
|
166
|
+
</EnergyGate>
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
CSS-only path — annotate elements with the range they belong to and the
|
|
170
|
+
stylesheet handles hiding as `data-energy-level` changes:
|
|
171
|
+
|
|
172
|
+
```html
|
|
173
|
+
<div data-energy-min="75">AI chat — needs 75+ energy</div>
|
|
174
|
+
<div data-energy-max="25">Recovery hint — low energy only</div>
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
## Focus sessions and the notification gate
|
|
178
|
+
|
|
179
|
+
These are the runtime half of the model: strategies _describe_ behavior,
|
|
180
|
+
the gate and session controller _enforce_ it.
|
|
181
|
+
|
|
182
|
+
```ts
|
|
183
|
+
import {
|
|
184
|
+
createEnergyEngine,
|
|
185
|
+
createNotificationGate,
|
|
186
|
+
createFocusSessionController,
|
|
187
|
+
} from '@kumbatio/energy-system'
|
|
188
|
+
|
|
189
|
+
const engine = createEnergyEngine({ initialLevel: 75 })
|
|
190
|
+
|
|
191
|
+
// The gate enforces notificationStrategy: at 50 it batches every 5 minutes
|
|
192
|
+
// and only lets 'high'+ through; at 0 everything is deferred, not dropped.
|
|
193
|
+
const gate = createNotificationGate(engine, {
|
|
194
|
+
onDeliver({ notifications, reason, channels }) {
|
|
195
|
+
if (channels.visual) showToast(notifications, reason)
|
|
196
|
+
},
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
gate.publish({ priority: 'high', payload: { title: 'Build finished' } })
|
|
200
|
+
|
|
201
|
+
// Focus sessions: time-boxed, auto-expiring suppression windows.
|
|
202
|
+
const focus = createFocusSessionController({ engine, gate })
|
|
203
|
+
focus.subscribe((event, session) => {
|
|
204
|
+
if (event === 'break') showBreakNudge()
|
|
205
|
+
if (event === 'end') showSessionSummary(session)
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
// Session length + break cadence default from the current energy level
|
|
209
|
+
// (expected productivity window / task-complexity guidance).
|
|
210
|
+
focus.start()
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
Two invariants are guaranteed by construction:
|
|
214
|
+
|
|
215
|
+
1. **Nothing is silently dropped.** A notification the current level does not
|
|
216
|
+
admit is deferred and released when energy rises, suppression lifts, or the
|
|
217
|
+
gate is disposed.
|
|
218
|
+
2. **Sessions always end.** Expiry is an emitted event (never a predicate you
|
|
219
|
+
must poll), and suppression is lifted _before_ the end event fires, so an
|
|
220
|
+
end-of-session notification can never be swallowed by the session itself.
|
|
221
|
+
|
|
222
|
+
## Deferral ("not now")
|
|
223
|
+
|
|
224
|
+
Deferring is an energy statement. Presets are pure `(now) => Date` functions;
|
|
225
|
+
`deferralStrategy` orders them by level so the one-tap default matches
|
|
226
|
+
capacity — at low energy the default is "tomorrow morning", not "in 1 hour".
|
|
227
|
+
|
|
228
|
+
```ts
|
|
229
|
+
import {
|
|
230
|
+
createDeferralPresets,
|
|
231
|
+
deferralStrategy,
|
|
232
|
+
resolveDeferral,
|
|
233
|
+
DEFERRAL_PRESET_IDS,
|
|
234
|
+
} from '@kumbatio/energy-system'
|
|
235
|
+
|
|
236
|
+
const presets = createDeferralPresets({ morningHour: 9, eveningHour: 18 })
|
|
237
|
+
const { defaultPresetId, orderedPresetIds } = engine.resolve(deferralStrategy)
|
|
238
|
+
|
|
239
|
+
const resurfaceAt = resolveDeferral(presets, defaultPresetId) // epoch ms
|
|
240
|
+
```
|
|
241
|
+
|
|
106
242
|
## Quick start (DOM)
|
|
107
243
|
|
|
108
244
|
```ts
|
|
@@ -135,6 +271,16 @@ Then use classes like:
|
|
|
135
271
|
- `.energy-toolbar`
|
|
136
272
|
- `.energy-content`
|
|
137
273
|
|
|
274
|
+
And presence attributes:
|
|
275
|
+
|
|
276
|
+
- `data-energy-min="75"` — element hides whenever the current level is below 75
|
|
277
|
+
- `data-energy-max="25"` — element hides whenever the current level is above 25
|
|
278
|
+
- `data-energy-presence="muted" | "hidden"` — hooks for JS-resolved presence
|
|
279
|
+
(`--energy-muted-opacity` controls the muted treatment)
|
|
280
|
+
|
|
281
|
+
The stylesheet honours `prefers-reduced-motion` for its own transitions; apps
|
|
282
|
+
animating presence changes should do the same.
|
|
283
|
+
|
|
138
284
|
## API map
|
|
139
285
|
|
|
140
286
|
### Core package
|
|
@@ -146,8 +292,21 @@ Then use classes like:
|
|
|
146
292
|
- `cycleDiscreteLevel(current, levels, fallback)`
|
|
147
293
|
- `mapToNearestDiscreteLevel(value, levels, fallback)`
|
|
148
294
|
- `mapToNearestEnergyLevel(value)`
|
|
149
|
-
- Strategies: `uiVisibilityStrategy`, `notificationStrategy`,
|
|
150
|
-
|
|
295
|
+
- Strategies: `uiVisibilityStrategy`, `notificationStrategy`,
|
|
296
|
+
`taskComplexityStrategy`, `interactionForgivenessStrategy`, `deferralStrategy`
|
|
297
|
+
- Presence: `defineEnergyPresence(spec)`, `presenceAtOrAbove(min, below?)`,
|
|
298
|
+
`presenceAtOrBelow(max, above?)`, `resolveEnergyPresence(map, level)`,
|
|
299
|
+
`isPresenceVisible(presence)`, `isEnergyPresence(value)`,
|
|
300
|
+
`createPresenceStrategy(name, map)`
|
|
301
|
+
- Focus sessions: `createFocusSessionController(options?)`,
|
|
302
|
+
`sessionRemainingMs(session, now?)`, `isSessionExpired(session, now?)`
|
|
303
|
+
- Notification gate: `createNotificationGate(engine, options)`,
|
|
304
|
+
`resolveNotificationOutcome(config, priority, suppressed)`,
|
|
305
|
+
`isNotificationPriority(value)`
|
|
306
|
+
- Deferral: `createDeferralPresets(options?)`, `resolveDeferral(presets, id, now?)`,
|
|
307
|
+
`DEFERRAL_PRESET_IDS`
|
|
308
|
+
- Types: `EnergyLevel`, `EnergyState`, `EnergyPresence`, `EnergyPresenceMap`,
|
|
309
|
+
`AdaptationStrategy`, `FocusSession`, `NotificationDelivery`, etc.
|
|
151
310
|
|
|
152
311
|
### `@kumbatio/energy-system/react`
|
|
153
312
|
|
|
@@ -157,6 +316,8 @@ Then use classes like:
|
|
|
157
316
|
- `useEnergyLevelCycler()`
|
|
158
317
|
- `useStrategy(strategy)`
|
|
159
318
|
- `useEnergyGate(minLevel)`
|
|
319
|
+
- `useEnergyPresence(presenceMap)`
|
|
320
|
+
- `EnergyGate` (presence-gated subtree: `presence` map or `min`/`max` shorthand)
|
|
160
321
|
- `EnergyIndicator`
|
|
161
322
|
|
|
162
323
|
### `@kumbatio/energy-system/persistence`
|
|
@@ -175,7 +336,12 @@ Then use classes like:
|
|
|
175
336
|
- `createEnergyEngine({ clock })` - inject a deterministic time source
|
|
176
337
|
- `createEnergyEngine({ originId })` - inject a deterministic producer identity for tests
|
|
177
338
|
- `createEnergyEngine({ onPersistenceError })` - observe failed save attempts before retry
|
|
178
|
-
- `
|
|
339
|
+
- `createEnergyEngine({ maxFutureSkewMs })` - reject hydrated/observed state stamped further
|
|
340
|
+
ahead of the local clock than this budget (default 5 minutes; `Number.POSITIVE_INFINITY`
|
|
341
|
+
accepts any finite timestamp). Guards reconciliation against contexts with bad clocks.
|
|
342
|
+
- `engine.flush()` - wait until the current state version is durably persisted; rejects if the
|
|
343
|
+
engine is disposed or an unchanged initial state cannot be safely reconciled after a hydration
|
|
344
|
+
read failure
|
|
179
345
|
- `engine.dispose()` - release engine-owned observation/subscription resources
|
|
180
346
|
- `EnergyPersistence.observe(onState)` - subscribe to external state changes
|
|
181
347
|
- `getEnergyMetrics(state, now?)` - derive productivity/break/task guidance metrics
|
|
@@ -232,7 +398,8 @@ clock timestamp. Local writes advance the logical revision when the clock does n
|
|
|
232
398
|
|
|
233
399
|
`setLevel()` updates in-memory subscribers synchronously. Persistence runs in the background with
|
|
234
400
|
bounded exponential backoff. Call `await engine.flush()` when a workflow must wait for durable
|
|
235
|
-
storage before reporting completion.
|
|
401
|
+
storage before reporting completion. An initial `flush()` waits for hydration before writing the
|
|
402
|
+
default state, and rejects rather than overwriting unread storage if that hydration read failed.
|
|
236
403
|
|
|
237
404
|
## Development
|
|
238
405
|
|
package/dist/defer.d.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { AdaptationStrategy } from './types.js';
|
|
2
|
+
/** A named deferral option */
|
|
3
|
+
export interface DeferralPreset {
|
|
4
|
+
readonly id: string;
|
|
5
|
+
readonly label: string;
|
|
6
|
+
/** Compute the resurface time from a reference moment */
|
|
7
|
+
compute(now: Date): Date;
|
|
8
|
+
}
|
|
9
|
+
export interface DeferralPresetOptions {
|
|
10
|
+
/** Hour (0-23) mornings resolve to. Default 9. */
|
|
11
|
+
morningHour?: number;
|
|
12
|
+
/** Hour (0-23) evenings resolve to. Default 18. */
|
|
13
|
+
eveningHour?: number;
|
|
14
|
+
}
|
|
15
|
+
/** Stable preset ids, exported so configs/strategies can reference them */
|
|
16
|
+
export declare const DEFERRAL_PRESET_IDS: Readonly<{
|
|
17
|
+
readonly inOneHour: 'in-1-hour';
|
|
18
|
+
readonly thisEvening: 'this-evening';
|
|
19
|
+
readonly tomorrowMorning: 'tomorrow-morning';
|
|
20
|
+
readonly nextWorkday: 'next-workday';
|
|
21
|
+
readonly nextMonday: 'next-monday';
|
|
22
|
+
}>;
|
|
23
|
+
/**
|
|
24
|
+
* Build the standard deferral presets. Times are computed in local time —
|
|
25
|
+
* "tomorrow morning" means the user's morning.
|
|
26
|
+
*/
|
|
27
|
+
export declare function createDeferralPresets(options?: DeferralPresetOptions): readonly DeferralPreset[];
|
|
28
|
+
/**
|
|
29
|
+
* Resolve a preset id to a resurface timestamp (epoch ms).
|
|
30
|
+
* Returns null for an unknown id — callers decide whether that is an error.
|
|
31
|
+
*/
|
|
32
|
+
export declare function resolveDeferral(presets: readonly DeferralPreset[], presetId: string, now?: Date): number | null;
|
|
33
|
+
export interface DeferralConfig {
|
|
34
|
+
/** Preset ids in suggestion order for this level (first = most prominent) */
|
|
35
|
+
readonly orderedPresetIds: readonly string[];
|
|
36
|
+
/** The preset a one-tap "defer" action should use at this level */
|
|
37
|
+
readonly defaultPresetId: string;
|
|
38
|
+
}
|
|
39
|
+
export declare const deferralStrategy: AdaptationStrategy<DeferralConfig>;
|
|
40
|
+
//# sourceMappingURL=defer.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"defer.d.ts","sourceRoot":"","sources":["../src/defer.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,kBAAkB,EAAe,MAAM,YAAY,CAAA;AAejE,8BAA8B;AAC9B,MAAM,WAAW,cAAc;IAC7B,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAA;IACnB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;IACtB,yDAAyD;IACzD,OAAO,CAAC,GAAG,EAAE,IAAI,GAAG,IAAI,CAAA;CACzB;AAED,MAAM,WAAW,qBAAqB;IACpC,kDAAkD;IAClD,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,mDAAmD;IACnD,WAAW,CAAC,EAAE,MAAM,CAAA;CACrB;AAED,2EAA2E;AAC3E,eAAO,MAAM,mBAAmB;wBACnB,WAAW;0BACT,cAAc;8BACV,kBAAkB;0BACtB,cAAc;yBACf,aAAa;EAChB,CAAA;AAmBX;;;GAGG;AACH,wBAAgB,qBAAqB,CACnC,OAAO,GAAE,qBAA0B,GAClC,SAAS,cAAc,EAAE,CA4D3B;AAED;;;GAGG;AACH,wBAAgB,eAAe,CAC7B,OAAO,EAAE,SAAS,cAAc,EAAE,EAClC,QAAQ,EAAE,MAAM,EAChB,GAAG,GAAE,IAAiB,GACrB,MAAM,GAAG,IAAI,CAIf;AAID,MAAM,WAAW,cAAc;IAC7B,6EAA6E;IAC7E,QAAQ,CAAC,gBAAgB,EAAE,SAAS,MAAM,EAAE,CAAA;IAC5C,mEAAmE;IACnE,QAAQ,CAAC,eAAe,EAAE,MAAM,CAAA;CACjC;AAyDD,eAAO,MAAM,gBAAgB,EAAE,kBAAkB,CAAC,cAAc,CAU/D,CAAA"}
|
package/dist/defer.js
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { getEnergyLevel } from './levels.js';
|
|
2
|
+
/**
|
|
3
|
+
* Deferral ("snooze") — the "not now" primitive. Deferring an item is an
|
|
4
|
+
* energy statement: it declares insufficient capacity for it right now and
|
|
5
|
+
* names when it should resurface. Presets are pure `(now) -> Date` functions;
|
|
6
|
+
* the energy-aware strategy orders them so the default suggestion matches
|
|
7
|
+
* current capacity (low energy -> longer deferrals, because items should
|
|
8
|
+
* resurface when capacity has plausibly recovered, not in an hour).
|
|
9
|
+
*/
|
|
10
|
+
function freezeObject(value) {
|
|
11
|
+
return Object.freeze(value);
|
|
12
|
+
}
|
|
13
|
+
/** Stable preset ids, exported so configs/strategies can reference them */
|
|
14
|
+
export const DEFERRAL_PRESET_IDS = freezeObject({
|
|
15
|
+
inOneHour: 'in-1-hour',
|
|
16
|
+
thisEvening: 'this-evening',
|
|
17
|
+
tomorrowMorning: 'tomorrow-morning',
|
|
18
|
+
nextWorkday: 'next-workday',
|
|
19
|
+
nextMonday: 'next-monday',
|
|
20
|
+
});
|
|
21
|
+
function validateHour(name, value) {
|
|
22
|
+
if (!Number.isInteger(value) || value < 0 || value > 23) {
|
|
23
|
+
throw new Error(`Invalid ${name}: ${String(value)} (expected an integer hour 0-23)`);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
function startOfDay(from) {
|
|
27
|
+
const day = new Date(from);
|
|
28
|
+
day.setHours(0, 0, 0, 0);
|
|
29
|
+
return day;
|
|
30
|
+
}
|
|
31
|
+
function isWeekend(day) {
|
|
32
|
+
const weekday = day.getDay();
|
|
33
|
+
return weekday === 0 || weekday === 6;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Build the standard deferral presets. Times are computed in local time —
|
|
37
|
+
* "tomorrow morning" means the user's morning.
|
|
38
|
+
*/
|
|
39
|
+
export function createDeferralPresets(options = {}) {
|
|
40
|
+
const { morningHour = 9, eveningHour = 18 } = options;
|
|
41
|
+
validateHour('morningHour', morningHour);
|
|
42
|
+
validateHour('eveningHour', eveningHour);
|
|
43
|
+
return Object.freeze([
|
|
44
|
+
freezeObject({
|
|
45
|
+
id: DEFERRAL_PRESET_IDS.inOneHour,
|
|
46
|
+
label: 'In 1 hour',
|
|
47
|
+
compute(now) {
|
|
48
|
+
return new Date(now.getTime() + 60 * 60_000);
|
|
49
|
+
},
|
|
50
|
+
}),
|
|
51
|
+
freezeObject({
|
|
52
|
+
id: DEFERRAL_PRESET_IDS.thisEvening,
|
|
53
|
+
label: `This evening (${eveningHour}:00)`,
|
|
54
|
+
compute(now) {
|
|
55
|
+
const evening = startOfDay(now);
|
|
56
|
+
evening.setHours(eveningHour);
|
|
57
|
+
if (evening.getTime() <= now.getTime()) {
|
|
58
|
+
evening.setDate(evening.getDate() + 1);
|
|
59
|
+
}
|
|
60
|
+
return evening;
|
|
61
|
+
},
|
|
62
|
+
}),
|
|
63
|
+
freezeObject({
|
|
64
|
+
id: DEFERRAL_PRESET_IDS.tomorrowMorning,
|
|
65
|
+
label: `Tomorrow morning (${morningHour}:00)`,
|
|
66
|
+
compute(now) {
|
|
67
|
+
const morning = startOfDay(now);
|
|
68
|
+
morning.setDate(morning.getDate() + 1);
|
|
69
|
+
morning.setHours(morningHour);
|
|
70
|
+
return morning;
|
|
71
|
+
},
|
|
72
|
+
}),
|
|
73
|
+
freezeObject({
|
|
74
|
+
id: DEFERRAL_PRESET_IDS.nextWorkday,
|
|
75
|
+
label: `Next workday (${morningHour}:00)`,
|
|
76
|
+
compute(now) {
|
|
77
|
+
const day = startOfDay(now);
|
|
78
|
+
day.setDate(day.getDate() + 1);
|
|
79
|
+
while (isWeekend(day)) {
|
|
80
|
+
day.setDate(day.getDate() + 1);
|
|
81
|
+
}
|
|
82
|
+
day.setHours(morningHour);
|
|
83
|
+
return day;
|
|
84
|
+
},
|
|
85
|
+
}),
|
|
86
|
+
freezeObject({
|
|
87
|
+
id: DEFERRAL_PRESET_IDS.nextMonday,
|
|
88
|
+
label: `Next Monday (${morningHour}:00)`,
|
|
89
|
+
compute(now) {
|
|
90
|
+
const day = startOfDay(now);
|
|
91
|
+
const delta = (1 - day.getDay() + 7) % 7 || 7;
|
|
92
|
+
day.setDate(day.getDate() + delta);
|
|
93
|
+
day.setHours(morningHour);
|
|
94
|
+
return day;
|
|
95
|
+
},
|
|
96
|
+
}),
|
|
97
|
+
]);
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Resolve a preset id to a resurface timestamp (epoch ms).
|
|
101
|
+
* Returns null for an unknown id — callers decide whether that is an error.
|
|
102
|
+
*/
|
|
103
|
+
export function resolveDeferral(presets, presetId, now = new Date()) {
|
|
104
|
+
const preset = presets.find((candidate) => candidate.id === presetId);
|
|
105
|
+
if (!preset)
|
|
106
|
+
return null;
|
|
107
|
+
return preset.compute(now).getTime();
|
|
108
|
+
}
|
|
109
|
+
const IDS = DEFERRAL_PRESET_IDS;
|
|
110
|
+
const DEFERRAL_CONFIGS = freezeObject({
|
|
111
|
+
100: freezeObject({
|
|
112
|
+
orderedPresetIds: Object.freeze([
|
|
113
|
+
IDS.inOneHour,
|
|
114
|
+
IDS.thisEvening,
|
|
115
|
+
IDS.tomorrowMorning,
|
|
116
|
+
IDS.nextWorkday,
|
|
117
|
+
IDS.nextMonday,
|
|
118
|
+
]),
|
|
119
|
+
defaultPresetId: IDS.inOneHour,
|
|
120
|
+
}),
|
|
121
|
+
75: freezeObject({
|
|
122
|
+
orderedPresetIds: Object.freeze([
|
|
123
|
+
IDS.inOneHour,
|
|
124
|
+
IDS.thisEvening,
|
|
125
|
+
IDS.tomorrowMorning,
|
|
126
|
+
IDS.nextWorkday,
|
|
127
|
+
IDS.nextMonday,
|
|
128
|
+
]),
|
|
129
|
+
defaultPresetId: IDS.inOneHour,
|
|
130
|
+
}),
|
|
131
|
+
50: freezeObject({
|
|
132
|
+
orderedPresetIds: Object.freeze([
|
|
133
|
+
IDS.thisEvening,
|
|
134
|
+
IDS.tomorrowMorning,
|
|
135
|
+
IDS.inOneHour,
|
|
136
|
+
IDS.nextWorkday,
|
|
137
|
+
IDS.nextMonday,
|
|
138
|
+
]),
|
|
139
|
+
defaultPresetId: IDS.thisEvening,
|
|
140
|
+
}),
|
|
141
|
+
25: freezeObject({
|
|
142
|
+
orderedPresetIds: Object.freeze([
|
|
143
|
+
IDS.tomorrowMorning,
|
|
144
|
+
IDS.nextWorkday,
|
|
145
|
+
IDS.thisEvening,
|
|
146
|
+
IDS.nextMonday,
|
|
147
|
+
IDS.inOneHour,
|
|
148
|
+
]),
|
|
149
|
+
defaultPresetId: IDS.tomorrowMorning,
|
|
150
|
+
}),
|
|
151
|
+
0: freezeObject({
|
|
152
|
+
orderedPresetIds: Object.freeze([
|
|
153
|
+
IDS.tomorrowMorning,
|
|
154
|
+
IDS.nextMonday,
|
|
155
|
+
IDS.nextWorkday,
|
|
156
|
+
IDS.thisEvening,
|
|
157
|
+
IDS.inOneHour,
|
|
158
|
+
]),
|
|
159
|
+
defaultPresetId: IDS.tomorrowMorning,
|
|
160
|
+
}),
|
|
161
|
+
});
|
|
162
|
+
export const deferralStrategy = {
|
|
163
|
+
name: 'deferral',
|
|
164
|
+
describe(level) {
|
|
165
|
+
const def = getEnergyLevel(level);
|
|
166
|
+
const config = DEFERRAL_CONFIGS[def.value];
|
|
167
|
+
return `${def.label}: default deferral is "${config.defaultPresetId}"`;
|
|
168
|
+
},
|
|
169
|
+
resolve(level) {
|
|
170
|
+
return DEFERRAL_CONFIGS[getEnergyLevel(level).value];
|
|
171
|
+
},
|
|
172
|
+
};
|
|
173
|
+
//# sourceMappingURL=defer.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"defer.js","sourceRoot":"","sources":["../src/defer.ts"],"names":[],"mappings":"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"}
|
package/dist/engine.d.ts
CHANGED
|
@@ -9,6 +9,14 @@ export interface EnergyEngineOptions {
|
|
|
9
9
|
clock?: EnergyClock | (() => number);
|
|
10
10
|
/** Stable producer identity for deterministic reconciliation. Primarily useful in tests. */
|
|
11
11
|
originId?: string;
|
|
12
|
+
/**
|
|
13
|
+
* Maximum tolerated future clock skew (ms) for externally supplied state
|
|
14
|
+
* (hydration and cross-context observation). States stamped further ahead of
|
|
15
|
+
* the local clock are rejected so one bad clock cannot win reconciliation
|
|
16
|
+
* until its timestamp passes. Pass Number.POSITIVE_INFINITY to accept any
|
|
17
|
+
* finite timestamp. Default: 5 minutes.
|
|
18
|
+
*/
|
|
19
|
+
maxFutureSkewMs?: number;
|
|
12
20
|
}
|
|
13
21
|
export interface EnergyEngine {
|
|
14
22
|
/** Get current energy state */
|
|
@@ -23,7 +31,11 @@ export interface EnergyEngine {
|
|
|
23
31
|
resolve<T>(strategy: AdaptationStrategy<T>): T;
|
|
24
32
|
/** Load persisted state (called automatically, but can be called manually) */
|
|
25
33
|
hydrate(): Promise<void>;
|
|
26
|
-
/**
|
|
34
|
+
/**
|
|
35
|
+
* Wait until the current state version is durably persisted.
|
|
36
|
+
* Rejects if the engine is disposed or an unchanged initial state cannot be
|
|
37
|
+
* reconciled because its persistence hydration read failed.
|
|
38
|
+
*/
|
|
27
39
|
flush(): Promise<void>;
|
|
28
40
|
/** Release engine-owned subscriptions/resources */
|
|
29
41
|
dispose(): void;
|
package/dist/engine.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"engine.d.ts","sourceRoot":"","sources":["../src/engine.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EACV,kBAAkB,EAClB,WAAW,EACX,oBAAoB,EACpB,WAAW,EACX,iBAAiB,EACjB,YAAY,EACZ,WAAW,EACZ,MAAM,YAAY,CAAA;AAEnB,MAAM,WAAW,mBAAmB;IAClC,YAAY,CAAC,EAAE,WAAW,CAAA;IAC1B,WAAW,CAAC,EAAE,iBAAiB,CAAA;IAC/B,QAAQ,CAAC,EAAE,oBAAoB,CAAA;IAC/B,mFAAmF;IACnF,kBAAkB,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,EAAE,KAAK,EAAE,WAAW,KAAK,IAAI,CAAA;IACjE,sDAAsD;IACtD,KAAK,CAAC,EAAE,WAAW,GAAG,CAAC,MAAM,MAAM,CAAC,CAAA;IACpC,4FAA4F;IAC5F,QAAQ,CAAC,EAAE,MAAM,CAAA;
|
|
1
|
+
{"version":3,"file":"engine.d.ts","sourceRoot":"","sources":["../src/engine.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EACV,kBAAkB,EAClB,WAAW,EACX,oBAAoB,EACpB,WAAW,EACX,iBAAiB,EACjB,YAAY,EACZ,WAAW,EACZ,MAAM,YAAY,CAAA;AAEnB,MAAM,WAAW,mBAAmB;IAClC,YAAY,CAAC,EAAE,WAAW,CAAA;IAC1B,WAAW,CAAC,EAAE,iBAAiB,CAAA;IAC/B,QAAQ,CAAC,EAAE,oBAAoB,CAAA;IAC/B,mFAAmF;IACnF,kBAAkB,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,EAAE,KAAK,EAAE,WAAW,KAAK,IAAI,CAAA;IACjE,sDAAsD;IACtD,KAAK,CAAC,EAAE,WAAW,GAAG,CAAC,MAAM,MAAM,CAAC,CAAA;IACpC,4FAA4F;IAC5F,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB;;;;;;OAMG;IACH,eAAe,CAAC,EAAE,MAAM,CAAA;CACzB;AAED,MAAM,WAAW,YAAY;IAC3B,+BAA+B;IAC/B,QAAQ,IAAI,WAAW,CAAA;IACvB,4CAA4C;IAC5C,QAAQ,CAAC,KAAK,EAAE,WAAW,EAAE,MAAM,CAAC,EAAE,YAAY,GAAG,IAAI,CAAA;IACzD,iCAAiC;IACjC,UAAU,IAAI,IAAI,CAAA;IAClB,gEAAgE;IAChE,SAAS,CAAC,QAAQ,EAAE,oBAAoB,GAAG,MAAM,IAAI,CAAA;IACrD,sDAAsD;IACtD,OAAO,CAAC,CAAC,EAAE,QAAQ,EAAE,kBAAkB,CAAC,CAAC,CAAC,GAAG,CAAC,CAAA;IAC9C,8EAA8E;IAC9E,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC,CAAA;IACxB;;;;OAIG;IACH,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAA;IACtB,mDAAmD;IACnD,OAAO,IAAI,IAAI,CAAA;CAChB;AA2FD,wBAAgB,kBAAkB,CAAC,OAAO,GAAE,mBAAwB,GAAG,YAAY,CAiUlF"}
|
package/dist/engine.js
CHANGED
|
@@ -4,6 +4,7 @@ function logEngineError(message, err) {
|
|
|
4
4
|
}
|
|
5
5
|
const PERSIST_RETRY_INITIAL_MS = 250;
|
|
6
6
|
const PERSIST_RETRY_MAX_MS = 30_000;
|
|
7
|
+
const DEFAULT_MAX_FUTURE_SKEW_MS = 5 * 60_000;
|
|
7
8
|
function resolveNow(clock) {
|
|
8
9
|
if (typeof clock === 'function')
|
|
9
10
|
return clock;
|
|
@@ -48,17 +49,25 @@ function isPreferredExternalState(candidate, current) {
|
|
|
48
49
|
}
|
|
49
50
|
return false;
|
|
50
51
|
}
|
|
51
|
-
function normalizeState(candidate) {
|
|
52
|
+
function normalizeState(candidate, nowMs, maxFutureSkewMs) {
|
|
52
53
|
if (!isEnergyLevel(candidate.level)) {
|
|
53
54
|
throw new Error(`Invalid energy level from persistence: ${String(candidate.level)}`);
|
|
54
55
|
}
|
|
55
56
|
if (!isEnergySource(candidate.source)) {
|
|
56
57
|
throw new Error(`Invalid energy source from persistence: ${String(candidate.source)}`);
|
|
57
58
|
}
|
|
59
|
+
if (candidate.timestamp - nowMs > maxFutureSkewMs) {
|
|
60
|
+
throw new Error(`Energy state timestamp ${String(candidate.timestamp)} exceeds local clock by more than ${String(maxFutureSkewMs)}ms`);
|
|
61
|
+
}
|
|
58
62
|
return createEnergyState(candidate.level, candidate.source, candidate.timestamp, candidate.revision, candidate.origin);
|
|
59
63
|
}
|
|
60
64
|
export function createEnergyEngine(options = {}) {
|
|
61
|
-
const { initialLevel = 100, persistence, onChange, onPersistenceError, clock, originId = createEnergyOrigin(), } = options;
|
|
65
|
+
const { initialLevel = 100, persistence, onChange, onPersistenceError, clock, originId = createEnergyOrigin(), maxFutureSkewMs = DEFAULT_MAX_FUTURE_SKEW_MS, } = options;
|
|
66
|
+
if (typeof maxFutureSkewMs !== 'number' ||
|
|
67
|
+
(!Number.isFinite(maxFutureSkewMs) && maxFutureSkewMs !== Number.POSITIVE_INFINITY) ||
|
|
68
|
+
maxFutureSkewMs < 0) {
|
|
69
|
+
throw new Error(`Invalid maxFutureSkewMs: ${String(maxFutureSkewMs)}`);
|
|
70
|
+
}
|
|
62
71
|
const now = resolveNow(clock);
|
|
63
72
|
const listeners = new Set();
|
|
64
73
|
const notificationQueue = [];
|
|
@@ -66,11 +75,17 @@ export function createEnergyEngine(options = {}) {
|
|
|
66
75
|
let disposed = false;
|
|
67
76
|
let isNotifying = false;
|
|
68
77
|
let state = createEnergyState(initialLevel, 'manual', now(), 0, originId);
|
|
69
|
-
|
|
78
|
+
// Version 0 is the initial in-memory state, not proof that a persistence
|
|
79
|
+
// adapter has durably stored it. Starting below the version domain keeps
|
|
80
|
+
// flush() honest even before the first state transition.
|
|
81
|
+
let persistedVersion = -1;
|
|
70
82
|
let requestedPersistVersion = 0;
|
|
71
83
|
let persistTask;
|
|
72
84
|
let persistRetryTimer;
|
|
73
85
|
let persistRetryDelayMs = PERSIST_RETRY_INITIAL_MS;
|
|
86
|
+
let initialHydrationTask;
|
|
87
|
+
let hasCompletedPersistenceLoad = false;
|
|
88
|
+
let persistenceLoadError;
|
|
74
89
|
const persistWaiters = [];
|
|
75
90
|
function resolvePersistWaiters() {
|
|
76
91
|
for (let index = persistWaiters.length - 1; index >= 0; index -= 1) {
|
|
@@ -180,12 +195,26 @@ export function createEnergyEngine(options = {}) {
|
|
|
180
195
|
return state;
|
|
181
196
|
},
|
|
182
197
|
setLevel(level, source = 'manual') {
|
|
198
|
+
if (disposed)
|
|
199
|
+
return;
|
|
183
200
|
const wallTime = now();
|
|
184
|
-
|
|
185
|
-
|
|
201
|
+
let timestamp = Math.max(wallTime, state.timestamp);
|
|
202
|
+
let revision = 0;
|
|
203
|
+
if (timestamp === state.timestamp) {
|
|
204
|
+
if (state.revision === Number.MAX_SAFE_INTEGER) {
|
|
205
|
+
// Preserve a strictly newer ordering key without producing an
|
|
206
|
+
// invalid revision when a deterministic/future clock cannot advance.
|
|
207
|
+
timestamp += 1;
|
|
208
|
+
}
|
|
209
|
+
else {
|
|
210
|
+
revision = state.revision + 1;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
186
213
|
applyState(createEnergyState(level, source, timestamp, revision, originId));
|
|
187
214
|
},
|
|
188
215
|
cycleLevel() {
|
|
216
|
+
if (disposed)
|
|
217
|
+
return;
|
|
189
218
|
engine.setLevel(cycleEnergyLevel(state.level), 'manual');
|
|
190
219
|
},
|
|
191
220
|
subscribe(listener) {
|
|
@@ -207,8 +236,11 @@ export function createEnergyEngine(options = {}) {
|
|
|
207
236
|
let stored;
|
|
208
237
|
try {
|
|
209
238
|
stored = await persistence.load();
|
|
239
|
+
hasCompletedPersistenceLoad = true;
|
|
240
|
+
persistenceLoadError = undefined;
|
|
210
241
|
}
|
|
211
242
|
catch (err) {
|
|
243
|
+
persistenceLoadError = err;
|
|
212
244
|
logEngineError('Failed to load persisted energy state', err);
|
|
213
245
|
return;
|
|
214
246
|
}
|
|
@@ -216,7 +248,7 @@ export function createEnergyEngine(options = {}) {
|
|
|
216
248
|
return;
|
|
217
249
|
let normalized;
|
|
218
250
|
try {
|
|
219
|
-
normalized = normalizeState(stored);
|
|
251
|
+
normalized = normalizeState(stored, now(), maxFutureSkewMs);
|
|
220
252
|
}
|
|
221
253
|
catch (err) {
|
|
222
254
|
logEngineError('Ignoring invalid persisted energy state', err);
|
|
@@ -234,6 +266,18 @@ export function createEnergyEngine(options = {}) {
|
|
|
234
266
|
if (disposed) {
|
|
235
267
|
throw new Error('Cannot flush a disposed energy engine');
|
|
236
268
|
}
|
|
269
|
+
// Do not persist the default state over an unread stored value. Once a
|
|
270
|
+
// local/external transition exists, that newer intent can persist
|
|
271
|
+
// immediately; an unchanged initial state must wait for auto-hydration.
|
|
272
|
+
if (stateVersion === 0 && initialHydrationTask) {
|
|
273
|
+
await initialHydrationTask;
|
|
274
|
+
}
|
|
275
|
+
if (disposed) {
|
|
276
|
+
throw new Error('Cannot flush a disposed energy engine');
|
|
277
|
+
}
|
|
278
|
+
if (stateVersion === 0 && !hasCompletedPersistenceLoad) {
|
|
279
|
+
throw new Error('Cannot flush the initial energy state because persistence hydration did not complete', { cause: persistenceLoadError });
|
|
280
|
+
}
|
|
237
281
|
const targetVersion = stateVersion;
|
|
238
282
|
if (persistedVersion >= targetVersion)
|
|
239
283
|
return;
|
|
@@ -248,7 +292,12 @@ export function createEnergyEngine(options = {}) {
|
|
|
248
292
|
if (disposed)
|
|
249
293
|
return;
|
|
250
294
|
disposed = true;
|
|
251
|
-
|
|
295
|
+
try {
|
|
296
|
+
disposePersistenceObservation();
|
|
297
|
+
}
|
|
298
|
+
catch (err) {
|
|
299
|
+
logEngineError('Failed to release persistence observation', err);
|
|
300
|
+
}
|
|
252
301
|
if (persistRetryTimer) {
|
|
253
302
|
clearTimeout(persistRetryTimer);
|
|
254
303
|
persistRetryTimer = undefined;
|
|
@@ -263,7 +312,7 @@ export function createEnergyEngine(options = {}) {
|
|
|
263
312
|
};
|
|
264
313
|
// Auto-hydrate from persistence
|
|
265
314
|
if (persistence) {
|
|
266
|
-
|
|
315
|
+
initialHydrationTask = engine.hydrate().catch((err) => {
|
|
267
316
|
logEngineError('Unexpected hydrate failure', err);
|
|
268
317
|
});
|
|
269
318
|
}
|
|
@@ -274,7 +323,7 @@ export function createEnergyEngine(options = {}) {
|
|
|
274
323
|
return;
|
|
275
324
|
let normalized;
|
|
276
325
|
try {
|
|
277
|
-
normalized = normalizeState(externalState);
|
|
326
|
+
normalized = normalizeState(externalState, now(), maxFutureSkewMs);
|
|
278
327
|
}
|
|
279
328
|
catch (err) {
|
|
280
329
|
logEngineError('Ignoring invalid observed energy state', err);
|