/
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),
)