This commit is contained in:
Corey Johnson 2025-06-22 11:17:20 -07:00
parent c2fbeef6e5
commit 04980a1869
16 changed files with 854 additions and 2 deletions

View File

@ -62,7 +62,7 @@
},
},
"packages/spike": {
"name": "@workshop/spike2",
"name": "@workshop/spike",
"dependencies": {
"@openai/agents": "^0.0.8",
"discord.js": "^14.19.3",
@ -77,8 +77,36 @@
"typescript": "^5",
},
},
"packages/text-do": {
"name": "text-do",
"dependencies": {
"@codemirror/commands": "^6.8.1",
"@codemirror/language": "^6.11.1",
"@codemirror/state": "^6.5.2",
"@codemirror/view": "^6.37.2",
"@lezer/generator": "^1.7.3",
"@lezer/highlight": "^1.2.1",
"@lezer/lr": "^1.4.2",
"hono": "^4.8.0",
"luxon": "^3.6.1",
},
"devDependencies": {
"@types/bun": "latest",
},
"peerDependencies": {
"typescript": "^5",
},
},
},
"packages": {
"@codemirror/commands": ["@codemirror/commands@6.8.1", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.4.0", "@codemirror/view": "^6.27.0", "@lezer/common": "^1.1.0" } }, "sha512-KlGVYufHMQzxbdQONiLyGQDUW0itrLZwq3CcY7xpv9ZLRHqzkBSoteocBHtMCoY7/Ci4xhzSrToIeLg7FxHuaw=="],
"@codemirror/language": ["@codemirror/language@6.11.1", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.23.0", "@lezer/common": "^1.1.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0", "style-mod": "^4.0.0" } }, "sha512-5kS1U7emOGV84vxC+ruBty5sUgcD0te6dyupyRVG2zaSjhTDM73LhVKUtVwiqSe6QwmEoA4SCiU8AKPFyumAWQ=="],
"@codemirror/state": ["@codemirror/state@6.5.2", "", { "dependencies": { "@marijn/find-cluster-break": "^1.0.0" } }, "sha512-FVqsPqtPWKVVL3dPSxy8wEF/ymIEuVzF1PK3VbUgrxXpJUSHQWWZz4JMToquRxnkw+36LTamCZG2iua2Ptq0fA=="],
"@codemirror/view": ["@codemirror/view@6.37.2", "", { "dependencies": { "@codemirror/state": "^6.5.0", "crelt": "^1.0.6", "style-mod": "^4.1.0", "w3c-keyname": "^2.2.4" } }, "sha512-XD3LdgQpxQs5jhOOZ2HRVT+Rj59O4Suc7g2ULvZ+Yi8eCkickrkZ5JFuoDhs2ST1mNI5zSsNYgR3NGa4OUrbnw=="],
"@discordjs/builders": ["@discordjs/builders@1.11.2", "", { "dependencies": { "@discordjs/formatters": "^0.6.1", "@discordjs/util": "^1.1.1", "@sapphire/shapeshift": "^4.0.0", "discord-api-types": "^0.38.1", "fast-deep-equal": "^3.1.3", "ts-mixer": "^6.0.4", "tslib": "^2.6.3" } }, "sha512-F1WTABdd8/R9D1icJzajC4IuLyyS8f3rTOz66JsSI3pKvpCAtsMBweu8cyNYsIyvcrKAVn9EPK+Psoymq+XC0A=="],
"@discordjs/collection": ["@discordjs/collection@1.5.3", "", {}, "sha512-SVb428OMd3WO1paV3rm6tSjM4wC+Kecaa1EUGX7vc6/fddvw/6lg90z4QtCqm21zvVe92vMMDt9+DkIvjXImQQ=="],
@ -91,6 +119,16 @@
"@discordjs/ws": ["@discordjs/ws@1.2.2", "", { "dependencies": { "@discordjs/collection": "^2.1.0", "@discordjs/rest": "^2.5.0", "@discordjs/util": "^1.1.0", "@sapphire/async-queue": "^1.5.2", "@types/ws": "^8.5.10", "@vladfrangu/async_event_emitter": "^2.2.4", "discord-api-types": "^0.38.1", "tslib": "^2.6.2", "ws": "^8.17.0" } }, "sha512-dyfq7yn0wO0IYeYOs3z79I6/HumhmKISzFL0Z+007zQJMtAFGtt3AEoq1nuLXtcunUE5YYYQqgKvybXukAK8/w=="],
"@lezer/common": ["@lezer/common@1.2.3", "", {}, "sha512-w7ojc8ejBqr2REPsWxJjrMFsA/ysDCFICn8zEOR9mrqzOu2amhITYuLD8ag6XZf0CFXDrhKqw7+tW8cX66NaDA=="],
"@lezer/generator": ["@lezer/generator@1.7.3", "", { "dependencies": { "@lezer/common": "^1.1.0", "@lezer/lr": "^1.3.0" }, "bin": { "lezer-generator": "src/lezer-generator.cjs" } }, "sha512-vAI2O1tPF8QMMgp+bdUeeJCneJNkOZvqsrtyb4ohnFVFdboSqPwBEacnt0HH4E+5h+qsIwTHUSAhffU4hzKl1A=="],
"@lezer/highlight": ["@lezer/highlight@1.2.1", "", { "dependencies": { "@lezer/common": "^1.0.0" } }, "sha512-Z5duk4RN/3zuVO7Jq0pGLJ3qynpxUVsh7IbUbGj88+uV2ApSAn6kWg2au3iJb+0Zi7kKtqffIESgNcRXWZWmSA=="],
"@lezer/lr": ["@lezer/lr@1.4.2", "", { "dependencies": { "@lezer/common": "^1.0.0" } }, "sha512-pu0K1jCIdnQ12aWNaAVU5bzi7Bd1w54J3ECgANPmYLtQKP0HBj2cE/5coBD66MT10xbtIuUr7tg0Shbsvk0mDA=="],
"@marijn/find-cluster-break": ["@marijn/find-cluster-break@1.0.2", "", {}, "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g=="],
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.12.3", "", { "dependencies": { "ajv": "^6.12.6", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.23.8", "zod-to-json-schema": "^3.24.1" } }, "sha512-DyVYSOafBvk3/j1Oka4z5BWT8o4AFmoNyZY9pALOm7Lh3GZglR71Co4r4dEUoqDWdDazIZQHBe7J2Nwkg6gHgQ=="],
"@openai/agents": ["@openai/agents@0.0.8", "", { "dependencies": { "@openai/agents-core": "0.0.8", "@openai/agents-openai": "0.0.8", "@openai/agents-realtime": "0.0.8", "debug": "^4.4.0", "openai": "^5.0.1" } }, "sha512-HAPP4QM47kWeWw70uxCzr5zjqHuDIvQ8Obx+98J66lcEeIZzMChHN60k5ew8DITScmzDVAVuwdzfAImSyq002w=="],
@ -131,7 +169,7 @@
"@workshop/shared": ["@workshop/shared@workspace:packages/shared"],
"@workshop/spike2": ["@workshop/spike2@workspace:packages/spike"],
"@workshop/spike": ["@workshop/spike@workspace:packages/spike"],
"accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
@ -157,6 +195,8 @@
"cors": ["cors@2.8.5", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g=="],
"crelt": ["crelt@1.0.6", "", {}, "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g=="],
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
"debug": ["debug@4.4.1", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ=="],
@ -307,6 +347,10 @@
"statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
"style-mod": ["style-mod@4.1.2", "", {}, "sha512-wnD1HyVqpJUI2+eKZ+eo1UwghftP6yuFheBqqe+bWCotBjC2K1YnteJILRMs3SM4V/0dLEW1SC27MWP5y+mwmw=="],
"text-do": ["text-do@workspace:packages/text-do"],
"toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
"ts-mixer": ["ts-mixer@6.0.4", "", {}, "sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA=="],
@ -327,6 +371,8 @@
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
"w3c-keyname": ["w3c-keyname@2.2.8", "", {}, "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ=="],
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
@ -341,6 +387,16 @@
"@discordjs/ws/@discordjs/collection": ["@discordjs/collection@2.1.1", "", {}, "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg=="],
"@workshop/spike/@types/bun": ["@types/bun@1.2.17", "", { "dependencies": { "bun-types": "1.2.17" } }, "sha512-l/BYs/JYt+cXA/0+wUhulYJB6a6p//GTPiJ7nV+QHa8iiId4HZmnu/3J/SowP5g0rTiERY2kfGKXEK5Ehltx4Q=="],
"http-errors/statuses": ["statuses@2.0.1", "", {}, "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ=="],
"text-do/@types/bun": ["@types/bun@1.2.17", "", { "dependencies": { "bun-types": "1.2.17" } }, "sha512-l/BYs/JYt+cXA/0+wUhulYJB6a6p//GTPiJ7nV+QHa8iiId4HZmnu/3J/SowP5g0rTiERY2kfGKXEK5Ehltx4Q=="],
"text-do/hono": ["hono@4.8.2", "", {}, "sha512-hM+1RIn9PK1I6SiTNS6/y7O1mvg88awYLFEuEtoiMtRyT3SD2iu9pSFgbBXT3b1Ua4IwzvSTLvwO0SEhDxCi4w=="],
"@workshop/spike/@types/bun/bun-types": ["bun-types@1.2.17", "", { "dependencies": { "@types/node": "*" } }, "sha512-ElC7ItwT3SCQwYZDYoAH+q6KT4Fxjl8DtZ6qDulUFBmXA8YB4xo+l54J9ZJN+k2pphfn9vk7kfubeSd5QfTVJQ=="],
"text-do/@types/bun/bun-types": ["bun-types@1.2.17", "", { "dependencies": { "@types/node": "*" } }, "sha512-ElC7ItwT3SCQwYZDYoAH+q6KT4Fxjl8DtZ6qDulUFBmXA8YB4xo+l54J9ZJN+k2pphfn9vk7kfubeSd5QfTVJQ=="],
}
}

