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, HtmlBuilder } from 'foldkit/html'
import { m } from 'foldkit/message'
import { evo } from 'foldkit/struct'

import { Button } from '@foldkit/ui'

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 CompletedDetermineStartTime = m('CompletedDetermineStartTime', {
  startTime: S.Number,
})
export const ClickedStop = m('ClickedStop')
export const ClickedReset = m('ClickedReset')
export const Ticked = m('Ticked')
export const CompletedDetermineTickTime = m('CompletedDetermineTickTime', {
  elapsedMs: S.Number,
})

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

// COMMAND

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

export const DetermineTickTime = Command.define('DetermineTickTime', {
  args: { startTime: S.Number },
  messages: [CompletedDetermineTickTime],
  execute: ({ startTime }) =>
    Effect.gen(function* () {
      const now = yield* Clock.currentTimeMillis
      return CompletedDetermineTickTime({ 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 })],
      ],

      CompletedDetermineStartTime: ({ 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 })],
      ],

      CompletedDetermineTickTime: ({ 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, h: HtmlBuilder<Message>): Document => ({
  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')],
            [
              Button.view(
                {
                  onClick: ClickedReset(),
                  toView: attributes =>
                    h.button(
                      [
                        ...attributes.button,
                        h.Class(buttonStyle + ' bg-gray-500 hover:bg-gray-600'),
                      ],
                      ['Reset'],
                    ),
                },
                h,
              ),
              startStopButton(model.isRunning, h),
            ],
          ),
        ],
      ),
    ],
  ),
})

const startStopButton = (isRunning: boolean, h: HtmlBuilder<Message>): Html =>
  isRunning
    ? Button.view(
        {
          onClick: ClickedStop(),
          toView: attributes =>
            h.button(
              [
                ...attributes.button,
                h.Class(buttonStyle + ' bg-red-500 hover:bg-red-600'),
              ],
              ['Stop'],
            ),
        },
        h,
      )
    : Button.view(
        {
          onClick: ClickedStart(),
          toView: attributes =>
            h.button(
              [
                ...attributes.button,
                h.Class(buttonStyle + ' bg-green-500 hover:bg-green-600'),
              ],
              ['Start'],
            ),
        },
        h,
      )

// STYLE

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