Skip to main content
All Examples

Todo

A todo list persisted in localStorage. Add, complete, and delete tasks.

Storage
/
import clsx from 'clsx'
import {
  Array,
  Clock,
  Effect,
  Match,
  Option,
  Random,
  Schema,
  String,
} from 'effect'
import { KeyValueStore } from 'effect/unstable/persistence'
import { Command, Runtime, type Update } from 'foldkit'
import { Document, Html, HtmlBuilder } from 'foldkit/html'
import { defineMessageUnion } from 'foldkit/message'
import { defineTaggedUnion } from 'foldkit/schema'
import { evo } from 'foldkit/struct'

import { BrowserKeyValueStore } from '@effect/platform-browser'
import { Button, Checkbox, Input } from '@foldkit/ui'

// CONSTANT

const TODOS_STORAGE_KEY = 'todos'

// MODEL

const Todo = Schema.Struct({
  id: Schema.String,
  text: Schema.String,
  completed: Schema.Boolean,
  createdAt: Schema.Number,
})
type Todo = typeof Todo.Type

const Todos = Schema.Array(Todo)
type Todos = typeof Todos.Type

const TodosJsonString = Schema.fromJsonString(Schema.toCodecJson(Todos))

const Filter = Schema.Literals(['All', 'Active', 'Completed'])
type Filter = typeof Filter.Type

export const EditingState = defineTaggedUnion({
  NotEditing: {},
  Editing: { id: Schema.String, text: Schema.String },
})
export type EditingState = typeof EditingState.Type

export const Model = Schema.Struct({
  todos: Todos,
  newTodoText: Schema.String,
  filter: Filter,
  editing: EditingState,
})
export type Model = typeof Model.Type

// MESSAGE

export const Message = defineMessageUnion({
  UpdatedNewTodo: { text: Schema.String },
  UpdatedEditingTodo: { text: Schema.String },
  AddedTodo: {},
  CompletedGenerateTodo: {
    id: Schema.String,
    timestamp: Schema.Number,
    text: Schema.String,
  },
  DeletedTodo: { id: Schema.String },
  ToggledTodo: { id: Schema.String },
  StartedEditing: { id: Schema.String },
  SavedEdit: {},
  CancelledEdit: {},
  ToggledAll: {},
  ClearedCompleted: {},
  SelectedFilter: { filter: Filter },
  SucceededSaveTodos: { todos: Todos },
  FailedSaveTodos: {},
})
export type Message = typeof Message.Type

// FLAGS

export const Flags = Schema.Struct({
  todos: Schema.Option(Todos),
})
export type Flags = typeof Flags.Type

// INIT

export const init: Runtime.ApplicationInit<Model, Message, Flags> = flags => ({
  model: {
    todos: Option.getOrElse(flags.todos, () => []),
    newTodoText: '',
    filter: 'All',
    editing: EditingState.NotEditing(),
  },
})

// UPDATE

type UpdateReturn = Update.Return<Model, Message>

