Merge remote-tracking branch 'origin/master'

This commit is contained in:
Daniel Flanagan 2024-01-17 21:17:00 -06:00
commit 468d6ae72a
Signed by: lytedev
GPG key ID: 5B2020A0F9921EF4
11 changed files with 317 additions and 62 deletions

View file

@ -1,3 +1,17 @@
# Homeman
Homeman is short for "home management". It is a simple digital dashboard for
managing tasks within a home primarily geared towards planning and organizing
around a touch-capable display.
**NOTE**: This project was _not_ created to be used outside my family, so some
things may be too inflexible for your use case.
# Optimizations
- Use atomic Deno KV operations?
- Event source streams share a single KV watch?
# Fresh project # Fresh project
Your new Fresh project is ready to go. You can follow the Fresh "Getting Your new Fresh project is ready to go. You can follow the Fresh "Getting
@ -9,7 +23,7 @@ Make sure to install Deno: https://deno.land/manual/getting_started/installation
Then start the project: Then start the project:
``` ```bash
deno task start deno task start
``` ```
@ -19,3 +33,4 @@ This will watch the project directory and restart as necessary.
- https://github.com/briosheje/Fresh-Deno-Mongo-Docker-Todoapp/tree/main - https://github.com/briosheje/Fresh-Deno-Mongo-Docker-Todoapp/tree/main
- https://hearthdisplay.com/ - https://hearthdisplay.com/
- https://github.com/denoland/showcase_todo

View file

