mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:21:09 +02:00
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:
parent
74ff066050
commit
101f20ec34
13 changed files with 438 additions and 371 deletions
|
|
@ -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() {
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
73
apps/calendar/apps/web/src/lib/utils/drag-helpers.ts
Normal file
73
apps/calendar/apps/web/src/lib/utils/drag-helpers.ts
Normal 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);
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
37
apps/todo/apps/web/src/lib/constants/priority.ts
Normal file
37
apps/todo/apps/web/src/lib/constants/priority.ts
Normal 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' },
|
||||||
|
];
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
30
apps/todo/apps/web/src/lib/stores/store-helpers.ts
Normal file
30
apps/todo/apps/web/src/lib/stores/store-helpers.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
12
apps/todo/apps/web/src/lib/utils/date-display.ts
Normal file
12
apps/todo/apps/web/src/lib/utils/date-display.ts
Normal 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 });
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue