Kumbatio
Core

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:

  1. Sessions auto-expire. Expiry is an emitted event, not a predicate the app must remember to poll - suppression can never outlive the session.
  2. 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,
): FocusSessionController
import {
  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
}
MethodBehavior
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) => void
  • start: a session began
  • break: a break nudge is due (recurring while the session runs; a nudge that would land at or after endsAt is skipped)
  • end: the session reached endsAt and auto-expired
  • stop: the session was ended manually before endsAt

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): number

Milliseconds left in a session (0 when expired). now defaults to Date.now().

isSessionExpired

function isSessionExpired(session: FocusSession, now?: number): boolean

True 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,
): NotificationGate

Creates 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
}
MethodBehavior
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,
): PublishOutcome

Pure 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:

  1. suppressed'deferred'
  2. priorityThreshold: 'none''deferred'
  3. priorityThreshold: 'critical' and priority is not critical'deferred'
  4. priorityThreshold: 'high' and priority is normal'deferred'
  5. Otherwise: 'batched' when batchInterval > 0, else 'delivered'

isNotificationPriority

function isNotificationPriority(value: unknown): value is NotificationPriority

Validate 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
}