@ -30,7 +30,7 @@ export function Dialog(
ref={self} ref={self}
> >
<header class='p-4 flex w-full items-center border-b-2 border-stone-500/20'> <header class='p-4 flex w-full items-center border-b-2 border-stone-500/20'>
<h1 class='text-xl grow'>{headerTitle}</h1> <h1 class='text-xl grow mr-2'>{headerTitle}</h1>
<Button <Button
onClick={() => self.current?.close()} onClick={() => self.current?.close()}
class='text-xl p-4 border-b-2 mr-4' class='text-xl p-4 border-b-2 mr-4'

View file

@ -6,7 +6,7 @@ export function Table(
return ( return (
<table <table
{...props} {...props}
class={`border-separate [border-spacing:1.25rem] text-left ${className}`} class={`border-separate [border-spacing:0.50rem] text-left ${className}`}
> >
{children} {children}
</table> </table>

View file

@ -108,7 +108,7 @@ export function Admin({ users, todos }: Props) {
userData={editUser.value} userData={editUser.value}
/> />
</Dialog> </Dialog>
<SectionHead text={`Users (${users.length})`}> <SectionHead text={`Users (${Object.keys(users).length})`}>
<Button onClick={() => showAddUserDialog.value = true}> <Button onClick={() => showAddUserDialog.value = true}>
<PlusOutline class='w-6 h-6' /> <PlusOutline class='w-6 h-6' />
</Button> </Button>
@ -131,7 +131,7 @@ export function Admin({ users, todos }: Props) {
<td> <td>
{avatarUrl == null {avatarUrl == null
? 'None' ? 'None'
: <Avatar className='h-16 w-16' src={avatarUrl} />} : <Avatar className='h-[64px] w-[64px]' src={avatarUrl} />}
</td> </td>
<td style={`color: #${color}`}> <td style={`color: #${color}`}>
#{color} #{color}
@ -163,12 +163,14 @@ export function Admin({ users, todos }: Props) {
<tr> <tr>
<th>Description</th> <th>Description</th>
<th>Assignee</th> <th>Assignee</th>
<th>Done At</th>
<th>Emoji</th>
<th>Actions</th> <th>Actions</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{Object.entries(todos).map(( {Object.entries(todos).map((
[_index, { id, description, assigneeUserId }], [_index, { id, emoji, doneAt, description, assigneeUserId }],
) => ( ) => (
<tr> <tr>
<td>{description}</td> <td>{description}</td>
@ -183,6 +185,8 @@ export function Admin({ users, todos }: Props) {
? 'Unassigned' ? 'Unassigned'
: users[assigneeUserId]?.name} : users[assigneeUserId]?.name}
</td> </td>
<td>{doneAt == null ? 'Not Done' : doneAt.toLocaleString()}</td>
<td>{emoji || 'No Emoji'}</td>
<td> <td>
<Button <Button
title='Delete' title='Delete'

View file

@ -7,10 +7,15 @@ import { Input } from '@homeman/components/Input.tsx'
import { Avatar } from '@homeman/components/Avatar.tsx' import { Avatar } from '@homeman/components/Avatar.tsx'
import { Button } from '@homeman/components/Button.tsx' import { Button } from '@homeman/components/Button.tsx'
import { JSX } from 'preact' import { JSX } from 'preact'
import { confetti } from 'https://esm.sh/@tsparticles/confetti@3.0.3'
import { useEffect } from 'preact/hooks'
interface Props { interface Props {
users: Record<string, UserWithTodos> users: Record<string, UserWithTodos>
todos: Record<string, Todo>
unassignedTodos: Todo[] unassignedTodos: Todo[]
lastUserIdUpdated: { value: string; versionstamp: string }
lastTodoIdUpdated: { value: string; versionstamp: string }
} }
const unassignedUserPlaceholder: User = { const unassignedUserPlaceholder: User = {
@ -63,10 +68,124 @@ function UserSelectButton(
) )
} }
export default function Dashboard({ users, unassignedTodos }: Props) { export default function Dashboard(
{ todos, users, unassignedTodos, lastTodoIdUpdated, lastUserIdUpdated }:
Props,
) {
console.log('lasttodo:', lastTodoIdUpdated)
console.log('lastuser:', lastUserIdUpdated)
const todoAssignUserId: Signal<string | null> = useSignal(null) const todoAssignUserId: Signal<string | null> = useSignal(null)
const showAddTodoDialog = useSignal(false) const showAddTodoDialog = useSignal(false)
useEffect(() => {
let es = new EventSource('/api/user')
es.addEventListener('message', (e) => {
console.log('user event from server:', e)
})
es.addEventListener('error', async () => {
es.close()
const backoff = 10000 + Math.random() * 5000
await new Promise((resolve) => setTimeout(resolve, backoff))
es = new EventSource('/api/user')
})
}, [])
useEffect(() => {
const count = 200,
defaults = {
origin: { y: 0.7 },
}
function fire(particleRatio: number, opts: {
spread?: number
startVelocity?: number
decay?: number
scalar?: number
}) {
confetti(
Object.assign({}, defaults, opts, {
particleCount: Math.floor(count * particleRatio),
}),
)
}
fire(0.25, {
spread: 26,
startVelocity: 55,
})
fire(0.2, {
spread: 60,
})
fire(0.35, {
spread: 100,
decay: 0.91,
scalar: 0.8,
})
fire(0.1, {
spread: 120,
startVelocity: 25,
decay: 0.92,
scalar: 1.2,
})
fire(0.1, {
spread: 120,
startVelocity: 45,
})
})
useEffect(() => {
let es = new EventSource('/api/todo')
console.log('Streaming todo events...')
es.addEventListener('message', (e) => {
console.log('todo event from server:', e)
const payload = JSON.parse(e.data)
const { id: id } = payload
if (
payload.versionstamp === lastTodoIdUpdated.versionstamp &&
payload.id === lastTodoIdUpdated.value
) {
console.log('skipping...')
// skip, we hydrated with it
return
}
if (!payload.value) {
// deleted, so reload
location.reload()
} else {
const { description, doneAt, assigneeUserId, emoji } = payload.value
const t = todos[id]
if (
!t ||
id != t.id ||
description != t.description ||
doneAt != t.doneAt ||
assigneeUserId != t.assigneeUserId ||
emoji != t.emoji
) {
console.log('Should reload!')
location.reload()
}
}
})
es.addEventListener('error', async (e) => {
console.log('Streaming todo events error:', e)
es.close()
const backoff = 10000 + Math.random() * 5000
await new Promise((resolve) => setTimeout(resolve, backoff))
es = new EventSource('/api/todo')
})
}, [])
const unassignedUser: UserWithTodos = { const unassignedUser: UserWithTodos = {
...unassignedUserPlaceholder, ...unassignedUserPlaceholder,
assignedTodos: unassignedTodos, assignedTodos: unassignedTodos,

View file

@ -17,6 +17,13 @@ export function Nav(/* props: {} */) {
showMenu.value = false showMenu.value = false
}} }}
> >
<section class='flex flex-col text-center p-2 gap-2'>
<a class='p-4 rounded bg-gray-500/20' href='/'>Dashboard</a>
<a class='p-4 rounded bg-gray-500/20' href='/routine'>
Daily Routine
</a>
<a class='p-4 rounded bg-gray-500/20' href='/admin'>Edit Users</a>
</section>
</Dialog> </Dialog>
<nav class='bg-stone-200 dark:bg-stone-800 flex justify-items-start items-center'> <nav class='bg-stone-200 dark:bg-stone-800 flex justify-items-start items-center'>
<button <button

View file

@ -2,17 +2,15 @@ import { Todo, UserWithTodos } from '@homeman/models.ts'
import { JSX } from 'preact' import { JSX } from 'preact'
import { Button } from '@homeman/components/Button.tsx' import { Button } from '@homeman/components/Button.tsx'
import { Avatar } from '@homeman/components/Avatar.tsx' import { Avatar } from '@homeman/components/Avatar.tsx'
import { TrashSolid } from 'preact-heroicons' import { ChevronDownMiniSolid, TrashSolid } from 'preact-heroicons'
export interface Props { export interface Props {
user: UserWithTodos user: UserWithTodos
onNewButtonClicked: JSX.MouseEventHandler<HTMLButtonElement> onNewButtonClicked: JSX.MouseEventHandler<HTMLButtonElement>
onTodoDone: (id: string) => Promise<void>
} }
export function TodoList( export function TodoList(
{ onTodoDone, onNewButtonClicked, user: { avatarUrl, name, color, ...user } }: { onNewButtonClicked, user: { avatarUrl, name, color, ...user } }: Props,
Props,
) { ) {
const doneTodos = user.assignedTodos.filter((t) => t.doneAt != null) const doneTodos = user.assignedTodos.filter((t) => t.doneAt != null)
const inProgressTodos = user.assignedTodos.filter((t) => t.doneAt == null) const inProgressTodos = user.assignedTodos.filter((t) => t.doneAt == null)
@ -41,7 +39,6 @@ export function TodoList(
method: 'PUT', method: 'PUT',
body: JSON.stringify({ id }), body: JSON.stringify({ id }),
}) })
await onTodoDone(id)
}} }}
> >
Done Done
@ -56,7 +53,6 @@ export function TodoList(
method: 'DELETE', method: 'DELETE',
body: JSON.stringify({ id }), body: JSON.stringify({ id }),
}) })
await onTodoDone(id)
}} }}
> >
Not Done Not Done
@ -111,14 +107,15 @@ export function TodoList(
</ul> </ul>
{doneTodos.length > 0 {doneTodos.length > 0
? ( ? (
<summary class='text-gray-500 mt-4'> <details class='text-gray-500 [&>summary>svg]:open:-rotate-180'>
+{doneTodos.length} completed todos <summary class='cursor-pointer marker:content-[""] flex justify-between hover:bg-gray-500/20 p-4 rounded mt-4'>
<details class='cursor-pointer'> <span>+{doneTodos.length} completed todos</span>
<ul class='flex flex-col gap-y-4 mt-4'> <ChevronDownMiniSolid className='w-6 h-6' />
{doneTodos.map(todoItem)} </summary>
</ul> <ul class='flex flex-col gap-y-4 mt-2'>
</details> {doneTodos.map(todoItem)}
</summary> </ul>
</details>
) )
: ''} : ''}
</div> </div>

View file

@ -1,21 +1,27 @@
import { Handlers } from '$fresh/server.ts' import { Handlers } from '$fresh/server.ts'
import { db, Todo, TodoModel } from '@homeman/models.ts' import { db, kv, Todo, TodoModel } from '@homeman/models.ts'
import { ulid } from 'https://deno.land/x/ulid@v0.3.0/mod.ts' import { ulid } from 'https://deno.land/x/ulid@v0.3.0/mod.ts'
import { z } from 'https://deno.land/x/zod@v3.21.4/mod.ts' import { z } from 'https://deno.land/x/zod@v3.21.4/mod.ts'
const TodoPayload = TodoModel.partial({ id: true }).omit({ createdAt: true }) const TodoPayload = TodoModel.partial({ id: true }).omit({
createdAt: true,
})
type TodoPayload = z.infer<typeof TodoPayload> type TodoPayload = z.infer<typeof TodoPayload>
async function createOrUpdate(todo: TodoPayload) { async function createOrUpdate(todo: TodoPayload) {
if (!todo.id) { if (!todo.id || todo.id === '') {
const newTodo: Todo = { const newTodo: Todo = {
...todo, ...todo,
id: ulid(), id: ulid(),
createdAt: new Date(), createdAt: new Date(),
} }
return await db.todos.create({ data: newTodo }) const result = await db.todos.create({ data: newTodo })
await kv.set(['last_todo_updated'], newTodo.id)
return result
} else { } else {
return await db.todos.update({ where: { id: todo.id }, data: todo }) const result = await db.todos.update({ where: { id: todo.id }, data: todo })
await kv.set(['last_todo_updated'], todo.id)
return result
} }
} }
@ -28,7 +34,7 @@ export const handler: Handlers<Todo | null> = {
return new Response(JSON.stringify(result)) return new Response(JSON.stringify(result))
} else { } else {
const form = await req.formData() const form = await req.formData()
const id = form.get('id')?.toString() const id = form.get('id')?.toString() || undefined
const doneAt = form.get('doneAt') const doneAt = form.get('doneAt')
console.log('todo POST doneAt:', doneAt) console.log('todo POST doneAt:', doneAt)
@ -38,13 +44,17 @@ export const handler: Handlers<Todo | null> = {
emoji: form.get('emoji')?.toString() || null, emoji: form.get('emoji')?.toString() || null,
doneAt: form.get('doneAt')?.toString() || null, doneAt: form.get('doneAt')?.toString() || null,
description: form.get('description')?.toString(), description: form.get('description')?.toString(),
assigneeUserId: form.get('assigneeUserId')?.toString(), assigneeUserId: form.get('assigneeUserId')?.toString() || null,
}) })
if (!id) { if (!id) {
delete todo.id delete todo.id
} }
if (!todo.assigneeUserId) {
delete todo.id
}
await createOrUpdate(todo) await createOrUpdate(todo)
const url = new URL(req.url) const url = new URL(req.url)
@ -63,10 +73,64 @@ export const handler: Handlers<Todo | null> = {
console.log('delete todo data:', data) console.log('delete todo data:', data)
const todoData = TodoModel.pick({ id: true }).parse(data) const todoData = TodoModel.pick({ id: true }).parse(data)
const result = await db.todos.delete({ where: todoData }) const result = await db.todos.delete({ where: todoData })
await kv.set(['last_todo_updated'], todoData.id)
return new Response(JSON.stringify(result)) return new Response(JSON.stringify(result))
}, },
async GET(req, _ctx) { async GET(req, ctx) {
// TODO: json or query params // TODO: json or query params
const accept = req.headers.get('accept')
if (accept === 'text/event-stream') {
const stream = kv.watch([['last_todo_updated']]).getReader()
const body = new ReadableStream({
async start(controller) {
console.log(
`Streaming todo updates to ${JSON.stringify(ctx.remoteAddr)}...`,
)
while (true) {
try {
const entries = await stream.read()
console.log('streaming todo entries:', entries)
for (const entry of entries.value || []) {
console.log('streaming todo entry:', entry)
if (typeof entry.value !== 'string') {
console.error('Invalid last_todo_updated entry:', entry)
continue
}
const todo = await db.todos.findFirst({
where: { id: entry.value },
})
const chunk = `data: ${
JSON.stringify({
id: entry.value,
versionstamp: entry.versionstamp,
value: todo,
})
}\n\n`
controller.enqueue(new TextEncoder().encode(chunk))
}
if (entries.done) {
return
}
} catch (e) {
console.error(`Error refreshing todo:`, e)
}
}
},
cancel() {
stream.cancel()
console.log(
`Closed todo updates stream to ${JSON.stringify(ctx.remoteAddr)}`,
)
},
})
return new Response(body, {
headers: {
'content-type': 'text/event-stream',
},
})
}
const data = await req.json().catch(() => {}) const data = await req.json().catch(() => {})
const todoData = TodoModel.pick({ id: true }).safeParse(data) const todoData = TodoModel.pick({ id: true }).safeParse(data)
if (todoData.success) { if (todoData.success) {

View file

@ -1,16 +1,20 @@
import { Handlers } from '$fresh/server.ts' import { Handlers } from '$fresh/server.ts'
import { db, TodoModel } from '@homeman/models.ts' import { db, kv, TodoModel } from '@homeman/models.ts'
const Model = TodoModel.pick({ id: true }) const Model = TodoModel.pick({ id: true })
async function setDoneAt(id: string, doneAt: Date | null) {
const result = await db.todos.update({ where: { id }, data: { doneAt } })
await kv.set(['last_todo_updated'], id)
return result
}
async function markDone(id: string) { async function markDone(id: string) {
const todo = await db.todos.findFirst({ where: { id } }) return await setDoneAt(id, new Date())
todo.doneAt = new Date()
return await db.todos.update({ where: { id }, data: todo })
} }
async function markNotDone(id: string) { async function markNotDone(id: string) {
return await db.todos.update({ where: { id }, data: { doneAt: null } }) return await setDoneAt(id, null)
} }
export const handler: Handlers = { export const handler: Handlers = {

View file

@ -1,5 +1,5 @@
import { Handlers } from '$fresh/server.ts' import { Handlers } from '$fresh/server.ts'
import { db, User, UserModel } from '@homeman/models.ts' import { db, kv, User, UserModel } from '@homeman/models.ts'
import { ulid } from 'https://deno.land/x/ulid@v0.3.0/mod.ts' import { ulid } from 'https://deno.land/x/ulid@v0.3.0/mod.ts'
import { z } from 'https://deno.land/x/zod@v3.21.4/mod.ts' import { z } from 'https://deno.land/x/zod@v3.21.4/mod.ts'
@ -12,9 +12,13 @@ async function createOrUpdate(user: UserPayload) {
} }
if (!user.id) { if (!user.id) {
const newUser: User = { ...user, id: ulid(), createdAt: new Date() } const newUser: User = { ...user, id: ulid(), createdAt: new Date() }
return await db.users.create({ data: newUser }) const result = await db.users.create({ data: newUser })
await kv.set(['last_user_updated'], newUser.id)
return result
} else { } else {
return await db.users.update({ where: { id: user.id }, data: user }) const result = await db.users.update({ where: { id: user.id }, data: user })
await kv.set(['last_user_updated'], user.id)
return result
} }
} }
@ -75,10 +79,54 @@ export const handler: Handlers<User | null> = {
} }
const userData = UserModel.pick({ id: true }).parse(data) const userData = UserModel.pick({ id: true }).parse(data)
const result = await db.users.delete({ where: userData }) const result = await db.users.delete({ where: userData })
await kv.set(['last_user_updated'], userData.id)
return new Response(JSON.stringify(result)) return new Response(JSON.stringify(result))
}, },
async GET(req, _ctx) { async GET(req, ctx) {
// TODO: json or query params // TODO: json or query params
const accept = req.headers.get('accept')
if (accept === 'text/event-stream') {
const stream = kv.watch([['last_user_updated']]).getReader()
const body = new ReadableStream({
async start(controller) {
console.log(
`Streaming user updates to ${JSON.stringify(ctx.remoteAddr)}...`,
)
while (true) {
try {
const entry = await stream.read()
if (entry.done) {
return
}
if (typeof entry.value !== 'string') {
console.error('Invalid last_user_updated:', entry.value)
continue
}
const user = await db.users.findFirst({
where: { id: entry.value },
})
const chunk = `data: ${JSON.stringify(user)}\n\n`
controller.enqueue(new TextEncoder().encode(chunk))
} catch (e) {
console.error(`Error refreshing user:`, e)
}
}
},
cancel() {
stream.cancel()
console.log(
`Closed user updates stream to ${JSON.stringify(ctx.remoteAddr)}`,
)
},
})
return new Response(body, {
headers: {
'content-type': 'text/event-stream',
},
})
}
const data = await req.json().catch(() => {}) const data = await req.json().catch(() => {})
const userData = UserModel.pick({ id: true }).safeParse(data) const userData = UserModel.pick({ id: true }).safeParse(data)
if (userData.success) { if (userData.success) {

View file

@ -1,23 +1,15 @@
import { Handlers, PageProps } from '$fresh/server.ts' import { Handlers, PageProps } from '$fresh/server.ts'
import { db, kv, schema, Todo, UserWithTodos } from '@homeman/models.ts' import { db, kv, Todo, UserWithTodos } from '@homeman/models.ts'
import Dashboard from '@homeman/islands/Dashboard.tsx' import Dashboard from '@homeman/islands/Dashboard.tsx'
import { useSignal } from '@preact/signals'
import { useEffect } from 'preact/hooks'
interface Data { interface Data {
users: Record<string, UserWithTodos> users: Record<string, UserWithTodos>
todos: Record<string, Todo>
unassignedTodos: Todo[] unassignedTodos: Todo[]
lastUserIdUpdated: { value: string; versionstamp: string }
lastTodoIdUpdated: { value: string; versionstamp: string }
} }
const allTableNames = Object.keys(schema).map((s) => [s])
async function watcher() {
console.log('watching:', allTableNames)
for await (const entry of kv.watch(allTableNames)) {
console.log('entry:', entry)
}
}
watcher()
export const handler: Handlers = { export const handler: Handlers = {
async GET(_req, ctx) { async GET(_req, ctx) {
const users = Object.fromEntries( const users = Object.fromEntries(
@ -25,24 +17,29 @@ export const handler: Handlers = {
(u) => [u.id, u], (u) => [u.id, u],
), ),
) )
const todos = Object.fromEntries(
(await db.todos.findMany({})).map(
(t) => [t.id, t],
),
)
const unassignedTodos = await db.todos.findMany({ const unassignedTodos = await db.todos.findMany({
where: { assigneeUserId: null }, where: { assigneeUserId: null },
}) })
return ctx.render({ users, unassignedTodos }) const [lastUserIdUpdated, lastTodoIdUpdated] = await kv.getMany([[
'last_user_updated',
], ['last_todo_updated']], { consistency: 'eventual' })
console.log({ lastTodoIdUpdated, lastUserIdUpdated })
return ctx.render({
users,
todos,
unassignedTodos,
lastUserIdUpdated,
lastTodoIdUpdated,
})
}, },
} }
export default function Home({ data }: PageProps<Data>) { export default function Home({ data }: PageProps<Data>) {
const rdata = useSignal(data)
useEffect(() => {
async function watcher() {
console.log('watcher watching...')
for await (const entry of kv.watch(allTableNames)) {
console.log('entry:', entry)
}
}
watcher().catch(console.error)
}, [rdata])
console.log('Home rendered') console.log('Home rendered')
return <Dashboard {...rdata.value} /> return <Dashboard {...data} />
} }