TodoMVC

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

Check out the Live Demo!

package todomvc

import calico.*
import calico.frp.{*, given}
import calico.html.io.{*, given}
import calico.router.*
import cats.data.*
import cats.effect.*
import cats.syntax.all.*
import fs2.concurrent.*
import fs2.dom.*
import io.circe
import io.circe.*
import io.circe.Decoder.Result
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",
          headerTag(cls := "header", h1("todos"), TodoInput(store)),
          sectionTag(
            cls := "main",
            input.withSelf(self =>
              (
                idAttr := "toggle-all",
                cls := "toggle-all",
                typ := "checkbox",
                checked <-- store.allCompleted,
                onInput(self.checked.get.flatMap(store.toggleAll)))),
            label(forId := "toggle-all", "Mark all as complete"),
            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, 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(editing.set(true)),
        children <-- editing.map {
          case true =>
            List(
              input.withSelf { self =>
                val endEdit = self.value.get.map(_.trim).flatMap { text =>
                  todo.update(t =>
                    text match {
                      case "" => None
                      case _ => t.map(_.copy(text = text))
                    })
                } *> editing.set(false)
                (
                  cls := "edit",
                  defaultValue <-- todo.map(_.foldMap(_.text)),
                  onKeyDown {
                    case e if e.key == KeyValue.Enter => endEdit
                    case e if e.key == KeyValue.Escape => editing.set(false)
                    case _ => IO.unit
                  },
                  onBlur {
                    // do not endEdit when blur is triggered after Escape
                    editing.get.ifM(endEdit, IO.unit)
                  }
                )
              }
            )
          case false =>
            List(div(
              cls := "view",
              input.withSelf { self =>
                (
                  cls := "toggle",
                  typ := "checkbox",
                  checked <-- todo.map(_.fold(false)(_.completed)),
                  onInput {
                    self.checked.get.flatMap { checked =>
                      todo.update(_.map(_.copy(completed = checked)))
                    }
                  }
                )
              },
              label(todo.map(_.map(_.text))),
              button(cls := "destroy", onClick(todo.set(None)))
            ))
        }
      )
    }

  def StatusBar(
      store: TodoStore,
      filter: Signal[IO, Filter],
      router: Router[IO]
  ): Resource[IO, HtmlElement[IO]] =
    footerTag(
      cls := "footer",
      span(
        cls := "todo-count",
        strong(store.activeCount.map(_.toString)),
        store.activeCount.map {
          case 1 => " item left"
          case n => " items left"
        }
      ),
      ul(
        cls := "filters",
        Filter.values.toList.map { f =>
          li(
            a(
              cls <-- filter.map(_ == f).map(Option.when(_)("selected").toList),
              onClick(router.navigate(Uri(fragment = f.fragment.some))),
              href := s"/#${f.fragment}",
              f.toString
            )
          )
        }
      ),
      store
        .hasCompleted
        .map(
          Option.when(_)(
            button(
              cls := "clear-completed",
              onClick(store.clearCompleted),
              "Clear completed"
            )))
    )

class TodoStore(entries: SignallingSortedMapRef[IO, Long, Todo], nextId: IO[Long]):
  def toggleAll(completed: Boolean): IO[Unit] =
    entries.update(sm => SortedMap.from(sm.view.mapValues(_.copy(completed = completed))))

  def allCompleted: Signal[IO, Boolean] = entries.map(_.values.forall(_.completed)).changes

  def hasCompleted: Signal[IO, Boolean] = entries.map(_.values.exists(_.completed)).changes

  def clearCompleted: IO[Unit] = entries.update(_.filterNot((_, todo) => todo.completed))

  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).changes

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

object TodoStore:

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

    implicit val encodeFoo: Encoder[(Long, Todo)] = new Encoder[(Long, Todo)] {
      override def apply(a: (Long, Todo)): Json = {
        val (id, todo) = a
        Json.obj(
          ("id", Json.fromLong(id)),
          ("title", Json.fromString(todo.text)),
          ("completed", Json.fromBoolean(todo.completed))
        )
      }
    }

    implicit val decodeFoo: Decoder[(Long, Todo)] = new Decoder[(Long, Todo)] {
      override def apply(c: HCursor): Result[(Long, Todo)] = for {
        id <- c.downField("id").as[Long]
        title <- c.downField("title").as[String]
        completed <- c.downField("completed").as[Boolean]
      } yield {
        (id, Todo(title, completed))
      }
    }

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

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

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

      _ <- mapRef
        .discrete
        .foreach((todos: Map[Long, Todo]) =>
          IO.cede *> window
            .localStorage
            .setItem(
              key,
              todos.toList.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)