Sessions and Gates
createFocusSessionController and createNotificationGate - time-boxed suppression and runtime notification enforcement
Focus sessions
Focus sessions are time-boxed "one thing at a time" windows layered on top of the energy model. A session is a temporary commitment, not an energy level: it suppresses interruptions for a bounded duration, surfaces break nudges, and always ends on time.
Two invariants come from field evidence:
- Sessions auto-expire. Expiry is an emitted event, not a predicate the app must remember to poll - suppression can never outlive the session.
- Suppression is lifted before the end event is emitted, so an end-of-session notification can never be swallowed by the session's own suppression.
createFocusSessionController
function createFocusSessionController(
options?: FocusSessionControllerOptions,
): FocusSessionControllerimport {
createEnergyEngine,
createFocusSessionController,
createNotificationGate,
} from '@kumbatio/energy-system'
const engine = createEnergyEngine()
const gate = createNotificationGate(engine, { onDeliver: (d) => render(d) })
const sessions = createFocusSessionController({ engine, gate })
sessions.subscribe((event, session) => {
if (event === 'end') celebrate(session)
})
sessions.start({ durationMinutes: 25, breakEveryMinutes: 0 })FocusSessionControllerOptions
Prop
Type
/>
FocusSessionController
interface FocusSessionController {
start(options?: StartFocusSessionOptions): FocusSession
stop(): void
getSession(): FocusSession | null
remainingMs(): number
subscribe(listener: FocusSessionListener): () => void
dispose(): void
}| Method | Behavior |
|---|---|
start(options?) | Start a session, replacing any active one (which is stopped first, emitting stop). Applies suppression to gate, schedules the end timer and break nudges, emits start, and returns the frozen session. Throws on a disposed controller, on a non-finite or non-positive duration, or a non-finite or negative break interval. |
stop() | End the active session early (emits stop). No-op when idle or disposed. |
getSession() | The active session snapshot, or null when idle. |
remainingMs() | Milliseconds left in the active session (0 when idle). |
subscribe(listener) | Subscribe to lifecycle events; returns an unsubscribe function. Listener exceptions are caught and logged. Returns a no-op on a disposed controller. |
dispose() | Stops any active session (releasing suppression and emitting stop before teardown) and releases resources. |
StartFocusSessionOptions
Prop
Type
/>
FocusSession
Prop
Type
/>
FocusSessionEvent and FocusSessionListener
type FocusSessionEvent = 'start' | 'break' | 'end' | 'stop'
type FocusSessionListener = (event: FocusSessionEvent, session: FocusSession) => voidstart: a session beganbreak: a break nudge is due (recurring while the session runs; a nudge that would land at or afterendsAtis skipped)end: the session reachedendsAtand auto-expiredstop: the session was ended manually beforeendsAt
FocusSuppressible
interface FocusSuppressible {
setSuppressed(suppressed: boolean): void
}Anything that can be suppressed for the lifetime of a session. NotificationGate satisfies this.
sessionRemainingMs
function sessionRemainingMs(session: FocusSession, now?: number): numberMilliseconds left in a session (0 when expired). now defaults to Date.now().
isSessionExpired
function isSessionExpired(session: FocusSession, now?: number): booleanTrue once a session has reached its end time. now defaults to Date.now().
Notification gate
The notification gate is the runtime that enforces NotificationConfig instead of leaving it as guidance. Apps publish notification intents through the gate; the gate resolves the current energy level's config and decides whether each intent is delivered now, batched, or deferred.
The gate never silently drops a notification. Anything not deliverable now is deferred and released when energy rises, suppression lifts, flush() is called, or the gate is disposed.
createNotificationGate
function createNotificationGate(
engine: EnergyEngine,
options: NotificationGateOptions,
): NotificationGateCreates a gate bound to an engine. The gate re-resolves its config on every energy change and on every suppression change, then re-judges everything it is holding against the new policy - in both directions. Deferred intents the new policy admits are released immediately rather than re-batched, because they already waited once; batched intents the new policy no longer admits move to the deferred queue instead of arriving under a policy that would not have accepted them.
The batch deadline is anchored to when the window opened, so a config change moves the deadline rather than restarting the wait. Throws a TypeError when onDeliver is not a function.
NotificationGateOptions
Prop
Type
/>
NotificationGate
interface NotificationGate {
publish(input: { priority?: NotificationPriority; payload?: unknown }): PublishOutcome
setSuppressed(suppressed: boolean): void
isSuppressed(): boolean
pendingCount(): { batched: number; deferred: number }
flush(): void
dispose(): void
}| Method | Behavior |
|---|---|
publish(input) | Publish a notification intent and return what the gate did with it. priority defaults to 'normal'; invalid priorities throw. Throws on a disposed gate. |
setSuppressed(next) | Hard-suppress delivery (e.g. during a focus session). While suppressed, every publish defers; lifting suppression releases the deferred queue. No-op when disposed or unchanged. |
isSuppressed() | Current suppression flag. |
pendingCount() | Counts of undelivered notifications currently held by the gate. |
flush() | Deliver the open batch now, subject to the current policy. Flushing overrides the wait, not the policy: under suppression or at a level that no longer admits them, the batched intents move to the deferred queue rather than being forced out. No-op when disposed. |
dispose() | Release resources. Pending notifications (batched and deferred) are delivered as a final 'released' delivery first - a disposed gate never swallows intents. |
resolveNotificationOutcome
function resolveNotificationOutcome(
config: NotificationConfig,
priority: NotificationPriority,
suppressed: boolean,
): PublishOutcomePure gating decision: given the active config, an intent priority, and the suppression flag, decide the outcome. Extracted so apps can unit-test their notification policy without constructing a gate.
Decision order:
suppressed→'deferred'priorityThreshold: 'none'→'deferred'priorityThreshold: 'critical'and priority is notcritical→'deferred'priorityThreshold: 'high'and priority isnormal→'deferred'- Otherwise:
'batched'whenbatchInterval > 0, else'delivered'
isNotificationPriority
function isNotificationPriority(value: unknown): value is NotificationPriorityValidate that an unknown value is a valid NotificationPriority.
Gate types
type NotificationPriority = 'normal' | 'high' | 'critical'
type NotificationDeliveryReason = 'immediate' | 'batch' | 'released'
type PublishOutcome = 'delivered' | 'batched' | 'deferred'EnergyNotification
Prop
Type
/>
NotificationChannels
Output channels permitted by the config active at delivery time.
Prop
Type
/>
NotificationDelivery
One call to onDeliver: one or more notifications plus delivery context.
Prop
Type
/>
GateScheduler
Timer contract so tests can drive batch windows deterministically. Also used by the focus session controller.
interface GateScheduler {
setTimeout(callback: () => void, ms: number): unknown
clearTimeout(handle: unknown): void
}