From bee8bcb23458749eae2a3d83292df6d2947c0e4b Mon Sep 17 00:00:00 2001 From: Till JS Date: Wed, 1 Apr 2026 14:55:42 +0200 Subject: [PATCH] feat(todo): add reminders with background worker and notification dispatch Server: reminder-worker checks due reminders every 60s, dispatches via mana-notify, integrates shared-hono middleware and Zod validation for RRULE. Web: ReminderSelector component, local-first reminder store, form integration. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/todo/apps/server/package.json | 4 +- apps/todo/apps/server/src/index.ts | 26 ++-- .../apps/server/src/lib/reminder-worker.ts | 142 ++++++++++++++++++ apps/todo/apps/server/src/routes/admin.ts | 2 +- apps/todo/apps/server/src/routes/reminders.ts | 3 - apps/todo/apps/server/src/routes/rrule.ts | 41 +++-- apps/todo/apps/web/.env.production.example | 4 +- .../web/src/lib/components/TaskList.svelte | 2 + .../components/form/ReminderSelector.svelte | 91 +++++++++++ .../apps/web/src/lib/components/form/index.ts | 1 + .../src/lib/composables/useTaskForm.svelte.ts | 35 +++++ .../apps/web/src/lib/data/task-queries.ts | 10 ++ .../web/src/lib/stores/reminders.svelte.ts | 67 +++++++++ .../apps/web/src/lib/stores/tasks.svelte.ts | 7 + .../apps/web/src/lib/stores/view.svelte.ts | 10 ++ .../apps/web/src/routes/(app)/+layout.svelte | 4 + .../web/src/routes/(app)/tags/+page.svelte | 4 +- apps/todo/docker-compose.prod.yml | 4 +- 18 files changed, 417 insertions(+), 40 deletions(-) create mode 100644 apps/todo/apps/server/src/lib/reminder-worker.ts create mode 100644 apps/todo/apps/web/src/lib/components/form/ReminderSelector.svelte create mode 100644 apps/todo/apps/web/src/lib/stores/reminders.svelte.ts diff --git a/apps/todo/apps/server/package.json b/apps/todo/apps/server/package.json index 0f6984a54..d4df885cf 100644 --- a/apps/todo/apps/server/package.json +++ b/apps/todo/apps/server/package.json @@ -10,10 +10,12 @@ "type-check": "bun x tsc --noEmit" }, "dependencies": { + "@manacore/shared-hono": "workspace:*", "drizzle-orm": "^0.45.1", "hono": "^4.7.0", "postgres": "^3.4.5", - "rrule": "^2.8.1" + "rrule": "^2.8.1", + "zod": "^3.25.0" }, "devDependencies": { "@types/bun": "^1.2.0", diff --git a/apps/todo/apps/server/src/index.ts b/apps/todo/apps/server/src/index.ts index 27b9fddb5..c4d34b6fd 100644 --- a/apps/todo/apps/server/src/index.ts +++ b/apps/todo/apps/server/src/index.ts @@ -12,13 +12,23 @@ import { Hono } from 'hono'; import { cors } from 'hono/cors'; import { logger } from 'hono/logger'; +import { + authMiddleware, + healthRoute, + errorHandler, + notFoundHandler, + rateLimitMiddleware, +} from '@manacore/shared-hono'; import { rruleRoutes } from './routes/rrule'; import { reminderRoutes } from './routes/reminders'; import { adminRoutes } from './routes/admin'; +import { startReminderWorker } from './lib/reminder-worker'; const app = new Hono(); // Middleware +app.onError(errorHandler); +app.notFound(notFoundHandler); app.use('*', logger()); app.use( '*', @@ -29,25 +39,21 @@ app.use( credentials: true, }) ); +app.route('/health', healthRoute('todo-server')); +app.use('/api/*', rateLimitMiddleware({ max: 100, windowMs: 60_000 })); +app.use('/api/*', authMiddleware()); // Routes app.route('/api/v1/compute', rruleRoutes); app.route('/api/v1', reminderRoutes); app.route('/api/v1/admin', adminRoutes); -// Health check -app.get('/health', (c) => - c.json({ - status: 'ok', - service: 'todo-server', - runtime: 'bun', - timestamp: new Date().toISOString(), - }) -); - // Start const port = Number(process.env.PORT ?? 3019); +// Start background worker for reminder notifications +startReminderWorker(); + console.log(`πŸš€ Todo server (Hono + Bun) starting on port ${port}`); export default { diff --git a/apps/todo/apps/server/src/lib/reminder-worker.ts b/apps/todo/apps/server/src/lib/reminder-worker.ts new file mode 100644 index 000000000..6c55e32ba --- /dev/null +++ b/apps/todo/apps/server/src/lib/reminder-worker.ts @@ -0,0 +1,142 @@ +/** + * Reminder Worker β€” Background cron that processes due reminders. + * + * Runs every 60 seconds, finds pending reminders whose reminderTime + * has passed, and dispatches them via mana-notify. Updates status + * to 'sent' or 'failed' accordingly. + */ + +import { eq, and, lte } from 'drizzle-orm'; +import { db, reminders, tasks } from '../db'; + +const MANA_NOTIFY_URL = process.env.MANA_NOTIFY_URL || 'http://localhost:3040'; +const SERVICE_KEY = + process.env.MANA_NOTIFY_SERVICE_KEY || process.env.SERVICE_KEY || 'dev-service-key'; +const TODO_WEB_URL = process.env.TODO_WEB_URL || 'http://localhost:5188'; +const CHECK_INTERVAL_MS = 60_000; // 1 minute + +let timer: ReturnType | null = null; + +async function processReminders() { + try { + const now = new Date(); + + // Find all pending reminders whose time has arrived + const dueReminders = await db.query.reminders.findMany({ + where: and(eq(reminders.status, 'pending'), lte(reminders.reminderTime, now)), + }); + + if (dueReminders.length === 0) return; + + console.log(`[reminder-worker] Processing ${dueReminders.length} due reminder(s)`); + + for (const reminder of dueReminders) { + try { + // Fetch the associated task for context + const task = await db.query.tasks.findFirst({ + where: eq(tasks.id, reminder.taskId), + }); + + if (!task) { + // Task was deleted β€” mark reminder as failed + await db + .update(reminders) + .set({ status: 'failed', sentAt: now }) + .where(eq(reminders.id, reminder.id)); + continue; + } + + // Send notification via mana-notify + const channels: string[] = []; + if (reminder.type === 'push' || reminder.type === 'both') channels.push('push'); + if (reminder.type === 'email' || reminder.type === 'both') channels.push('email'); + + for (const channel of channels) { + await sendNotification({ + userId: reminder.userId, + channel, + taskTitle: task.title, + taskId: task.id, + dueDate: task.dueDate ? new Date(task.dueDate).toISOString() : undefined, + }); + } + + // Mark as sent + await db + .update(reminders) + .set({ status: 'sent', sentAt: now }) + .where(eq(reminders.id, reminder.id)); + } catch (err) { + console.error(`[reminder-worker] Failed to process reminder ${reminder.id}:`, err); + + // Mark as failed + await db.update(reminders).set({ status: 'failed' }).where(eq(reminders.id, reminder.id)); + } + } + } catch (err) { + console.error('[reminder-worker] Error in processing loop:', err); + } +} + +async function sendNotification(params: { + userId: string; + channel: string; + taskTitle: string; + taskId: string; + dueDate?: string; +}) { + const { userId, channel, taskTitle, taskId, dueDate } = params; + + const body = { + userId, + channel, + templateSlug: 'task-reminder', + variables: { + taskTitle, + taskUrl: `${TODO_WEB_URL}/task/${taskId}`, + dueDate: dueDate + ? new Date(dueDate).toLocaleString('de-DE', { + dateStyle: 'medium', + timeStyle: 'short', + }) + : '', + }, + // Fallback if template not found β€” send direct + subject: `Erinnerung: ${taskTitle}`, + body: `Aufgabe "${taskTitle}" ist ${dueDate ? `fΓ€llig am ${new Date(dueDate).toLocaleString('de-DE')}` : 'bald fΓ€llig'}.`, + }; + + const response = await fetch(`${MANA_NOTIFY_URL}/api/v1/notifications/send`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Service-Key': SERVICE_KEY, + }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + const text = await response.text().catch(() => 'unknown'); + throw new Error(`mana-notify responded with ${response.status}: ${text}`); + } +} + +export function startReminderWorker() { + if (timer) return; + + console.log(`[reminder-worker] Started (checking every ${CHECK_INTERVAL_MS / 1000}s)`); + + // Run immediately on startup + processReminders(); + + // Then run on interval + timer = setInterval(processReminders, CHECK_INTERVAL_MS); +} + +export function stopReminderWorker() { + if (timer) { + clearInterval(timer); + timer = null; + console.log('[reminder-worker] Stopped'); + } +} diff --git a/apps/todo/apps/server/src/routes/admin.ts b/apps/todo/apps/server/src/routes/admin.ts index 36a9a439c..2f66c5993 100644 --- a/apps/todo/apps/server/src/routes/admin.ts +++ b/apps/todo/apps/server/src/routes/admin.ts @@ -5,7 +5,7 @@ import { Hono } from 'hono'; import { eq, sql } from 'drizzle-orm'; -import { serviceAuthMiddleware } from '../lib/auth'; +import { serviceAuthMiddleware } from '@manacore/shared-hono'; import { db, tasks, projects, reminders } from '../db'; const adminRoutes = new Hono(); diff --git a/apps/todo/apps/server/src/routes/reminders.ts b/apps/todo/apps/server/src/routes/reminders.ts index a8176e05e..ba23b8309 100644 --- a/apps/todo/apps/server/src/routes/reminders.ts +++ b/apps/todo/apps/server/src/routes/reminders.ts @@ -7,13 +7,10 @@ import { Hono } from 'hono'; import { eq, and, asc } from 'drizzle-orm'; -import { authMiddleware } from '../lib/auth'; import { db, reminders, tasks } from '../db'; const reminderRoutes = new Hono(); -reminderRoutes.use('/*', authMiddleware()); - /** List reminders for a task. */ reminderRoutes.get('/tasks/:taskId/reminders', async (c) => { const userId = c.get('userId'); diff --git a/apps/todo/apps/server/src/routes/rrule.ts b/apps/todo/apps/server/src/routes/rrule.ts index b943d46a9..c55f5a096 100644 --- a/apps/todo/apps/server/src/routes/rrule.ts +++ b/apps/todo/apps/server/src/routes/rrule.ts @@ -8,30 +8,28 @@ import { Hono } from 'hono'; import { rrulestr } from 'rrule'; -import { authMiddleware } from '../lib/auth'; +import { z } from 'zod'; const rruleRoutes = new Hono(); -rruleRoutes.use('/*', authMiddleware()); +const NextOccurrenceSchema = z.object({ + rrule: z.string().min(1, 'Missing rrule parameter').max(500, 'RRULE too long (max 500 chars)'), + recurrenceEndDate: z.string().datetime({ offset: true }).optional(), + after: z.string().datetime({ offset: true }).optional(), +}); + +const ValidateSchema = z.object({ + rrule: z.string().min(1).max(500), +}); /** Validate an RRULE string and return the next occurrence. */ rruleRoutes.post('/next-occurrence', async (c) => { - const body = await c.req.json<{ - rrule: string; - recurrenceEndDate?: string; - after?: string; - }>(); - - const { rrule: rruleString, recurrenceEndDate, after } = body; - - if (!rruleString) { - return c.json({ error: 'Missing rrule parameter' }, 400); + const parsed = NextOccurrenceSchema.safeParse(await c.req.json()); + if (!parsed.success) { + return c.json({ error: parsed.error.issues[0]?.message ?? 'Invalid input' }, 400); } - // DoS protection - if (rruleString.length > 500) { - return c.json({ error: 'RRULE too long (max 500 chars)' }, 400); - } + const { rrule: rruleString, recurrenceEndDate, after } = parsed.data; try { const rule = rrulestr(rruleString); @@ -76,14 +74,15 @@ rruleRoutes.post('/next-occurrence', async (c) => { /** Validate an RRULE without computing next occurrence. */ rruleRoutes.post('/validate', async (c) => { - const body = await c.req.json<{ rrule: string }>(); - - if (!body.rrule || body.rrule.length > 500) { - return c.json({ valid: false, error: 'Missing or too long' }); + const parsed = ValidateSchema.safeParse(await c.req.json()); + if (!parsed.success) { + return c.json({ valid: false, error: parsed.error.issues[0]?.message ?? 'Invalid input' }); } + const { rrule: rruleString } = parsed.data; + try { - const rule = rrulestr(body.rrule); + const rule = rrulestr(rruleString); const tenYearsFromNow = new Date(); tenYearsFromNow.setFullYear(tenYearsFromNow.getFullYear() + 10); diff --git a/apps/todo/apps/web/.env.production.example b/apps/todo/apps/web/.env.production.example index 80a5d0166..027f34cb9 100644 --- a/apps/todo/apps/web/.env.production.example +++ b/apps/todo/apps/web/.env.production.example @@ -15,5 +15,5 @@ PUBLIC_MANA_CORE_AUTH_URL=https://auth.mana.how # OPTIONAL # ============================================================================= -# Analytics (if using Umami or similar) -# PUBLIC_ANALYTICS_ID=your-analytics-id +# Umami Analytics +# PUBLIC_UMAMI_WEBSITE_ID=your-umami-website-id diff --git a/apps/todo/apps/web/src/lib/components/TaskList.svelte b/apps/todo/apps/web/src/lib/components/TaskList.svelte index 514372d70..01daf956d 100644 --- a/apps/todo/apps/web/src/lib/components/TaskList.svelte +++ b/apps/todo/apps/web/src/lib/components/TaskList.svelte @@ -15,6 +15,7 @@ Lightning, Trash, } from '@manacore/shared-icons'; + import { TodoEvents } from '@manacore/shared-utils/analytics'; // Context menu state let contextMenuVisible = $state(false); @@ -93,6 +94,7 @@ async function handleSetPriority(taskId: string, priority: string) { await tasksStore.updateTask(taskId, { priority: priority as Task['priority'] }); + TodoEvents.priorityChanged(priority); } interface Props { diff --git a/apps/todo/apps/web/src/lib/components/form/ReminderSelector.svelte b/apps/todo/apps/web/src/lib/components/form/ReminderSelector.svelte new file mode 100644 index 000000000..f6d87d72a --- /dev/null +++ b/apps/todo/apps/web/src/lib/components/form/ReminderSelector.svelte @@ -0,0 +1,91 @@ + + +
+
+ {#if value !== null} + + {:else} + + {/if} +
+ +
+ + diff --git a/apps/todo/apps/web/src/lib/components/form/index.ts b/apps/todo/apps/web/src/lib/components/form/index.ts index dddf9df4c..8d0d0d7b4 100644 --- a/apps/todo/apps/web/src/lib/components/form/index.ts +++ b/apps/todo/apps/web/src/lib/components/form/index.ts @@ -3,3 +3,4 @@ export { default as StorypointsSelector } from './StorypointsSelector.svelte'; export { default as DurationPicker } from './DurationPicker.svelte'; export { default as FunRatingPicker } from './FunRatingPicker.svelte'; export { default as TagSelector } from './TagSelector.svelte'; +export { default as ReminderSelector } from './ReminderSelector.svelte'; diff --git a/apps/todo/apps/web/src/lib/composables/useTaskForm.svelte.ts b/apps/todo/apps/web/src/lib/composables/useTaskForm.svelte.ts index 5c8ac208a..772772824 100644 --- a/apps/todo/apps/web/src/lib/composables/useTaskForm.svelte.ts +++ b/apps/todo/apps/web/src/lib/composables/useTaskForm.svelte.ts @@ -9,6 +9,8 @@ import type { import type { ContactReference, ContactOrManual } from '@manacore/shared-types'; import { format } from 'date-fns'; import { contactsStore } from '$lib/stores/contacts.svelte'; +import { reminderCollection, type LocalReminder } from '$lib/data/local-store'; +import { remindersStore } from '$lib/stores/reminders.svelte'; /** * Shared composable for task form state and logic. @@ -31,6 +33,7 @@ export function useTaskForm() { let funRating = $state(null); let assignee = $state([]); let involvedContacts = $state([]); + let reminderMinutes = $state(null); // UI state let showDeleteConfirm = $state(false); @@ -59,12 +62,37 @@ export function useTaskForm() { involvedContacts = task.metadata?.involvedContacts || []; showDeleteConfirm = false; + // Load existing reminder for this task + reminderMinutes = null; + reminderCollection.getAll().then((all) => { + const existing = all.find((r) => r.taskId === task.id); + if (existing) reminderMinutes = existing.minutesBefore; + }); + // Check contacts availability contactsStore.checkAvailability().then((available) => { contactsAvailable = available; }); } + /** + * Persist reminder changes (create/delete based on form state). + * Called after saving the task. + */ + async function persistReminder(taskId: string) { + const all = await reminderCollection.getAll(); + const existing = all.find((r) => r.taskId === taskId); + + if (reminderMinutes === null && existing) { + await remindersStore.deleteReminder(existing.id); + } else if (reminderMinutes !== null && !existing) { + await remindersStore.createReminder(taskId, reminderMinutes); + } else if (reminderMinutes !== null && existing && existing.minutesBefore !== reminderMinutes) { + await remindersStore.deleteReminder(existing.id); + await remindersStore.createReminder(taskId, reminderMinutes); + } + } + /** * Extract ContactReference from ContactOrManual (filter out manual entries). */ @@ -199,6 +227,12 @@ export function useTaskForm() { set involvedContacts(v: ContactOrManual[]) { involvedContacts = v; }, + get reminderMinutes() { + return reminderMinutes; + }, + set reminderMinutes(v: number | null) { + reminderMinutes = v; + }, get showDeleteConfirm() { return showDeleteConfirm; }, @@ -221,6 +255,7 @@ export function useTaskForm() { // Functions initFromTask, buildUpdateInput, + persistReminder, toContactReference, }; } diff --git a/apps/todo/apps/web/src/lib/data/task-queries.ts b/apps/todo/apps/web/src/lib/data/task-queries.ts index 0d006db4f..2b85f1a03 100644 --- a/apps/todo/apps/web/src/lib/data/task-queries.ts +++ b/apps/todo/apps/web/src/lib/data/task-queries.ts @@ -10,8 +10,10 @@ import { useLiveQueryWithDefault } from '@manacore/local-store/svelte'; import { taskCollection, boardViewCollection, + reminderCollection, type LocalTask, type LocalBoardView, + type LocalReminder, } from './local-store'; import type { Task } from '@todo/shared'; import { isToday, isPast, isFuture, startOfDay, addDays } from 'date-fns'; @@ -65,6 +67,14 @@ export function useAllBoardViews() { }, [] as LocalBoardView[]); } +/** All reminders, keyed by taskId. Auto-updates on any change. */ +export function useAllReminders() { + return useLiveQueryWithDefault(async () => { + const locals = await reminderCollection.getAll(); + return locals; + }, [] as LocalReminder[]); +} + // ─── Pure Filter Functions (for $derived) ────────────────── export function filterIncomplete(tasks: Task[]): Task[] { diff --git a/apps/todo/apps/web/src/lib/stores/reminders.svelte.ts b/apps/todo/apps/web/src/lib/stores/reminders.svelte.ts new file mode 100644 index 000000000..2f1ca3e45 --- /dev/null +++ b/apps/todo/apps/web/src/lib/stores/reminders.svelte.ts @@ -0,0 +1,67 @@ +/** + * Reminders Store β€” Local-First CRUD for task reminders + * + * Manages reminders in IndexedDB via the reminderCollection. + * Syncs to server in the background via mana-sync. + */ + +import { reminderCollection, type LocalReminder } from '$lib/data/local-store'; +import { TodoEvents } from '@manacore/shared-utils/analytics'; +import { withErrorHandling } from './store-helpers'; + +let error = $state(null); +const setError = (e: string | null) => (error = e); + +export const remindersStore = { + get error() { + return error; + }, + + async createReminder( + taskId: string, + minutesBefore: number, + type: 'push' | 'email' | 'both' = 'push' + ) { + return withErrorHandling( + setError, + async () => { + const reminder: LocalReminder = { + id: crypto.randomUUID(), + taskId, + minutesBefore, + type, + status: 'pending', + }; + const inserted = await reminderCollection.insert(reminder); + TodoEvents.reminderCreated(minutesBefore === 0 ? 'absolute' : 'relative'); + return inserted; + }, + 'Failed to create reminder' + ); + }, + + async deleteReminder(id: string) { + return withErrorHandling( + setError, + async () => { + await reminderCollection.delete(id); + }, + 'Failed to delete reminder' + ); + }, + + async deleteByTaskId(taskId: string) { + return withErrorHandling( + setError, + async () => { + const all = await reminderCollection.getAll(); + const forTask = all.filter((r) => r.taskId === taskId); + for (const r of forTask) { + await reminderCollection.delete(r.id); + } + }, + 'Failed to delete reminders', + { rethrow: false } + ); + }, +}; diff --git a/apps/todo/apps/web/src/lib/stores/tasks.svelte.ts b/apps/todo/apps/web/src/lib/stores/tasks.svelte.ts index 97de3c721..fccea4c30 100644 --- a/apps/todo/apps/web/src/lib/stores/tasks.svelte.ts +++ b/apps/todo/apps/web/src/lib/stores/tasks.svelte.ts @@ -49,6 +49,7 @@ export const tasksStore = { const inserted = await taskCollection.insert(newLocal); TodoEvents.taskCreated(!!data.dueDate); + if (data.recurrenceRule) TodoEvents.recurringTaskCreated(data.recurrenceRule); return toTask(inserted); }, 'Failed to create task' @@ -80,6 +81,11 @@ export const tasksStore = { async () => { const updated = await taskCollection.update(id, data as Partial); if (updated) { + if (data.priority !== undefined) TodoEvents.priorityChanged(data.priority); + if (data.dueDate !== undefined) TodoEvents.dueDateSet(); + if (data.recurrenceRule !== undefined && data.recurrenceRule) { + TodoEvents.recurringTaskCreated(data.recurrenceRule); + } return toTask(updated); } }, @@ -184,6 +190,7 @@ export const tasksStore = { for (let i = 0; i < taskIds.length; i++) { await taskCollection.update(taskIds[i], { order: i } as Partial); } + TodoEvents.taskReordered(); }, 'Failed to reorder tasks', { rethrow: false } diff --git a/apps/todo/apps/web/src/lib/stores/view.svelte.ts b/apps/todo/apps/web/src/lib/stores/view.svelte.ts index 2df3f848f..58b5cafbe 100644 --- a/apps/todo/apps/web/src/lib/stores/view.svelte.ts +++ b/apps/todo/apps/web/src/lib/stores/view.svelte.ts @@ -3,6 +3,7 @@ */ import type { TaskPriority } from '@todo/shared'; +import { TodoEvents } from '@manacore/shared-utils/analytics'; export type ViewType = 'inbox' | 'today' | 'upcoming' | 'label' | 'completed' | 'search'; export type SortBy = 'dueDate' | 'priority' | 'title' | 'createdAt' | 'order'; @@ -58,6 +59,7 @@ export const viewStore = { currentView = 'inbox'; currentLabelId = null; searchQuery = ''; + TodoEvents.viewChanged('inbox'); }, /** @@ -67,6 +69,7 @@ export const viewStore = { currentView = 'today'; currentLabelId = null; searchQuery = ''; + TodoEvents.viewChanged('today'); }, /** @@ -76,6 +79,7 @@ export const viewStore = { currentView = 'upcoming'; currentLabelId = null; searchQuery = ''; + TodoEvents.viewChanged('upcoming'); }, /** @@ -85,6 +89,7 @@ export const viewStore = { currentView = 'label'; currentLabelId = labelId; searchQuery = ''; + TodoEvents.viewChanged('label'); }, /** @@ -94,6 +99,7 @@ export const viewStore = { currentView = 'completed'; currentLabelId = null; searchQuery = ''; + TodoEvents.viewChanged('completed'); }, /** @@ -103,6 +109,7 @@ export const viewStore = { currentView = 'search'; currentLabelId = null; searchQuery = query; + TodoEvents.viewChanged('search'); }, /** @@ -139,6 +146,7 @@ export const viewStore = { */ setFilterPriorities(priorities: TaskPriority[]) { filterPriorities = priorities; + if (priorities.length > 0) TodoEvents.filterUsed('priority'); }, /** @@ -146,6 +154,7 @@ export const viewStore = { */ setFilterLabelIds(ids: string[]) { filterLabelIds = ids; + if (ids.length > 0) TodoEvents.filterUsed('label'); }, /** @@ -153,6 +162,7 @@ export const viewStore = { */ setFilterSearchQuery(query: string) { filterSearchQuery = query; + if (query.trim()) TodoEvents.filterUsed('search'); }, /** diff --git a/apps/todo/apps/web/src/routes/(app)/+layout.svelte b/apps/todo/apps/web/src/routes/(app)/+layout.svelte index 95a74915c..5fc0da510 100644 --- a/apps/todo/apps/web/src/routes/(app)/+layout.svelte +++ b/apps/todo/apps/web/src/routes/(app)/+layout.svelte @@ -277,6 +277,7 @@ event.preventDefault(); const route = navRoutes[num - 1]; if (route) { + TodoEvents.keyboardShortcutUsed(`ctrl+${num}`); goto(route); } } @@ -291,6 +292,7 @@ !event.altKey ) { event.preventDefault(); + TodoEvents.keyboardShortcutUsed('f-immersive'); todoSettings.toggleImmersiveMode(); } } @@ -414,6 +416,7 @@ Zum Inhalt springen @@ -505,6 +508,7 @@ onclick={handlePillNavToggle} title={isPillNavCollapsed ? 'Navigation anzeigen' : 'Navigation ausblenden'} aria-label={isPillNavCollapsed ? 'Navigation anzeigen' : 'Navigation ausblenden'} + data-umami-event="pillnav-toggle" > {#if isPillNavCollapsed} diff --git a/apps/todo/apps/web/src/routes/(app)/tags/+page.svelte b/apps/todo/apps/web/src/routes/(app)/tags/+page.svelte index 07c569572..a2d9484f6 100644 --- a/apps/todo/apps/web/src/routes/(app)/tags/+page.svelte +++ b/apps/todo/apps/web/src/routes/(app)/tags/+page.svelte @@ -5,6 +5,7 @@ import { MagnifyingGlass, Plus, CaretLeft, Check } from '@manacore/shared-icons'; import { tagMutations } from '@manacore/shared-stores'; import type { Tag } from '@manacore/shared-tags'; + import { TodoEvents } from '@manacore/shared-utils/analytics'; // Live tags from layout context const tagsCtx: { readonly value: Tag[] } = getContext('tags'); @@ -56,6 +57,7 @@ isCreating = true; try { await tagMutations.createTag({ name: newTagName.trim(), color: newTagColor }); + TodoEvents.labelCreated(); newTagName = ''; newTagColor = '#8b5cf6'; } catch (e) { @@ -137,7 +139,7 @@
- +

Tags

diff --git a/apps/todo/docker-compose.prod.yml b/apps/todo/docker-compose.prod.yml index e9ddb8d33..451609299 100644 --- a/apps/todo/docker-compose.prod.yml +++ b/apps/todo/docker-compose.prod.yml @@ -5,7 +5,9 @@ services: todo-backend: build: context: ../.. - dockerfile: apps/todo/apps/backend/Dockerfile + dockerfile: docker/Dockerfile.hono-server + args: + APP: todo container_name: todo-backend restart: unless-stopped ports: