The duration becomes a timer: once its minutes are set, it starts, may pause and resume, and finishes. It ticks
every second while it runs, and rings a bell when the time is up. Its code is
examples/timer/timer.ts, and the rules
of step 1 carry over: "+" and "−" now apply only while the timer is being set.
Where the timer is, is one value with a case for each phase, holding only what exists in it. Flags such as
isRunning and isPaused would allow combinations that cannot be; a union cannot hold them (see
state and messages).
export type Phase = Data.TaggedEnum<{
Setting: object
Running: { secondsLeft: number }
Paused: { secondsLeft: number }
Finished: object
}>
export const Phase = Data.taggedEnum<Phase>()
/** `minutes` is kept while the timer runs, so that a reset sets it again as it was. */
export type State = Readonly<{ minutes: number; phase: Phase }>
export type Message = Data.TaggedEnum<{
MoreClicked: object
LessClicked: object
StartClicked: object
PauseClicked: object
ResumeClicked: object
ResetClicked: object
Ticked: object
}>
export const Message = Data.taggedEnum<Message>()
update never runs an effect. When the timer finishes, it returns a command, data that says what to do,
and the program's runCommand turns it into an Effect. Ringing needs a bell, a service the program declares and
whoever runs it provides. Ringing gives update nothing to learn, so its execution answers None; a command that
has an answer, such as a save, answers with one message (see
commands).
export type Command = Data.TaggedEnum<{ Ring: object }>
export const Command = Data.taggedEnum<Command>()
/** The bell the timer rings. The program knows only this interface: `main.ts` and the tests provide it. */
export class Bell extends Context.Service<Bell, { readonly ring: Effect.Effect<void> }>()('examples/timer/Bell') {}
// Ringing only acts on the world: there is no answer for `update`, so the execution answers `None`.
export const runCommand = (command: Command): Execution<Message, Bell> => {
switch (command._tag) {
case 'Ring':
return Effect.flatMap(Effect.service(Bell), (bell) => bell.ring).pipe(Effect.as(Opt.none()))
}
}
An execution cannot fail: its type says so. A command that can fail turns its failure into a message, as
examples/form does with a save.
Each message applies in some phases and is rejected in the others. A tick is different: the timer listens to
the clock only while it runs (below), so a tick in any other phase cannot arrive, and its arrival is a bug.
update throws defect(reason) on it, and the run dies instead of hiding it (see
rejections and defects).
export const update: Update<State, Message, Command> = (message) => (state) => {
const { minutes, phase } = state
switch (message._tag) {
case 'MoreClicked':
return phase._tag === 'Setting' ? { state: { ...state, minutes: minutes + 1 } } : reject
case 'LessClicked':
return phase._tag === 'Setting' && minutes > 1 ? { state: { ...state, minutes: minutes - 1 } } : reject
case 'StartClicked':
return phase._tag === 'Setting'
? { state: { ...state, phase: Phase.Running({ secondsLeft: minutes * 60 }) } }
: reject
case 'PauseClicked':
return phase._tag === 'Running'
? { state: { ...state, phase: Phase.Paused({ secondsLeft: phase.secondsLeft }) } }
: reject
case 'ResumeClicked':
return phase._tag === 'Paused'
? { state: { ...state, phase: Phase.Running({ secondsLeft: phase.secondsLeft }) } }
: reject
case 'ResetClicked':
return phase._tag === 'Setting' ? reject : { state: { ...state, phase: Phase.Setting() } }
// Ticks come only while running: pausing stops the subscription, and the run drops what it sent. A tick in
// any other phase is a bug, and the run dies with it.
case 'Ticked':
if (phase._tag !== 'Running') return defect('A tick while the timer was not running')
return phase.secondsLeft > 1
? { state: { ...state, phase: Phase.Running({ secondsLeft: phase.secondsLeft - 1 }) } }
: { state: { ...state, phase: Phase.Finished() }, commands: [Command.Ring()] }
}
}
The last tick moves the timer to Finished and returns the command with the new state, in one step.
A subscription is what the program listens to. subscriptions(state) returns them in a HashMap, by key,
and after every change of state the run compares the keys: a new key starts its subscription, a kept key leaves
it running, and a key that went away stops it, and drops what it sent that update has not seen (see
subscriptions).
The timer has the key 'ticks' only while it runs. Pausing removes it, so the clock stops; resuming brings it
back, and a new clock starts. The clock is an Effect Channel that sends Ticked every second and never ends:
const ticks: Subscription<Message, never> = Channel.forever(
Channel.fromEffectDrain(Effect.sleep('1 second')).pipe(
Channel.concatWith(() => Channel.succeed(Message.Ticked())),
),
)
The model has a case for each phase the screen tells apart, each with the messages of its controls:
export type Model = Data.TaggedEnum<{
Setting: { minutes: number; more: Message; less: Opt.Option<Message>; start: Message }
Running: { secondsLeft: number; pause: Message; reset: Message }
Paused: { secondsLeft: number; resume: Message; reset: Message }
Finished: { reset: Message }
}>
const makeModel = ({ minutes, phase }: State): Model => {
const reset = Message.ResetClicked()
switch (phase._tag) {
case 'Setting':
return {
_tag: 'Setting',
minutes,
more: Message.MoreClicked(),
less: minutes > 1 ? Opt.some(Message.LessClicked()) : Opt.none(),
start: Message.StartClicked(),
}
case 'Running':
return { _tag: 'Running', secondsLeft: phase.secondsLeft, pause: Message.PauseClicked(), reset }
case 'Paused':
return { _tag: 'Paused', secondsLeft: phase.secondsLeft, resume: Message.ResumeClicked(), reset }
case 'Finished':
return { _tag: 'Finished', reset }
}
}
The program now declares its commands and its services, and has runCommand and subscriptions:
export const timer: Program<{
state: State
model: Model
message: Message
command: Command
services: Bell
}> = {
init: { state: { minutes: 25, phase: Phase.Setting() } },
update,
runCommand,
subscriptions: (state) => state.phase._tag === 'Running' ? HashMap.make(['ticks', ticks]) : HashMap.empty(),
makeModel,
}
Its view, timerView in
examples/timer/view.ts, draws each case
as the duration's view did, and formats the seconds left as m:ss.
Next, many timers.