├── .gitignore ├── LICENSE ├── Makefile ├── README.md ├── albatross.service ├── data └── .keep ├── lib ├── torus.js.oak └── uid.oak ├── src ├── app.js.oak ├── dateutils.js.oak ├── defaults.js.oak └── main.oak └── static ├── css └── main.css ├── dyn-index.html ├── img ├── albatross-github.png ├── albatross-social.png ├── egg.jpg └── empty-page.jpg ├── index.html └── js ├── bundle.js ├── libsearch.js └── torus.min.js /.gitignore: -------------------------------------------------------------------------------- 1 | db.json 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Linus Lee 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | all: serve 2 | 3 | # run server 4 | serve: 5 | oak src/main.oak 6 | 7 | # build app client 8 | build: 9 | oak build --entry src/app.js.oak --output static/js/bundle.js --web 10 | b: build 11 | 12 | # build whenever Oak sources change 13 | watch: 14 | ls lib/*.oak src/*.oak | entr -cr make build 15 | w: watch 16 | 17 | # format changed Oak source 18 | fmt: 19 | oak fmt --changes --fix 20 | f: fmt 21 | 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Albatross 🪶 2 | 3 | **Albatross** is a little to-do list app I made for myself and [Karina](https://karinanguyen.com/) to distract myself from [more important responsibilities](https://twitter.com/thesephist/status/1588795933096964096). It's designed mainly for the two of us, so it prioritizes simplicity and fitting our specific needs and preferences. However, if you really must try it, you can go see a [demo here](https://albatross.oaklang.org/). 4 | 5 | ![Albatross's desktop web view](static/img/albatross-social.png) 6 | 7 | Like many of my projects, Albatross is written in [Oak](https://oaklang.org/) and [Torus](https://github.com/thesephist/torus). Search is handled with [libsearch](https://github.com/thesephist/libsearch). Illustrations used in the empty states in the app were generated with DALL-E 2. 8 | 9 | ## Feature set 10 | 11 | - Tasks that support categories, due dates, and an extra detail/comment field 12 | - Fast as-you-type search with [libsearch](https://github.com/thesephist/libsearch) 13 | - Light and dark themes 14 | - Speed 🏃‍♂️ 15 | 16 | I've also considered adding some ✨AI features✨ because doing that seems very hype these days, but haven't gotten around to it yet. Here are some ideas I've thought about, though: 17 | 18 | - Natural language "quick create" for tasks, e.g. being able to type in _Call Rob about improving deploy playbook @ next Tues_ and have it be parsed into the right shape 19 | - Automatically "cleaning up" notes under a task, especially for tasks or notes that are sloppy records of conversations 20 | - Custom encouragements whenever a task is completed, e.g. checking off "Get back to Theo via email" should elicit some encouragement message like "Great job sending that email!" 21 | - A tool for breaking down larger tasks into smaller sub-tasks and making them more approachable 22 | 23 | ## Development 24 | 25 | Like many of my projects, Albatross is built and managed with [Oak](https://oaklang.org/). There's a short [Makefile](Makefile) that wraps common `oak` commands: 26 | 27 | - `make` runs the Flask web server, and is equivalent to `flask run` 28 | - `make fmt` or `make f` auto-formats any tracked changes in the repository 29 | - `make build` or `make b` builds the client JavaScript bundle from `src/app.js.oak` 30 | - `make watch` or `make w` watches for file changes and runs the `make build` on any change 31 | 32 | ### Deployments 33 | 34 | Albatross is a bit unique in that it's **designed to be deployable as both a server-backed full-stack web app, persisting data on the backend; and a client-only static single-page app, persisting to browser `localStorage`**. Both versions run very nearly the same code, except for the bits that concern syncing. 35 | 36 | The static SPA is deployed via Vercel, with `./static/` as the "build output directory". 37 | 38 | The full-stack app is defined by `albatross.service` and runs as a systemd service in a Linux box. 39 | -------------------------------------------------------------------------------- /albatross.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Albatross 3 | ConditionPathExists=/home/albatross-user/go/bin/oak 4 | After=network.target 5 | 6 | [Service] 7 | Type=simple 8 | User=albatross-user 9 | LimitNOFILE=1024 10 | PermissionsStartOnly=true 11 | 12 | Restart=on-failure 13 | RestartSec=100ms 14 | StartLimitIntervalSec=60 15 | 16 | Environment="PORT=9888" 17 | Environment="PWD=/home/albatross-user/albatross" 18 | 19 | WorkingDirectory=/home/albatross-user/albatross 20 | ExecStart=/home/albatross-user/go/bin/oak ./src/main.oak 21 | 22 | # make sure log directory exists and owned by syslog 23 | PermissionsStartOnly=true 24 | ExecStartPre=/bin/mkdir -p /var/log/albatross 25 | ExecStartPre=/bin/chown syslog:adm /var/log/albatross 26 | ExecStartPre=/bin/chmod 755 /var/log/albatross 27 | StandardOutput=syslog 28 | StandardError=syslog 29 | SyslogIdentifier=albatross 30 | 31 | [Install] 32 | WantedBy=multi-user.target 33 | -------------------------------------------------------------------------------- /data/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thesephist/albatross/c6d0c2aa25629b08db83e31dca8575e87f7f9432/data/.keep -------------------------------------------------------------------------------- /lib/torus.js.oak: -------------------------------------------------------------------------------- 1 | // torus.js.oak is an Oak wrapper around Torus 2 | 3 | { 4 | default: default 5 | map: map 6 | } := import('std') 7 | 8 | fn h(tag, args...) { 9 | if len(args) { 10 | 0 -> ? 11 | 1 -> [children] := args 12 | 2 -> [classes, children] := args 13 | 3 -> [classes, attrs, children] := args 14 | _ -> [classes, attrs, events, children] := args 15 | } 16 | classes := classes |> default([]) 17 | attrs := attrs |> default({}) 18 | events := events |> default({}) 19 | children := children |> default([]) 20 | { 21 | tag: String(tag |> string()) 22 | attrs: attrs.'class' := classes |> map(String) 23 | events: events 24 | children: children |> with map() fn(child) if type(child) { 25 | :string -> String(child) 26 | _ -> child 27 | } 28 | } 29 | } 30 | 31 | fn Renderer(root) { 32 | if type(root) = :string -> root <- document.querySelector(root) 33 | 34 | render := window.Torus.render 35 | initialDOM := h(:div) 36 | node := render(?, ?, initialDOM) 37 | root.appendChild(node) 38 | 39 | self := { 40 | node: node 41 | prev: initialDOM 42 | update: fn update(jdom) { 43 | self.node := render(self.node, self.prev, jdom) 44 | self.prev := jdom 45 | self.node 46 | } 47 | } 48 | } 49 | 50 | -------------------------------------------------------------------------------- /lib/uid.oak: -------------------------------------------------------------------------------- 1 | // uid creates unique short IDs 2 | 3 | { 4 | map: map 5 | } := import('std') 6 | { 7 | choice: choice 8 | } := import('random') 9 | 10 | fn newChar { 11 | choice('ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789') 12 | } 13 | 14 | fn new { 15 | // 16-char IDs 16 | '1234567890123456' |> map(newChar) 17 | } 18 | -------------------------------------------------------------------------------- /src/app.js.oak: -------------------------------------------------------------------------------- 1 | { 2 | println: println 3 | default: default 4 | is: is 5 | map: map 6 | find: find 7 | last: last 8 | slice: slice 9 | filter: filter 10 | append: append 11 | reverse: reverse 12 | debounce: debounce 13 | } := import('std') 14 | { 15 | lower: lower 16 | trim: trim 17 | contains?: strContains? 18 | } := import('str') 19 | fmt := import('fmt') 20 | sort := import('sort') 21 | random := import('random') 22 | datetime := import('datetime') 23 | json := { 24 | parse: __native_json_parse 25 | serialize: __native_json_serialize 26 | } 27 | uid := import('../lib/uid') 28 | { 29 | Renderer: Renderer 30 | h: h 31 | } := import('../lib/torus.js') 32 | { 33 | dayList: dayList 34 | today: today 35 | tomorrow: tomorrow 36 | yesterday: yesterday 37 | addDays: addDays 38 | } := import('dateutils.js') 39 | defaults := import('defaults.js') 40 | 41 | SyncInterval := 3 42 | LocalStorageKey := 'albatross:0' 43 | ColorSchemeKey := 'albatross:theme' 44 | Colors := [ 45 | '#11b6a5' 46 | '#4e70c1' 47 | '#715d8c' 48 | '#8b5cb2' 49 | '#8c5d5d' 50 | '#90b847' 51 | '#969696' 52 | '#b25c5c' 53 | '#cfab4a' 54 | ] 55 | UseLocalStorage? := Preload = ? 56 | ClientRenderer := if { 57 | navigator.userAgent |> strContains?('Firefox') -> :gecko 58 | navigator.userAgent |> strContains?('Chrome') -> :blink 59 | navigator.userAgent |> strContains?('Safari') -> :webkit 60 | _ -> :unknownRenderer 61 | } 62 | 63 | if Preload = ? -> { 64 | Preload <- if persisted := window.localStorage.getItem(LocalStorageKey) { 65 | ? -> defaults.DefaultData 66 | _ -> json.parse(persisted) 67 | } 68 | } 69 | 70 | State := { 71 | // constants 72 | Today: today() 73 | WeekDays: dayList(7) 74 | MonthDays: dayList(30) 75 | 76 | // state 77 | theme: window.localStorage.getItem(ColorSchemeKey) |> default('light') 78 | syncing?: false 79 | editingCategories?: false 80 | task: ? 81 | range: 'week' 82 | search: '' 83 | showDone: false 84 | categories: Preload.categories 85 | tasks: Preload.tasks 86 | results: [] 87 | } 88 | 89 | enqueueSync := with debounce(SyncInterval) fn syncImmediately { 90 | State.syncing? := true 91 | render() 92 | 93 | if UseLocalStorage? { 94 | true -> { 95 | window.localStorage.setItem(LocalStorageKey, json.serialize({ 96 | categories: State.categories 97 | tasks: State.tasks 98 | })) 99 | State.syncing? := false 100 | render() 101 | } 102 | _ -> { 103 | resp := fetch('/tasks', { 104 | method: 'PUT' 105 | headers: { 106 | 'Content-Type': 'application/json' 107 | } 108 | body: json.serialize({ 109 | categories: State.categories 110 | tasks: State.tasks 111 | }) 112 | }) 113 | resp |> call(:catch, fn(err) { 114 | State.syncing? := String(err) 115 | render() 116 | }) 117 | resp.then(fn { 118 | State.syncing? := false 119 | render() 120 | }) 121 | } 122 | } 123 | } 124 | 125 | fn sync { 126 | State.syncing? := true 127 | enqueueSync() 128 | } 129 | 130 | fn dayName(day) { 131 | d := datetime.parse(day + 'T00:00:00') |> 132 | datetime.timestamp() |> 133 | datetime.describe() 134 | jsd := new(Date) 135 | jsd.setFullYear(d.year) 136 | jsd.setMonth(d.month - 1) 137 | jsd.setDate(d.day) 138 | 139 | if jsd.getDay() { 140 | 0 -> 'Sun' 141 | 1 -> 'Mon' 142 | 2 -> 'Tue' 143 | 3 -> 'Wed' 144 | 4 -> 'Thu' 145 | 5 -> 'Fri' 146 | 6 -> 'Sat' 147 | } 148 | } 149 | 150 | fn fmtDay(day) { 151 | d := datetime.parse(day + 'T00:00:00') |> 152 | datetime.timestamp() |> 153 | datetime.describe() 154 | 155 | monthName := if d.month { 156 | 1 -> 'Jan' 157 | 2 -> 'Feb' 158 | 3 -> 'Mar' 159 | 4 -> 'Apr' 160 | 5 -> 'May' 161 | 6 -> 'Jun' 162 | 7 -> 'Jul' 163 | 8 -> 'Aug' 164 | 9 -> 'Sep' 165 | 10 -> 'Oct' 166 | 11 -> 'Nov' 167 | 12 -> 'Dec' 168 | } 169 | 170 | if day { 171 | today() -> 'Today' 172 | tomorrow() -> 'Tomorrow' 173 | yesterday() -> 'Yesterday' 174 | _ -> '{{0}}, {{1}} {{2}}' |> fmt.format(dayName(day), monthName, d.day) 175 | } 176 | } 177 | 178 | fn editCategories() { 179 | State.editingCategories? := true 180 | render() 181 | 182 | document.querySelector('dialog.category-editor').showModal() // show backdrop 183 | } 184 | 185 | fn focusSearchField { 186 | if el := document.querySelector('.task-list-search-input') { 187 | ? -> {} 188 | _ -> el.focus() 189 | } 190 | } 191 | 192 | fn focusFieldWithDataID(id) { 193 | if el := document.querySelector('[data-id="' + id + '"]') { 194 | ? -> {} 195 | _ -> el.focus() 196 | } 197 | } 198 | 199 | fn addTaskAndFocus() { 200 | id := uid.new() 201 | State.tasks << { 202 | id: id 203 | text: '' 204 | body: '' 205 | due: today() 206 | done: ? 207 | cat: ? 208 | } 209 | State.task := id 210 | sync() 211 | updateSearchResults() 212 | 213 | focusFieldWithDataID(id) 214 | } 215 | 216 | fn updateSearchResults() { 217 | tasks := State.tasks 218 | if !State.showDone -> tasks <- tasks |> filter(fn(t) t.done = ?) 219 | 220 | State.results := tasks |> libsearch.search( 221 | String(State.search) 222 | fn(t) String(t.text + ' ' + t.body) 223 | { 224 | mode: String('autocomplete') 225 | } 226 | ) 227 | render() 228 | } 229 | 230 | r := Renderer('#root') 231 | 232 | fn FlexSpacer h(:div, ['flex-spacer'], []) 233 | 234 | fn Editable(attrs, allowTab?, handleInput, handleEnter) { 235 | handleEnter := handleEnter |> default(fn {}) 236 | 237 | h(:div, ['textarea-group'], [ 238 | h(:div, [ 239 | 'textarea-shadow' 240 | if attrs.value |> last() { 241 | '\n' -> 'extra-height' 242 | _ -> '' 243 | } 244 | ], [attrs.value]) 245 | h(:textarea, ['textarea-itself'], attrs, { 246 | input: handleInput 247 | keydown: fn(evt) if evt.key { 248 | 'Enter' -> handleEnter(evt) 249 | 'Tab' -> if allowTab? -> { 250 | evt.preventDefault() 251 | 252 | if ? != idx := evt.target.selectionStart -> { 253 | val := evt.target.value 254 | front := val |> slice(0, idx) 255 | back := val |> slice(idx) 256 | evt.target.value := front + '\t' + back 257 | evt.target.setSelectionRange(idx + 1, idx + 1) 258 | 259 | handleInput(evt) 260 | } 261 | } 262 | } 263 | }, []) 264 | ]) 265 | } 266 | 267 | fn Category(id) { 268 | cat := State.categories.( 269 | State.categories |> find(fn(c) c.id = id) 270 | ) 271 | 272 | if cat { 273 | ? -> h(:div, ['category'], ['Unknown category']) 274 | _ -> h(:div, ['category'], { 275 | style: { 276 | background: cat.color 277 | } 278 | }, [ 279 | if cat.name { 280 | '' -> 'Unnamed category' 281 | _ -> cat.name 282 | } 283 | ]) 284 | } 285 | } 286 | 287 | fn Task(task) { 288 | h(:li, ['task-li'], [ 289 | h(:div, [ 290 | 'task' 291 | if task.done { 292 | ? -> 'task-undone' 293 | _ -> 'task-done' 294 | } 295 | if State.task { 296 | task.id -> 'active' 297 | _ -> '' 298 | } 299 | ], { 300 | 'data-task-id': task.id 301 | }, [ 302 | h(:label, ['task-status'], [ 303 | h(:input, ['task-checkbox'], { 304 | type: 'checkbox' 305 | checked: task.done != ? 306 | }, { 307 | change: fn(evt) { 308 | task.done := if task.done { 309 | ? -> today() 310 | _ -> ? 311 | } 312 | sync() 313 | render() 314 | 315 | // give user a second to visually confirm checked 316 | // before removing it from view 317 | with wait(1) fn { 318 | updateSearchResults() 319 | } 320 | } 321 | }, []) 322 | ]) 323 | h(:div, ['task-content'], { 324 | tabIndex: 0 325 | }, { 326 | click: fn { 327 | State.task := if State.task { 328 | task.id -> ? 329 | _ -> task.id 330 | } 331 | render() 332 | } 333 | keydown: fn(evt) if evt.key = 'Enter' -> { 334 | State.task := if State.task { 335 | task.id -> ? 336 | _ -> task.id 337 | } 338 | render() 339 | } 340 | }, [ 341 | h(:div, ['task-text'], [ 342 | if task.text |> trim() { 343 | '' -> 'Unnamed task' 344 | _ -> task.text 345 | } 346 | ]) 347 | h(:div, ['task-body-preview'], [ 348 | if { 349 | State.search |> trim() != '' 350 | State.range = 'all' -> if task.due != ? -> { 351 | h(:span, [], [ 352 | h(:span, ['task-due'], [fmtDay(task.due)]) 353 | if task.body |> trim() != '' -> ' · ' 354 | ]) 355 | } 356 | } 357 | task.body 358 | ]) 359 | if task.cat != ? -> h(:div, ['task-category'], [ 360 | Category(task.cat) 361 | ]) 362 | ]) 363 | ]) 364 | ]) 365 | } 366 | 367 | fn Tasks(tasks) { 368 | h(:ul, ['tasks', 'flex-col'], { 369 | tasks |> with map() fn(task) { 370 | Task(task) 371 | } 372 | }) 373 | } 374 | 375 | fn Day(day) { 376 | if dayTasks := State.results |> filter(fn(task) task.due = day) { 377 | [] -> ? 378 | _ -> h(:div, ['day', 'day-' + dayName(day) |> lower()], [ 379 | h(:div, ['day-header', 'flex-row'], [ 380 | fmtDay(day) 381 | FlexSpacer() 382 | string(len(dayTasks)) 383 | ]) 384 | Tasks(dayTasks) 385 | ]) 386 | } 387 | } 388 | 389 | fn TaskList() { 390 | h(:div, ['task-list'], [ 391 | h(:div, ['task-list-search', 'flex-col'], [ 392 | h(:div, ['task-list-search-row', 'flex-row'], [ 393 | h(:input, ['task-list-search-input'], { 394 | placeholder: 'Search {{0}} tasks...' |> fmt.format(State.tasks |> len()) 395 | value: State.search 396 | }, { 397 | input: fn(evt) { 398 | State.search := evt.target.value 399 | updateSearchResults() 400 | } 401 | keydown: fn(evt) if evt.key = 'Escape' -> { 402 | if evt.target.value { 403 | '' -> evt.target.blur() 404 | _ -> { 405 | State.search := '' 406 | updateSearchResults() 407 | } 408 | } 409 | } 410 | }, []) 411 | h(:button, ['task-list-new-task'], { title: 'New task' }, { 412 | click: fn() addTaskAndFocus() 413 | }, ['+']) 414 | ]) 415 | h(:div, ['task-list-search-row', 'flex-row'], [ 416 | h(:button, ['task-list-show-done'], { title: 'Show tasks marked as done' }, { 417 | click: fn { 418 | State.showDone := !State.showDone 419 | sync() 420 | updateSearchResults() 421 | } 422 | }, [ 423 | if State.showDone { 424 | true -> 'Hide done' 425 | _ -> 'Show done' 426 | } 427 | ]) 428 | h(:select, ['task-list-range-select'], { 429 | value: State.range 430 | }, { 431 | change: fn(evt) { 432 | State.range := evt.target.value 433 | render() 434 | } 435 | }, { 436 | [ 437 | ['today', 'Today'] 438 | ['week', 'This week'] 439 | ['month', 'Next 30 days'] 440 | ['all', 'All tasks'] 441 | ] |> with map() fn(option) { 442 | [value, text] := option 443 | h(:option, [], { value: value }, { 444 | selected: State.range = value 445 | }, [text]) 446 | 447 | } 448 | }) 449 | ]) 450 | ]) 451 | h(:div, ['task-list-list'], { 452 | if State.search |> trim() { 453 | '' -> { 454 | past := if overdues := State.results |> filter(fn(t) t.due < State.Today) { 455 | [] -> [] 456 | _ -> [ 457 | h(:div, ['day', 'day-overdue'], [ 458 | h(:div, ['day-header', 'flex-row'], [ 459 | 'Overdue' 460 | FlexSpacer() 461 | len(overdues) 462 | ]) 463 | Tasks(overdues) 464 | ]) 465 | ] 466 | } 467 | if State.range { 468 | 'today' -> past |> append([State.Today] |> map(Day)) 469 | 'week' -> past |> append(State.WeekDays |> map(Day)) 470 | 'month' -> past |> append(State.MonthDays |> map(Day)) 471 | 'all' -> State.results |> sort.sort(:due) |> reverse() |> map(Task) 472 | } 473 | } 474 | _ -> State.results |> map(Task) 475 | } 476 | }) 477 | ]) 478 | } 479 | 480 | fn TaskPageHeader(task) { 481 | fn swapTasks(t1, t2) { 482 | State.tasks := State.tasks |> map(fn(t) if t.id { 483 | t1.id -> t2 484 | t2.id -> t1 485 | _ -> t 486 | }) 487 | sync() 488 | updateSearchResults() 489 | } 490 | 491 | h(:div, ['task-page-header', 'flex-row'], [ 492 | h(:button, ['task-page-header-close'], { title: 'Close' }, { 493 | click: fn { 494 | State.task := ? 495 | render() 496 | } 497 | }, ['Close']) 498 | h(:button, ['task-page-header-up'], { title: 'Move up in list' }, { 499 | click: fn { 500 | taskIDs := Array.from(document.querySelectorAll('[data-task-id]')) |> 501 | map(fn(e) e.getAttribute('data-task-id')) 502 | if ? != prevTaskID := taskIDs.(taskIDs |> find(is(task.id)) - 1) -> { 503 | swapTasks( 504 | State.tasks.(State.tasks |> find(fn(t) t.id = prevTaskID)) 505 | task 506 | ) 507 | } 508 | } 509 | }, ['↖']) 510 | h(:button, ['task-page-header-down'], { title: 'Move down in list' }, { 511 | click: fn { 512 | taskIDs := Array.from(document.querySelectorAll('[data-task-id]')) |> 513 | map(fn(e) e.getAttribute('data-task-id')) 514 | if ? != nextTaskID := taskIDs.(taskIDs |> find(is(task.id)) + 1) -> { 515 | swapTasks( 516 | State.tasks.(State.tasks |> find(fn(t) t.id = nextTaskID)) 517 | task 518 | ) 519 | } 520 | } 521 | }, ['↘']) 522 | FlexSpacer() 523 | h(:button, ['task-page-header-delete'], { title: 'Delete task' }, { 524 | click: fn() if confirm('Delete this task?') -> { 525 | State.task := ? 526 | State.tasks := State.tasks |> filter(fn(t) t.id != task.id) 527 | sync() 528 | updateSearchResults() 529 | } 530 | }, ['Delete']) 531 | h(:button, ['task-page-header-prev'], { title: 'Pull forward 1 day' }, { 532 | click: fn { 533 | task.due := addDays(task.due, -1) 534 | sync() 535 | updateSearchResults() 536 | } 537 | }, ['←']) 538 | h(:button, ['task-page-header-today'], { title: 'Make due day' }, { 539 | click: fn { 540 | task.due := today() 541 | sync() 542 | updateSearchResults() 543 | } 544 | }, ['↓']) 545 | h(:button, ['task-page-header-next'], { title: 'Postpone 1 day' }, { 546 | click: fn { 547 | task.due := addDays(task.due, 1) 548 | sync() 549 | updateSearchResults() 550 | } 551 | }, ['→']) 552 | ]) 553 | } 554 | 555 | fn TaskPage(task) { 556 | h(:div, ['task-page'], { 557 | if task { 558 | ? -> [h(:div, ['task-page-empty'], [])] 559 | _ -> [ 560 | TaskPageHeader(task) 561 | h(:div, ['task-editor', 'flex-col'], [ 562 | h(:div, ['task-editor-text'], [ 563 | Editable({ 564 | value: task.text 565 | placeholder: 'Do this' 566 | 'data-id': task.id 567 | }, false, fn(evt) { 568 | task.text := evt.target.value 569 | sync() 570 | render() 571 | }, fn(evt) { 572 | evt.preventDefault() 573 | focusFieldWithDataID(string(task.id) + '-body') 574 | }) 575 | ]) 576 | h(:div, ['task-editor-option-row'], [ 577 | h(:div, ['task-editor-option-subrow'], [ 578 | h(:input, ['task-editor-due-input'], { 579 | type: 'date' 580 | value: task.due 581 | }, { 582 | change: fn(evt) { 583 | task.due := evt.target.value 584 | sync() 585 | render() 586 | } 587 | }, []) 588 | h(:button, ['task-editor-due-reset'], { title: 'Unset due date' }, { 589 | click: fn { 590 | task.due := ? 591 | sync() 592 | render() 593 | } 594 | }, ['Unset']) 595 | ]) 596 | h(:div, ['task-editor-option-subrow'], [ 597 | h(:select, ['task-editor-category-select'], {}, { 598 | change: fn(evt) { 599 | task.cat := if value := evt.target.value { 600 | '' -> ? 601 | _ -> value 602 | } 603 | sync() 604 | render() 605 | } 606 | }, [ 607 | h(:option, [], { 608 | value: '' 609 | selected: task.cat = ? 610 | }, ['No category']) 611 | h(:optgroup, [], { label: 'Categories' }, { 612 | State.categories |> with map() fn(cat) { 613 | h(:option, [], { 614 | value: cat.id 615 | selected: task.cat = cat.id 616 | }, [ 617 | if cat.name { 618 | '' -> 'Unnamed category' 619 | _ -> cat.name 620 | } 621 | ]) 622 | } 623 | }) 624 | ]) 625 | h(:button, ['task-editor-category-edit'], {}, { 626 | click: fn() editCategories() 627 | }, ['Edit']) 628 | ]) 629 | ]) 630 | h(:div, ['task-editor-body'], [ 631 | Editable({ 632 | value: task.body 633 | placeholder: 'Some more context' 634 | 'data-id': string(task.id) + '-body' 635 | }, true, fn(evt) { 636 | task.body := evt.target.value 637 | sync() 638 | render() 639 | }) 640 | ]) 641 | ]) 642 | ] 643 | } 644 | }) 645 | } 646 | 647 | fn CategoryEditor() { 648 | h(:dialog, ['category-editor', 'flex-col'], [ 649 | h(:div, ['category-editor-header', 'flex-row'], [ 650 | h(:h2, ['Categories']) 651 | FlexSpacer() 652 | h(:button, ['dialog-close'], { title: 'Save and close' }, { 653 | click: fn { 654 | State.editingCategories? := false 655 | render() 656 | } 657 | }, ['Done']) 658 | ]) 659 | h(:ul, ['category-editor-list', 'flex-col'], { 660 | State.categories |> with map() fn(cat) { 661 | h(:li, ['category-editor-item', 'flex-row'], [ 662 | h(:input, ['category-editor-color-input'], { 663 | type: 'color' 664 | value: cat.color 665 | }, { 666 | input: fn(evt) { 667 | cat.color := evt.target.value 668 | sync() 669 | render() 670 | } 671 | }, []) 672 | h(:input, ['category-editor-name-input'], { 673 | value: cat.name 674 | placeholder: 'New category' 675 | 'data-id': cat.id 676 | }, { 677 | input: fn(evt) { 678 | cat.name := evt.target.value 679 | sync() 680 | render() 681 | } 682 | }, []) 683 | if State.tasks |> filter(fn(t) t.cat = cat.id) = [] -> h(:button, ['category-editor-delete'], { title: 'Delete category' }, { 684 | click: fn { 685 | State.categories := State.categories |> filter(fn(c) c.id != cat.id) 686 | sync() 687 | render() 688 | } 689 | }, ['X']) 690 | ]) 691 | } 692 | }) 693 | h(:button, ['category-editor-add'], { title: 'Add category' }, { 694 | click: fn { 695 | id := uid.new() 696 | State.categories << { 697 | id: id 698 | name: '' 699 | color: random.choice(Colors) 700 | } 701 | sync() 702 | render() 703 | 704 | focusFieldWithDataID(id) 705 | } 706 | }, ['+ New Category']) 707 | ]) 708 | } 709 | 710 | fn render { 711 | document.body.classList.toggle('dark', State.theme = 'dark') 712 | 713 | with r.update() h(:div, [ 714 | 'app' 715 | if State.task { 716 | ? -> 'no-active-task' 717 | _ -> 'active-task' 718 | } 719 | ], [ 720 | h(:header, ['flex-row'], [ 721 | h(:a, ['logo'], { href: '/' }, ['Albatross']) 722 | FlexSpacer() 723 | if errMsg := State.syncing? { 724 | true, false -> ? // don't report normal states 725 | _ -> h(:button, ['sync-try-again'], { 726 | title: 'Sync error: {{0}}\nClick to try again.' |> fmt.format(errMsg) 727 | }, { 728 | click: fn() syncImmediately() 729 | }, ['⚠️']) 730 | } 731 | h(:button, ['theme-button'], { 732 | title: if State.theme { 733 | 'light' -> 'Dark mode' 734 | _ -> 'Light mode' 735 | } 736 | }, { 737 | click: fn { 738 | State.theme := if State.theme { 739 | 'light' -> 'dark' 740 | _ -> 'light' 741 | } 742 | window.localStorage.setItem(ColorSchemeKey, State.theme) 743 | render() 744 | } 745 | }, [ 746 | if State.theme { 747 | 'light' -> '🌘' 748 | _ -> '☀️' 749 | } 750 | ]) 751 | ]) 752 | h(:main, ['flex-row'], [ 753 | TaskList() 754 | TaskPage( 755 | State.tasks.( 756 | State.tasks |> find(fn(t) t.id = State.task) 757 | ) 758 | ) 759 | ]) 760 | h(:footer, [ 761 | h(:a, [], { href: 'https://github.com/thesephist/albatross', target: '_blank' }, ['Albatross']) 762 | ', a way to organize life, by ' 763 | h(:a, [], { href: 'https://thesephist.com', target: '_blank' }, ['L']) 764 | ' for ' 765 | h(:a, [], { href: 'https://karinanguyen.com', target: '_blank' }, ['K']) 766 | '.' 767 | ]) 768 | if State.editingCategories? -> CategoryEditor() 769 | ]) 770 | } 771 | 772 | with document.body.addEventListener('keydown') fn(evt) if [evt.ctrlKey | evt.metaKey, evt.shiftKey, evt.key] { 773 | [true, false, '/'] -> focusSearchField() 774 | [true, false, 'k'] -> if State.task != ? -> focusFieldWithDataID(State.task) 775 | [true, true, 'k'] -> addTaskAndFocus() 776 | } 777 | 778 | // prevent close if persisting 779 | with window.addEventListener('beforeunload') fn(evt) if State.syncing? { 780 | false -> evt.returnValue := _ 781 | _ -> { 782 | evt.preventDefault() 783 | evt.returnValue := 0 // cannot be null or undefined 784 | } 785 | } 786 | 787 | fn enqueueMinutelyUpdate { 788 | render() 789 | with wait(30) fn { 790 | enqueueMinutelyUpdate() 791 | } 792 | } 793 | enqueueMinutelyUpdate() 794 | 795 | // for browser renderer- or engine-dependent hacks, mark the body 796 | document.body.classList.add(string(ClientRenderer)) 797 | 798 | // default blank search on first load 799 | updateSearchResults() 800 | 801 | render() 802 | 803 | -------------------------------------------------------------------------------- /src/dateutils.js.oak: -------------------------------------------------------------------------------- 1 | { 2 | default: default 3 | range: range 4 | map: map 5 | take: take 6 | } := import('std') 7 | datetime := import('datetime') 8 | 9 | OneDay := 86400 10 | 11 | fn dayList(n, dir) { 12 | dir := dir |> default(:future) 13 | dayCounts := if dir { 14 | :future -> range(n) 15 | :past -> range(1 - n, 1) 16 | } 17 | now := int(time()) 18 | tzOffset := new(Date).getTimezoneOffset() 19 | dayCounts |> map(fn(n) { 20 | datetime.format(now + n * OneDay, -tzOffset) |> take(10) 21 | }) 22 | } 23 | 24 | fn today() dayList(1).0 25 | 26 | fn tomorrow() dayList(2).1 27 | 28 | fn yesterday() dayList(2, :past).0 29 | 30 | fn addDays(day, n) { 31 | desc := datetime.parse(day + 'T00:00:00') 32 | (datetime.timestamp(desc) + n * OneDay) |> 33 | datetime.format() |> 34 | take(10) 35 | } 36 | 37 | -------------------------------------------------------------------------------- /src/defaults.js.oak: -------------------------------------------------------------------------------- 1 | { 2 | today: today 3 | tomorrow: tomorrow 4 | } := import('dateutils.js') 5 | 6 | DefaultData := { 7 | categories: [ 8 | { 9 | id: 'xmFWgdzyhWVHmkMA' 10 | name: 'Side Projects' 11 | color: '#11b6a5' 12 | } 13 | { 14 | id: 'JuVAgJQzI6IQCquO' 15 | name: 'Networking' 16 | color: '#90b847' 17 | } 18 | { 19 | id: 'TQkckfPUXfsOnQFK' 20 | name: 'Writing' 21 | color: '#4e70c1' 22 | } 23 | ] 24 | tasks: [ 25 | { 26 | id: 'ckgtqfVmMPrzn5jv' 27 | text: 'How to use Albatross 🤔' 28 | body: 'Albatross is very minimal\n- Create todos and filter/search in the left sidebar\n- If you click on a todo, it\'ll open on the right side (or in a new view on mobile)\n- The diagonal arrow buttons move the todo up/down in the left sidebar (it\'s a little buggy)\n- The left/bottom/right buttons above move the current todo\'s due date forward 1 day, to today, or backward 1 day, respectively\n- Use the \"Edit\" button to create new categories or edit existing ones\n- Switch light/dark theme with the button in the top right corner\n\nKeyboard shortcuts\n- Ctrl/Cmd+K to focus the title field of a todo\n- Ctrl/Cmd+/ to focus the search field\n- Ctrl/Cmd+Shift+K to create a new todo\n\nMore information at https://github.com/thesephist/albatross' 29 | due: today() 30 | done: ? 31 | cat: 'xmFWgdzyhWVHmkMA' 32 | } 33 | { 34 | id: 'HWqMlNluXUtYRBqN' 35 | text: 'Send email connecting Theo and James' 36 | body: 'They should talk more about tools for thought!' 37 | due: today() 38 | done: ? 39 | cat: 'JuVAgJQzI6IQCquO' 40 | } 41 | { 42 | id: 'by3oDreliq6EKrMK' 43 | text: 'Write a short sci-fi piece about LLMs and in-context learning' 44 | body: 'Geoffrey told me this could be an interesting idea for a sci-fi story!' 45 | due: today() 46 | done: ? 47 | cat: 'TQkckfPUXfsOnQFK' 48 | } 49 | { 50 | id: '9rxjTrmIGs2JpaOV' 51 | text: 'Launch Albatross 🪶' 52 | body: 'Before launch\n- Make sure page has social images + head tags\n- Finish writing a README\n\nAfter launch\n- Post to Twitter\n- Post to Linkedin' 53 | due: tomorrow() 54 | done: ? 55 | cat: 'xmFWgdzyhWVHmkMA' 56 | } 57 | { 58 | id: 'yOkTFzRnYCCVwKfv' 59 | text: 'Write research report about latent space models' 60 | body: '' 61 | due: tomorrow() 62 | done: ? 63 | cat: 'TQkckfPUXfsOnQFK' 64 | } 65 | ] 66 | } 67 | 68 | -------------------------------------------------------------------------------- /src/main.oak: -------------------------------------------------------------------------------- 1 | // albatross 2 | 3 | std := import('std') 4 | str := import('str') 5 | fmt := import('fmt') 6 | fs := import('fs') 7 | path := import('path') 8 | http := import('http') 9 | 10 | Port := 9888 11 | StaticDir := './static' 12 | DataFile := './data/db.json' 13 | 14 | // create DataFile if not already exists 15 | if fs.statFile(DataFile) = ? -> { 16 | if fs.writeFile(DataFile, '{ "categories": [], "tasks": [] }') = ? -> { 17 | std.println('error: could not create db') 18 | exit(1) 19 | } 20 | } 21 | 22 | server := http.Server() 23 | 24 | fn err(msg) { 25 | status: 500 26 | headers: { 'Content-Type': http.MimeTypes.txt } 27 | body: msg 28 | } 29 | 30 | fn serveIndex(end) with fs.readFile(path.join(StaticDir, 'dyn-index.html')) fn(file) if file { 31 | ? -> end(http.NotFound) 32 | _ -> with fs.readFile(DataFile) fn(dataFile) if dataFile { 33 | ? -> end(http.NotFound) 34 | _ -> end({ 35 | status: 200 36 | headers: { 'Content-Type': http.MimeTypes.html } 37 | body: file |> fmt.format({ preload: dataFile }) 38 | }) 39 | } 40 | } 41 | 42 | with server.route('/tasks') fn(params) fn(req, end) if req.method { 43 | 'GET' -> with fs.readFile(DataFile) fn(file) if file { 44 | ? -> end(http.NotFound) 45 | _ -> end({ 46 | status: 200 47 | headers: { 'Content-Type': http.MimeTypes.json } 48 | body: file 49 | }) 50 | } 51 | 'PUT' -> with fs.writeFile(DataFile, req.body) fn(res) if res { 52 | ? -> end({ 53 | status: 500 54 | headers: { 'Content-Type': http.MimeTypes.txt } 55 | body: 'error: could not save' 56 | }) 57 | _ -> end({ 58 | status: 201 59 | headers: { 'Content-Type': http.MimeTypes.txt } 60 | body: '' 61 | }) 62 | } 63 | _ -> end(http.MethodNotAllowed) 64 | } 65 | 66 | with server.route('/*staticPath') fn(params) { 67 | http.handleStatic(path.join(StaticDir, params.staticPath)) 68 | } 69 | 70 | with server.route('/') fn(params) fn(req, end) if req.method { 71 | 'GET' -> serveIndex(end) 72 | _ -> end(http.MethodNotAllowed) 73 | } 74 | 75 | server.start(Port) 76 | std.println('Albatross running at port', Port) 77 | 78 | -------------------------------------------------------------------------------- /static/css/main.css: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | margin: 0; 4 | } 5 | 6 | body, 7 | dialog::backdrop, 8 | *::before, 9 | *::after { 10 | --primary-bg: #fdfeff; 11 | --primary-text: #111111; 12 | --secondary-bg: #eeeef3; 13 | --secondary-text: #9b9b9b; 14 | --hover-bg: #dde1e5; 15 | --active-bg: #cdcfd2; 16 | --shadow-soft: rgba(0, 0, 0, .15); 17 | --shadow-hard: rgba(0, 0, 0, .36); 18 | --red: #bc0c0c; 19 | 20 | --dark-primary-bg: #30373a; 21 | --dark-primary-text: #ebebeb; 22 | --dark-secondary-bg: #141516; 23 | --dark-secondary-text: #a4a7a9; 24 | --dark-hover-bg: #474c50; 25 | --dark-active-bg: #626569; 26 | --dark-shadow-soft: rgba(255, 255, 255, .15); 27 | --dark-shadow-hard: rgba(255, 255, 255, .36); 28 | --dark-red: #d46060; 29 | 30 | --accent: #bd4444; 31 | --hover-accent: #ac3232; 32 | --active-accent: #891111; 33 | } 34 | 35 | .dark, 36 | .dark dialog::backdrop, 37 | .dark *::before, 38 | .dark *::after { 39 | --primary-bg: var(--dark-primary-bg); 40 | --primary-text: var(--dark-primary-text); 41 | --secondary-bg: var(--dark-secondary-bg); 42 | --secondary-text: var(--dark-secondary-text); 43 | --hover-bg: var(--dark-hover-bg); 44 | --active-bg: var(--dark-active-bg); 45 | --red: var(--dark-red); 46 | } 47 | 48 | body { 49 | font-family: system-ui, sans-serif; 50 | color: var(--primary-text); 51 | background: var(--secondary-bg); 52 | } 53 | 54 | input, 55 | button, 56 | select, 57 | textarea { 58 | font-size: inherit; 59 | font-family: inherit; 60 | color: inherit; 61 | background: inherit; 62 | margin: 0; 63 | } 64 | 65 | input::placeholder, 66 | textarea::placeholder { 67 | color: var(--secondary-text); 68 | } 69 | 70 | p, 71 | li { 72 | line-height: 1.5em; 73 | max-width: 64ch; 74 | } 75 | 76 | dialog { 77 | color: var(--primary-text); 78 | background: var(--primary-bg); 79 | border: 0; 80 | border-radius: 6px; 81 | box-shadow: 0 2px 52px -6px var(--shadow-hard); 82 | } 83 | 84 | dialog::backdrop { 85 | background: var(--shadow-soft); 86 | } 87 | 88 | /* textarea trick */ 89 | 90 | .textarea-group { 91 | position: relative; 92 | tab-size: 4; 93 | } 94 | 95 | body.webkit .textarea-group, 96 | body.webkit .textarea-itself { 97 | /* When path highlight s are placed into .text-annotations, they 98 | * disrupt kerning between letters and punctuation around words in a way 99 | * that can't be replicated in a