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) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-01 14:55:42 +02:00
parent 3e99d86ba6
commit bee8bcb234
18 changed files with 417 additions and 40 deletions

View file

@ -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",

View file

@ -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 {

View file

@ -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<typeof setInterval> | 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');
}
}

View file

@ -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();

View file

@ -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');

View file

@ -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);

View file

@ -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

View file

@ -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 {

View file

@ -0,0 +1,91 @@
<script lang="ts">
import { t } from 'svelte-i18n';
import { Bell, BellSlash } from '@manacore/shared-icons';
interface Props {
value: number | null;
onChange: (minutes: number | null) => void;
disabled?: boolean;
}
let { value, onChange, disabled = false }: Props = $props();
const options: { minutes: number | null; labelKey: string }[] = [
{ minutes: null, labelKey: 'reminders.none' },
{ minutes: 0, labelKey: 'reminders.atTime' },
{ minutes: 5, labelKey: 'reminders.5min' },
{ minutes: 15, labelKey: 'reminders.15min' },
{ minutes: 30, labelKey: 'reminders.30min' },
{ minutes: 60, labelKey: 'reminders.1hour' },
{ minutes: 120, labelKey: 'reminders.2hours' },
{ minutes: 1440, labelKey: 'reminders.1day' },
{ minutes: 2880, labelKey: 'reminders.2days' },
{ minutes: 10080, labelKey: 'reminders.1week' },
];
function handleChange(e: Event) {
const raw = (e.target as HTMLSelectElement).value;
onChange(raw === '' ? null : Number(raw));
}
</script>
<div class="reminder-selector">
<div class="reminder-icon" class:active={value !== null}>
{#if value !== null}
<Bell size={14} weight="fill" />
{:else}
<BellSlash size={14} />
{/if}
</div>
<select
class="reminder-select"
value={value === null ? '' : String(value)}
onchange={handleChange}
{disabled}
>
{#each options as opt}
<option value={opt.minutes === null ? '' : String(opt.minutes)}>
{$t(opt.labelKey)}
</option>
{/each}
</select>
</div>
<style>
.reminder-selector {
display: flex;
align-items: center;
gap: 0.375rem;
}
.reminder-icon {
display: flex;
align-items: center;
color: var(--color-muted-foreground);
transition: color 0.15s;
}
.reminder-icon.active {
color: var(--color-primary);
}
.reminder-select {
border: none;
background: transparent;
font-size: 0.8125rem;
color: var(--color-foreground);
font-family: inherit;
padding: 0;
outline: none;
cursor: pointer;
min-width: 0;
}
.reminder-select:focus {
color: var(--color-primary);
}
:global(.dark) .reminder-select {
color-scheme: dark;
}
</style>

View file

@ -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';

View file

@ -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<number | null>(null);
let assignee = $state<ContactOrManual[]>([]);
let involvedContacts = $state<ContactOrManual[]>([]);
let reminderMinutes = $state<number | null>(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,
};
}

View file

@ -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[] {

View file

@ -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<string | null>(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 }
);
},
};

View file

@ -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<LocalTask>);
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<LocalTask>);
}
TodoEvents.taskReordered();
},
'Failed to reorder tasks',
{ rethrow: false }

View file

@ -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');
},
/**

View file

@ -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 @@
<a
href="#main-content"
class="sr-only focus:not-sr-only focus:fixed focus:top-2 focus:left-2 focus:z-[100] focus:rounded-lg focus:bg-primary focus:px-4 focus:py-2 focus:text-white"
data-umami-event="skip-to-content"
>
Zum Inhalt springen
</a>
@ -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}
<!-- Menu icon -->

View file

@ -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 @@
<div class="page-container">
<!-- Header -->
<header class="header">
<a href="/" class="back-button" aria-label="Zurück">
<a href="/" class="back-button" aria-label="Zurück" data-umami-event="tags-back-nav">
<CaretLeft size={20} weight="bold" />
</a>
<h1 class="title">Tags</h1>

View file

@ -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: