Skip to content

Instantly share code, notes, and snippets.

@prescod
Created February 17, 2026 00:55
Show Gist options
  • Select an option

  • Save prescod/ae51afa421450c989c6954b48c22298a to your computer and use it in GitHub Desktop.

Select an option

Save prescod/ae51afa421450c989c6954b48c22298a to your computer and use it in GitHub Desktop.
Todo list app in Roc
app [main!] {
cli: platform "https://github.com/roc-lang/basic-cli/releases/download/0.20.0/X73hGh05nNTkDHU06FHC0YfFaQB1pimX7gncRcao5mU.tar.br",
weaver: "https://github.com/smores56/weaver/releases/download/0.6.0/4GmRnyE7EFjzv6dDpebJoWWwXV285OMt4ntHIc6qvmY.tar.br",
json: "https://github.com/lukewilliamboswell/roc-json/releases/download/0.13.0/RqendgZw5e1RsQa3kFhgtnMP8efWoqGRsAvubx4-zus.tar.br",
}
import cli.Arg exposing [Arg]
import cli.Stdout
import cli.File
import cli.Utc
import weaver.Cli
import weaver.Opt
import weaver.Param
import weaver.SubCmd
import json.Json
# ----------------------------
# Data model
# ----------------------------
Status : [Inbox, Next, Waiting, Someday, Done]
# Simple lowercase for status strings only
normalize_status : Str -> Str
normalize_status = |s|
when s is
"Inbox" | "INBOX" -> "inbox"
"Next" | "NEXT" -> "next"
"Waiting" | "WAITING" -> "waiting"
"Someday" | "SOMEDAY" -> "someday"
"Done" | "DONE" -> "done"
_ -> s
status_from_str : Str -> Result Status Str
status_from_str = |raw|
lower = normalize_status(raw)
when lower is
"inbox" -> Ok(Inbox)
"next" -> Ok(Next)
"waiting" -> Ok(Waiting)
"someday" -> Ok(Someday)
"done" -> Ok(Done)
_ ->
Err(
"Unknown status '${raw}'. Expected one of: inbox, next, waiting, someday, done",
)
Task : {
id: U64,
title: Str,
notes: Str,
status: Str, # persisted as string for simpler JSON encoding/decoding
project: Str,
context: Str,
created_ms: U64,
due_ms: I64, # -1 means no value
done_ms: I64, # -1 means no value
}
Db : { next_id: U64, tasks: List Task }
empty_db : Db
empty_db =
{ next_id: 1, tasks: [] }
db_path : Str
db_path =
".roc-todo.json"
# ----------------------------
# CLI types
# ----------------------------
Command : [
Add { title: Str, notes: Str, project: Str, context: Str, status: Str },
List { status: Result Str [NoValue], project: Result Str [NoValue], context: Result Str [NoValue], all: Bool },
Done { id: U64 },
Move { id: U64, status: Str },
Show { id: U64 },
Delete { id: U64 },
Projects,
Contexts,
Inbox,
Next,
Waiting,
Someday,
]
# ----------------------------
# Main
# ----------------------------
main! : List Arg => Result {} _
main! = |args|
cmd =
Cli.parse_or_display_message(cli_parser, args, Arg.to_os_raw)
|> Result.on_err!(|message|
Stdout.line!(message)?
Err(Exit(1, ""))
)?
db = load_db!({})?
when cmd is
Add(payload) ->
db2 = add_task!(db, payload)?
save_db!(db2)?
Stdout.line!("Added #${Num.to_str(payload_to_assigned_id(db))}: ${payload.title}")?
Ok({})
List(filters) ->
Stdout.line!(render_list(db, filters))?
Ok({})
Done(rec) ->
db2 = mark_done!(db, rec.id)?
save_db!(db2)?
Ok({})
Move(rec) ->
db2 = move_status!(db, rec.id, rec.status)?
save_db!(db2)?
Ok({})
Show(rec) ->
Stdout.line!(render_show(db, rec.id))?
Ok({})
Delete(rec) ->
db2 = delete_task!(db, rec.id)?
save_db!(db2)?
Ok({})
Projects ->
Stdout.line!(render_unique(db, .project))?
Ok({})
Contexts ->
Stdout.line!(render_unique(db, .context))?
Ok({})
Inbox ->
Stdout.line!(render_list(db, { status: Ok("inbox"), project: Err(NoValue), context: Err(NoValue), all: Bool.false }))?
Ok({})
Next ->
Stdout.line!(render_list(db, { status: Ok("next"), project: Err(NoValue), context: Err(NoValue), all: Bool.false }))?
Ok({})
Waiting ->
Stdout.line!(render_list(db, { status: Ok("waiting"), project: Err(NoValue), context: Err(NoValue), all: Bool.false }))?
Ok({})
Someday ->
Stdout.line!(render_list(db, { status: Ok("someday"), project: Err(NoValue), context: Err(NoValue), all: Bool.false }))?
Ok({})
payload_to_assigned_id : Db -> U64
payload_to_assigned_id = |db|
db.next_id
# ----------------------------
# CLI parser (Weaver)
# ----------------------------
cli_parser : Cli.CliParser Command
cli_parser =
add_sub =
{ Cli.weave <-
notes: Opt.str({ short: "n", long: "notes", help: "Optional notes.", default: Value("") }),
project: Opt.str({ short: "p", long: "project", help: "Project name.", default: Value("") }),
context: Opt.str({ short: "c", long: "context", help: "Context (e.g. @home, @computer).", default: Value("") }),
status: Opt.str({ short: "s", long: "status", help: "inbox|next|waiting|someday|done", default: Value("inbox") }),
title: Param.str({ name: "title", help: "Task title (required)." }),
}
|> Cli.map(|r| Add(r))
|> SubCmd.finish({
name: "add",
description: "Capture something new (GTD capture).",
mapper: |cmd| cmd,
})
list_sub =
{ Cli.weave <-
status: Opt.maybe_str({ short: "s", long: "status", help: "Filter by status." }),
project: Opt.maybe_str({ short: "p", long: "project", help: "Filter by project." }),
context: Opt.maybe_str({ short: "c", long: "context", help: "Filter by context." }),
all: Opt.flag({ short: "a", long: "all", help: "Include done tasks too." }),
}
|> Cli.map(|r| List(r))
|> SubCmd.finish({
name: "list",
description: "List tasks (GTD review).",
mapper: |cmd| cmd,
})
done_sub =
Param.u64({ name: "id", help: "Task id." })
|> Cli.map(|id| Done({ id }))
|> SubCmd.finish({
name: "done",
description: "Mark a task as done.",
mapper: |cmd| cmd,
})
move_sub =
{ Cli.weave <-
id: Param.u64({ name: "id", help: "Task id." }),
status: Param.str({ name: "status", help: "New status: inbox|next|waiting|someday|done" }),
}
|> Cli.map(|r| Move(r))
|> SubCmd.finish({
name: "move",
description: "Move a task to a new GTD bucket.",
mapper: |cmd| cmd,
})
show_sub =
Param.u64({ name: "id", help: "Task id." })
|> Cli.map(|id| Show({ id }))
|> SubCmd.finish({
name: "show",
description: "Show a task in detail.",
mapper: |cmd| cmd,
})
delete_sub =
Param.u64({ name: "id", help: "Task id." })
|> Cli.map(|id| Delete({ id }))
|> SubCmd.finish({
name: "delete",
description: "Delete a task.",
mapper: |cmd| cmd,
})
projects_sub =
Opt.flag({ short: "h", long: "help-dummy", help: "" })
|> Cli.map(|_| Projects)
|> SubCmd.finish({
name: "projects",
description: "List projects found in tasks.",
mapper: |cmd| cmd,
})
contexts_sub =
Opt.flag({ short: "h", long: "help-dummy", help: "" })
|> Cli.map(|_| Contexts)
|> SubCmd.finish({
name: "contexts",
description: "List contexts found in tasks.",
mapper: |cmd| cmd,
})
inbox_sub =
Opt.flag({ short: "h", long: "help-dummy", help: "" })
|> Cli.map(|_| Inbox)
|> SubCmd.finish({ name: "inbox", description: "Shortcut for: list --status inbox", mapper: |cmd| cmd })
next_sub =
Opt.flag({ short: "h", long: "help-dummy", help: "" })
|> Cli.map(|_| Next)
|> SubCmd.finish({ name: "next", description: "Shortcut for: list --status next", mapper: |cmd| cmd })
waiting_sub =
Opt.flag({ short: "h", long: "help-dummy", help: "" })
|> Cli.map(|_| Waiting)
|> SubCmd.finish({ name: "waiting", description: "Shortcut for: list --status waiting", mapper: |cmd| cmd })
someday_sub =
Opt.flag({ short: "h", long: "help-dummy", help: "" })
|> Cli.map(|_| Someday)
|> SubCmd.finish({ name: "someday", description: "Shortcut for: list --status someday", mapper: |cmd| cmd })
SubCmd.required([
add_sub,
list_sub,
done_sub,
move_sub,
show_sub,
delete_sub,
projects_sub,
contexts_sub,
inbox_sub,
next_sub,
waiting_sub,
someday_sub,
])
|> Cli.finish({
name: "todo",
version: "v0.1.0",
authors: ["Paul Prescod"],
description: "A tiny GTD-ish todo manager in Roc.",
})
|> Cli.assert_valid
# ----------------------------
# Persistence
# ----------------------------
load_db! : {} => Result Db _
load_db! = |_|
when File.read_bytes!(db_path) is
Ok(bytes) ->
Decode.from_bytes(bytes, Json.utf8)
|> Result.map_err(|e| Exit(1, "Failed to parse ${db_path}: ${Inspect.to_str(e)}"))
Err(_) ->
Ok(empty_db)
save_db! : Db => Result {} _
save_db! = |db|
bytes = Encode.to_bytes(db, Json.utf8)
File.write_bytes!(bytes, db_path)
|> Result.map_err(|e| Exit(1, "Failed to write ${db_path}: ${Inspect.to_str(e)}"))
# ----------------------------
# Operations
# ----------------------------
now_ms! : {} => Result U64 _
now_ms! = |{}|
inst = Utc.now!({})
millis = Utc.to_millis_since_epoch(inst)
Ok(Num.to_u64(millis))
add_task! : Db, { title: Str, notes: Str, project: Str, context: Str, status: Str } => Result Db _
add_task! = |db, p|
# Validate status early
_validated =
status_from_str(p.status)
|> Result.map_err(|msg| Exit(1, msg))?
created = now_ms!({})?
task : Task
task =
{
id: db.next_id,
title: p.title,
notes: p.notes,
status: normalize_status(p.status),
project: p.project,
context: p.context,
created_ms: created,
due_ms: -1,
done_ms: -1,
}
Ok({
next_id: db.next_id + 1,
tasks: List.append(db.tasks, task),
})
mark_done! : Db, U64 => Result Db _
mark_done! = |db, id|
done_time = now_ms!({})?
updated =
List.map(db.tasks, |t|
if t.id == id then
{ t & status: "done", done_ms: Num.to_i64(done_time) }
else
t
)
if List.any(updated, |t| t.id == id) then
Ok({ db & tasks: updated })
else
Err(Exit(1, "No task with id ${Num.to_str(id)}"))
move_status! : Db, U64, Str => Result Db _
move_status! = |db, id, status_str|
_validated =
status_from_str(status_str)
|> Result.map_err(|msg| Exit(1, msg))?
lower = normalize_status(status_str)
# Get current time upfront for potential done_ms update
current_time = now_ms!({})?
updated =
List.map(db.tasks, |t|
if t.id == id then
if lower == "done" then
# moving to done sets done_ms if absent
if t.done_ms >= 0 then
{ t & status: "done" }
else
{ t & status: "done", done_ms: Num.to_i64(current_time) }
else
{ t & status: lower }
else
t
)
if List.any(updated, |t| t.id == id) then
Ok({ db & tasks: updated })
else
Err(Exit(1, "No task with id ${Num.to_str(id)}"))
delete_task! : Db, U64 => Result Db _
delete_task! = |db, id|
kept = List.keep_if(db.tasks, |t| t.id != id)
if List.len(kept) == List.len(db.tasks) then
Err(Exit(1, "No task with id ${Num.to_str(id)}"))
else
Ok({ db & tasks: kept })
# ----------------------------
# Rendering
# ----------------------------
render_list : Db, { status: Result Str [NoValue], project: Result Str [NoValue], context: Result Str [NoValue], all: Bool } -> Str
render_list = |db, f|
tasks =
db.tasks
|> List.keep_if(|t|
include_done =
if f.all then
Bool.true
else
t.status != "done"
status_ok =
when f.status is
Ok(s) -> t.status == normalize_status(s)
Err(NoValue) -> Bool.true
project_ok =
when f.project is
Ok(p) -> t.project == p
Err(NoValue) -> Bool.true
context_ok =
when f.context is
Ok(c) -> t.context == c
Err(NoValue) -> Bool.true
include_done && status_ok && project_ok && context_ok
)
if List.is_empty(tasks) then
"(no matching tasks)"
else
tasks
|> List.map(render_one_line)
|> Str.join_with("\n")
render_one_line : Task -> Str
render_one_line = |t|
proj =
if t.project == "" then "" else " [${t.project}]"
ctx =
if t.context == "" then "" else " @${t.context}"
"#${Num.to_str(t.id)} (${t.status}) ${t.title}${proj}${ctx}"
render_show : Db, U64 -> Str
render_show = |db, id|
found = List.find_first(db.tasks, |t| t.id == id)
when found is
Ok(t) ->
notes =
if t.notes == "" then "(none)" else t.notes
due =
if t.due_ms >= 0 then
Num.to_str(t.due_ms)
else
"(none)"
done =
if t.done_ms >= 0 then
Num.to_str(t.done_ms)
else
"(not done)"
project_str =
if t.project == "" then "(none)" else t.project
context_str =
if t.context == "" then "(none)" else t.context
[
"#${Num.to_str(t.id)} ${t.title}",
"status: ${t.status}",
"project: ${project_str}",
"context: ${context_str}",
"due_ms: ${due}",
"done_ms: ${done}",
"",
"notes:",
notes,
]
|> Str.join_with("\n")
Err(_) ->
"No task with id ${Num.to_str(id)}"
list_unique : List Str -> List Str
list_unique = |list|
List.walk(list, [], |acc, item|
if List.contains(acc, item) then
acc
else
List.append(acc, item)
)
render_unique : Db, (Task -> Str) -> Str
render_unique = |db, get|
vals =
db.tasks
|> List.map(get)
|> List.keep_if(|s| s != "")
|> list_unique
if List.is_empty(vals) then
"(none)"
else
vals |> Str.join_with("\n")
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment