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
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:
```
```bash
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://hearthdisplay.com/
- https://github.com/denoland/showcase_todo

View file

@ -30,7 +30,7 @@ export function Dialog(
ref={self}
>
<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
onClick={() => self.current?.close()}
class='text-xl p-4 border-b-2 mr-4'

View file

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

View file

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

View file

@ -7,10 +7,15 @@ import { Input } from '@homeman/components/Input.tsx'
import { Avatar } from '@homeman/components/Avatar.tsx'
import { Button } from '@homeman/components/Button.tsx'
import { JSX } from 'preact'
import { confetti } from 'https://esm.sh/@tsparticles/confetti@3.0.3'
import { useEffect } from 'preact/hooks'
interface Props {
users: Record<string, UserWithTodos>
todos: Record<string, Todo>
unassignedTodos: Todo[]
lastUserIdUpdated: { value: string; versionstamp: string }
lastTodoIdUpdated: { value: string; versionstamp: string }
}
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 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 = {
...unassignedUserPlaceholder,
assignedTodos: unassignedTodos,

View file

@ -17,6 +17,13 @@ export function Nav(/* props: {} */) {
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>
<nav class='bg-stone-200 dark:bg-stone-800 flex justify-items-start items-center'>
<button

View file

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

View file

@ -1,21 +1,27 @@
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 { 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>
async function createOrUpdate(todo: TodoPayload) {
if (!todo.id) {
if (!todo.id || todo.id === '') {
const newTodo: Todo = {
...todo,
id: ulid(),
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 {
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))
} else {
const form = await req.formData()
const id = form.get('id')?.toString()
const id = form.get('id')?.toString() || undefined
const doneAt = form.get('doneAt')
console.log('todo POST doneAt:', doneAt)
@ -38,13 +44,17 @@ export const handler: Handlers<Todo | null> = {
emoji: form.get('emoji')?.toString() || null,
doneAt: form.get('doneAt')?.toString() || null,
description: form.get('description')?.toString(),
assigneeUserId: form.get('assigneeUserId')?.toString(),
assigneeUserId: form.get('assigneeUserId')?.toString() || null,
})
if (!id) {
delete todo.id
}
if (!todo.assigneeUserId) {
delete todo.id
}
await createOrUpdate(todo)
const url = new URL(req.url)
@ -63,10 +73,64 @@ export const handler: Handlers<Todo | null> = {
console.log('delete todo data:', data)
const todoData = TodoModel.pick({ id: true }).parse(data)
const result = await db.todos.delete({ where: todoData })
await kv.set(['last_todo_updated'], todoData.id)
return new Response(JSON.stringify(result))
},
async GET(req, _ctx) {
async GET(req, ctx) {
// 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 todoData = TodoModel.pick({ id: true }).safeParse(data)
if (todoData.success) {

View file

@ -1,16 +1,20 @@
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 })
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) {
const todo = await db.todos.findFirst({ where: { id } })
todo.doneAt = new Date()
return await db.todos.update({ where: { id }, data: todo })
return await setDoneAt(id, new Date())
}
async function markNotDone(id: string) {
return await db.todos.update({ where: { id }, data: { doneAt: null } })
return await setDoneAt(id, null)
}
export const handler: Handlers = {

View file

@ -1,5 +1,5 @@
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 { z } from 'https://deno.land/x/zod@v3.21.4/mod.ts'
@ -12,9 +12,13 @@ async function createOrUpdate(user: UserPayload) {
}
if (!user.id) {
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 {
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 result = await db.users.delete({ where: userData })
await kv.set(['last_user_updated'], userData.id)
return new Response(JSON.stringify(result))
},
async GET(req, _ctx) {
async GET(req, ctx) {
// 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 userData = UserModel.pick({ id: true }).safeParse(data)
if (userData.success) {

View file

@ -1,23 +1,15 @@
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 { useSignal } from '@preact/signals'
import { useEffect } from 'preact/hooks'
interface Data {
users: Record<string, UserWithTodos>
todos: Record<string, 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 = {
async GET(_req, ctx) {
const users = Object.fromEntries(
@ -25,24 +17,29 @@ export const handler: Handlers = {
(u) => [u.id, u],
),
)
const todos = Object.fromEntries(
(await db.todos.findMany({})).map(
(t) => [t.id, t],
),
)
const unassignedTodos = await db.todos.findMany({
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>) {
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')
return <Dashboard {...rdata.value} />
return <Dashboard {...data} />
}