Merge remote-tracking branch 'origin/master'
This commit is contained in:
commit
468d6ae72a
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
|
# 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
|
||||||
|
|
|
@ -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'
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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'
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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 = {
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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} />
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue