Created
February 17, 2026 00:55
-
-
Save prescod/ae51afa421450c989c6954b48c22298a to your computer and use it in GitHub Desktop.
Todo list app in Roc
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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