Skip to content

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
  • TodoList is a type with 1 field. Its key is the name of the list. Example: home.
  • Status is an enum. A field of an enum type accepts only its values: open and done.
  • list: Ref[TodoList] | None = None is a reference. It contains the key of a TodoList. Example: the value "home" points to the list home.
  • due: dt.date | None = None is an optional date. A task with no due date has None.

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.failing gives the task that does not agree. The task t4 is 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.removed gives the facts of the earlier view that the later view does not have. The task t3 ended on 5 Nov.
  • changes.changed gives each fact with a new version. fields gives the fields that changed. The status of t1 went from open to done.

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 retract for a mistake. Use end for 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.

Next: What was true, and what you knew.