TodoMVC

A work-in-progress implementation of the TodoMVC application.

Check out the Live Demo!

package todomvc

import calico.*
import calico.frp.*
import calico.frp.given
import calico.html.io.*
import calico.html.io.given
import calico.router.*
import calico.syntax.*
import cats.data.*
import cats.effect.*
import cats.effect.std.*
import cats.effect.syntax.all.*
import cats.syntax.all.*
import fs2.concurrent.*
import fs2.dom.*
import io.circe.Codec
import io.circe.jawn
import io.circe.syntax.*
import org.http4s.*
import org.scalajs.dom.KeyValue

import scala.collection.immutable.SortedMap

object TodoMvc extends IOWebApp:

  def render = (TodoStore(window), Router(window).toResource).flatMapN { (store, router) =>
    router.dispatch {
      Routes.one[IO] {
        case uri if uri.fragment == Some("/active") => Filter.Active
        case uri if uri.fragment == Some("/completed") => Filter.Completed
        case _ => Filter.All
      } { filter =>
        div(
          cls := "todoapp",
          div(cls := "header", h1("todos"), TodoInput(store)),
          div(
            cls := "main",
            ul(
              cls := "todo-list",
              children[Long](id => TodoItem(store.entry(id))) <-- filter.flatMap(store.ids(_))
            )
          ),
          store
            .size
            .map(_ > 0)
            .changes
            .map(if _ then StatusBar(store.activeCount, filter, router).some else None)
        )
      }
    }
  }

  def TodoInput(store: TodoStore): Resource[IO, HtmlInputElement[IO]] =
    input.withSelf { self =>
      (
        cls := "new-todo",
        placeholder := "What needs to be done?",
        autoFocus := true,
        onKeyDown --> {
          _.filter(_.key == KeyValue.Enter)
            .evalMap(_ => self.value.get)
            .filterNot(_.isEmpty)
            .foreach(store.create(_) *> self.value.set(""))
        }
      )
    }

  def TodoItem(todo: SignallingRef[IO, Option[Todo]]): Resource[IO, HtmlLiElement[IO]] =
    SignallingRef[IO].of(false).toResource.flatMap { editing =>
      li(
        cls <-- (todo: Signal[IO, Option[Todo]], editing: Signal[IO, Boolean]).mapN { (t, e) =>
          val completed = Option.when(t.exists(_.completed))("completed")
          val editing = Option.when(e)("editing")
          completed.toList ++ editing.toList
        },
        onDblClick --> (_.foreach(_ => editing.set(true))),
        children <-- editing.map {
          case true =>
            List(
              input.withSelf { self =>
                val endEdit = self.value.get.flatMap { text =>
                  todo.update(_.map(_.copy(text = text))) *> editing.set(false)
                }

                (
                  cls := "edit",
                  defaultValue <-- todo.map(_.foldMap(_.text)),
                  onKeyDown --> {
                    _.filter(_.key == KeyValue.Enter).foreach(_ => endEdit)
                  },
                  onBlur --> (_.foreach(_ => endEdit))
                )
              }
            )
          case false =>
            List(
              input.withSelf { self =>
                (
                  cls := "toggle",
                  typ := "checkbox",
                  checked <-- todo.map(_.fold(false)(_.completed)),
                  onInput --> {
                    _.foreach { _ =>
                      self.checked.get.flatMap { checked =>
                        todo.update(_.map(_.copy(completed = checked)))
                      }
                    }
                  }
                )
              },
              label(todo.map(_.map(_.text))),
              button(cls := "destroy", onClick --> (_.foreach(_ => todo.set(None))))
            )
        }
      )
    }

  def StatusBar(
      activeCount: Signal[IO, Int],
      filter: Signal[IO, Filter],
      router: Router[IO]
  ): Resource[IO, HtmlElement[IO]] =
    footerTag(
      cls := "footer",
      span(
        cls := "todo-count",
        activeCount.map {
          case 1 => "1 item left"
          case n => n.toString + " items left"
        }
      ),
      ul(
        cls := "filters",
        Filter
          .values
          .toList
          .map { f =>
            li(
              a(
                cls <-- filter.map(_ == f).map(Option.when(_)("selected").toList),
                onClick --> (_.foreach(_ => router.navigate(Uri(fragment = f.fragment.some)))),
                f.toString
              )
            )
          }
      )
    )

class TodoStore(entries: SignallingSortedMapRef[IO, Long, Todo], nextId: IO[Long]):
  def create(text: String): IO[Unit] =
    nextId.flatMap(entries(_).set(Some(Todo(text, false))))

  def entry(id: Long): SignallingRef[IO, Option[Todo]] = entries(id)

  def ids(filter: Filter): Signal[IO, List[Long]] =
    entries.map(_.filter((_, t) => filter.pred(t)).keySet.toList)

  def size: Signal[IO, Int] = entries.map(_.size)

  def activeCount: Signal[IO, Int] = entries.map(_.values.count(!_.completed))

object TodoStore:

  def apply(window: Window[IO]): Resource[IO, TodoStore] =
    val key = "todos-calico"

    for
      mapRef <- SignallingSortedMapRef[IO, Long, Todo].toResource

      _ <- Resource.eval {
        OptionT(window.localStorage.getItem(key))
          .subflatMap(jawn.decode[SortedMap[Long, Todo]](_).toOption)
          .foreachF(mapRef.set(_))
      }

      _ <- window
        .localStorage
        .events(window)
        .foreach {
          case Storage.Event.Updated(`key`, _, value, _) =>
            jawn.decode[SortedMap[Long, Todo]](value).foldMapM(mapRef.set(_))
          case _ => IO.unit
        }
        .compile
        .drain
        .background

      _ <- mapRef
        .discrete
        .foreach(todos => IO.cede *> window.localStorage.setItem(key, todos.asJson.noSpaces))
        .compile
        .drain
        .background
    yield new TodoStore(mapRef, IO.realTime.map(_.toMillis))

case class Todo(text: String, completed: Boolean) derives Codec.AsObject

enum Filter(val fragment: String, val pred: Todo => Boolean):
  case All extends Filter("/", _ => true)
  case Active extends Filter("/active", !_.completed)
  case Completed extends Filter("/completed", _.completed)