Tutorial: a to-do app¶
This tutorial grows 1 world for a to-do app in 14 steps. It starts with 1 type and 1 task. At the end the log has 2 lists and 5 tasks, and the app has 1 goal and 2 functions. Do the quickstart first.
Real output
The code on this page is one script, todo.py. The documentation build runs the
script. Each output on this page comes from that run.
import datetime as dt
from enum import StrEnum
from scriptt.world import Fact, Facts, Goal, Key, Ref, Row, World, say
1. One type gives one table¶
class Task(Fact):
id: Key[str]
title: str
todo = World.new(Task)
todo.add(Task(id="t1", title="Buy milk"), source="me", known="2 Nov 2026 09:00")
print(todo.facts(Task))
id title
t1 Buy milk
The world has 1 type and 1 fact. facts(Task) gives the facts of the type as a table, with 1 column for each
field. This add has no true_from. Thus the task is true for all time.
2. The world gets a new type¶
A to-do app keeps tasks in lists. The next code adds the type TodoList to the same world. It also declares
Task again, with 3 more fields.
class TodoList(Fact):
name: Key[str]
class Status(StrEnum):
OPEN = "open"
DONE = "done"
class Task(Fact):
id: Key[str]
title: str
list: Ref[TodoList] | None = None
status: Status = Status.OPEN
due: dt.date | None = None
todo.add(TodoList(name="home"), source="me", known="2 Nov 2026 09:00")
todo.add(TodoList(name="work"), source="me", known="2 Nov 2026 09:00")
print(todo.facts(Task))
id title list status due
t1 Buy milk none open none
TodoListis a type with 1 field. Its key is the name of the list. Example:home.Statusis an enum. A field of an enum type accepts only its values:openanddone.list: Ref[TodoList] | None = Noneis a reference. It contains the key of aTodoList. Example: the value"home"points to the listhome.due: dt.date | None = Noneis an optional date. A task with no due date hasNone.
Each new field has a default. Thus the task t1 of step 1 is still a valid task. The table writes None as
none.
3. Add the tasks¶
for id, list, title, due in [
("t1", "home", "Buy milk", dt.date(2026, 11, 3)),
("t2", "work", "Send the report", dt.date(2026, 11, 4)),
("t3", "work", "Book the room", dt.date(2026, 11, 6)),
("t4", "home", "Call the plumber", None),
]:
todo.add(
Task(id=id, list=list, title=title, due=due),
source="me",
true_from="2 Nov 2026",
known="2 Nov 2026 09:00",
)
print(todo.on("2 Nov 2026").facts(Task))
id title list status due
t1 Buy milk home open 3 Nov 2026
t2 Send the report work open 4 Nov 2026
t3 Book the room work open 6 Nov 2026
t4 Call the plumber home open none
The loop adds 4 tasks. Each task is true from 2 Nov, and you got each task at 09:00 on 2 Nov. The task t1
already exists. Thus this add gives t1 a new version, with a list and a due date.
4. A done task is a new version¶
milk = todo.on("2 Nov 2026").one(Task, id="t1")
todo.add(
milk.but(status=Status.DONE),
source="me",
true_from="3 Nov 2026",
known="3 Nov 2026 18:30",
)
print(todo.on("2 Nov 2026").one(Task, id="t1").status)
print(todo.on("3 Nov 2026").one(Task, id="t1").status)
open
done
milk.but(status=Status.DONE) gives a copy of the task t1 with a new status. The copy has the same key. Thus
add writes it as a new version of t1. The old versions stay in the log.
| View | Status of t1 |
Cause |
|---|---|---|
todo.on("2 Nov 2026") |
open | The done version is true from 3 Nov. |
todo.on("3 Nov 2026") |
done | The done version is true on 3 Nov. |
5. The log keeps each version¶
print(
todo.history(milk).map(
lambda entry: Row(
list=entry.body.get("list"),
status=entry.body.get("status"),
true_from=entry.true_from,
known=entry.known,
)
)
)
list status true_from known
none none none 2 Nov 2026, 09:00
home open 2 Nov 2026 2 Nov 2026, 09:00
home done 3 Nov 2026 3 Nov 2026, 18:30
history gives each entry of the log for 1 fact. entry.body is the fields of the fact as a dictionary. map
makes 1 row from each entry. Row(list=..., status=...) makes 1 table row with the columns that you name.
| Output line | Meaning |
|---|---|
| 2 | The task of step 1. It has no list and no status field, and no true-from date. |
| 3 | The version of step 3. It has the list home and the status open, true from 2 Nov. |
| 4 | The done version. It is true from 3 Nov. You got it at 18:30 on 3 Nov. |
6. A reference to a list that does not exist¶
The next task points to the list shop. The world has no list with that name.
todo.add(
Task(id="t5", list="shop", title="Buy a lamp"),
source="me",
true_from="3 Nov 2026",
known="3 Nov 2026 19:00",
)
print(todo.on("3 Nov 2026").problems())
rule fact field value
Task.list points to a TodoList task t5 list shop
problems gives each reference that points to no fact. The row names the rule, the fact, the field and the
value.
7. Retract a mistake¶
todo.retract(Task, id="t5", source="me", known="3 Nov 2026 19:05")
print(todo.on("3 Nov 2026").problems().count())
0
To retract a fact is to mark it as a mistake. retract writes 1 more entry to the log. A view that knows
the retraction leaves the task out. Thus the view of 3 Nov has 0 problems. A view with an earlier known time
still shows t5.
8. End a task¶
You do not need the room after 4 Nov. Thus you end the task "Book the room" on 5 Nov.
todo.end(Task, id="t3", on="5 Nov 2026", source="me", known="4 Nov 2026 16:00")
print(todo.on("4 Nov 2026").facts(Task).count())
print(todo.on("5 Nov 2026").facts(Task))
4
id title list status due
t1 Buy milk home done 3 Nov 2026
t2 Send the report work open 4 Nov 2026
t4 Call the plumber home open none
To end a fact is to give the date on which it stops being true. The view of 4 Nov has 4 tasks. The view of 5 Nov has 3 tasks. An ended fact stays true in each view before its end date.
9. The functions of the app¶
def open_tasks(view: World) -> Facts[Task]:
"""The tasks that are open on the date of the view."""
return view.facts(Task).where(lambda task: task.status == Status.OPEN)
def overdue(view: World) -> Facts[Task]:
"""The open tasks whose due date is before the date of the view."""
day = view.on_date
return open_tasks(view).where(lambda task: task.due is not None and task.due < day)
print(overdue(todo.on("5 Nov 2026")))
id title list status due
t2 Send the report work open 4 Nov 2026
The app has 2 functions. Each function reads a view and gives Facts[Task].
| Function | Gives | Result on 5 Nov |
|---|---|---|
open_tasks |
The tasks with the status open. |
t2 and t4 |
overdue |
The open tasks with a due date before the date of the view. | t2, due on 4 Nov |
overdue calls open_tasks. view.on_date is the date of the view. A function can call a different function
of the app.
10. Count the open tasks of each list¶
print(open_tasks(todo.on("5 Nov 2026")).count_by(list=lambda task: task.list))
list n
work 1
home 1
count_by counts the facts in each group. The argument name list becomes the name of the column. The column
n gives the count.
11. A rule for every task¶
every_due = Goal(
"every open task has a due date",
Task,
lambda task: task.status == Status.DONE or task.due is not None,
every=True,
)
result = todo.on("5 Nov 2026").check(every_due)
print(result)
print(result.failing)
every open task has a due date on 5 Nov 2026: not met (2 of 3)
id title list status due
t4 Call the plumber home open none
A goal is a rule and the number of facts that must agree with it. With every=True the number is every
task of the view. The view of 5 Nov has 3 tasks. check tests the goal on the view.
- Line 1 is the result. "(2 of 3)" shows that 2 of the 3 tasks agree. Thus the goal is not met.
result.failinggives the task that does not agree. The taskt4is open and has no due date.
12. What changed between 2 dates¶
changes = todo.on("5 Nov 2026") - todo.on("2 Nov 2026")
print(changes.removed)
print(
Facts(
Row(task=say(t"{change.after}"), field=field, before=before, after=after)
for change in changes.changed
for field, (before, after) in change.fields().items()
)
)
id title list status due
t3 Book the room work open 6 Nov 2026
task field before after
task t1 status open done
The - operator gives the changes from the view of 2 Nov to the view of 5 Nov.
changes.removedgives the facts of the earlier view that the later view does not have. The taskt3ended on 5 Nov.changes.changedgives each fact with a new version.fieldsgives the fields that changed. The status oft1went fromopentodone.
13. A sentence for the person¶
view = todo.on("5 Nov 2026")
late = overdue(view)
print(
say(
t"On {view.on_date} you have {open_tasks(view).count()} open tasks. "
t"{late.count()} is late: {late[0]}."
)
)
On 5 Nov 2026 you have 2 open tasks. 1 is late: task t2.
say writes a fact by its type and its key. Thus late[0] becomes "task t2".
14. A question in SQL¶
print(
view.sql(
t"""SELECT status, count(*) AS tasks
FROM {Task} GROUP BY status ORDER BY status"""
)
)
status tasks
open 2
done 1
sql runs 1 DuckDB query on the facts of the view. {Task} in the t-string becomes a table of the tasks.
status is a DuckDB enum. Thus ORDER BY status uses the order of Status: open, then done.
Summary¶
- Give each kind of thing a type. Mark the fields that name the thing with
Key. - Add a type to the world when the app needs it. Give each new field a default.
- Write a change as a new version with
but. The log keeps each version. - Use
retractfor a mistake. Useendfor a fact that stops being true. - Put each question of the app in a function that reads a view.
- Use a goal for a rule that each fact must agree with.