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

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

View file

@ -1,7 +1,7 @@
<script lang="ts">
import type { Task } from '@todo/shared';
import { format, isToday, isPast, isTomorrow } from 'date-fns';
import { de } from 'date-fns/locale';
import { isToday, isPast } from 'date-fns';
import { formatDueDate } from '$lib/utils/date-display';
import { ConfirmationModal, ContactAvatar } from '@manacore/shared-ui';
import TaskEditModal from '../TaskEditModal.svelte';
import {
@ -12,6 +12,7 @@
Note,
Trash,
} from '@manacore/shared-icons';
import { PRIORITY_BG_CLASSES } from '$lib/constants/priority';
interface Props {
task: Task;
@ -36,21 +37,12 @@
let contextMenuX = $state(0);
let contextMenuY = $state(0);
// Priority colors (consistent with KanbanFilters)
const priorityColors: Record<string, string> = {
low: 'bg-blue-500',
medium: 'bg-yellow-500',
high: 'bg-orange-500',
urgent: 'bg-red-500',
};
const priorityColors = PRIORITY_BG_CLASSES;
// Format due date
let dueDateText = $derived(() => {
if (!task.dueDate) return null;
const date = new Date(task.dueDate);
if (isToday(date)) return 'Heute';
if (isTomorrow(date)) return 'Morgen';
return format(date, 'dd. MMM', { locale: de });
return formatDueDate(new Date(task.dueDate));
});
// 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 { withErrorHandling } from './store-helpers';
let error = $state<string | null>(null);
const setError = (e: string | null) => (error = e);
export const boardViewsStore = {
get error() {
@ -15,70 +17,74 @@ export const boardViewsStore = {
},
async createView(data: Omit<LocalBoardView, 'id'>) {
error = null;
try {
const count = await boardViewCollection.count();
const newView: LocalBoardView = {
...data,
id: crypto.randomUUID(),
order: data.order ?? count,
};
return await boardViewCollection.insert(newView);
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to create view';
throw e;
}
return withErrorHandling(
setError,
async () => {
const count = await boardViewCollection.count();
const newView: LocalBoardView = {
...data,
id: crypto.randomUUID(),
order: data.order ?? count,
};
return await boardViewCollection.insert(newView);
},
'Failed to create view',
{ log: false }
);
},
async updateView(id: string, data: Partial<LocalBoardView>) {
error = null;
try {
return await boardViewCollection.update(id, data as Partial<LocalBoardView>);
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to update view';
throw e;
}
return withErrorHandling(
setError,
async () => {
return await boardViewCollection.update(id, data as Partial<LocalBoardView>);
},
'Failed to update view',
{ log: false }
);
},
async deleteView(id: string) {
error = null;
try {
await boardViewCollection.delete(id);
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to delete view';
throw e;
}
return withErrorHandling(
setError,
async () => {
await boardViewCollection.delete(id);
},
'Failed to delete view',
{ log: false }
);
},
async reorderViews(viewIds: string[]) {
error = null;
try {
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';
}
return withErrorHandling(
setError,
async () => {
for (let i = 0; i < viewIds.length; i++) {
await boardViewCollection.update(viewIds[i], { order: i } as Partial<LocalBoardView>);
}
},
'Failed to reorder views',
{ rethrow: false, log: false }
);
},
/** Update a column's taskIds (for custom groupBy with manual task assignment) */
async updateColumnTaskIds(viewId: string, columnId: string, taskIds: string[]) {
error = null;
try {
const view = await boardViewCollection.get(viewId);
if (!view) return;
return withErrorHandling(
setError,
async () => {
const view = await boardViewCollection.get(viewId);
if (!view) return;
const updatedColumns = view.columns.map((col: ViewColumn) =>
col.id === columnId
? { ...col, match: { ...col.match, taskIds } }
: col
);
await boardViewCollection.update(viewId, {
columns: updatedColumns,
} as Partial<LocalBoardView>);
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to update column';
throw e;
}
const updatedColumns = view.columns.map((col: ViewColumn) =>
col.id === columnId ? { ...col, match: { ...col.match, taskIds } } : col
);
await boardViewCollection.update(viewId, {
columns: updatedColumns,
} as Partial<LocalBoardView>);
},
'Failed to update column',
{ log: false }
);
},
};

View file

@ -10,8 +10,10 @@ import type { Project } from '@todo/shared';
import { projectCollection, type LocalProject } from '$lib/data/local-store';
import { toProject } from '$lib/data/task-queries';
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 projectsStore = {
get error() {
@ -19,85 +21,80 @@ export const projectsStore = {
},
async createProject(data: { name: string; description?: string; color?: string; icon?: string }) {
error = null;
try {
const count = await projectCollection.count();
const newLocal: LocalProject = {
id: crypto.randomUUID(),
name: data.name,
color: data.color ?? '#6b7280',
icon: data.icon ?? null,
order: count,
isArchived: false,
isDefault: false,
};
return withErrorHandling(
setError,
async () => {
const count = await projectCollection.count();
const newLocal: LocalProject = {
id: crypto.randomUUID(),
name: data.name,
color: data.color ?? '#6b7280',
icon: data.icon ?? null,
order: count,
isArchived: false,
isDefault: false,
};
const inserted = await projectCollection.insert(newLocal);
TodoEvents.projectCreated();
return toProject(inserted);
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to create project';
console.error('Failed to create project:', e);
throw e;
}
const inserted = await projectCollection.insert(newLocal);
TodoEvents.projectCreated();
return toProject(inserted);
},
'Failed to create project'
);
},
async updateProject(
id: string,
data: { name?: string; description?: string; color?: string; icon?: string }
) {
error = null;
try {
const updated = await projectCollection.update(id, data as Partial<LocalProject>);
if (updated) {
return toProject(updated);
}
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to update project';
console.error('Failed to update project:', e);
throw e;
}
return withErrorHandling(
setError,
async () => {
const updated = await projectCollection.update(id, data as Partial<LocalProject>);
if (updated) {
return toProject(updated);
}
},
'Failed to update project'
);
},
async deleteProject(id: string) {
error = null;
try {
await projectCollection.delete(id);
TodoEvents.projectDeleted();
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to delete project';
console.error('Failed to delete project:', e);
throw e;
}
return withErrorHandling(
setError,
async () => {
await projectCollection.delete(id);
TodoEvents.projectDeleted();
},
'Failed to delete project'
);
},
async archiveProject(id: string) {
error = null;
try {
const updated = await projectCollection.update(id, {
isArchived: true,
} as Partial<LocalProject>);
if (updated) {
return toProject(updated);
}
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to archive project';
console.error('Failed to archive project:', e);
throw e;
}
return withErrorHandling(
setError,
async () => {
const updated = await projectCollection.update(id, {
isArchived: true,
} as Partial<LocalProject>);
if (updated) {
return toProject(updated);
}
},
'Failed to archive project'
);
},
async reorderProjects(projectIds: string[]) {
error = null;
try {
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);
throw e;
}
return withErrorHandling(
setError,
async () => {
for (let i = 0; i < projectIds.length; i++) {
await projectCollection.update(projectIds[i], { order: i } as Partial<LocalProject>);
}
},
'Failed to reorder projects'
);
},
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 { toTask } from '$lib/data/task-queries';
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 tasksStore = {
get error() {
@ -29,31 +31,30 @@ export const tasksStore = {
recurrenceRule?: string;
estimatedDuration?: number;
}) {
error = null;
try {
const count = await taskCollection.count();
const newLocal: LocalTask = {
id: crypto.randomUUID(),
title: data.title,
description: data.description,
projectId: data.projectId ?? null,
priority: data.priority ?? 'medium',
isCompleted: false,
dueDate: data.dueDate ?? null,
estimatedDuration: data.estimatedDuration ?? null,
order: count,
recurrenceRule: data.recurrenceRule ?? null,
subtasks: data.subtasks,
};
return withErrorHandling(
setError,
async () => {
const count = await taskCollection.count();
const newLocal: LocalTask = {
id: crypto.randomUUID(),
title: data.title,
description: data.description,
projectId: data.projectId ?? null,
priority: data.priority ?? 'medium',
isCompleted: false,
dueDate: data.dueDate ?? null,
estimatedDuration: data.estimatedDuration ?? null,
order: count,
recurrenceRule: data.recurrenceRule ?? null,
subtasks: data.subtasks,
};
const inserted = await taskCollection.insert(newLocal);
TodoEvents.taskCreated(!!data.dueDate);
return toTask(inserted);
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to create task';
console.error('Failed to create task:', e);
throw e;
}
const inserted = await taskCollection.insert(newLocal);
TodoEvents.taskCreated(!!data.dueDate);
return toTask(inserted);
},
'Failed to create task'
);
},
async updateTask(
@ -77,17 +78,16 @@ export const tasksStore = {
labelIds?: string[];
}
) {
error = null;
try {
const updated = await taskCollection.update(id, data as Partial<LocalTask>);
if (updated) {
return toTask(updated);
}
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to update task';
console.error('Failed to update task:', e);
throw e;
}
return withErrorHandling(
setError,
async () => {
const updated = await taskCollection.update(id, data as Partial<LocalTask>);
if (updated) {
return toTask(updated);
}
},
'Failed to update task'
);
},
async updateTaskOptimistic(
@ -108,107 +108,102 @@ export const tasksStore = {
},
async deleteTask(id: string) {
error = null;
try {
await taskCollection.delete(id);
TodoEvents.taskDeleted();
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to delete task';
console.error('Failed to delete task:', e);
throw e;
}
return withErrorHandling(
setError,
async () => {
await taskCollection.delete(id);
TodoEvents.taskDeleted();
},
'Failed to delete task'
);
},
async completeTask(id: string) {
error = null;
try {
const updated = await taskCollection.update(id, {
isCompleted: true,
completedAt: new Date().toISOString(),
} as Partial<LocalTask>);
if (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);
throw e;
}
return withErrorHandling(
setError,
async () => {
const updated = await taskCollection.update(id, {
isCompleted: true,
completedAt: new Date().toISOString(),
} as Partial<LocalTask>);
if (updated) {
TodoEvents.taskCompleted();
return toTask(updated);
}
},
'Failed to complete task'
);
},
async uncompleteTask(id: string) {
error = null;
try {
const updated = await taskCollection.update(id, {
isCompleted: false,
completedAt: null,
} as Partial<LocalTask>);
if (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);
throw e;
}
return withErrorHandling(
setError,
async () => {
const updated = await taskCollection.update(id, {
isCompleted: false,
completedAt: null,
} as Partial<LocalTask>);
if (updated) {
TodoEvents.taskUncompleted();
return toTask(updated);
}
},
'Failed to uncomplete task'
);
},
async moveTask(id: string, projectId: string | null) {
error = null;
try {
const updated = await taskCollection.update(id, { projectId } as Partial<LocalTask>);
if (updated) {
return toTask(updated);
}
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to move task';
console.error('Failed to move task:', e);
throw e;
}
return withErrorHandling(
setError,
async () => {
const updated = await taskCollection.update(id, { projectId } as Partial<LocalTask>);
if (updated) {
return toTask(updated);
}
},
'Failed to move task'
);
},
async updateLabels(id: string, labelIds: string[]) {
error = null;
try {
const updated = await taskCollection.update(id, {
metadata: { labelIds },
} as Partial<LocalTask>);
if (updated) {
return toTask(updated);
}
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to update labels';
console.error('Failed to update labels:', e);
throw e;
}
return withErrorHandling(
setError,
async () => {
const updated = await taskCollection.update(id, {
metadata: { labelIds },
} as Partial<LocalTask>);
if (updated) {
return toTask(updated);
}
},
'Failed to update labels'
);
},
async updateSubtasks(id: string, subtasks: Subtask[]) {
error = null;
try {
const updated = await taskCollection.update(id, { subtasks } as Partial<LocalTask>);
if (updated) {
return toTask(updated);
}
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to update subtasks';
console.error('Failed to update subtasks:', e);
throw e;
}
return withErrorHandling(
setError,
async () => {
const updated = await taskCollection.update(id, { subtasks } as Partial<LocalTask>);
if (updated) {
return toTask(updated);
}
},
'Failed to update subtasks'
);
},
async reorderTasks(taskIds: string[]) {
error = null;
try {
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);
}
return withErrorHandling(
setError,
async () => {
for (let i = 0; i < taskIds.length; i++) {
await taskCollection.update(taskIds[i], { order: i } as Partial<LocalTask>);
}
},
'Failed to reorder tasks',
{ rethrow: false }
);
},
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 });
}