export const update = (model: Model, message: Message) =>
  Message.match<UpdateReturn>(message, {
    UpdatedNewTodo: ({ text }) => ({
      model: evo(model, {
        newTodoText: () => text,
      }),
    }),

    UpdatedEditingTodo: ({ text }) => ({
      model: evo(model, {
        editing: () =>
          EditingState.match(model.editing, {
            NotEditing: () => model.editing,
            Editing: ({ id }) => EditingState.Editing({ id, text }),
          }),
      }),
    }),

    AddedTodo: () => {
      if (String.isEmpty(String.trim(model.newTodoText))) {
        return { model }
      }

      return {
        model,
        commands: [GenerateTodo({ text: String.trim(model.newTodoText) })],
      }
    },

    CompletedGenerateTodo: ({ id, timestamp, text }) => {
      const newTodo: Todo = {
        id,
        text,
        completed: false,
        createdAt: timestamp,
      }

      const updatedTodos = [...model.todos, newTodo]

      return {
        model: evo(model, {
          todos: () => updatedTodos,
          newTodoText: () => '',
        }),
        commands: [SaveTodos({ todos: updatedTodos })],
      }
    },

    DeletedTodo: ({ id }) => {
      const updatedTodos = Array.filter(model.todos, todo => todo.id !== id)

      return {
        model: evo(model, {
          todos: () => updatedTodos,
        }),
        commands: [SaveTodos({ todos: updatedTodos })],
      }
    },

    ToggledTodo: ({ id }) => {
      const updatedTodos = Array.map(model.todos, todo =>
        todo.id === id
          ? evo(todo, { completed: completed => !completed })
          : todo,
      )

      return {
        model: evo(model, {
          todos: () => updatedTodos,
        }),
        commands: [SaveTodos({ todos: updatedTodos })],
      }
    },

    StartedEditing: ({ id }) => {
      const maybeTodo = Array.findFirst(model.todos, todo => todo.id === id)
      return {
        model: evo(model, {
          editing: () =>
            EditingState.Editing({
              id,
              text: Option.match(maybeTodo, {
                onNone: () => '',
                onSome: todo => todo.text,
              }),
            }),
        }),
      }
    },

    SavedEdit: () =>
      EditingState.match<UpdateReturn>(model.editing, {
        NotEditing: () => ({ model }),

        Editing: ({ id, text }) => {
          if (String.isEmpty(String.trim(text))) {
            return {
              model: evo(model, {
                editing: () => EditingState.NotEditing(),
              }),
            }
          }

          const updatedTodos = Array.map(model.todos, todo =>
            todo.id === id
              ? evo(todo, { text: () => String.trim(text) })
              : todo,
          )

          return {
            model: evo(model, {
              todos: () => updatedTodos,
              editing: () => EditingState.NotEditing(),
            }),
            commands: [SaveTodos({ todos: updatedTodos })],
          }
        },
      }),

    CancelledEdit: () => ({
      model: evo(model, {
        editing: () => EditingState.NotEditing(),
      }),
    }),

    ToggledAll: () => {
      const allCompleted = Array.every(model.todos, todo => todo.completed)
      const updatedTodos = Array.map(model.todos, todo =>
        evo(todo, {
          completed: () => !allCompleted,
        }),
      )

      return {
        model: evo(model, {
          todos: () => updatedTodos,
        }),
        commands: [SaveTodos({ todos: updatedTodos })],
      }
    },

    ClearedCompleted: () => {
      const updatedTodos = Array.filter(model.todos, todo => !todo.completed)

      return {
        model: evo(model, {
          todos: () => updatedTodos,
        }),
        commands: [SaveTodos({ todos: updatedTodos })],
      }
    },

    SelectedFilter: ({ filter }) => ({
      model: evo(model, {
        filter: () => filter,
      }),
    }),

    SucceededSaveTodos: ({ todos }) => ({
      model: evo(model, {
        todos: () => todos,
      }),
    }),

    FailedSaveTodos: () => ({ model }),
  })

// COMMAND

export const GenerateTodo = Command.define('GenerateTodo', {
  args: { text: Schema.String },
  messages: [Message.CompletedGenerateTodo],
  execute: ({ text }) =>
    Effect.gen(function* () {
      const id = yield* Random.nextIntBetween(0, Number.MAX_SAFE_INTEGER).pipe(
        Effect.map(value => value.toString(36)),
      )
      const timestamp = yield* Clock.currentTimeMillis
      return Message.CompletedGenerateTodo({ id, timestamp, text })
    }),
})

export const SaveTodos = Command.define('SaveTodos', {
  args: { todos: Todos },
  messages: [Message.SucceededSaveTodos, Message.FailedSaveTodos],
  execute: ({ todos }) =>
    Effect.gen(function* () {
      const store = yield* KeyValueStore.KeyValueStore
      yield* store.set(
        TODOS_STORAGE_KEY,
        Schema.encodeSync(TodosJsonString)(todos),
      )
      return Message.SucceededSaveTodos({ todos })
    }).pipe(
      Effect.catch(() => Effect.succeed(Message.FailedSaveTodos())),
      Effect.provide(BrowserKeyValueStore.layerLocalStorage),
    ),
})

// VIEW

const editingTextFor = (
  editing: EditingState,
  todoId: string,
): Option.Option<string> =>
  EditingState.match(editing, {
    NotEditing: () => Option.none(),
    Editing: ({ id, text }) => Option.liftPredicate(text, () => id === todoId),
  })

const todoItemView = (
  todo: Todo,
  maybeEditingText: Option.Option<string>,
  h: HtmlBuilder<Message>,
): Html =>
  Option.match(maybeEditingText, {
    onNone: () => nonEditingTodoView(todo, h),
    onSome: text => editingTodoView(todo, text, h),
  })

