Simulation 02
Todo Mission
A full todo app with a Yesterday/Today view. Demonstrates each, model,
getItemContext, computed filtering, and effect-based
localStorage persistence — all without a build step.
Source Code
<div id="todo-app">
<!-- View selector -->
<button cls="todayTabCls" onclick="showToday">
Today <span text="activeCount"></span>
</button>
<button cls="yesterdayTabCls" onclick="showCompleted">
Yesterday <span text="completedCount"></span>
</button>
<!-- Add form, hidden in yesterday view -->
<div show="isToday">
<input model="newTodo" placeholder="Mission objective...">
<button onclick="addTodo">Launch</button>
</div>
<!-- Keyed list -->
<ul each="visibleTodos" key="id">
<template>
<li>
<span text="text" cls="textCls"></span>
<button onclick="toggleTodo" text="actionLabel"></button>
<button onclick="removeTodo">Abort</button>
</li>
</template>
</ul>
<p text="summary"></p>
</div>
import { bind, signal, computed, effect, getItemContext } from 'rdbl'
// Item factory — bakes display properties into each item
function makeTodo(text, done = false) {
return {
id: Date.now() + Math.random(),
text,
done,
textCls: done ? 'todo-text done' : 'todo-text',
actionLabel: done ? 'RESTORE' : 'COMPLETE',
}
}
// ── Signals ──────────────────────────────────────────────────
const todos = signal([makeTodo('Review mission parameters')])
const view = signal('today') // 'today' | 'yesterday'
const newTodo = signal('')
// ── Computed ─────────────────────────────────────────────────
const activeTodos = computed(() => todos().filter(t => !t.done))
const completedTodos = computed(() => todos().filter(t => t.done))
const visibleTodos = computed(() =>
view() === 'today' ? activeTodos() : completedTodos()
)
const summary = computed(() => {
const done = completedTodos().length
const total = todos().length
return `${done} of ${total} missions complete`
})
const isToday = computed(() => view() === 'today')
const todayTabCls = computed(() => view() === 'today' ? 'tab tab-active' : 'tab')
const yesterdayTabCls = computed(() => view() === 'yesterday' ? 'tab tab-active' : 'tab')
// ── Effect: persist on every change ──────────────────────────
effect(() => {
localStorage.setItem('todos', JSON.stringify(todos()))
})
// ── Handlers ─────────────────────────────────────────────────
function addTodo() {
const text = newTodo().trim()
if (!text) return
todos.set([...todos(), makeTodo(text)])
newTodo.set('')
}
function toggleTodo(event, el) {
const { item } = getItemContext(el)
todos.set(todos().map(t =>
t.id !== item.id ? t : {
...t,
done: !t.done,
textCls: !t.done ? 'todo-text done' : 'todo-text',
actionLabel: !t.done ? 'RESTORE' : 'COMPLETE',
}
))
}
function removeTodo(event, el) {
const { item } = getItemContext(el)
todos.set(todos().filter(t => t.id !== item.id))
}
// ── Bind ─────────────────────────────────────────────────────
bind(document.querySelector('#todo-app'), {
todos, newTodo, visibleTodos,
activeCount: computed(() => activeTodos().length),
completedCount: computed(() => completedTodos().length),
isEmpty: computed(() => visibleTodos().length === 0),
isToday, todayTabCls, yesterdayTabCls, summary,
addTodo, toggleTodo, removeTodo,
showToday: () => view.set('today'),
showCompleted: () => view.set('completed'),
})
// Signals (reactive sources)
todos : Signal<Todo[]> // array of all todos
view : Signal<string> // 'today' | 'yesterday'
newTodo : Signal<string> // input value
// Computeds (derived from signals)
activeTodos // todos where done === false
completedTodos // todos where done === true
visibleTodos // whichever list is active
activeCount // number
completedCount // number
isEmpty // boolean
isToday // boolean — controls show= on add form
todayTabCls // CSS class string for today tab
yesterdayTabCls // CSS class string for yesterday tab
summary // "N of M missions complete"
// Per-item display properties (baked into the array items)
item.textCls // 'todo-text' | 'todo-text done'
item.actionLabel // 'COMPLETE' | 'RESTORE'
// Handlers
addTodo, toggleTodo, removeTodo
showToday, showCompleted
Key Patterns
Yesterday/Today — filter with computed
Two views, one array. visibleTodos is a computed that filters based on the view signal.
Switching tabs just calls view.set('yesterday') — the list re-renders automatically.
const visibleTodos = computed(() =>
view() === 'today' ? activeTodos() : completedTodos()
)
Per-item display properties
Inside an each template, the scope is the item itself. For conditional styling, bake the derived
properties into the item when creating or updating it — instead of reaching back to the parent scope.
// When toggling, update the display properties too
todos.set(todos().map(t =>
t.id !== item.id ? t : {
...t,
done: !t.done,
textCls: !t.done ? 'done' : '',
actionLabel: !t.done ? 'RESTORE' : 'COMPLETE',
}
))
getItemContext — which item was clicked?
Inside a list event handler, getItemContext(el) walks up the DOM to find the bound item for
that row. Works regardless of how deeply nested the clicked element is.
function removeTodo(event, el) {
const { item, index } = getItemContext(el)
todos.set(todos().filter(t => t.id !== item.id))
}
localStorage persistence with effect
One effect handles all persistence. It runs whenever todos changes — no need to call it manually.
effect(() => {
localStorage.setItem('todos', JSON.stringify(todos()))
})
effect + localStorage in four lines.