View File

@ -0,0 +1,115 @@
import KV from "@workshop/shared/kv"
import { Form, type Head, type LoaderProps, useAction } from "@workshop/nano-remix"
import { addReminder, deleteReminder, type Reminder } from "@workshop/shared/reminders"
import { CreateReminder } from "@/components/createReminder"
export const head: Head = {
title: "Todos",
scripts: [{ src: "https://cdn.tailwindcss.com" }],
}
export const loader = async (req: Request) => {
const todos = await KV.get("todos", {})
return { todos }
}
export const action = async (req: Request) => {
const formData = await req.formData()
const action = formData.get("_action") as string
// Handle delete action
if (action === "delete") {
const id = formData.get("id") as string
if (!id) {
return { error: "Reminder ID is required" }
}
try {
await deleteReminder(id)
return { success: true, message: "Reminder deleted successfully" }
} catch (error) {
return { error: `Failed to delete reminder: ${error}` }
}
} else if (action === "create") {
// Handle create action (default)
const title = formData.get("title") as string
const dueDate = formData.get("dueDate") as string
const assignee = (formData.get("assignee") as string) || undefined
if (!title) {
return { error: "Title is required" }
}
if (!dueDate) {
return { error: "Due date is required" }
}
try {
const newReminder = await addReminder(title, dueDate, assignee as any)
return { success: true, newReminder }
} catch (error) {
return { error: `Failed to create reminder: ${error}` }
}
}
}
export default (props: LoaderProps<typeof loader>) => {
const { data, loading, error } = useAction()
return (
<div class="bg-blue-50 min-h-screen pb-8">
<h1 class="p-4 text-2xl bg-blue-100 text-blue-800">Reminders</h1>
<main class="p-4">
<div>
<h2 class="text-xl font-medium text-blue-800 mb-4">Current Reminders</h2>
<Reminders reminders={props.reminders} />
</div>
<div class="mt-8">
<CreateReminder loading={loading} success={data?.success} error={error ?? data?.error} />
</div>
</main>
</div>
)
}
const Reminders = ({ reminders }: { reminders: Reminder[] }) => {
return (
<>
{reminders.length === 0 ? (
<p class="text-gray-600">No reminders found.</p>
) : (
<ul class="bg-white rounded-lg shadow overflow-hidden divide-y divide-gray-200">
{reminders.map((reminder) => (
<li key={reminder.id} class="p-4 hover:bg-gray-50">
<div class="flex justify-between items-start">
<div>
<div class="font-medium">{reminder.title}</div>
<div class="text-sm text-gray-500 flex justify-between mt-1 items-center gap-2">
<span>Due: {new Date(reminder.dueDate).toLocaleString()}</span>
{reminder.assignee && (
<span class="bg-blue-100 text-blue-800 text-xs px-2 py-1 rounded ml-2">
{reminder.assignee}
</span>
)}
<div class="text-sm text-gray-500 flex p-2">{reminder.status}</div>
</div>
</div>
<Form method="POST" class="inline-block">
<input type="hidden" name="_action" value="delete" />
<input type="hidden" name="id" value={reminder.id} />
<button
type="submit"
class="text-red-600 hover:text-red-800 text-sm font-medium"
title="Delete Reminder"
>
Delete
</button>
</Form>
</div>
</li>
))}
</ul>
)}
</>
)
}

