Quick add task from the header (owner): modal, g t shortcut, auto_handle + Daisy webhook
Some checks failed
Deploy production / deploy (push) Has been cancelled
Some checks failed
Deploy production / deploy (push) Has been cancelled
- + button next to Dashboard (desktop and mobile sheet), lazy-loaded native <dialog>
- POST /trackers/tasks/quick inserts source 'quick-add', notes, due date, priority
- tasks.tasks.auto_handle (default false) with a Daisy badge in the tracker
- DAISY_TASK_WEBHOOK_URL/KEY/HEADER: fire-and-forget POST {task_id, title}, 3 s timeout
This commit is contained in:
1
drizzle/20261024160855_tasks_auto_handle/migration.sql
Normal file
1
drizzle/20261024160855_tasks_auto_handle/migration.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "tasks"."tasks" ADD COLUMN "auto_handle" boolean DEFAULT false NOT NULL;
|
||||
19846
drizzle/20261024160855_tasks_auto_handle/snapshot.json
Normal file
19846
drizzle/20261024160855_tasks_auto_handle/snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
127
src/lib/components/QuickAddTask.svelte
Normal file
127
src/lib/components/QuickAddTask.svelte
Normal file
@@ -0,0 +1,127 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { invalidateAll } from '$app/navigation';
|
||||
|
||||
// Quick add task modal, lazy-loaded by SiteChrome on first open (button or g then t).
|
||||
// A native <dialog>: focus trap, Esc and backdrop come with the browser, no UI library.
|
||||
let { onclose, ondone }: { onclose: () => void; ondone: (message: string) => void } = $props();
|
||||
|
||||
let dialog = $state<HTMLDialogElement>();
|
||||
let title = $state('');
|
||||
let notes = $state('');
|
||||
let due = $state('');
|
||||
let priority = $state(0);
|
||||
let autoHandle = $state(false);
|
||||
let saving = $state(false);
|
||||
let problem = $state('');
|
||||
|
||||
onMount(() => {
|
||||
dialog?.showModal();
|
||||
});
|
||||
|
||||
async function submit(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
if (!title.trim() || saving) return;
|
||||
saving = true;
|
||||
problem = '';
|
||||
try {
|
||||
const res = await fetch('/trackers/tasks/quick', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ title, notes, due, priority, autoHandle })
|
||||
});
|
||||
const body = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
problem = body.message ?? `Could not save (HTTP ${res.status})`;
|
||||
return;
|
||||
}
|
||||
ondone(
|
||||
body.hook === 'sent'
|
||||
? `Added “${body.title}”, Daisy is on it`
|
||||
: body.hook === 'not-configured'
|
||||
? `Added “${body.title}” (Daisy hook not configured)`
|
||||
: `Added “${body.title}”`
|
||||
);
|
||||
dialog?.close();
|
||||
invalidateAll();
|
||||
} catch {
|
||||
problem = 'Could not reach the server';
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Enter in the title submits; in the description it makes a new line.
|
||||
function titleKey(event: KeyboardEvent) {
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
(event.currentTarget as HTMLInputElement).form?.requestSubmit();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<dialog
|
||||
bind:this={dialog}
|
||||
{onclose}
|
||||
class="m-auto w-[min(28rem,calc(100vw-2rem))] rounded-lg border border-border bg-popover p-0 text-popover-foreground shadow-xl backdrop:bg-black/50"
|
||||
aria-labelledby="quick-add-title"
|
||||
>
|
||||
<form class="flex flex-col gap-3 p-4" onsubmit={submit}>
|
||||
<h2 id="quick-add-title" class="text-base font-semibold">Quick add task</h2>
|
||||
<!-- svelte-ignore a11y_autofocus -->
|
||||
<input
|
||||
bind:value={title}
|
||||
onkeydown={titleKey}
|
||||
required
|
||||
maxlength="500"
|
||||
autofocus
|
||||
placeholder="What needs doing? (! flags, !!! = high)"
|
||||
aria-label="Title"
|
||||
class="rounded-md border border-input bg-background px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
<textarea
|
||||
bind:value={notes}
|
||||
rows="3"
|
||||
placeholder="Description (optional)"
|
||||
aria-label="Description"
|
||||
class="resize-y rounded-md border border-input bg-background px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
></textarea>
|
||||
<div class="grid grid-cols-2 gap-3 text-sm">
|
||||
<label class="flex flex-col gap-1"
|
||||
><span class="text-muted-foreground">Deadline</span><input
|
||||
type="date"
|
||||
bind:value={due}
|
||||
class="rounded-md border border-input bg-background px-2 py-1.5"
|
||||
/></label
|
||||
>
|
||||
<label class="flex flex-col gap-1"
|
||||
><span class="text-muted-foreground">Priority</span><select
|
||||
bind:value={priority}
|
||||
class="rounded-md border border-input bg-background px-2 py-1.5"
|
||||
>
|
||||
<option value={0}>None</option>
|
||||
<option value={1}>Low</option>
|
||||
<option value={2}>Medium</option>
|
||||
<option value={3}>High</option>
|
||||
</select></label
|
||||
>
|
||||
</div>
|
||||
<label class="flex items-center gap-2 text-sm"
|
||||
><input type="checkbox" bind:checked={autoHandle} class="size-4" /> Let Daisy handle it</label
|
||||
>
|
||||
{#if problem}<p class="text-sm text-red-500" role="alert">{problem}</p>{/if}
|
||||
<div class="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md px-3 py-1.5 text-sm hover:bg-accent"
|
||||
onclick={() => dialog?.close()}>Cancel</button
|
||||
>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving || !title.trim()}
|
||||
class="rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground disabled:opacity-50"
|
||||
>{saving ? 'Adding…' : 'Add task'}</button
|
||||
>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { onMount, type Snippet } from 'svelte';
|
||||
import { onMount, type Component, type Snippet } from 'svelte';
|
||||
import { resolve } from '$app/paths';
|
||||
import logo from '#lib/assets/brand/favicon-32x32.png?url';
|
||||
import { hidesFooter, isTyping } from '#lib/chrome.js';
|
||||
@@ -15,6 +15,7 @@
|
||||
import ExternalLink from 'lucide-svelte/icons/external-link';
|
||||
import Menu from 'lucide-svelte/icons/menu';
|
||||
import LayoutDashboard from 'lucide-svelte/icons/layout-dashboard';
|
||||
import Plus from 'lucide-svelte/icons/plus';
|
||||
import SiOsu from '~icons/simple-icons/osu';
|
||||
import SiSteam from '~icons/simple-icons/steam';
|
||||
import SiTwitch from '~icons/simple-icons/twitch';
|
||||
@@ -117,7 +118,8 @@
|
||||
return page.url.pathname === href || (href !== '/' && page.url.pathname.startsWith(href + '/'));
|
||||
}
|
||||
|
||||
// Owner shortcut: press g then d (outside text fields) to open the /start dashboard.
|
||||
// Owner shortcuts (outside text fields): g then d opens the /start dashboard, g then t
|
||||
// opens quick add task.
|
||||
let gPressedAt = 0;
|
||||
function handleShortcut(event: KeyboardEvent) {
|
||||
if (!data.isOwner || event.defaultPrevented || event.repeat) return;
|
||||
@@ -125,6 +127,10 @@
|
||||
const key = event.key.toLowerCase();
|
||||
if (key === 'g') {
|
||||
gPressedAt = event.timeStamp;
|
||||
} else if (key === 't' && gPressedAt && event.timeStamp - gPressedAt < 1500) {
|
||||
gPressedAt = 0;
|
||||
event.preventDefault();
|
||||
openQuickAdd();
|
||||
} else if (key === 'd' && gPressedAt && event.timeStamp - gPressedAt < 1500) {
|
||||
gPressedAt = 0;
|
||||
event.preventDefault();
|
||||
@@ -135,6 +141,25 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Quick add task: the modal's code is fetched on first open only.
|
||||
let QuickAdd = $state<Component<{ onclose: () => void; ondone: (m: string) => void }> | null>(
|
||||
null
|
||||
);
|
||||
let quickAddOpen = $state(false);
|
||||
let toast = $state('');
|
||||
let toastTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
async function openQuickAdd() {
|
||||
if (!data.isOwner) return;
|
||||
menuOpen = false;
|
||||
QuickAdd ??= (await import('#lib/components/QuickAddTask.svelte')).default;
|
||||
quickAddOpen = true;
|
||||
}
|
||||
function showToast(message: string) {
|
||||
toast = message;
|
||||
clearTimeout(toastTimer);
|
||||
toastTimer = setTimeout(() => (toast = ''), 4000);
|
||||
}
|
||||
|
||||
function handleMenuPointerEnter(event: PointerEvent, href: string) {
|
||||
if (event.pointerType === 'touch') return;
|
||||
openMenu = href;
|
||||
@@ -285,6 +310,14 @@
|
||||
aria-keyshortcuts="g d"
|
||||
><LayoutDashboard size={16} aria-hidden="true" />dashboard</Button
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onclick={openQuickAdd}
|
||||
aria-label="Quick add task"
|
||||
title="Quick add task (g then t)"
|
||||
aria-keyshortcuts="g t"><Plus size={18} aria-hidden="true" /></Button
|
||||
>
|
||||
{/if}
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -326,6 +359,9 @@
|
||||
onclick={() => (menuOpen = false)}
|
||||
><LayoutDashboard size={16} aria-hidden="true" />dashboard</Button
|
||||
>
|
||||
<Button variant="outline" class="mb-2 justify-start gap-2" onclick={openQuickAdd}
|
||||
><Plus size={16} aria-hidden="true" />quick add task</Button
|
||||
>
|
||||
{/if}
|
||||
{#each navigation as item (item.href)}
|
||||
<Button
|
||||
@@ -436,6 +472,18 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if QuickAdd && quickAddOpen}
|
||||
<QuickAdd onclose={() => (quickAddOpen = false)} ondone={showToast} />
|
||||
{/if}
|
||||
{#if toast}
|
||||
<div
|
||||
role="status"
|
||||
class="fixed bottom-4 left-1/2 z-[60] -translate-x-1/2 rounded-md border border-border bg-popover px-4 py-2 text-sm text-popover-foreground shadow-lg"
|
||||
>
|
||||
{toast}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
@media (min-width: 1100px) and (min-height: 700px) {
|
||||
.cod-footer {
|
||||
|
||||
37
src/lib/server/daisy-hook.test.ts
Normal file
37
src/lib/server/daisy-hook.test.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { daisyHookConfig, notifyDaisy } from './daisy-hook';
|
||||
|
||||
describe('daisy task webhook', () => {
|
||||
it('is off until both URL and key are set', () => {
|
||||
expect(daisyHookConfig({})).toBeNull();
|
||||
expect(
|
||||
daisyHookConfig({ DAISY_TASK_WEBHOOK_URL: 'https://x', DAISY_TASK_WEBHOOK_KEY: '' })
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('sends a bearer token by default and the bare key in a custom header', () => {
|
||||
const base = { DAISY_TASK_WEBHOOK_URL: 'https://x/hook', DAISY_TASK_WEBHOOK_KEY: 'k1' };
|
||||
expect(daisyHookConfig(base)?.headers.Authorization).toBe('Bearer k1');
|
||||
const custom = daisyHookConfig({ ...base, DAISY_TASK_WEBHOOK_HEADER: 'X-Api-Key' });
|
||||
expect(custom?.headers['X-Api-Key']).toBe('k1');
|
||||
expect(custom?.headers.Authorization).toBeUndefined();
|
||||
});
|
||||
|
||||
it('posts {task_id, title} without waiting, and skips quietly when unconfigured', async () => {
|
||||
const fetcher = vi.fn(async () => new Response(null, { status: 204 }));
|
||||
const config = daisyHookConfig({
|
||||
DAISY_TASK_WEBHOOK_URL: 'https://x/hook',
|
||||
DAISY_TASK_WEBHOOK_KEY: 'k'
|
||||
});
|
||||
expect(
|
||||
notifyDaisy({ id: 7, title: 'Buy milk' }, config, fetcher as unknown as typeof fetch)
|
||||
).toBe('sent');
|
||||
const [url, init] = fetcher.mock.calls[0] as unknown as [string, RequestInit];
|
||||
expect(url).toBe('https://x/hook');
|
||||
expect(JSON.parse(String(init.body))).toEqual({ task_id: 7, title: 'Buy milk' });
|
||||
expect(notifyDaisy({ id: 8, title: 'x' }, null, fetcher as unknown as typeof fetch)).toBe(
|
||||
'not-configured'
|
||||
);
|
||||
expect(fetcher).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
38
src/lib/server/daisy-hook.ts
Normal file
38
src/lib/server/daisy-hook.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
// "Let Daisy handle it" on quick add: tell Daisy's routine about the new task.
|
||||
// Configured in /etc/personal-website/prod.env:
|
||||
// DAISY_TASK_WEBHOOK_URL where to POST {task_id, title}
|
||||
// DAISY_TASK_WEBHOOK_KEY secret sent with it
|
||||
// DAISY_TASK_WEBHOOK_HEADER header name (default Authorization, sent as "Bearer <key>";
|
||||
// any other header gets the bare key)
|
||||
// Unset URL or key: nothing is sent and the task is still saved.
|
||||
|
||||
export type DaisyHookConfig = { url: string; headers: Record<string, string> };
|
||||
|
||||
export function daisyHookConfig(env: Record<string, string | undefined> = process.env) {
|
||||
const url = env.DAISY_TASK_WEBHOOK_URL?.trim();
|
||||
const key = env.DAISY_TASK_WEBHOOK_KEY?.trim();
|
||||
if (!url || !key) return null;
|
||||
const header = env.DAISY_TASK_WEBHOOK_HEADER?.trim() || 'Authorization';
|
||||
const value = header.toLowerCase() === 'authorization' ? `Bearer ${key}` : key;
|
||||
return { url, headers: { 'content-type': 'application/json', [header]: value } };
|
||||
}
|
||||
|
||||
/** Fire and forget: never awaited by the request, 3 s timeout, failures only logged. */
|
||||
export function notifyDaisy(
|
||||
task: { id: number; title: string },
|
||||
config: DaisyHookConfig | null = daisyHookConfig(),
|
||||
fetcher: typeof fetch = fetch
|
||||
): 'sent' | 'not-configured' {
|
||||
if (!config) return 'not-configured';
|
||||
fetcher(config.url, {
|
||||
method: 'POST',
|
||||
headers: config.headers,
|
||||
body: JSON.stringify({ task_id: task.id, title: task.title }),
|
||||
signal: AbortSignal.timeout(3000)
|
||||
})
|
||||
.then((res) => {
|
||||
if (!res.ok) console.error(`[daisy-hook] task ${task.id}: HTTP ${res.status}`);
|
||||
})
|
||||
.catch((err) => console.error(`[daisy-hook] task ${task.id}: ${String(err)}`));
|
||||
return 'sent';
|
||||
}
|
||||
@@ -95,6 +95,8 @@ export const todoTasks = tasksSchema.table(
|
||||
sortOrder: integer('sort_order').notNull().default(0),
|
||||
source: text('source'), // e.g. 'daisy-import', 'manual', 'ticktick'
|
||||
externalId: text('external_id'), // id in the source system, for future syncs
|
||||
// Quick add "Let Daisy handle it": Daisy's routine picks these up (webhook on create).
|
||||
autoHandle: boolean('auto_handle').notNull().default(false),
|
||||
startAt: timestamptz('start_at'), // planned start (TickTick startDate)
|
||||
timeZone: text('time_zone'), // zone the task was planned in (TickTick); due_time stays Amsterdam
|
||||
startedAt: timestamptz('started_at'),
|
||||
|
||||
@@ -276,6 +276,7 @@
|
||||
<p class="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<button type="button" class="title" onclick={() => openTask(t)}>{t.title}</button>
|
||||
{#if t.flagged}<Flag size={13} class="text-red-500" aria-label="Flagged" />{/if}
|
||||
{#if t.autoHandle}<span class="chip daisy" title="Daisy handles this">Daisy</span>{/if}
|
||||
{#if t.priority >= 2}<span class="chip prio-{t.priority}">{PRIORITY[t.priority]}</span>{/if}
|
||||
{#if t.dueDate}<span class="chip due-{isOpen(t) ? due : 'later'}">{dueLabel(t)}</span>{/if}
|
||||
{#if t.reminderAt && isOpen(t)}<span class="chip" title="Reminder"
|
||||
@@ -839,6 +840,11 @@
|
||||
color: var(--site-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.daisy {
|
||||
background: rgb(139 92 246 / 0.15);
|
||||
border-color: rgb(139 92 246 / 0.5);
|
||||
color: rgb(139 92 246);
|
||||
}
|
||||
.due-overdue {
|
||||
background: rgb(239 68 68 / 0.15);
|
||||
border-color: rgb(239 68 68 / 0.5);
|
||||
|
||||
45
src/routes/trackers/tasks/quick/+server.ts
Normal file
45
src/routes/trackers/tasks/quick/+server.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import * as z from 'zod';
|
||||
import { error, json } from '@sveltejs/kit';
|
||||
import { db } from '#lib/server/db/index.js';
|
||||
import { todoActivity, todoTasks } from '#lib/server/db/schema/index.js';
|
||||
import { requireOwner } from '#lib/server/owner.js';
|
||||
import { parseQuickTitle } from '#lib/server/tasks.js';
|
||||
import { notifyDaisy } from '#lib/server/daisy-hook.js';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
// Header "quick add task" modal (any page, owner only; hooks.server.ts also refuses
|
||||
// non-owner writes under /trackers/tasks). "!"/"!!!" in the title work as on the tracker.
|
||||
const input = z.object({
|
||||
title: z.string().trim().min(1, 'Give the task a title').max(500),
|
||||
notes: z.string().trim().max(10_000).optional().default(''),
|
||||
due: z
|
||||
.string()
|
||||
.regex(/^(\d{4}-\d{2}-\d{2})?$/, 'Invalid deadline')
|
||||
.optional()
|
||||
.default(''),
|
||||
priority: z.coerce.number().int().min(0).max(3).optional().default(0),
|
||||
autoHandle: z.boolean().optional().default(false)
|
||||
});
|
||||
|
||||
export const POST: RequestHandler = async (event) => {
|
||||
requireOwner(event);
|
||||
const parsed = input.safeParse(await event.request.json().catch(() => null));
|
||||
if (!parsed.success) error(400, parsed.error.issues[0]?.message ?? 'Invalid task');
|
||||
const quick = parseQuickTitle(parsed.data.title);
|
||||
if (!quick.title) error(400, 'Give the task a title');
|
||||
const [row] = await db
|
||||
.insert(todoTasks)
|
||||
.values({
|
||||
title: quick.title,
|
||||
notes: parsed.data.notes || null,
|
||||
dueDate: parsed.data.due || null,
|
||||
priority: parsed.data.priority || quick.priority,
|
||||
flagged: quick.flagged,
|
||||
autoHandle: parsed.data.autoHandle,
|
||||
source: 'quick-add'
|
||||
})
|
||||
.returning({ id: todoTasks.id, title: todoTasks.title });
|
||||
await db.insert(todoActivity).values({ taskId: row.id, kind: 'created', toStatus: 'todo' });
|
||||
const hook = parsed.data.autoHandle ? notifyDaisy(row) : 'off';
|
||||
return json({ id: row.id, title: row.title, hook });
|
||||
};
|
||||
Reference in New Issue
Block a user