const editingTodoView = (
  todo: Todo,
  text: string,
  h: HtmlBuilder<Message>,
): Html =>
  h.keyed('li')(
    todo.id,
    [h.Class('flex items-center gap-3 p-3 bg-gray-50 rounded-lg')],
    [
      Input.view(
        {
          id: `edit-${todo.id}`,
          value: text,
          onInput: text => Message.UpdatedEditingTodo({ text }),
          toView: attributes =>
            h.input([
              ...attributes.input,
              h.AriaLabel('Edit todo'),
              h.Class(
                'flex-1 px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500',
              ),
            ]),
        },
        h,
      ),
      Button.view(
        {
          onClick: Message.SavedEdit(),
          toView: attributes =>
            h.button(
              [
                ...attributes.button,
                h.Class(
                  'px-3 py-1 bg-green-500 text-white rounded hover:bg-green-600',
                ),
              ],
              ['Save'],
            ),
        },
        h,
      ),
      Button.view(
        {
          onClick: Message.CancelledEdit(),
          toView: attributes =>
            h.button(
              [
                ...attributes.button,
                h.Class(
                  'px-3 py-1 bg-gray-500 text-white rounded hover:bg-gray-600',
                ),
              ],
              ['Cancel'],
            ),
        },
        h,
      ),
    ],
  )

const checkboxBoxClassName = (isChecked: boolean): string =>
  clsx(
    'flex h-4 w-4 items-center justify-center rounded border transition cursor-pointer',
    isChecked ? 'border-blue-600 bg-blue-600' : 'border-gray-300',
  )

const nonEditingTodoView = (todo: Todo, h: HtmlBuilder<Message>): Html =>
  h.keyed('li')(
    todo.id,
    [h.Class('flex items-center gap-3 p-3 hover:bg-gray-50 rounded-lg group')],
    [
      Checkbox.view(
        {
          id: `todo-${todo.id}`,
          isChecked: todo.completed,
          onToggle: () => Message.ToggledTodo({ id: todo.id }),
          toView: attributes =>
            h.div(
              [h.Class('flex items-center')],
              [
                h.div(
                  [
                    ...attributes.checkbox,
                    h.Class(checkboxBoxClassName(todo.completed)),
                  ],
                  todo.completed
                    ? [h.span([h.Class('text-white text-xs')], ['✓'])]
                    : [],
                ),
                h.span([...attributes.label, h.AriaLabel(todo.text)]),
              ],
            ),
        },
        h,
      ),
      h.span(
        [
          h.Class(
            `flex-1 ${todo.completed ? 'line-through text-gray-500' : 'text-gray-900'}`,
          ),
          h.OnClick(Message.StartedEditing({ id: todo.id })),
        ],
        [todo.text],
      ),
      Button.view(
        {
          onClick: Message.DeletedTodo({ id: todo.id }),
          toView: attributes =>
            h.button(
              [
                ...attributes.button,
                h.AriaLabel(`Delete ${todo.text}`),
                h.Class(
                  'px-2 py-1 text-red-600 opacity-0 group-hover:opacity-100 hover:bg-red-100 rounded transition-opacity',
                ),
              ],
              ['×'],
            ),
        },
        h,
      ),
    ],
  )

const filterButtonView =
  (model: Model) =>
  (filter: Filter, label: string, h: HtmlBuilder<Message>): Html =>
    Button.view(
      {
        onClick: Message.SelectedFilter({ filter }),
        toView: attributes =>
          h.button(
            [
              ...attributes.button,
              h.Class(
                `px-3 py-1 rounded ${
                  model.filter === filter
                    ? 'bg-blue-500 text-white'
                    : 'bg-gray-200 text-gray-700 hover:bg-gray-300'
                }`,
              ),
            ],
            [label],
          ),
      },
      h,
    )