View File

@ -6,6 +6,7 @@ export type Conversation = { message: string; role: "user" | "assistant" }
type Keys = {
threads: Record<string, string> // threadId: previousResponseId
reminders: Reminder[]
todos: Record<string, string> // todoId: todoText
}
const version = 10

34
packages/todo/.gitignore vendored Normal file
View File

@ -0,0 +1,34 @@
# dependencies (bun install)
node_modules
# output
out
dist
*.tgz
# code coverage
coverage
*.lcov
# logs
logs
_.log
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# caches
.eslintcache
.cache
*.tsbuildinfo
# IntelliJ based IDEs
.idea
# Finder (MacOS) folder config
.DS_Store

15
packages/todo/README.md Normal file
View File

@ -0,0 +1,15 @@
# text-do
To install dependencies:
```bash
bun install
```
To run:
```bash
bun run index.ts
```
This project was created using `bun init` in bun v1.2.16. [Bun](https://bun.sh) is a fast all-in-one JavaScript runtime.

1
packages/todo/index.ts Normal file
View File

@ -0,0 +1 @@
console.log("Hello via Bun!");

View File

@ -0,0 +1,27 @@
{
"name": "todo",
"module": "index.ts",
"type": "module",
"private": true,
"dependencies": {
"@codemirror/commands": "^6.8.1",
"@codemirror/language": "^6.11.1",
"@codemirror/state": "^6.5.2",
"@codemirror/view": "^6.37.2",
"@lezer/generator": "^1.7.3",
"@lezer/highlight": "^1.2.1",
"@lezer/lr": "^1.4.2",
"hono": "^4.8.0",
"luxon": "^3.6.1",
},
"prettier": {
"semi": false,
"printWidth": 110
},
"devDependencies": {
"@types/bun": "latest"
},
"peerDependencies": {
"typescript": "^5"
}
}

View File

@ -0,0 +1,37 @@
import { EditorView } from "@codemirror/view"
export const autoTodoOnNewline = EditorView.updateListener.of((update) => {
if (update.state.selection.ranges.length > 1) return
for (const tr of update.transactions) {
if (!tr.isUserEvent("input")) return
tr.changes.iterChanges((fromA, toA, fromB, toB, inserted) => {
if (inserted.toString() !== "\n") return
const prefix = "- [ ] "
// If previous line was an empty todo, remove the empty line instead of continuing
const insertPos = fromB + inserted.toString().length
const prevLine = update.state.doc.lineAt(insertPos - 1)
// if the previous line is empty or just whitespace, just insert the newline
if (prevLine.text.trim() === "") {
return
} else if (prevLine.text.trim() === prefix.trim()) {
const start = prevLine.from
const end = insertPos
update.view.dispatch({
changes: { from: start, to: end, insert: "" },
selection: { anchor: start },
userEvent: "delete",
})
} else {
update.view.dispatch({
changes: { from: insertPos, to: insertPos, insert: prefix },
selection: { anchor: insertPos + prefix.length },
userEvent: "todo.newline",
})
}
})
}
})

View File

@ -0,0 +1,3 @@
export {TodoEditor } from "@/todoEditor"
export {parseTodoLine, parseTodoList} from "@/todo"
export type {Todo} from "@/todo"

View File

@ -0,0 +1,171 @@
import { parseTodoList, parseTodoLine, Todo, TodoMeta } from "./todo"
import { test, expect } from "bun:test"
test("parse things", () => {
check("- [ ] some words and that is it", {
done: false,
text: "some words and that is it",
tags: [],
children: [],
header: undefined,
})
check("- [x] some words", {
done: true,
text: "some words",
tags: [],
children: [],
header: undefined,
})
check("- [ ] some words -and - [ ] not another task", {
done: false,
text: "some words -and - [ ] not another task",
tags: [],
children: [],
header: undefined,
})
check("- [ ] a task with a #tag", {
done: false,
text: "a task with a #tag",
tags: ["tag"],
children: [],
header: undefined,
})
check("- [ ] a #tag in a task", {
done: false,
text: "a #tag in a task",
tags: ["tag"],
children: [],
header: undefined,
})
check("- [ ] a #tag and #another.", {
done: false,
text: "a #tag and #another.",
tags: ["tag", "another"],
children: [],
header: undefined,
})
check("- [ ] a word with a po#nd sign", {
done: false,
text: "a word with a po#nd sign",
tags: [],
children: [],
header: undefined,
})
check("- [ ] a date @1/2/25!", {
done: false,
text: "a date @1/2/25!",
tags: [],
dueDate: "2025-01-02",
children: [],
header: undefined,
})
check("- [ ] a date @2-2-25!", {
done: false,
text: "a date @2-2-25!",
tags: [],
dueDate: "2025-02-02",
children: [],
header: undefined,
})
check("- [ ] a @1/2/25 date and #tag", {
done: false,
text: "a @1/2/25 date and #tag",
tags: ["tag"],
dueDate: "2025-01-02",
children: [],
header: undefined,
})
check(" ", [])
// Invalid formats
check("- [x status error", [])
check("- [. ] status error", [])
check("just text", [])
// A full todo list
check(
`
# Today
- [ ] a task with a #tag
- [x] a #tag and date @2/2/25 in a task
# Tomorrow
- [ ] another task
- [ ] a subtask
- [x] completed
- [ ] a task with a @1/2/25 date
`,
[
{
done: false,
text: "a task with a #tag",
tags: ["tag"],
dueDate: undefined,
children: [],
header: "Today",
},
{
done: true,
text: "a #tag and date @2/2/25 in a task",
tags: ["tag"],
dueDate: "2025-02-02",
children: [],
header: "Today",
},
{
done: false,
text: "another task",
tags: [],
dueDate: undefined,
children: [
{ done: false, text: "a subtask", tags: [], dueDate: undefined, children: [], header: "Tomorrow" },
{ done: true, text: "completed", tags: [], dueDate: undefined, children: [], header: "Tomorrow" },
],
header: "Tomorrow",
},
{
done: false,
text: "a task with a @1/2/25 date",
tags: [],
dueDate: "2025-01-02",
children: [],
header: "Tomorrow",
},
]
)
})
test("parse metadata positions", () => {
const line = "- [ ] do #tag @1/2/25"
const parsed = parseTodoLine(line)
expect(parsed, line).toBeDefined()
const { meta } = parsed as { todo: Todo; meta: TodoMeta }
// '#tag' starts at index 9, ends at 9 + 4
expect(meta.tags).toEqual([{ start: 7, end: 12 }])
// '+1/2/25' starts at index 14, ends at 14 + 6 + 1 = 21
expect(meta.dates).toEqual([{ start: 12, end: 18 }])
})
const check = (input: string, expectation: Todo | Todo[]) => {
const result = parseTodoList(input)
if (expectation === undefined) {
expect(result, input).toBeUndefined()
} else {
expect(result, input).toEqual(Array.isArray(expectation) ? expectation : [expectation])
}
}

142
packages/todo/src/todo.ts Normal file
View File

@ -0,0 +1,142 @@
import { DateTime } from "luxon"
export type Todo = {
done: boolean
text: string
tags: string[]
dueDate?: string
children: Todo[]
header?: string
nested?: boolean
}
export type TodoMeta = {
type: "tag" | "date"
start: number
end: number
}[]
export const parseTodoList = (text: string, zone = "America/Los_Angeles") => {
const todos: Todo[] = []
const lines = text.split("\n")
let currentParent: Todo | undefined
let currentHeader
for (const line of lines) {
const headerMatch = line.match(/^\s*#+\s*(.*)/)
if (headerMatch) {
currentParent = undefined
currentHeader = headerMatch[1].trim()
continue
} else if (line.trim() === "") {
continue
}
const parsed = parseTodoLine(line, currentHeader, currentParent, zone)
if (parsed) {
const { todo } = parsed
if (todo && !todo.nested) {
todos.push(todo)
currentParent = todo
}
} else {
// console.warn(`❌ Skipping invalid todo line "${line}"`)
}
}
return todos
}
export const todoToText = (todo: Todo): string => {
let indent = ""
if (todo.nested) {
indent += " " // Indent for nested todos
}
const text = `${indent}- [${todo.done ? "x" : " "}] ${todo.text}`
return text
}
const invalidLine = { todo: undefined, meta: undefined } as const
export const parseTodoLine = (
line: string,
header?: string,
parent?: Todo,
zone = "America/Los_Angeles"
): { todo: Todo; meta: TodoMeta } | typeof invalidLine => {
const meta: TodoMeta = []
let offset = 0
const todo: Todo = {
done: false,
text: "",
tags: [],
dueDate: undefined,
children: [],
header,
nested: isSubtodo(line),
}
// Is it a nested task?
if (todo.nested) {
if (!parent) {
// console.warn(`❌ Ignoring Nested task without a parent "${line}"`)
return invalidLine
}
parent.children.push(todo)
}
offset += line.search(/\S/)
line = line.trimStart()
// Check for status
const match = line.match(/- \[\s*(\w?)\s*\]\s+/i)
if (!match) return invalidLine // Invalid format, no status found
todo.done = Boolean(match[1])
offset += match[0].length
line = line.slice(match[0].length).trimStart()
// Save text
todo.text = line
// Extract tags
todo.text.matchAll(/(?:^|\s)#(\w+)/g).forEach((match) => {
const start = match.index! + offset
const end = start + match[0].length
meta.push({ type: "tag", start, end })
todo.tags.push(match[1])
})
// Extract due date in Month/Day/Year
const dateMatches = todo.text.matchAll(/(?:^|\s)@([\d\/\-]{6,})/g)
for (const match of dateMatches) {
let [month, day, year] = match[1].split(/[\/-]/)
if (!month || !day || !year) continue
if (year.length == 2) year = `20${year}` // Assume 21st century for two-digit years
const dateTime = DateTime.fromObject(
{
year: parseInt(year, 10),
month: parseInt(month, 10),
day: parseInt(day, 10),
},
{ zone }
)
if (dateTime.isValid) {
const start = match.index + offset
const end = start + match[0].length
meta.push({ type: "date", start, end })
todo.dueDate = dateTime.toISODate()
break
} else {
console.warn(`🕰️ Invalid date format "${match[1]}"`)
}
}
meta.sort((a, b) => a.start - b.start) // CodeMiror expects metadata to be sorted by start position
return { todo, meta }
}
const isSubtodo = (line: string) => {
return /^\s+/.test(line)
}

View File

@ -0,0 +1,64 @@
import { parseTodoLine, Todo } from "@/todo/todo"
import { RangeSetBuilder, StateEffect } from "@codemirror/state"
import { EditorView, Decoration, ViewPlugin, ViewUpdate } from "@codemirror/view"
import { RefObject } from "hono/jsx"
// Effect to trigger a decoration refresh on filter change
export const refreshFilterEffect = StateEffect.define<void>()
export const todoDecorations = (filterRef: RefObject<string>) => {
return ViewPlugin.fromClass(
class {
decorations: any
constructor(view: EditorView) {
this.decorations = this.buildDecorations(view)
}
update(update: ViewUpdate) {
if (
update.docChanged ||
update.viewportChanged ||
update.transactions.some((tr) => tr.effects.some((e) => e.is(refreshFilterEffect)))
) {
this.decorations = this.buildDecorations(update.view)
}
}
buildDecorations(view: EditorView) {
const builder = new RangeSetBuilder<Decoration>()
const { doc } = view.state
const dummyParent: Todo = { done: false, text: "", tags: [], children: [] }
for (const { from, to } of view.visibleRanges) {
let pos = from
while (pos <= to) {
const line = doc.lineAt(pos)
const text = line.text
const { todo, meta } = parseTodoLine(text, "", dummyParent)
if (todo) {
if (filterRef.current && !todo.tags.find((t) => t.startsWith(filterRef.current!))) {
builder.add(line.from, line.from, Decoration.line({ attributes: { style: "display: none" } }))
}
if (todo.done) {
builder.add(line.from, line.to, Decoration.mark({ class: "todo-completed" }))
}
for (const { type, start, end } of meta) {
builder.add(line.from + start, line.from + end, Decoration.mark({ class: `todo-${type}` }))
}
} else if (/^\s*#+\s/.test(text)) {
builder.add(line.from, line.to, Decoration.mark({ class: "todo-header" }))
} else if (text.trim() !== "") {
builder.add(line.from, line.to, Decoration.mark({ class: "todo-invalid" }))
}
pos = line.to + 1
}
}
return builder.finish()
}
},
{ decorations: (v) => v.decorations }
)
}

View File

@ -0,0 +1,101 @@
import { useCallback, useEffect, useRef, useState } from "hono/jsx"
import { defaultKeymap, history, historyKeymap } from "@codemirror/commands"
import { EditorState } from "@codemirror/state"
import { EditorView, lineNumbers, keymap } from "@codemirror/view"
import { keymap as viewKeymap } from "@codemirror/view" // ensure keymap is imported from view if not already
import { foldGutter, foldKeymap } from "@codemirror/language"
import { refreshFilterEffect, todoDecorations } from "@/todo/todoDecorations"
import { autoTodoOnNewline } from "@/todo/autoTodoOnNewline"
import { todoKeymap } from "@/todo/todoKeymap"
import { DateTime } from "luxon"
import "./index.css"
export const TodoEditor = () => {
const editorContainer = useRef<HTMLDivElement>(null)
const editorRef = useRef<EditorView>(null)
const [filter, setFilter] = useState<string>("")
const filterRef = useRef(filter)
const filterElRef = useRef<HTMLInputElement>(null)
useEffect(() => {
filterRef.current = filter
editorRef.current?.dispatch({ effects: refreshFilterEffect.of() }) // trigger update to decorations when filter changes
}, [filter])
useEffect(() => {
if (editorRef.current) editorRef.current.destroy()
if (!editorContainer.current) return
const changeListener = EditorView.updateListener.of((update) => {
localStorage.setItem("todo-editor-content", update.state.doc.toString())
})
const savedDoc = localStorage.getItem("todo-editor-content")
const docText = savedDoc ?? defaultDoc
const state = EditorState.create({
doc: docText,
extensions: [
foldGutter(),
lineNumbers(),
history(),
todoDecorations(filterRef),
changeListener,
autoTodoOnNewline,
todoKeymap(filterElRef),
keymap.of(historyKeymap),
keymap.of(defaultKeymap),
viewKeymap.of(foldKeymap),
],
})
const view = new EditorView({ state, parent: editorContainer.current })
view.focus()
editorRef.current = view
return () => view.destroy()
}, [])
const filterInput = useCallback((e: KeyboardEvent) => {
if (e.key === "Enter" || e.key === "Escape") {
if (!editorRef.current) return
editorRef.current?.focus()
e.preventDefault()
} else {
setFilter((e.target as HTMLInputElement).value)
}
}, [])
return (
<div class="h-dvh w-dvw flex flex-col">
<input
type="text"
placeholder="Filter by tag"
value={filter}
ref={filterElRef}
onKeyUp={filterInput}
onFocus={(e) => (e.target as HTMLInputElement).select()}
class="p-2 border-b"
/>
<div ref={editorContainer} class="grow" />
</div>
)
}
export default App
const defaultDoc = `
# Today (Group tasks by when they are due)
- [ ] Sample task with a due date @${DateTime.local().toFormat("yyyy/MM/dd")}
- [ ] You can use a #tag to filter tasks
- [ ] A sub task! Create nested tasks by indenting with <tab>
- [x] Complete a task by pressing <opt+k>
# This week
- [ ] Another task with a due date @${DateTime.local().plus({ days: 3 }).toFormat("yyyy/MM/dd")}
# Later
- [ ] I use later as a junk drawer for tasks I don't want to forget
`.trim()

View File

@ -0,0 +1,51 @@
import { indentMore, indentLess } from "@codemirror/commands"
import { EditorView, keymap } from "@codemirror/view"
import { parseTodoLine, todoToText } from "./todo"
import { RefObject } from "hono/jsx"
export const todoKeymap = (filterElRef: RefObject<HTMLInputElement>) => {
return keymap.of([
{
key: "alt-l", // Focus the filter input
preventDefault: true,
run: (_view: EditorView) => {
filterElRef.current?.focus()
return true
},
},
{
key: "alt-k", // toggle done state of the todo item
preventDefault: true,
run: (view: EditorView) => {
const { state } = view
const { head } = state.selection.main
const line = state.doc.lineAt(head)
const { todo, meta } = parseTodoLine(line.text)
if (!todo) return false
todo.done = !todo.done
const updatedLine = todoToText(todo)
const from = line.from
const to = line.to
const offset = head - from
view.dispatch({
changes: { from, to, insert: updatedLine },
selection: { anchor: from + offset },
userEvent: "input",
})
return true
},
},
{
key: "Tab",
preventDefault: true,
run: indentMore,
},
{
key: "Shift-Tab",
preventDefault: true,
run: indentLess,
},
])
}

View File

@ -0,0 +1,34 @@
{
"compilerOptions": {
// Environment setup & latest features
"lib": ["ESNext"],
"target": "ESNext",
"module": "Preserve",
"moduleDetection": "force",
"jsx": "react-jsx",
"allowJs": true,
// Bundler mode
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
// Best practices
"strict": true,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
// Some stricter flags (disabled by default)
"noUnusedLocals": false,
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
}