refactor(todo,calendar): extract duplicated constants and utilities

Todo:
- Extract priority colors to lib/constants/priority.ts (was in TaskItem + KanbanTaskCard)
- Extract formatDueDate to lib/utils/date-display.ts (was in TaskItem + KanbanTaskCard + task-parser)
- Extract withErrorHandling to lib/stores/store-helpers.ts (was in 3 stores)

Calendar:
- Extract formatTime, snapToGrid, getDayFromX, getMinutesFromY to lib/utils/drag-helpers.ts
  (was duplicated across 4 drag/drop composables)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-31 13:01:00 +02:00
parent 74ff066050
commit 101f20ec34
13 changed files with 438 additions and 371 deletions

View file

@ -3,10 +3,8 @@
* Handles click-and-drag on the calendar grid to create new events * Handles click-and-drag on the calendar grid to create new events
*/ */
import { import { DEFAULT_EVENT_DURATION_MINUTES } from '$lib/utils/calendarConstants';
SNAP_INTERVAL_MINUTES, import { formatTime, getSnapMinutes, getDayFromX, getMinutesFromY } from '$lib/utils/drag-helpers';
DEFAULT_EVENT_DURATION_MINUTES,
} from '$lib/utils/calendarConstants';
export interface DragToCreateConfig { export interface DragToCreateConfig {
containerEl: HTMLElement | null; containerEl: HTMLElement | null;
@ -30,38 +28,21 @@ export function useDragToCreate(getConfig: () => DragToCreateConfig) {
let createPreviewHeight = $state(0); let createPreviewHeight = $state(0);
let hasMoved = $state(false); let hasMoved = $state(false);
function getSnapMinutes(): number { function dayFromX(clientX: number): Date | null {
return getConfig().snapMinutes ?? SNAP_INTERVAL_MINUTES; const config = getConfig();
return getDayFromX(clientX, config.containerEl, config.days);
} }
function getDayFromX(clientX: number): Date | null { function minutesFromY(clientY: number): number {
const config = getConfig(); const config = getConfig();
if (!config.containerEl) return null; return getMinutesFromY(
clientY,
const rect = config.containerEl.getBoundingClientRect(); config.containerEl,
const relativeX = clientX - rect.left; config.totalVisibleHours,
const dayWidth = rect.width / config.days.length; config.hourHeight,
const dayIndex = Math.floor(relativeX / dayWidth); config.firstVisibleHour,
config.snapMinutes
if (dayIndex >= 0 && dayIndex < config.days.length) { );
return config.days[dayIndex];
}
return null;
}
function getMinutesFromY(clientY: number): number {
const config = getConfig();
if (!config.containerEl) return 0;
const rect = config.containerEl.getBoundingClientRect();
const scrollTop = config.containerEl.parentElement?.scrollTop || 0;
const relativeY = clientY - rect.top + scrollTop;
const visibleMinutes =
(relativeY / (config.totalVisibleHours * config.hourHeight)) * config.totalVisibleHours * 60;
const totalMinutes = visibleMinutes + config.firstVisibleHour * 60;
const snap = getSnapMinutes();
return Math.round(totalMinutes / snap) * snap;
} }
function updatePreview() { function updatePreview() {
@ -87,11 +68,11 @@ export function useDragToCreate(getConfig: () => DragToCreateConfig) {
e.preventDefault(); e.preventDefault();
const day = getDayFromX(e.clientX); const day = dayFromX(e.clientX);
if (!day) return; if (!day) return;
const minutes = getMinutesFromY(e.clientY); const minutes = minutesFromY(e.clientY);
const snap = getSnapMinutes(); const snap = getSnapMinutes(config.snapMinutes);
const snappedMinutes = Math.round(minutes / snap) * snap; const snappedMinutes = Math.round(minutes / snap) * snap;
isCreating = true; isCreating = true;
@ -111,12 +92,12 @@ export function useDragToCreate(getConfig: () => DragToCreateConfig) {
hasMoved = true; hasMoved = true;
const config = getConfig(); const config = getConfig();
const snap = getSnapMinutes(); const snap = getSnapMinutes(config.snapMinutes);
const day = getDayFromX(e.clientX); const day = dayFromX(e.clientX);
if (day) createTargetDay = day; if (day) createTargetDay = day;
const minutes = getMinutesFromY(e.clientY); const minutes = minutesFromY(e.clientY);
const snappedMinutes = Math.round(minutes / snap) * snap; const snappedMinutes = Math.round(minutes / snap) * snap;
if (snappedMinutes >= createStartMinutes) { if (snappedMinutes >= createStartMinutes) {
@ -156,8 +137,7 @@ export function useDragToCreate(getConfig: () => DragToCreateConfig) {
} }
function getCreatePreviewTime(): string { function getCreatePreviewTime(): string {
const pad = (n: number) => n.toString().padStart(2, '0'); return `${formatTime(Math.floor(createStartMinutes / 60), createStartMinutes % 60)} - ${formatTime(Math.floor(createEndMinutes / 60), createEndMinutes % 60)}`;
return `${pad(Math.floor(createStartMinutes / 60))}:${pad(createStartMinutes % 60)} - ${pad(Math.floor(createEndMinutes / 60))}:${pad(createEndMinutes % 60)}`;
} }
function cancel() { function cancel() {

View file

@ -7,7 +7,7 @@ import type { CalendarEvent } from '@calendar/shared';
import { differenceInMinutes, addMinutes, setHours, setMinutes } from 'date-fns'; import { differenceInMinutes, addMinutes, setHours, setMinutes } from 'date-fns';
import { toDate } from '$lib/utils/eventDateHelpers'; import { toDate } from '$lib/utils/eventDateHelpers';
import { eventsStore } from '$lib/stores/events.svelte'; import { eventsStore } from '$lib/stores/events.svelte';
import { SNAP_INTERVAL_MINUTES } from '$lib/utils/calendarConstants'; import { formatTime, getDayFromX, getMinutesFromY } from '$lib/utils/drag-helpers';
export interface EventDragDropConfig { export interface EventDragDropConfig {
/** Reference to the container element for position calculations */ /** Reference to the container element for position calculations */
@ -69,51 +69,21 @@ export function useEventDragDrop(getConfig: () => EventDragDropConfig) {
// ========== Helper Functions ========== // ========== Helper Functions ==========
function getSnapMinutes(): number { function dayFromX(clientX: number): Date | null {
return getConfig().snapMinutes ?? SNAP_INTERVAL_MINUTES;
}
function snapToGrid(minutes: number): number {
const snap = getSnapMinutes();
return Math.round(minutes / snap) * snap;
}
/**
* Get day from X coordinate (for multi-day views)
*/
function getDayFromX(clientX: number): Date | null {
const config = getConfig(); const config = getConfig();
if (!config.containerEl) return null; return getDayFromX(clientX, config.containerEl, config.days);
const rect = config.containerEl.getBoundingClientRect();
const relativeX = clientX - rect.left;
const dayWidth = rect.width / config.days.length;
const dayIndex = Math.floor(relativeX / dayWidth);
if (dayIndex >= 0 && dayIndex < config.days.length) {
return config.days[dayIndex];
}
return null;
} }
/** function minutesFromY(clientY: number): number {
* Get minutes from Y coordinate
*/
function getMinutesFromY(clientY: number): number {
const config = getConfig(); const config = getConfig();
if (!config.containerEl) return 0; return getMinutesFromY(
clientY,
const rect = config.containerEl.getBoundingClientRect(); config.containerEl,
const scrollTop = config.containerEl.parentElement?.scrollTop || 0; config.totalVisibleHours,
const relativeY = clientY - rect.top + scrollTop; config.hourHeight,
config.firstVisibleHour,
// Account for hidden early hours config.snapMinutes
const visibleMinutes = );
(relativeY / (config.totalVisibleHours * config.hourHeight)) * config.totalVisibleHours * 60;
const totalMinutes = visibleMinutes + config.firstVisibleHour * 60;
// Snap to interval
return snapToGrid(totalMinutes);
} }
// ========== Drag Functions ========== // ========== Drag Functions ==========
@ -139,7 +109,7 @@ export function useEventDragDrop(getConfig: () => EventDragDropConfig) {
dragTargetDay = start; dragTargetDay = start;
// Calculate offset from event start to click position // Calculate offset from event start to click position
const clickMinutes = getMinutesFromY(e.clientY); const clickMinutes = minutesFromY(e.clientY);
dragOffsetMinutes = clickMinutes - startMinutes; dragOffsetMinutes = clickMinutes - startMinutes;
document.addEventListener('pointermove', handleDragMove); document.addEventListener('pointermove', handleDragMove);
@ -153,8 +123,8 @@ export function useEventDragDrop(getConfig: () => EventDragDropConfig) {
hasMoved = true; hasMoved = true;
// Calculate new position // Calculate new position
const newDay = getDayFromX(e.clientX); const newDay = dayFromX(e.clientX);
const newMinutes = getMinutesFromY(e.clientY) - dragOffsetMinutes; const newMinutes = minutesFromY(e.clientY) - dragOffsetMinutes;
// Clamp to valid range // Clamp to valid range
const clampedMinutes = Math.max( const clampedMinutes = Math.max(
@ -183,7 +153,7 @@ export function useEventDragDrop(getConfig: () => EventDragDropConfig) {
const duration = differenceInMinutes(end, start); const duration = differenceInMinutes(end, start);
// Calculate new start time // Calculate new start time
const newMinutes = getMinutesFromY(e.clientY) - dragOffsetMinutes; const newMinutes = minutesFromY(e.clientY) - dragOffsetMinutes;
const clampedMinutes = Math.max(0, Math.min(24 * 60 - 15, newMinutes)); const clampedMinutes = Math.max(0, Math.min(24 * 60 - 15, newMinutes));
const newHours = Math.floor(clampedMinutes / 60); const newHours = Math.floor(clampedMinutes / 60);
const newMins = clampedMinutes % 60; const newMins = clampedMinutes % 60;
@ -244,7 +214,7 @@ export function useEventDragDrop(getConfig: () => EventDragDropConfig) {
resizePreviewHeight = (duration / (config.totalVisibleHours * 60)) * 100; resizePreviewHeight = (duration / (config.totalVisibleHours * 60)) * 100;
// Calculate offset between snapped click position and actual event boundary // Calculate offset between snapped click position and actual event boundary
const clickMinutes = getMinutesFromY(e.clientY); const clickMinutes = minutesFromY(e.clientY);
if (edge === 'top') { if (edge === 'top') {
resizeOffsetMinutes = clickMinutes - startMinutes; resizeOffsetMinutes = clickMinutes - startMinutes;
} else { } else {
@ -261,7 +231,7 @@ export function useEventDragDrop(getConfig: () => EventDragDropConfig) {
const config = getConfig(); const config = getConfig();
hasMoved = true; hasMoved = true;
const currentMinutes = getMinutesFromY(e.clientY); const currentMinutes = minutesFromY(e.clientY);
// Apply offset to prevent jumping when drag starts // Apply offset to prevent jumping when drag starts
const adjustedMinutes = currentMinutes - resizeOffsetMinutes; const adjustedMinutes = currentMinutes - resizeOffsetMinutes;
const originalStartMinutes = const originalStartMinutes =
@ -298,7 +268,7 @@ export function useEventDragDrop(getConfig: () => EventDragDropConfig) {
} }
const config = getConfig(); const config = getConfig();
const currentMinutes = getMinutesFromY(e.clientY); const currentMinutes = minutesFromY(e.clientY);
// Apply offset to prevent jumping // Apply offset to prevent jumping
const adjustedMinutes = currentMinutes - resizeOffsetMinutes; const adjustedMinutes = currentMinutes - resizeOffsetMinutes;
const originalStartMinutes = const originalStartMinutes =
@ -399,8 +369,7 @@ export function useEventDragDrop(getConfig: () => EventDragDropConfig) {
endMin = Math.round(previewEndMinutes); endMin = Math.round(previewEndMinutes);
} }
const pad = (n: number) => n.toString().padStart(2, '0'); return `${formatTime(Math.floor(startMin / 60), startMin % 60)} - ${formatTime(Math.floor(endMin / 60), endMin % 60)}`;
return `${pad(Math.floor(startMin / 60))}:${pad(startMin % 60)} - ${pad(Math.floor(endMin / 60))}:${pad(endMin % 60)}`;
} }
return { return {

View file

@ -5,7 +5,7 @@
import { todosStore } from '$lib/stores/todos.svelte'; import { todosStore } from '$lib/stores/todos.svelte';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { SNAP_INTERVAL_MINUTES } from '$lib/utils/calendarConstants'; import { formatTime, getSnapMinutes } from '$lib/utils/drag-helpers';
export interface SidebarDropConfig { export interface SidebarDropConfig {
/** First visible hour (for filtered hours mode) */ /** First visible hour (for filtered hours mode) */
@ -20,14 +20,6 @@ export function useSidebarDrop(getConfig: () => SidebarDropConfig) {
// Track active drop target (for visual feedback) // Track active drop target (for visual feedback)
let dropTarget = $state<{ day: Date; y: number } | null>(null); let dropTarget = $state<{ day: Date; y: number } | null>(null);
function getSnapMinutes(): number {
return getConfig().snapMinutes ?? SNAP_INTERVAL_MINUTES;
}
function formatTime(hours: number, minutes: number): string {
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`;
}
/** /**
* Handle dragover event on a day column * Handle dragover event on a day column
*/ */
@ -82,7 +74,7 @@ export function useSidebarDrop(getConfig: () => SidebarDropConfig) {
const minutesPerPercent = (config.totalVisibleHours * 60) / 100; const minutesPerPercent = (config.totalVisibleHours * 60) / 100;
const rawMinutes = percentY * minutesPerPercent; const rawMinutes = percentY * minutesPerPercent;
const snapMinutes = getSnapMinutes(); const snapMinutes = getSnapMinutes(getConfig().snapMinutes);
const snappedMinutes = Math.round(rawMinutes / snapMinutes) * snapMinutes; const snappedMinutes = Math.round(rawMinutes / snapMinutes) * snapMinutes;
const totalMinutes = config.firstVisibleHour * 60 + snappedMinutes; const totalMinutes = config.firstVisibleHour * 60 + snappedMinutes;

View file

@ -8,7 +8,7 @@
import type { Task } from '$lib/stores/todos.svelte'; import type { Task } from '$lib/stores/todos.svelte';
import { todosStore } from '$lib/stores/todos.svelte'; import { todosStore } from '$lib/stores/todos.svelte';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { SNAP_INTERVAL_MINUTES } from '$lib/utils/calendarConstants'; import { formatTime, getSnapMinutes } from '$lib/utils/drag-helpers';
export interface TaskDragDropConfig { export interface TaskDragDropConfig {
/** Reference to the container element for position calculations */ /** Reference to the container element for position calculations */
@ -43,14 +43,6 @@ export function useTaskDragDrop(getConfig: () => TaskDragDropConfig) {
// ========== Helper Functions ========== // ========== Helper Functions ==========
function getSnapMinutes(): number {
return getConfig().snapMinutes ?? SNAP_INTERVAL_MINUTES;
}
function formatTime(hours: number, minutes: number): string {
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`;
}
// ========== Drag Functions ========== // ========== Drag Functions ==========
function startDrag(task: Task, e: PointerEvent) { function startDrag(task: Task, e: PointerEvent) {
@ -105,7 +97,7 @@ export function useTaskDragDrop(getConfig: () => TaskDragDropConfig) {
// Snap to intervals // Snap to intervals
const minutesPerPercent = (config.totalVisibleHours * 60) / 100; const minutesPerPercent = (config.totalVisibleHours * 60) / 100;
const rawMinutes = percentY * minutesPerPercent; const rawMinutes = percentY * minutesPerPercent;
const snapMinutes = getSnapMinutes(); const snapMinutes = getSnapMinutes(getConfig().snapMinutes);
const snappedMinutes = Math.round(rawMinutes / snapMinutes) * snapMinutes; const snappedMinutes = Math.round(rawMinutes / snapMinutes) * snapMinutes;
taskDragPreviewTop = (snappedMinutes / (config.totalVisibleHours * 60)) * 100; taskDragPreviewTop = (snappedMinutes / (config.totalVisibleHours * 60)) * 100;
} }
@ -192,7 +184,7 @@ export function useTaskDragDrop(getConfig: () => TaskDragDropConfig) {
const percentY = Math.max(0, Math.min(100, (relativeY / rect.height) * 100)); const percentY = Math.max(0, Math.min(100, (relativeY / rect.height) * 100));
const minutesPerPercent = (config.totalVisibleHours * 60) / 100; const minutesPerPercent = (config.totalVisibleHours * 60) / 100;
const snapMinutes = getSnapMinutes(); const snapMinutes = getSnapMinutes(getConfig().snapMinutes);
if (taskResizeEdge === 'top') { if (taskResizeEdge === 'top') {
// Adjust start time, keep end fixed // Adjust start time, keep end fixed

View file

@ -0,0 +1,73 @@
/**
* Shared drag/drop utility functions
* Used by useEventDragDrop, useDragToCreate
*/
import { SNAP_INTERVAL_MINUTES } from '$lib/utils/calendarConstants';
/**
* Format hours and minutes as HH:MM string
*/
export function formatTime(hours: number, minutes: number): string {
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`;
}
/**
* Get the effective snap interval, falling back to the default constant
*/
export function getSnapMinutes(snapMinutes?: number): number {
return snapMinutes ?? SNAP_INTERVAL_MINUTES;
}
/**
* Snap a minute value to the nearest grid interval
*/
export function snapToGrid(minutes: number, snapMinutes?: number): number {
const snap = getSnapMinutes(snapMinutes);
return Math.round(minutes / snap) * snap;
}
/**
* Map an X client coordinate to a day column based on container width
*/
export function getDayFromX(
clientX: number,
containerEl: HTMLElement | null,
days: Date[]
): Date | null {
if (!containerEl) return null;
const rect = containerEl.getBoundingClientRect();
const relativeX = clientX - rect.left;
const dayWidth = rect.width / days.length;
const dayIndex = Math.floor(relativeX / dayWidth);
if (dayIndex >= 0 && dayIndex < days.length) {
return days[dayIndex];
}
return null;
}
/**
* Map a Y client coordinate to total minutes in the day,
* accounting for scroll offset, visible hour range, and snap interval
*/
export function getMinutesFromY(
clientY: number,
containerEl: HTMLElement | null,
totalVisibleHours: number,
hourHeight: number,
firstVisibleHour: number,
snapMinutes?: number
): number {
if (!containerEl) return 0;
const rect = containerEl.getBoundingClientRect();
const scrollTop = containerEl.parentElement?.scrollTop || 0;
const relativeY = clientY - rect.top + scrollTop;
const visibleMinutes = (relativeY / (totalVisibleHours * hourHeight)) * totalVisibleHours * 60;
const totalMinutes = visibleMinutes + firstVisibleHour * 60;
return snapToGrid(totalMinutes, snapMinutes);
}

View file

@ -9,8 +9,8 @@
} from '@todo/shared'; } from '@todo/shared';
import type { ContactReference, ContactOrManual } from '@manacore/shared-types'; import type { ContactReference, ContactOrManual } from '@manacore/shared-types';
import { STATUS_OPTIONS, RECURRENCE_OPTIONS } from '@todo/shared'; import { STATUS_OPTIONS, RECURRENCE_OPTIONS } from '@todo/shared';
import { format, isToday, isPast, isTomorrow } from 'date-fns'; import { isToday, isPast } from 'date-fns';
import { de } from 'date-fns/locale'; import { formatDueDate } from '$lib/utils/date-display';
import { getContext } from 'svelte'; import { getContext } from 'svelte';
import type { Project } from '@todo/shared'; import type { Project } from '@todo/shared';
import { getActiveProjects, getProjectColor } from '$lib/data/task-queries'; import { getActiveProjects, getProjectColor } from '$lib/data/task-queries';
@ -27,6 +27,7 @@
FunRatingPicker, FunRatingPicker,
TagSelector, TagSelector,
} from './form'; } from './form';
import { PRIORITY_COLORS } from '$lib/constants/priority';
interface Props { interface Props {
task: Task; task: Task;
@ -291,21 +292,12 @@
subtasks = newSubtasks; subtasks = newSubtasks;
} }
// Priority colors const priorityColors = PRIORITY_COLORS;
const priorityColors: Record<string, string> = {
low: '#22c55e',
medium: '#eab308',
high: '#f97316',
urgent: '#ef4444',
};
// Format due date // Format due date
let dueDateText = $derived(() => { let dueDateText = $derived(() => {
if (!task.dueDate) return null; if (!task.dueDate) return null;
const date = new Date(task.dueDate); return formatDueDate(new Date(task.dueDate));
if (isToday(date)) return 'Heute';
if (isTomorrow(date)) return 'Morgen';
return format(date, 'dd. MMM', { locale: de });
}); });
// Check if overdue // Check if overdue

View file

@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import type { Task } from '@todo/shared'; import type { Task } from '@todo/shared';
import { format, isToday, isPast, isTomorrow } from 'date-fns'; import { isToday, isPast } from 'date-fns';
import { de } from 'date-fns/locale'; import { formatDueDate } from '$lib/utils/date-display';
import { ConfirmationModal, ContactAvatar } from '@manacore/shared-ui'; import { ConfirmationModal, ContactAvatar } from '@manacore/shared-ui';
import TaskEditModal from '../TaskEditModal.svelte'; import TaskEditModal from '../TaskEditModal.svelte';
import { import {
@ -12,6 +12,7 @@
Note, Note,
Trash, Trash,
} from '@manacore/shared-icons'; } from '@manacore/shared-icons';
import { PRIORITY_BG_CLASSES } from '$lib/constants/priority';
interface Props { interface Props {
task: Task; task: Task;
@ -36,21 +37,12 @@
let contextMenuX = $state(0); let contextMenuX = $state(0);
let contextMenuY = $state(0); let contextMenuY = $state(0);
// Priority colors (consistent with KanbanFilters) const priorityColors = PRIORITY_BG_CLASSES;
const priorityColors: Record<string, string> = {
low: 'bg-blue-500',
medium: 'bg-yellow-500',
high: 'bg-orange-500',
urgent: 'bg-red-500',
};
// Format due date // Format due date
let dueDateText = $derived(() => { let dueDateText = $derived(() => {
if (!task.dueDate) return null; if (!task.dueDate) return null;
const date = new Date(task.dueDate); return formatDueDate(new Date(task.dueDate));
if (isToday(date)) return 'Heute';
if (isTomorrow(date)) return 'Morgen';
return format(date, 'dd. MMM', { locale: de });
}); });
// Check if overdue // Check if overdue

View file

@ -0,0 +1,37 @@
import type { TaskPriority } from '@todo/shared';
/**
* Hex color for each priority level, used for inline styles (e.g. checkbox tint).
*/
export const PRIORITY_COLORS: Record<TaskPriority, string> = {
low: '#22c55e',
medium: '#eab308',
high: '#f97316',
urgent: '#ef4444',
};
/**
* Tailwind background-color class for each priority level,
* used for dot indicators and filter pill backgrounds.
*/
export const PRIORITY_BG_CLASSES: Record<TaskPriority, string> = {
low: 'bg-blue-500',
medium: 'bg-yellow-500',
high: 'bg-orange-500',
urgent: 'bg-red-500',
};
/**
* Full priority option descriptors for filter UIs.
*/
export const PRIORITY_OPTIONS: {
value: TaskPriority;
label: string;
color: string;
bgColor: string;
}[] = [
{ value: 'urgent', label: 'Dringend', color: '#ef4444', bgColor: 'bg-red-500' },
{ value: 'high', label: 'Hoch', color: '#f97316', bgColor: 'bg-orange-500' },
{ value: 'medium', label: 'Normal', color: '#eab308', bgColor: 'bg-yellow-500' },
{ value: 'low', label: 'Niedrig', color: '#3b82f6', bgColor: 'bg-blue-500' },
];

View file

@ -6,8 +6,10 @@
*/ */
import { boardViewCollection, type LocalBoardView, type ViewColumn } from '$lib/data/local-store'; import { boardViewCollection, type LocalBoardView, type ViewColumn } from '$lib/data/local-store';
import { withErrorHandling } from './store-helpers';
let error = $state<string | null>(null); let error = $state<string | null>(null);
const setError = (e: string | null) => (error = e);
export const boardViewsStore = { export const boardViewsStore = {
get error() { get error() {
@ -15,70 +17,74 @@ export const boardViewsStore = {
}, },
async createView(data: Omit<LocalBoardView, 'id'>) { async createView(data: Omit<LocalBoardView, 'id'>) {
error = null; return withErrorHandling(
try { setError,
const count = await boardViewCollection.count(); async () => {
const newView: LocalBoardView = { const count = await boardViewCollection.count();
...data, const newView: LocalBoardView = {
id: crypto.randomUUID(), ...data,
order: data.order ?? count, id: crypto.randomUUID(),
}; order: data.order ?? count,
return await boardViewCollection.insert(newView); };
} catch (e) { return await boardViewCollection.insert(newView);
error = e instanceof Error ? e.message : 'Failed to create view'; },
throw e; 'Failed to create view',
} { log: false }
);
}, },
async updateView(id: string, data: Partial<LocalBoardView>) { async updateView(id: string, data: Partial<LocalBoardView>) {
error = null; return withErrorHandling(
try { setError,
return await boardViewCollection.update(id, data as Partial<LocalBoardView>); async () => {
} catch (e) { return await boardViewCollection.update(id, data as Partial<LocalBoardView>);
error = e instanceof Error ? e.message : 'Failed to update view'; },
throw e; 'Failed to update view',
} { log: false }
);
}, },
async deleteView(id: string) { async deleteView(id: string) {
error = null; return withErrorHandling(
try { setError,
await boardViewCollection.delete(id); async () => {
} catch (e) { await boardViewCollection.delete(id);
error = e instanceof Error ? e.message : 'Failed to delete view'; },
throw e; 'Failed to delete view',
} { log: false }
);
}, },
async reorderViews(viewIds: string[]) { async reorderViews(viewIds: string[]) {
error = null; return withErrorHandling(
try { setError,
for (let i = 0; i < viewIds.length; i++) { async () => {
await boardViewCollection.update(viewIds[i], { order: i } as Partial<LocalBoardView>); for (let i = 0; i < viewIds.length; i++) {
} await boardViewCollection.update(viewIds[i], { order: i } as Partial<LocalBoardView>);
} catch (e) { }
error = e instanceof Error ? e.message : 'Failed to reorder views'; },
} 'Failed to reorder views',
{ rethrow: false, log: false }
);
}, },
/** Update a column's taskIds (for custom groupBy with manual task assignment) */ /** Update a column's taskIds (for custom groupBy with manual task assignment) */
async updateColumnTaskIds(viewId: string, columnId: string, taskIds: string[]) { async updateColumnTaskIds(viewId: string, columnId: string, taskIds: string[]) {
error = null; return withErrorHandling(
try { setError,
const view = await boardViewCollection.get(viewId); async () => {
if (!view) return; const view = await boardViewCollection.get(viewId);
if (!view) return;
const updatedColumns = view.columns.map((col: ViewColumn) => const updatedColumns = view.columns.map((col: ViewColumn) =>
col.id === columnId col.id === columnId ? { ...col, match: { ...col.match, taskIds } } : col
? { ...col, match: { ...col.match, taskIds } } );
: col await boardViewCollection.update(viewId, {
); columns: updatedColumns,
await boardViewCollection.update(viewId, { } as Partial<LocalBoardView>);
columns: updatedColumns, },
} as Partial<LocalBoardView>); 'Failed to update column',
} catch (e) { { log: false }
error = e instanceof Error ? e.message : 'Failed to update column'; );
throw e;
}
}, },
}; };

View file

@ -10,8 +10,10 @@ import type { Project } from '@todo/shared';
import { projectCollection, type LocalProject } from '$lib/data/local-store'; import { projectCollection, type LocalProject } from '$lib/data/local-store';
import { toProject } from '$lib/data/task-queries'; import { toProject } from '$lib/data/task-queries';
import { TodoEvents } from '@manacore/shared-utils/analytics'; import { TodoEvents } from '@manacore/shared-utils/analytics';
import { withErrorHandling } from './store-helpers';
let error = $state<string | null>(null); let error = $state<string | null>(null);
const setError = (e: string | null) => (error = e);
export const projectsStore = { export const projectsStore = {
get error() { get error() {
@ -19,85 +21,80 @@ export const projectsStore = {
}, },
async createProject(data: { name: string; description?: string; color?: string; icon?: string }) { async createProject(data: { name: string; description?: string; color?: string; icon?: string }) {
error = null; return withErrorHandling(
try { setError,
const count = await projectCollection.count(); async () => {
const newLocal: LocalProject = { const count = await projectCollection.count();
id: crypto.randomUUID(), const newLocal: LocalProject = {
name: data.name, id: crypto.randomUUID(),
color: data.color ?? '#6b7280', name: data.name,
icon: data.icon ?? null, color: data.color ?? '#6b7280',
order: count, icon: data.icon ?? null,
isArchived: false, order: count,
isDefault: false, isArchived: false,
}; isDefault: false,
};
const inserted = await projectCollection.insert(newLocal); const inserted = await projectCollection.insert(newLocal);
TodoEvents.projectCreated(); TodoEvents.projectCreated();
return toProject(inserted); return toProject(inserted);
} catch (e) { },
error = e instanceof Error ? e.message : 'Failed to create project'; 'Failed to create project'
console.error('Failed to create project:', e); );
throw e;
}
}, },
async updateProject( async updateProject(
id: string, id: string,
data: { name?: string; description?: string; color?: string; icon?: string } data: { name?: string; description?: string; color?: string; icon?: string }
) { ) {
error = null; return withErrorHandling(
try { setError,
const updated = await projectCollection.update(id, data as Partial<LocalProject>); async () => {
if (updated) { const updated = await projectCollection.update(id, data as Partial<LocalProject>);
return toProject(updated); if (updated) {
} return toProject(updated);
} catch (e) { }
error = e instanceof Error ? e.message : 'Failed to update project'; },
console.error('Failed to update project:', e); 'Failed to update project'
throw e; );
}
}, },
async deleteProject(id: string) { async deleteProject(id: string) {
error = null; return withErrorHandling(
try { setError,
await projectCollection.delete(id); async () => {
TodoEvents.projectDeleted(); await projectCollection.delete(id);
} catch (e) { TodoEvents.projectDeleted();
error = e instanceof Error ? e.message : 'Failed to delete project'; },
console.error('Failed to delete project:', e); 'Failed to delete project'
throw e; );
}
}, },
async archiveProject(id: string) { async archiveProject(id: string) {
error = null; return withErrorHandling(
try { setError,
const updated = await projectCollection.update(id, { async () => {
isArchived: true, const updated = await projectCollection.update(id, {
} as Partial<LocalProject>); isArchived: true,
if (updated) { } as Partial<LocalProject>);
return toProject(updated); if (updated) {
} return toProject(updated);
} catch (e) { }
error = e instanceof Error ? e.message : 'Failed to archive project'; },
console.error('Failed to archive project:', e); 'Failed to archive project'
throw e; );
}
}, },
async reorderProjects(projectIds: string[]) { async reorderProjects(projectIds: string[]) {
error = null; return withErrorHandling(
try { setError,
for (let i = 0; i < projectIds.length; i++) { async () => {
await projectCollection.update(projectIds[i], { order: i } as Partial<LocalProject>); for (let i = 0; i < projectIds.length; i++) {
} await projectCollection.update(projectIds[i], { order: i } as Partial<LocalProject>);
} catch (e) { }
error = e instanceof Error ? e.message : 'Failed to reorder projects'; },
console.error('Failed to reorder projects:', e); 'Failed to reorder projects'
throw e; );
}
}, },
get guestInboxId() { get guestInboxId() {

View file

@ -0,0 +1,30 @@
/**
* Shared error-handling helper for mutation stores.
*
* Wraps an async operation with consistent error state management:
* clears the error before the operation, captures it on failure,
* and optionally logs / re-throws.
*/
export async function withErrorHandling<T>(
setError: (e: string | null) => void,
operation: () => Promise<T>,
errorMessage: string,
options?: { rethrow?: boolean; log?: boolean }
): Promise<T | undefined> {
const { rethrow = true, log = true } = options ?? {};
setError(null);
try {
return await operation();
} catch (e) {
const msg = e instanceof Error ? e.message : errorMessage;
setError(msg);
if (log) {
console.error(errorMessage + ':', e);
}
if (rethrow) {
throw e;
}
return undefined;
}
}

View file

@ -10,8 +10,10 @@ import type { Task, TaskPriority, TaskStatus, Subtask } from '@todo/shared';
import { taskCollection, type LocalTask } from '$lib/data/local-store'; import { taskCollection, type LocalTask } from '$lib/data/local-store';
import { toTask } from '$lib/data/task-queries'; import { toTask } from '$lib/data/task-queries';
import { TodoEvents } from '@manacore/shared-utils/analytics'; import { TodoEvents } from '@manacore/shared-utils/analytics';
import { withErrorHandling } from './store-helpers';
let error = $state<string | null>(null); let error = $state<string | null>(null);
const setError = (e: string | null) => (error = e);
export const tasksStore = { export const tasksStore = {
get error() { get error() {
@ -29,31 +31,30 @@ export const tasksStore = {
recurrenceRule?: string; recurrenceRule?: string;
estimatedDuration?: number; estimatedDuration?: number;
}) { }) {
error = null; return withErrorHandling(
try { setError,
const count = await taskCollection.count(); async () => {
const newLocal: LocalTask = { const count = await taskCollection.count();
id: crypto.randomUUID(), const newLocal: LocalTask = {
title: data.title, id: crypto.randomUUID(),
description: data.description, title: data.title,
projectId: data.projectId ?? null, description: data.description,
priority: data.priority ?? 'medium', projectId: data.projectId ?? null,
isCompleted: false, priority: data.priority ?? 'medium',
dueDate: data.dueDate ?? null, isCompleted: false,
estimatedDuration: data.estimatedDuration ?? null, dueDate: data.dueDate ?? null,
order: count, estimatedDuration: data.estimatedDuration ?? null,
recurrenceRule: data.recurrenceRule ?? null, order: count,
subtasks: data.subtasks, recurrenceRule: data.recurrenceRule ?? null,
}; subtasks: data.subtasks,
};
const inserted = await taskCollection.insert(newLocal); const inserted = await taskCollection.insert(newLocal);
TodoEvents.taskCreated(!!data.dueDate); TodoEvents.taskCreated(!!data.dueDate);
return toTask(inserted); return toTask(inserted);
} catch (e) { },
error = e instanceof Error ? e.message : 'Failed to create task'; 'Failed to create task'
console.error('Failed to create task:', e); );
throw e;
}
}, },
async updateTask( async updateTask(
@ -77,17 +78,16 @@ export const tasksStore = {
labelIds?: string[]; labelIds?: string[];
} }
) { ) {
error = null; return withErrorHandling(
try { setError,
const updated = await taskCollection.update(id, data as Partial<LocalTask>); async () => {
if (updated) { const updated = await taskCollection.update(id, data as Partial<LocalTask>);
return toTask(updated); if (updated) {
} return toTask(updated);
} catch (e) { }
error = e instanceof Error ? e.message : 'Failed to update task'; },
console.error('Failed to update task:', e); 'Failed to update task'
throw e; );
}
}, },
async updateTaskOptimistic( async updateTaskOptimistic(
@ -108,107 +108,102 @@ export const tasksStore = {
}, },
async deleteTask(id: string) { async deleteTask(id: string) {
error = null; return withErrorHandling(
try { setError,
await taskCollection.delete(id); async () => {
TodoEvents.taskDeleted(); await taskCollection.delete(id);
} catch (e) { TodoEvents.taskDeleted();
error = e instanceof Error ? e.message : 'Failed to delete task'; },
console.error('Failed to delete task:', e); 'Failed to delete task'
throw e; );
}
}, },
async completeTask(id: string) { async completeTask(id: string) {
error = null; return withErrorHandling(
try { setError,
const updated = await taskCollection.update(id, { async () => {
isCompleted: true, const updated = await taskCollection.update(id, {
completedAt: new Date().toISOString(), isCompleted: true,
} as Partial<LocalTask>); completedAt: new Date().toISOString(),
if (updated) { } as Partial<LocalTask>);
TodoEvents.taskCompleted(); if (updated) {
return toTask(updated); TodoEvents.taskCompleted();
} return toTask(updated);
} catch (e) { }
error = e instanceof Error ? e.message : 'Failed to complete task'; },
console.error('Failed to complete task:', e); 'Failed to complete task'
throw e; );
}
}, },
async uncompleteTask(id: string) { async uncompleteTask(id: string) {
error = null; return withErrorHandling(
try { setError,
const updated = await taskCollection.update(id, { async () => {
isCompleted: false, const updated = await taskCollection.update(id, {
completedAt: null, isCompleted: false,
} as Partial<LocalTask>); completedAt: null,
if (updated) { } as Partial<LocalTask>);
TodoEvents.taskUncompleted(); if (updated) {
return toTask(updated); TodoEvents.taskUncompleted();
} return toTask(updated);
} catch (e) { }
error = e instanceof Error ? e.message : 'Failed to uncomplete task'; },
console.error('Failed to uncomplete task:', e); 'Failed to uncomplete task'
throw e; );
}
}, },
async moveTask(id: string, projectId: string | null) { async moveTask(id: string, projectId: string | null) {
error = null; return withErrorHandling(
try { setError,
const updated = await taskCollection.update(id, { projectId } as Partial<LocalTask>); async () => {
if (updated) { const updated = await taskCollection.update(id, { projectId } as Partial<LocalTask>);
return toTask(updated); if (updated) {
} return toTask(updated);
} catch (e) { }
error = e instanceof Error ? e.message : 'Failed to move task'; },
console.error('Failed to move task:', e); 'Failed to move task'
throw e; );
}
}, },
async updateLabels(id: string, labelIds: string[]) { async updateLabels(id: string, labelIds: string[]) {
error = null; return withErrorHandling(
try { setError,
const updated = await taskCollection.update(id, { async () => {
metadata: { labelIds }, const updated = await taskCollection.update(id, {
} as Partial<LocalTask>); metadata: { labelIds },
if (updated) { } as Partial<LocalTask>);
return toTask(updated); if (updated) {
} return toTask(updated);
} catch (e) { }
error = e instanceof Error ? e.message : 'Failed to update labels'; },
console.error('Failed to update labels:', e); 'Failed to update labels'
throw e; );
}
}, },
async updateSubtasks(id: string, subtasks: Subtask[]) { async updateSubtasks(id: string, subtasks: Subtask[]) {
error = null; return withErrorHandling(
try { setError,
const updated = await taskCollection.update(id, { subtasks } as Partial<LocalTask>); async () => {
if (updated) { const updated = await taskCollection.update(id, { subtasks } as Partial<LocalTask>);
return toTask(updated); if (updated) {
} return toTask(updated);
} catch (e) { }
error = e instanceof Error ? e.message : 'Failed to update subtasks'; },
console.error('Failed to update subtasks:', e); 'Failed to update subtasks'
throw e; );
}
}, },
async reorderTasks(taskIds: string[]) { async reorderTasks(taskIds: string[]) {
error = null; return withErrorHandling(
try { setError,
for (let i = 0; i < taskIds.length; i++) { async () => {
await taskCollection.update(taskIds[i], { order: i } as Partial<LocalTask>); for (let i = 0; i < taskIds.length; i++) {
} await taskCollection.update(taskIds[i], { order: i } as Partial<LocalTask>);
} catch (e) { }
error = e instanceof Error ? e.message : 'Failed to reorder tasks'; },
console.error('Failed to reorder tasks:', e); 'Failed to reorder tasks',
} { rethrow: false }
);
}, },
isDemoTask(_taskId: string) { isDemoTask(_taskId: string) {

View file

@ -0,0 +1,12 @@
import { format, isToday, isTomorrow } from 'date-fns';
import { de } from 'date-fns/locale';
/**
* Format a due date for display.
* Returns 'Heute' for today, 'Morgen' for tomorrow, or 'dd. MMM' (German locale) otherwise.
*/
export function formatDueDate(date: Date): string {
if (isToday(date)) return 'Heute';
if (isTomorrow(date)) return 'Morgen';
return format(date, 'dd. MMM', { locale: de });
}