const footerView = (
  model: Model,
  activeCount: number,
  completedCount: number,
  h: HtmlBuilder<Message>,
): Html =>
  Array.match(model.todos, {
    onEmpty: () => h.empty,
    onNonEmpty: () =>
      h.div(
        [h.Class('flex flex-col gap-4')],
        [
          h.div(
            [h.Class('text-sm text-gray-600 text-center'), h.Role('status')],
            [`${activeCount} active, ${completedCount} completed`],
          ),

          h.div(
            [h.Class('flex justify-center gap-2')],
            [
              filterButtonView(model)('All', 'All', h),
              filterButtonView(model)('Active', 'Active', h),
              filterButtonView(model)('Completed', 'Completed', h),
            ],
          ),

          h.div(
            [h.Class('flex justify-center gap-2')],
            [
              Array.match(model.todos, {
                onEmpty: () => h.empty,
                onNonEmpty: todos =>
                  Button.view(
                    {
                      onClick: Message.ToggledAll(),
                      toView: attributes =>
                        h.button(
                          [
                            ...attributes.button,
                            h.Class(
                              'px-3 py-1 text-sm bg-gray-200 text-gray-700 rounded hover:bg-gray-300',
                            ),
                          ],
                          [
                            Array.every(todos, todo => todo.completed)
                              ? 'Mark all active'
                              : 'Mark all complete',
                          ],
                        ),
                    },
                    h,
                  ),
              }),

              completedCount > 0
                ? Button.view(
                    {
                      onClick: Message.ClearedCompleted(),
                      toView: attributes =>
                        h.button(
                          [
                            ...attributes.button,
                            h.Class(
                              'px-3 py-1 text-sm bg-red-100 text-red-700 rounded hover:bg-red-200',
                            ),
                          ],
                          [`Clear ${completedCount} completed`],
                        ),
                    },
                    h,
                  )
                : h.empty,
            ],
          ),
        ],
      ),
  })

const filterTodos = (todos: Todos, filter: Filter): Todos =>
  Match.value(filter).pipe(
    Match.when('All', () => todos),
    Match.when('Active', () => Array.filter(todos, todo => !todo.completed)),
    Match.when('Completed', () => Array.filter(todos, todo => todo.completed)),
    Match.exhaustive,
  )

export const view = (model: Model, h: HtmlBuilder<Message>): Document => {
  const filteredTodos = filterTodos(model.todos, model.filter)
  const activeCount = Array.length(
    Array.filter(model.todos, todo => !todo.completed),
  )
  const completedCount = Array.length(model.todos) - activeCount

  const body = h.div(
    [h.Class('min-h-screen bg-gray-100 py-8')],
    [
      h.div(
        [h.Class('max-w-md mx-auto bg-white rounded-xl shadow-lg p-6')],
        [
          h.h1(
            [h.Class('text-3xl font-bold text-gray-800 text-center mb-8')],
            ['Todo App'],
          ),

          h.form(
            [h.Class('mb-6'), h.OnSubmit(Message.AddedTodo())],
            [
              h.div(
                [h.Class('flex gap-3')],
                [
                  Input.view(
                    {
                      id: 'new-todo',
                      value: model.newTodoText,
                      placeholder: 'What needs to be done?',
                      onInput: text => Message.UpdatedNewTodo({ text }),
                      toView: attributes =>
                        h.input([
                          ...attributes.input,
                          h.AriaLabel('New todo'),
                          h.Class(
                            'flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500',
                          ),
                        ]),
                    },
                    h,
                  ),
                  Button.view(
                    {
                      type: 'submit',
                      toView: attributes =>
                        h.button(
                          [
                            ...attributes.button,
                            h.Class(
                              'px-6 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500',
                            ),
                          ],
                          ['Add'],
                        ),
                    },
                    h,
                  ),
                ],
              ),
            ],
          ),

          Array.match(filteredTodos, {
            onEmpty: () =>
              h.div(
                [h.Class('text-center text-gray-500 py-8')],
                [
                  Match.value(model.filter).pipe(
                    Match.when('All', () => 'No todos yet. Add one above!'),
                    Match.when('Active', () => 'No active todos'),
                    Match.when('Completed', () => 'No completed todos'),
                    Match.exhaustive,
                  ),
                ],
              ),
            onNonEmpty: todos =>
              h.ul(
                [h.Class('space-y-2 mb-6')],
                Array.map(todos, todo =>
                  todoItemView(todo, editingTextFor(model.editing, todo.id), h),
                ),
              ),
          }),

          footerView(model, activeCount, completedCount, h),
        ],
      ),
    ],
  )

  return { title: `Todos (${activeCount})`, body }
}

// FLAG

export const flags: Effect.Effect<Flags> = Effect.gen(function* () {
  const store = yield* KeyValueStore.KeyValueStore
  const todosJson = yield* Effect.fromOption(
    Option.fromNullishOr(yield* store.get(TODOS_STORAGE_KEY)),
  )

  const decodeTodos = Schema.decodeEffect(TodosJsonString)
  const todos = yield* decodeTodos(todosJson)

  return { todos: Option.some(todos) }
}).pipe(
  Effect.catch(() => Effect.succeed({ todos: Option.none() })),
  Effect.provide(BrowserKeyValueStore.layerLocalStorage),
)