WIP
This commit is contained in:
parent
5e9cd1139d
commit
676e7777b4
9 changed files with 299 additions and 49 deletions
17
README.md
17
README.md
|
@ -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
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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 * as confetti from 'https://esm.sh/tsparticles@3.1.0'
|
||||
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,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -7,12 +7,10 @@ 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
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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 = {
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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} />
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue