Skip to main content
All Examples

Stopwatch

A stopwatch with start, stop, and reset. Demonstrates time-based subscriptions.

Subscriptions
/
import {
  Clock,
  Duration,
  Effect,
  Match as M,
  Schema as S,
  Stream,
  String,
  flow,
  pipe,
} from 'effect'
import { Command, Runtime, Subscription } from 'foldkit'
import { Document, Html, html } from 'foldkit/html'
import { m } from 'foldkit/message'
import { evo } from 'foldkit/struct'

const TICK_INTERVAL_MS = 10

// MODEL

export const Model = S.Struct({
  elapsedMs: S.Number,
  isRunning: S.Boolean,
  startTime: S.Number,
})
export type Model = typeof Model.Type

// MESSAGE

export const ClickedStart = m('ClickedStart')
export const DeterminedStartTime = m('DeterminedStartTime', {
  startTime: S.Number,
})
export const ClickedStop = m('ClickedStop')
export const ClickedReset = m('ClickedReset')
export const Ticked = m('Ticked')
export const DeterminedTickTime = m('DeterminedTickTime', {
  elapsedMs: S.Number,
})

export const Message = S.Union([
  ClickedStart,
  DeterminedStartTime,
  ClickedStop,
  ClickedReset,
  Ticked,
  DeterminedTickTime,
])
export type Message = typeof Message.Type

// COMMAND

export const DetermineStartTime = Command.define(
  'DetermineStartTime',
  { elapsedMs: S.Number },
  DeterminedStartTime,
)(({ elapsedMs }) =>
  Effect.gen(function* () {
    const now = yield* Clock.currentTimeMillis
    return DeterminedStartTime({ startTime: now - elapsedMs })
  }),
)

export const DetermineTickTime = Command.define(
  'DetermineTickTime',
  { startTime: S.Number },
  DeterminedTickTime,
)(({ startTime }) =>
  Effect.gen(function* () {
    const now = yield* Clock.currentTimeMillis
    return DeterminedTickTime({ elapsedMs: now - startTime })
  }),
)

// UPDATE

export const update = (
  model: Model,
  message: Message,
): readonly [Model, ReadonlyArray<Command.Command<Message>>] =>
  M.value(message).pipe(
    M.withReturnType<
      readonly [Model, ReadonlyArray<Command.Command<Message>>]
    >(),
    M.tagsExhaustive({
      ClickedStart: () => [
        model,
        [DetermineStartTime({ elapsedMs: model.elapsedMs })],
      ],

      DeterminedStartTime: ({ startTime }) => [
        evo(model, {
          isRunning: () => true,
          startTime: () => startTime,
        }),
        [],
      ],

      ClickedStop: () => [
        evo(model, {
          isRunning: () => false,
        }),
        [],
      ],

      ClickedReset: () => [
        evo(model, {
          elapsedMs: () => 0,
          isRunning: () => false,
          startTime: () => 0,
        }),
        [],
      ],

      Ticked: () => [
        model,
        [DetermineTickTime({ startTime: model.startTime })],
      ],

      DeterminedTickTime: ({ elapsedMs }) => [
        evo(model, {
          elapsedMs: () => elapsedMs,
        }),
        [],
      ],
    }),
  )

// INIT

export const init: Runtime.ApplicationInit<Model, Message> = () => [
  {
    elapsedMs: 0,
    isRunning: false,
    startTime: 0,
  },
  [],
]

// SUBSCRIPTION

export const subscriptions = Subscription.make<Model, Message>()(entry => ({
  tick: entry(
    { isRunning: S.Boolean },
    {
      modelToDependencies: model => ({ isRunning: model.isRunning }),
      dependenciesToStream: ({ isRunning }) =>
        Stream.when(
          Stream.tick(Duration.millis(TICK_INTERVAL_MS)).pipe(
            Stream.map(Ticked),
          ),
          Effect.sync(() => isRunning),
        ),
    },
  ),
}))

// VIEW

const formatTime = (ms: number): string => {
  const minutes = pipe(Duration.millis(ms), Duration.toMinutes, floorAndPad)

  const seconds = pipe(
    Duration.millis(ms % 60000),
    Duration.toSeconds,
    floorAndPad,
  )

  const centiseconds = pipe(
    Duration.millis(ms % 1000),
    Duration.toMillis,
    v => v / 10,
    floorAndPad,
  )

  return `${minutes}:${seconds}.${centiseconds}`
}

const floorAndPad = flow(Math.floor, v => v.toString(), String.padStart(2, '0'))

export const view = (model: Model): Document => {
  const h = html<Message>()

  return {
    title: `Stopwatch ${formatTime(model.elapsedMs)}`,
    body: h.div(
      [h.Class('min-h-screen bg-gray-200 flex items-center justify-center')],
      [
        h.div(
          [h.Class('bg-white text-center')],
          [
            h.div(
              [h.Class('text-6xl font-mono font-bold text-gray-800 p-8')],
              [formatTime(model.elapsedMs)],
            ),
            h.div(
              [h.Class('flex')],
              [
                h.button(
                  [
                    h.OnClick(ClickedReset()),
                    h.Class(buttonStyle + ' bg-gray-500 hover:bg-gray-600'),
                  ],
                  ['Reset'],
                ),
                startStopButton(model.isRunning),
              ],
            ),
          ],
        ),
      ],
    ),
  }
}

const startStopButton = (isRunning: boolean): Html => {
  const h = html<Message>()

  return isRunning
    ? h.button(
        [
          h.OnClick(ClickedStop()),
          h.Class(buttonStyle + ' bg-red-500 hover:bg-red-600'),
        ],
        ['Stop'],
      )
    : h.button(
        [
          h.OnClick(ClickedStart()),
          h.Class(buttonStyle + ' bg-green-500 hover:bg-green-600'),
        ],
        ['Start'],
      )
}

// STYLE

const buttonStyle =
  'px-6 py-4 flex-1 font-semibold text-white transition-colors'