mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 22:01:09 +02:00
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:
parent
3e99d86ba6
commit
bee8bcb234
18 changed files with 417 additions and 40 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
142
apps/todo/apps/server/src/lib/reminder-worker.ts
Normal file
142
apps/todo/apps/server/src/lib/reminder-worker.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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[] {
|
||||
|
|
|
|||
67
apps/todo/apps/web/src/lib/stores/reminders.svelte.ts
Normal file
67
apps/todo/apps/web/src/lib/stores/reminders.svelte.ts
Normal 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 }
|
||||
);
|
||||
},
|
||||
};
|
||||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
},
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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 -->
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue