diff --git a/.env.development b/.env.development index 484e7d955..f2c0b8483 100644 --- a/.env.development +++ b/.env.development @@ -228,6 +228,7 @@ CLOCK_DATABASE_URL=postgresql://manacore:devpassword@localhost:5432/clock # ============================================ TODO_BACKEND_PORT=3018 +TODO_BACKEND_URL=http://localhost:3018 TODO_DATABASE_URL=postgresql://manacore:devpassword@localhost:5432/todo # ============================================ diff --git a/apps/calendar/apps/web/package.json b/apps/calendar/apps/web/package.json index 3c8e29479..535a04307 100644 --- a/apps/calendar/apps/web/package.json +++ b/apps/calendar/apps/web/package.json @@ -31,7 +31,6 @@ "dependencies": { "@calendar/shared": "workspace:*", "@manacore/shared-auth": "workspace:*", - "@manacore/shared-tags": "workspace:*", "@manacore/shared-auth-ui": "workspace:*", "@manacore/shared-branding": "workspace:*", "@manacore/shared-feedback-service": "workspace:*", @@ -40,13 +39,16 @@ "@manacore/shared-icons": "workspace:*", "@manacore/shared-profile-ui": "workspace:*", "@manacore/shared-subscription-ui": "workspace:*", + "@manacore/shared-tags": "workspace:*", "@manacore/shared-tailwind": "workspace:*", "@manacore/shared-theme": "workspace:*", "@manacore/shared-theme-ui": "workspace:*", "@manacore/shared-ui": "workspace:*", + "@manacore/shared-utils": "workspace:*", "@neodrag/svelte": "^2.3.3", "d3-force": "^3.0.0", "date-fns": "^4.1.0", + "lucide-svelte": "^0.559.0", "svelte-dnd-action": "^0.9.68", "svelte-i18n": "^4.0.1" }, diff --git a/apps/calendar/apps/web/src/lib/api/todos.ts b/apps/calendar/apps/web/src/lib/api/todos.ts new file mode 100644 index 000000000..fa649d829 --- /dev/null +++ b/apps/calendar/apps/web/src/lib/api/todos.ts @@ -0,0 +1,370 @@ +/** + * Cross-App API Client for Todo Backend + * Allows Calendar app to fetch/manage todos from the Todo service + */ + +import { browser } from '$app/environment'; +import { env } from '$env/dynamic/public'; + +const TODO_API_BASE = env.PUBLIC_TODO_BACKEND_URL || 'http://localhost:3018'; + +// ============================================ +// Types (mirrored from @todo/shared for cross-app use) +// ============================================ + +export type TaskPriority = 'low' | 'medium' | 'high' | 'urgent'; +export type TaskStatus = 'pending' | 'in_progress' | 'completed' | 'cancelled'; + +export interface Subtask { + id: string; + title: string; + isCompleted: boolean; + completedAt?: string | null; + order: number; +} + +export interface Label { + id: string; + userId: string; + name: string; + color: string; + createdAt: string; + updatedAt: string; +} + +export interface Project { + id: string; + userId: string; + name: string; + description?: string | null; + color: string; + icon?: string | null; + order: number; + isArchived: boolean; + isDefault: boolean; + createdAt: string; + updatedAt: string; +} + +export interface TaskMetadata { + notes?: string; + attachments?: string[]; + linkedCalendarEventId?: string | null; + storyPoints?: number | null; + effectiveDuration?: { + value: number; + unit: 'minutes' | 'hours' | 'days'; + } | null; + funRating?: number | null; +} + +export interface Task { + id: string; + projectId?: string | null; + userId: string; + parentTaskId?: string | null; + title: string; + description?: string | null; + dueDate?: string | null; + dueTime?: string | null; + startDate?: string | null; + priority: TaskPriority; + status: TaskStatus; + isCompleted: boolean; + completedAt?: string | null; + order: number; + columnId?: string | null; + columnOrder?: number; + recurrenceRule?: string | null; + recurrenceEndDate?: string | null; + lastOccurrence?: string | null; + subtasks?: Subtask[] | null; + metadata?: TaskMetadata | null; + labels?: Label[]; + project?: Project | null; + createdAt: string; + updatedAt: string; +} + +export interface CreateTaskInput { + title: string; + description?: string; + projectId?: string | null; + dueDate?: string | null; + dueTime?: string | null; + priority?: TaskPriority; + labelIds?: string[]; + subtasks?: Omit[]; + recurrenceRule?: string | null; + metadata?: TaskMetadata; +} + +export interface UpdateTaskInput { + title?: string; + description?: string | null; + projectId?: string | null; + dueDate?: string | null; + dueTime?: string | null; + priority?: TaskPriority; + status?: TaskStatus; + isCompleted?: boolean; + subtasks?: Subtask[] | null; + recurrenceRule?: string | null; + metadata?: TaskMetadata | null; + labelIds?: string[]; +} + +export interface TaskQuery { + projectId?: string; + labelId?: string; + priority?: TaskPriority; + status?: TaskStatus; + isCompleted?: boolean; + dueDateFrom?: string; + dueDateTo?: string; + search?: string; + sortBy?: 'dueDate' | 'priority' | 'createdAt' | 'order'; + sortOrder?: 'asc' | 'desc'; + limit?: number; + offset?: number; +} + +// ============================================ +// API Response Types +// ============================================ + +interface TasksResponse { + tasks: Task[]; +} + +interface TaskResponse { + task: Task; +} + +interface ProjectsResponse { + projects: Project[]; +} + +interface LabelsResponse { + labels: Label[]; +} + +// ============================================ +// API Client +// ============================================ + +type FetchOptions = { + method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; + body?: unknown; + token?: string; +}; + +async function fetchTodoApi( + endpoint: string, + options: FetchOptions = {} +): Promise<{ data: T | null; error: Error | null }> { + const { method = 'GET', body, token } = options; + + let authToken = token; + if (!authToken && browser) { + authToken = localStorage.getItem('@auth/appToken') || undefined; + } + + try { + const headers: Record = { + 'Content-Type': 'application/json', + }; + + if (authToken) { + headers['Authorization'] = `Bearer ${authToken}`; + } + + const response = await fetch(`${TODO_API_BASE}/api/v1${endpoint}`, { + method, + headers, + body: body ? JSON.stringify(body) : undefined, + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + return { + data: null, + error: new Error(errorData.message || `Todo API error: ${response.status}`), + }; + } + + // Handle empty responses (204 No Content) + if (response.status === 204) { + return { data: null, error: null }; + } + + const data = await response.json(); + return { data, error: null }; + } catch (error) { + return { + data: null, + error: error instanceof Error ? error : new Error('Failed to connect to Todo service'), + }; + } +} + +// ============================================ +// Helper Functions +// ============================================ + +function buildQueryString(query: TaskQuery): string { + const params = new URLSearchParams(); + Object.entries(query).forEach(([key, value]) => { + if (value !== undefined && value !== null) { + params.append(key, String(value)); + } + }); + const queryString = params.toString(); + return queryString ? `?${queryString}` : ''; +} + +// ============================================ +// Task API Functions +// ============================================ + +export async function getTasks( + query: TaskQuery = {} +): Promise<{ data: Task[] | null; error: Error | null }> { + const queryString = buildQueryString(query); + const result = await fetchTodoApi(`/tasks${queryString}`); + return { + data: result.data?.tasks || null, + error: result.error, + }; +} + +export async function getTask(id: string): Promise<{ data: Task | null; error: Error | null }> { + const result = await fetchTodoApi(`/tasks/${id}`); + return { + data: result.data?.task || null, + error: result.error, + }; +} + +export async function createTask( + data: CreateTaskInput +): Promise<{ data: Task | null; error: Error | null }> { + const result = await fetchTodoApi('/tasks', { + method: 'POST', + body: data, + }); + return { + data: result.data?.task || null, + error: result.error, + }; +} + +export async function updateTask( + id: string, + data: UpdateTaskInput +): Promise<{ data: Task | null; error: Error | null }> { + const result = await fetchTodoApi(`/tasks/${id}`, { + method: 'PUT', + body: data, + }); + return { + data: result.data?.task || null, + error: result.error, + }; +} + +export async function deleteTask(id: string): Promise<{ error: Error | null }> { + const result = await fetchTodoApi(`/tasks/${id}`, { + method: 'DELETE', + }); + return { error: result.error }; +} + +export async function completeTask( + id: string +): Promise<{ data: Task | null; error: Error | null }> { + const result = await fetchTodoApi(`/tasks/${id}/complete`, { + method: 'POST', + }); + return { + data: result.data?.task || null, + error: result.error, + }; +} + +export async function uncompleteTask( + id: string +): Promise<{ data: Task | null; error: Error | null }> { + const result = await fetchTodoApi(`/tasks/${id}/uncomplete`, { + method: 'POST', + }); + return { + data: result.data?.task || null, + error: result.error, + }; +} + +export async function getTodayTasks(): Promise<{ data: Task[] | null; error: Error | null }> { + const result = await fetchTodoApi('/tasks/today'); + return { + data: result.data?.tasks || null, + error: result.error, + }; +} + +export async function getUpcomingTasks(): Promise<{ data: Task[] | null; error: Error | null }> { + const result = await fetchTodoApi('/tasks/upcoming'); + return { + data: result.data?.tasks || null, + error: result.error, + }; +} + +// ============================================ +// Project API Functions +// ============================================ + +export async function getProjects(): Promise<{ data: Project[] | null; error: Error | null }> { + const result = await fetchTodoApi('/projects'); + return { + data: result.data?.projects || null, + error: result.error, + }; +} + +// ============================================ +// Label API Functions +// ============================================ + +export async function getLabels(): Promise<{ data: Label[] | null; error: Error | null }> { + const result = await fetchTodoApi('/labels'); + return { + data: result.data?.labels || null, + error: result.error, + }; +} + +// ============================================ +// Priority Colors Helper +// ============================================ + +export const PRIORITY_COLORS: Record = { + urgent: 'hsl(var(--color-danger))', + high: 'hsl(var(--color-warning))', + medium: 'hsl(var(--color-accent))', + low: 'hsl(var(--color-success))', +}; + +export const PRIORITY_LABELS: Record = { + urgent: 'Dringend', + high: 'Wichtig', + medium: 'Normal', + low: 'Später', +}; + +export const PRIORITY_ORDER: Record = { + urgent: 0, + high: 1, + medium: 2, + low: 3, +}; diff --git a/apps/calendar/apps/web/src/lib/components/agenda/AgendaFilters.svelte b/apps/calendar/apps/web/src/lib/components/agenda/AgendaFilters.svelte new file mode 100644 index 000000000..15500781d --- /dev/null +++ b/apps/calendar/apps/web/src/lib/components/agenda/AgendaFilters.svelte @@ -0,0 +1,151 @@ + + +
+
+ + +
+ +
+
+ + +
+
+
+ + diff --git a/apps/calendar/apps/web/src/lib/components/agenda/AgendaItem.svelte b/apps/calendar/apps/web/src/lib/components/agenda/AgendaItem.svelte new file mode 100644 index 000000000..ce0620e0f --- /dev/null +++ b/apps/calendar/apps/web/src/lib/components/agenda/AgendaItem.svelte @@ -0,0 +1,217 @@ + + +{#if type === 'event' && event} + +{:else if type === 'todo' && todo} +
+
+ +
+ +
+{/if} + + diff --git a/apps/calendar/apps/web/src/lib/components/calendar/DayView.svelte b/apps/calendar/apps/web/src/lib/components/calendar/DayView.svelte index c0cca55b2..aaf62dd22 100644 --- a/apps/calendar/apps/web/src/lib/components/calendar/DayView.svelte +++ b/apps/calendar/apps/web/src/lib/components/calendar/DayView.svelte @@ -3,6 +3,8 @@ import { eventsStore } from '$lib/stores/events.svelte'; import { calendarsStore } from '$lib/stores/calendars.svelte'; import { settingsStore } from '$lib/stores/settings.svelte'; + import { todosStore } from '$lib/stores/todos.svelte'; + import TodoRow from './TodoRow.svelte'; import { goto } from '$app/navigation'; import { format, @@ -405,6 +407,16 @@ {/if} + + {#if todosStore.serviceAvailable && todosStore.getTodosForDay(viewStore.currentDate).length > 0} +
+
+
+ +
+
+ {/if} +
@@ -533,6 +545,16 @@ cursor: pointer; } + /* Todos section */ + .todos-section { + display: flex; + border-bottom: 1px solid hsl(var(--color-border) / 0.5); + } + + .todos-content { + flex: 1; + } + /* Block-style all-day events (displayed as full-day blocks in the grid) */ .all-day-block-event { position: absolute; diff --git a/apps/calendar/apps/web/src/lib/components/calendar/MonthView.svelte b/apps/calendar/apps/web/src/lib/components/calendar/MonthView.svelte index 1227fdccf..a8b53381d 100644 --- a/apps/calendar/apps/web/src/lib/components/calendar/MonthView.svelte +++ b/apps/calendar/apps/web/src/lib/components/calendar/MonthView.svelte @@ -3,6 +3,8 @@ import { eventsStore } from '$lib/stores/events.svelte'; import { calendarsStore } from '$lib/stores/calendars.svelte'; import { settingsStore } from '$lib/stores/settings.svelte'; + import { todosStore } from '$lib/stores/todos.svelte'; + import TodoDayCell from './TodoDayCell.svelte'; import { goto } from '$app/navigation'; import { format, @@ -265,6 +267,11 @@ {format(day, 'd')} + + {#if todosStore.serviceAvailable} + + {/if} +
{#each getEventsForDay(day) as event} {@const isBeingDragged = isDragging && draggedEvent?.id === event.id} diff --git a/apps/calendar/apps/web/src/lib/components/calendar/TodoDayCell.svelte b/apps/calendar/apps/web/src/lib/components/calendar/TodoDayCell.svelte new file mode 100644 index 000000000..d2e0f44fa --- /dev/null +++ b/apps/calendar/apps/web/src/lib/components/calendar/TodoDayCell.svelte @@ -0,0 +1,121 @@ + + +{#if todosForDay.length > 0} +
+ {#each visibleTodos as task (task.id)} + + {/each} + + {#if overflowCount > 0} + +{overflowCount} Aufgaben + {/if} +
+{/if} + + +{#if selectedTask} + +{/if} + + diff --git a/apps/calendar/apps/web/src/lib/components/calendar/TodoRow.svelte b/apps/calendar/apps/web/src/lib/components/calendar/TodoRow.svelte new file mode 100644 index 000000000..e18440e09 --- /dev/null +++ b/apps/calendar/apps/web/src/lib/components/calendar/TodoRow.svelte @@ -0,0 +1,169 @@ + + +{#if todosForDay.length > 0} +
+ Aufgaben: +
+ {#each visibleTodos as task (task.id)} + + + {/each} + + {#if overflowCount > 0} + + {/if} +
+
+{/if} + + +{#if selectedTask} + +{/if} + + diff --git a/apps/calendar/apps/web/src/lib/components/calendar/TodoSidebarSection.svelte b/apps/calendar/apps/web/src/lib/components/calendar/TodoSidebarSection.svelte new file mode 100644 index 000000000..2d2f28899 --- /dev/null +++ b/apps/calendar/apps/web/src/lib/components/calendar/TodoSidebarSection.svelte @@ -0,0 +1,292 @@ + + +
+ + + + + + {#if isExpanded} +
+ {#if !todosStore.serviceAvailable} +
+ + Todo-Service nicht erreichbar +
+ {:else if todosStore.loading} +
+
+ Laden... +
+ {:else if displayTodos.length === 0} +
+ + Keine offenen Aufgaben +
+ {:else} +
+ {#each displayTodos as task (task.id)} + handleTaskClick(task)} + /> + {/each} +
+ + {#if totalActiveCount > maxItems} + + {/if} + {/if} + + + {#if showQuickAdd} +
+ +
+ {/if} +
+ {/if} +
+ + +{#if selectedTask} + +{/if} + + diff --git a/apps/calendar/apps/web/src/lib/components/calendar/WeekView.svelte b/apps/calendar/apps/web/src/lib/components/calendar/WeekView.svelte index ed05c57a1..4b21db563 100644 --- a/apps/calendar/apps/web/src/lib/components/calendar/WeekView.svelte +++ b/apps/calendar/apps/web/src/lib/components/calendar/WeekView.svelte @@ -3,6 +3,8 @@ import { eventsStore } from '$lib/stores/events.svelte'; import { calendarsStore } from '$lib/stores/calendars.svelte'; import { settingsStore } from '$lib/stores/settings.svelte'; + import { todosStore } from '$lib/stores/todos.svelte'; + import TodoRow from './TodoRow.svelte'; import { goto } from '$app/navigation'; import { format, @@ -499,6 +501,18 @@
{/if} + + {#if todosStore.serviceAvailable} +
+
+ {#each days as day} +
+ +
+ {/each} +
+ {/if} +
@@ -651,6 +665,18 @@ cursor: pointer; } + /* Todos row */ + .todos-row { + display: flex; + border-bottom: 1px solid hsl(var(--color-border) / 0.5); + } + + .todos-cell { + flex: 1; + border-left: 1px solid hsl(var(--color-border)); + min-height: 0; + } + /* Block-style all-day events (displayed as full-day blocks in the grid) */ .all-day-block-event { position: absolute; diff --git a/apps/calendar/apps/web/src/lib/components/todo/PriorityBadge.svelte b/apps/calendar/apps/web/src/lib/components/todo/PriorityBadge.svelte new file mode 100644 index 000000000..2ad0693ec --- /dev/null +++ b/apps/calendar/apps/web/src/lib/components/todo/PriorityBadge.svelte @@ -0,0 +1,124 @@ + + +{#if variant === 'dot'} + +{:else if variant === 'badge'} + + {#if showLabel} + {label} + {:else} + {priority.charAt(0).toUpperCase()} + {/if} + +{:else if variant === 'pill'} + + + {#if showLabel} + {label} + {/if} + +{/if} + + diff --git a/apps/calendar/apps/web/src/lib/components/todo/QuickAddTodo.svelte b/apps/calendar/apps/web/src/lib/components/todo/QuickAddTodo.svelte new file mode 100644 index 000000000..573e3794a --- /dev/null +++ b/apps/calendar/apps/web/src/lib/components/todo/QuickAddTodo.svelte @@ -0,0 +1,226 @@ + + +{#if showButton && !isExpanded} + +{:else} +
+ + + {#if showButton} + + {/if} + + +
+{/if} + + diff --git a/apps/calendar/apps/web/src/lib/components/todo/TodoCheckbox.svelte b/apps/calendar/apps/web/src/lib/components/todo/TodoCheckbox.svelte new file mode 100644 index 000000000..640043528 --- /dev/null +++ b/apps/calendar/apps/web/src/lib/components/todo/TodoCheckbox.svelte @@ -0,0 +1,130 @@ + + + + + diff --git a/apps/calendar/apps/web/src/lib/components/todo/TodoDetailModal.svelte b/apps/calendar/apps/web/src/lib/components/todo/TodoDetailModal.svelte new file mode 100644 index 000000000..59b0e6ddb --- /dev/null +++ b/apps/calendar/apps/web/src/lib/components/todo/TodoDetailModal.svelte @@ -0,0 +1,625 @@ + + + + + + + + diff --git a/apps/calendar/apps/web/src/lib/components/todo/TodoItem.svelte b/apps/calendar/apps/web/src/lib/components/todo/TodoItem.svelte new file mode 100644 index 000000000..0a24b386d --- /dev/null +++ b/apps/calendar/apps/web/src/lib/components/todo/TodoItem.svelte @@ -0,0 +1,287 @@ + + +
+ + +
+
+ {#if showPriority && variant !== 'minimal'} + + {/if} + + {task.title} + + {#if subtaskProgress && variant === 'default'} + + {subtaskProgress.completed}/{subtaskProgress.total} + + {/if} +
+ + {#if variant !== 'minimal'} +
+ {#if showDueDate && dueDateLabel} + + {dueDateLabel} + + {/if} + + {#if showProject && task.project} + + {task.project.name} + + {/if} + + {#if task.labels && task.labels.length > 0 && variant === 'default'} +
+ {#each task.labels.slice(0, 2) as label} + + {label.name} + + {/each} + {#if task.labels.length > 2} + +{task.labels.length - 2} + {/if} +
+ {/if} +
+ {/if} +
+
+ + diff --git a/apps/calendar/apps/web/src/lib/stores/statistics.svelte.ts b/apps/calendar/apps/web/src/lib/stores/statistics.svelte.ts new file mode 100644 index 000000000..20f739c87 --- /dev/null +++ b/apps/calendar/apps/web/src/lib/stores/statistics.svelte.ts @@ -0,0 +1,270 @@ +/** + * Calendar Statistics Store - Calculates calendar statistics using Svelte 5 runes + */ + +import type { CalendarEvent, Calendar } from '@calendar/shared'; +import { + startOfDay, + startOfWeek, + endOfWeek, + subDays, + format, + differenceInMinutes, + isToday, + isSameWeek, + parseISO, + eachDayOfInterval, + addDays, +} from 'date-fns'; +import { de } from 'date-fns/locale'; +import type { + HeatmapDataPoint, + TrendDataPoint, + DonutSegment, + ProgressItem, +} from '@manacore/shared-ui'; + +// Types +export interface EventStatusBreakdown { + status: 'confirmed' | 'tentative' | 'cancelled'; + count: number; + percentage: number; + color: string; +} + +const STATUS_COLORS: Record = { + confirmed: '#10B981', // green + tentative: '#F59E0B', // orange + cancelled: '#EF4444', // red +}; + +const STATUS_LABELS: Record = { + confirmed: 'Bestätigt', + tentative: 'Vorläufig', + cancelled: 'Abgesagt', +}; + +// State +let events = $state([]); +let calendars = $state([]); + +export const calendarStatisticsStore = { + // Setters + setEvents(newEvents: CalendarEvent[]) { + events = newEvents; + }, + + setCalendars(newCalendars: Calendar[]) { + calendars = newCalendars; + }, + + // Quick Stats + get totalEvents() { + return events.length; + }, + + get eventsToday() { + return events.filter((e) => { + const startTime = typeof e.startTime === 'string' ? parseISO(e.startTime) : e.startTime; + return isToday(startTime); + }).length; + }, + + get eventsThisWeek() { + const now = new Date(); + return events.filter((e) => { + const startTime = typeof e.startTime === 'string' ? parseISO(e.startTime) : e.startTime; + return isSameWeek(startTime, now, { weekStartsOn: 1 }); + }).length; + }, + + get upcomingEvents() { + const now = new Date(); + const nextWeek = addDays(now, 7); + return events.filter((e) => { + const startTime = typeof e.startTime === 'string' ? parseISO(e.startTime) : e.startTime; + return startTime > now && startTime <= nextWeek; + }).length; + }, + + get busyHoursThisWeek() { + const weekStart = startOfWeek(new Date(), { weekStartsOn: 1 }); + const weekEnd = endOfWeek(new Date(), { weekStartsOn: 1 }); + + let totalMinutes = 0; + + events.forEach((e) => { + if (e.isAllDay) return; // Skip all-day events + + const startTime = typeof e.startTime === 'string' ? parseISO(e.startTime) : e.startTime; + const endTime = typeof e.endTime === 'string' ? parseISO(e.endTime) : e.endTime; + + if (startTime >= weekStart && startTime <= weekEnd) { + totalMinutes += differenceInMinutes(endTime, startTime); + } + }); + + return Math.round((totalMinutes / 60) * 10) / 10; // Round to 1 decimal + }, + + get totalCalendars() { + return calendars.length; + }, + + get averageEventDuration() { + const timedEvents = events.filter((e) => !e.isAllDay); + if (timedEvents.length === 0) return 0; + + const totalMinutes = timedEvents.reduce((sum, e) => { + const startTime = typeof e.startTime === 'string' ? parseISO(e.startTime) : e.startTime; + const endTime = typeof e.endTime === 'string' ? parseISO(e.endTime) : e.endTime; + return sum + differenceInMinutes(endTime, startTime); + }, 0); + + return Math.round(totalMinutes / timedEvents.length); + }, + + // Activity Heatmap (last 6 months) - based on event creation + get activityHeatmap(): HeatmapDataPoint[] { + const endDate = new Date(); + const startDate = subDays(endDate, 180); + + // Count events per day based on start time + const eventMap = new Map(); + + events.forEach((e) => { + const startTime = typeof e.startTime === 'string' ? parseISO(e.startTime) : e.startTime; + const dateKey = format(startTime, 'yyyy-MM-dd'); + eventMap.set(dateKey, (eventMap.get(dateKey) || 0) + 1); + }); + + // Generate all days + const days = eachDayOfInterval({ start: startDate, end: endDate }); + + return days.map((day) => { + const dateKey = format(day, 'yyyy-MM-dd'); + return { + date: dateKey, + count: eventMap.get(dateKey) || 0, + dayOfWeek: day.getDay(), + }; + }); + }, + + // Weekly Trend (last 4 weeks) + get weeklyTrend(): TrendDataPoint[] { + const endDate = new Date(); + const startDate = subDays(endDate, 27); + + const eventMap = new Map(); + + events.forEach((e) => { + const startTime = typeof e.startTime === 'string' ? parseISO(e.startTime) : e.startTime; + if (startTime >= startDate && startTime <= endDate) { + const dateKey = format(startTime, 'yyyy-MM-dd'); + eventMap.set(dateKey, (eventMap.get(dateKey) || 0) + 1); + } + }); + + const days = eachDayOfInterval({ start: startDate, end: endDate }); + + return days.map((day) => { + const dateKey = format(day, 'yyyy-MM-dd'); + return { + date: dateKey, + count: eventMap.get(dateKey) || 0, + label: format(day, 'EEE', { locale: de }), + }; + }); + }, + + // Status Breakdown (Donut Chart) + get statusBreakdown(): DonutSegment[] { + const total = events.length; + if (total === 0) return []; + + const counts: Record = { + confirmed: 0, + tentative: 0, + cancelled: 0, + }; + + events.forEach((e) => { + const status = e.status || 'confirmed'; + if (counts[status] !== undefined) { + counts[status]++; + } + }); + + return (['confirmed', 'tentative', 'cancelled'] as const).map((status) => ({ + id: status, + label: STATUS_LABELS[status], + count: counts[status], + percentage: total > 0 ? Math.round((counts[status] / total) * 100) : 0, + color: STATUS_COLORS[status], + })); + }, + + // Calendar Activity (Progress Bars) + get calendarActivity(): ProgressItem[] { + const calendarMap = new Map(); + + // Initialize with all calendars + calendars.forEach((c) => { + calendarMap.set(c.id, { total: 0, thisWeek: 0 }); + }); + + const now = new Date(); + + // Count events per calendar + events.forEach((e) => { + const calendarId = e.calendarId; + const data = calendarMap.get(calendarId) || { total: 0, thisWeek: 0 }; + data.total++; + + const startTime = typeof e.startTime === 'string' ? parseISO(e.startTime) : e.startTime; + if (isSameWeek(startTime, now, { weekStartsOn: 1 })) { + data.thisWeek++; + } + + calendarMap.set(calendarId, data); + }); + + // Convert to array + const result: ProgressItem[] = []; + + calendarMap.forEach((data, calendarId) => { + if (data.total === 0) return; + + const calendar = calendars.find((c) => c.id === calendarId); + + result.push({ + id: calendarId, + name: calendar?.name || 'Unbekannt', + color: calendar?.color || '#6B7280', + total: data.total, + completed: data.thisWeek, + percentage: data.total > 0 ? Math.round((data.thisWeek / data.total) * 100) : 0, + }); + }); + + // Sort by total events descending + return result.sort((a, b) => b.total - a.total); + }, + + // All-day vs Timed events ratio + get allDayRatio() { + const allDay = events.filter((e) => e.isAllDay).length; + const timed = events.filter((e) => !e.isAllDay).length; + return { + allDay, + timed, + allDayPercentage: events.length > 0 ? Math.round((allDay / events.length) * 100) : 0, + }; + }, + + // Recurring events count + get recurringEventsCount() { + return events.filter((e) => e.recurrenceRule).length; + }, +}; diff --git a/apps/calendar/apps/web/src/lib/stores/todos.svelte.ts b/apps/calendar/apps/web/src/lib/stores/todos.svelte.ts new file mode 100644 index 000000000..1311d0971 --- /dev/null +++ b/apps/calendar/apps/web/src/lib/stores/todos.svelte.ts @@ -0,0 +1,405 @@ +/** + * Todos Store - Manages todos from Todo-App using Svelte 5 runes + * Cross-app integration with Todo Backend + */ + +import * as api from '$lib/api/todos'; +import type { + Task, + TaskPriority, + CreateTaskInput, + UpdateTaskInput, + TaskQuery, + Project, + Label, +} from '$lib/api/todos'; +import { PRIORITY_ORDER } from '$lib/api/todos'; +import { + format, + parseISO, + isSameDay, + isToday, + isBefore, + startOfDay, + addDays, + isWithinInterval, +} from 'date-fns'; + +// Re-export types for convenience +export type { Task, TaskPriority, CreateTaskInput, UpdateTaskInput, Project, Label }; + +// State +let todos = $state([]); +let projects = $state([]); +let labels = $state([]); +let loading = $state(false); +let error = $state(null); +let loadedRange = $state<{ start: Date; end: Date } | null>(null); +let serviceAvailable = $state(true); + +export const todosStore = { + // ========== Getters ========== + get todos() { + return todos ?? []; + }, + get projects() { + return projects ?? []; + }, + get labels() { + return labels ?? []; + }, + get loading() { + return loading; + }, + get error() { + return error; + }, + get serviceAvailable() { + return serviceAvailable; + }, + + // ========== Derived Getters ========== + + /** + * Get todos for a specific day + */ + getTodosForDay(date: Date): Task[] { + const currentTodos = todos ?? []; + if (!Array.isArray(currentTodos)) return []; + + return currentTodos.filter((task) => { + if (!task.dueDate || task.isCompleted) return false; + const dueDate = typeof task.dueDate === 'string' ? parseISO(task.dueDate) : task.dueDate; + return isSameDay(dueDate, date); + }); + }, + + /** + * Get todos within a date range + */ + getTodosInRange(start: Date, end: Date): Task[] { + const currentTodos = todos ?? []; + if (!Array.isArray(currentTodos)) return []; + + return currentTodos.filter((task) => { + if (!task.dueDate) return false; + const dueDate = typeof task.dueDate === 'string' ? parseISO(task.dueDate) : task.dueDate; + return isWithinInterval(dueDate, { start, end }); + }); + }, + + /** + * Get today's uncompleted todos + */ + get todaysTodos(): Task[] { + const currentTodos = todos ?? []; + if (!Array.isArray(currentTodos)) return []; + + return currentTodos + .filter((task) => { + if (task.isCompleted) return false; + if (!task.dueDate) return false; + const dueDate = typeof task.dueDate === 'string' ? parseISO(task.dueDate) : task.dueDate; + return isToday(dueDate); + }) + .sort((a, b) => PRIORITY_ORDER[a.priority] - PRIORITY_ORDER[b.priority]); + }, + + /** + * Get overdue todos (due before today, not completed) + */ + get overdueTodos(): Task[] { + const currentTodos = todos ?? []; + if (!Array.isArray(currentTodos)) return []; + + const today = startOfDay(new Date()); + + return currentTodos + .filter((task) => { + if (task.isCompleted) return false; + if (!task.dueDate) return false; + const dueDate = typeof task.dueDate === 'string' ? parseISO(task.dueDate) : task.dueDate; + return isBefore(startOfDay(dueDate), today); + }) + .sort((a, b) => PRIORITY_ORDER[a.priority] - PRIORITY_ORDER[b.priority]); + }, + + /** + * Get upcoming todos (next 7 days, not including today) + */ + get upcomingTodos(): Task[] { + const currentTodos = todos ?? []; + if (!Array.isArray(currentTodos)) return []; + + const tomorrow = startOfDay(addDays(new Date(), 1)); + const weekFromNow = startOfDay(addDays(new Date(), 7)); + + return currentTodos + .filter((task) => { + if (task.isCompleted) return false; + if (!task.dueDate) return false; + const dueDate = typeof task.dueDate === 'string' ? parseISO(task.dueDate) : task.dueDate; + return isWithinInterval(startOfDay(dueDate), { start: tomorrow, end: weekFromNow }); + }) + .sort((a, b) => { + // First sort by date + const dateA = a.dueDate ? parseISO(a.dueDate as string) : new Date(); + const dateB = b.dueDate ? parseISO(b.dueDate as string) : new Date(); + const dateDiff = dateA.getTime() - dateB.getTime(); + if (dateDiff !== 0) return dateDiff; + // Then by priority + return PRIORITY_ORDER[a.priority] - PRIORITY_ORDER[b.priority]; + }); + }, + + /** + * Get todos without due date + */ + get unscheduledTodos(): Task[] { + const currentTodos = todos ?? []; + if (!Array.isArray(currentTodos)) return []; + + return currentTodos + .filter((task) => !task.isCompleted && !task.dueDate) + .sort((a, b) => PRIORITY_ORDER[a.priority] - PRIORITY_ORDER[b.priority]); + }, + + /** + * Get completed todos + */ + get completedTodos(): Task[] { + const currentTodos = todos ?? []; + if (!Array.isArray(currentTodos)) return []; + + return currentTodos.filter((task) => task.isCompleted); + }, + + /** + * Get combined sidebar todos (overdue + today, sorted by priority) + * Limited to show in sidebar + */ + getSidebarTodos(limit = 5): Task[] { + const overdue = this.overdueTodos; + const today = this.todaysTodos; + + // Combine and sort: overdue first, then today, both by priority + const combined = [...overdue, ...today]; + + return combined.slice(0, limit); + }, + + /** + * Get total count of active todos (not completed) + */ + get activeTodosCount(): number { + const currentTodos = todos ?? []; + if (!Array.isArray(currentTodos)) return 0; + + return currentTodos.filter((task) => !task.isCompleted).length; + }, + + // ========== API Methods ========== + + /** + * Fetch todos for a date range + */ + async fetchTodos(startDate?: Date, endDate?: Date) { + loading = true; + error = null; + + const query: TaskQuery = { + isCompleted: false, + }; + + if (startDate) { + query.dueDateFrom = format(startDate, 'yyyy-MM-dd'); + } + if (endDate) { + query.dueDateTo = format(endDate, 'yyyy-MM-dd'); + } + + const result = await api.getTasks(query); + + if (result.error) { + error = result.error.message; + serviceAvailable = false; + } else { + todos = result.data || []; + serviceAvailable = true; + if (startDate && endDate) { + loadedRange = { start: startDate, end: endDate }; + } + } + + loading = false; + return result; + }, + + /** + * Fetch today's todos (shortcut) + */ + async fetchTodayTodos() { + loading = true; + error = null; + + const result = await api.getTodayTasks(); + + if (result.error) { + error = result.error.message; + serviceAvailable = false; + } else { + // Merge with existing todos (avoid duplicates) + const newTodos = result.data || []; + const existingIds = new Set(todos.map((t) => t.id)); + const uniqueNew = newTodos.filter((t) => !existingIds.has(t.id)); + todos = [...todos, ...uniqueNew]; + serviceAvailable = true; + } + + loading = false; + return result; + }, + + /** + * Fetch upcoming todos (shortcut) + */ + async fetchUpcomingTodos() { + loading = true; + error = null; + + const result = await api.getUpcomingTasks(); + + if (result.error) { + error = result.error.message; + serviceAvailable = false; + } else { + // Merge with existing todos (avoid duplicates) + const newTodos = result.data || []; + const existingIds = new Set(todos.map((t) => t.id)); + const uniqueNew = newTodos.filter((t) => !existingIds.has(t.id)); + todos = [...todos, ...uniqueNew]; + serviceAvailable = true; + } + + loading = false; + return result; + }, + + /** + * Fetch projects + */ + async fetchProjects() { + const result = await api.getProjects(); + + if (!result.error && result.data) { + projects = result.data; + } + + return result; + }, + + /** + * Fetch labels + */ + async fetchLabels() { + const result = await api.getLabels(); + + if (!result.error && result.data) { + labels = result.data; + } + + return result; + }, + + /** + * Create a new todo + */ + async createTodo(data: CreateTaskInput) { + const result = await api.createTask(data); + + if (result.data) { + todos = [...todos, result.data]; + } + + return result; + }, + + /** + * Update a todo + */ + async updateTodo(id: string, data: UpdateTaskInput) { + const result = await api.updateTask(id, data); + + if (result.data) { + todos = todos.map((t) => (t.id === id ? result.data! : t)); + } + + return result; + }, + + /** + * Delete a todo + */ + async deleteTodo(id: string) { + const result = await api.deleteTask(id); + + if (!result.error) { + todos = todos.filter((t) => t.id !== id); + } + + return result; + }, + + /** + * Toggle todo completion + */ + async toggleComplete(id: string) { + const todo = todos.find((t) => t.id === id); + if (!todo) return { data: null, error: new Error('Todo not found') }; + + const result = todo.isCompleted ? await api.uncompleteTask(id) : await api.completeTask(id); + + if (result.data) { + todos = todos.map((t) => (t.id === id ? result.data! : t)); + } + + return result; + }, + + /** + * Get todo by ID + */ + getById(id: string): Task | undefined { + const currentTodos = todos ?? []; + if (!Array.isArray(currentTodos)) return undefined; + + return currentTodos.find((t) => t.id === id); + }, + + /** + * Get project by ID + */ + getProjectById(id: string): Project | undefined { + const currentProjects = projects ?? []; + if (!Array.isArray(currentProjects)) return undefined; + + return currentProjects.find((p) => p.id === id); + }, + + /** + * Clear todos cache + */ + clear() { + todos = []; + loadedRange = null; + }, + + /** + * Check if Todo service is available + */ + async checkServiceHealth(): Promise { + const result = await api.getTasks({ limit: 1 }); + serviceAvailable = !result.error; + return serviceAvailable; + }, +}; diff --git a/apps/calendar/apps/web/src/lib/utils/event-parser.ts b/apps/calendar/apps/web/src/lib/utils/event-parser.ts new file mode 100644 index 000000000..5a7beab9b --- /dev/null +++ b/apps/calendar/apps/web/src/lib/utils/event-parser.ts @@ -0,0 +1,261 @@ +/** + * Event Parser for Calendar App + * + * Extends the base parser with event-specific patterns: + * - Calendar: @CalendarName + * - Duration: für 2 Stunden, 30 min + * - Location: in Berlin, bei Firma XY + */ + +import { + parseBaseInput, + extractAtReference, + combineDateAndTime, + formatDatePreview, + formatTimePreview, +} from '@manacore/shared-utils'; + +export interface ParsedEvent { + title: string; + startTime?: Date; + endTime?: Date; + calendarName?: string; + location?: string; + tagNames: string[]; + isAllDay: boolean; +} + +interface Calendar { + id: string; + name: string; +} + +interface Tag { + id: string; + name: string; +} + +export interface ParsedEventWithIds { + title: string; + startTime?: string; + endTime?: string; + calendarId?: string; + tagIds: string[]; + location?: string; + isAllDay: boolean; +} + +// Duration patterns (event-specific) +const DURATION_PATTERNS: { pattern: RegExp; getMinutes: (match: RegExpMatchArray) => number }[] = [ + // "für X Stunden" or "X Stunden" + { + pattern: /(?:für\s+)?(\d+(?:[.,]\d+)?)\s*(?:stunde?n?|h)\b/i, + getMinutes: (match) => Math.round(parseFloat(match[1].replace(',', '.')) * 60), + }, + // "für X Minuten" or "X min" + { + pattern: /(?:für\s+)?(\d+)\s*(?:minuten?|min)\b/i, + getMinutes: (match) => parseInt(match[1], 10), + }, + // "1,5h" or "1.5h" + { + pattern: /(\d+[.,]\d+)\s*h\b/i, + getMinutes: (match) => Math.round(parseFloat(match[1].replace(',', '.')) * 60), + }, +]; + +// Location patterns (event-specific) +const LOCATION_PATTERNS: RegExp[] = [ + /\bin\s+([^@#!]+?)(?=\s+(?:@|#|!|\d{1,2}[:.]\d{2}|um\s+\d|\d{1,2}\s*uhr)|$)/i, + /\bbei\s+([^@#!]+?)(?=\s+(?:@|#|!|\d{1,2}[:.]\d{2}|um\s+\d|\d{1,2}\s*uhr)|$)/i, +]; + +/** + * Extract duration from text + */ +function extractDuration(text: string): { minutes?: number; remaining: string } { + for (const { pattern, getMinutes } of DURATION_PATTERNS) { + const match = text.match(pattern); + if (match) { + return { + minutes: getMinutes(match), + remaining: text.replace(pattern, '').trim(), + }; + } + } + return { minutes: undefined, remaining: text }; +} + +/** + * Extract location from text + */ +function extractLocation(text: string): { location?: string; remaining: string } { + for (const pattern of LOCATION_PATTERNS) { + const match = text.match(pattern); + if (match) { + return { + location: match[1].trim(), + remaining: text.replace(pattern, '').trim(), + }; + } + } + return { location: undefined, remaining: text }; +} + +/** + * Parse natural language event input + * + * Examples: + * - "Meeting morgen 14 Uhr für 1 Stunde @Arbeit in Büro #wichtig" + * - "Arzttermin Montag 10:30 30 min bei Dr. Müller" + * - "Geburtstag 15.12. ganztägig #privat" + */ +export function parseEventInput(input: string): ParsedEvent { + let text = input.trim(); + + // Check for all-day indicator first + const allDayPattern = /\bganztägig\b|\ball[- ]?day\b/i; + const isAllDay = allDayPattern.test(text); + text = text.replace(allDayPattern, '').trim(); + + // Extract calendar (@CalendarName) - event-specific + const calendarResult = extractAtReference(text); + text = calendarResult.remaining; + const calendarName = calendarResult.value; + + // Extract duration first (before base parser) + const durationResult = extractDuration(text); + text = durationResult.remaining; + const durationMinutes = durationResult.minutes; + + // Extract location (before base parser to avoid conflicts) + const locationResult = extractLocation(text); + text = locationResult.remaining; + const location = locationResult.location; + + // Use base parser for common patterns (date, time, tags) + const base = parseBaseInput(text); + + // Combine date and time for start + const startTime = combineDateAndTime(base.date, base.time); + + // Calculate end time based on duration (default 1 hour) + let endTime: Date | undefined; + if (startTime && !isAllDay) { + const duration = durationMinutes || 60; // Default 1 hour + endTime = new Date(startTime.getTime() + duration * 60 * 1000); + } else if (startTime && isAllDay) { + // All-day events: end time is end of day + endTime = new Date(startTime); + endTime.setHours(23, 59, 59, 999); + } + + return { + title: base.title, + startTime, + endTime, + calendarName, + location, + tagNames: base.tagNames, + isAllDay, + }; +} + +/** + * Resolve calendar and tag names to IDs + */ +export function resolveEventIds( + parsed: ParsedEvent, + calendars: Calendar[], + tags: Tag[] +): ParsedEventWithIds { + let calendarId: string | undefined; + const tagIds: string[] = []; + + // Find calendar by name (case-insensitive) + if (parsed.calendarName) { + const calendar = calendars.find( + (c) => c.name.toLowerCase() === parsed.calendarName!.toLowerCase() + ); + if (calendar) { + calendarId = calendar.id; + } + } + + // Use default calendar if none specified + if (!calendarId && calendars.length > 0) { + const defaultCalendar = calendars.find((c: any) => c.isDefault) || calendars[0]; + calendarId = defaultCalendar.id; + } + + // Find tags by name (case-insensitive) + for (const tagName of parsed.tagNames) { + const tag = tags.find((t) => t.name.toLowerCase() === tagName.toLowerCase()); + if (tag) { + tagIds.push(tag.id); + } + } + + return { + title: parsed.title, + startTime: parsed.startTime?.toISOString(), + endTime: parsed.endTime?.toISOString(), + calendarId, + tagIds, + location: parsed.location, + isAllDay: parsed.isAllDay, + }; +} + +/** + * Format parsed event for preview display + */ +export function formatParsedEventPreview(parsed: ParsedEvent): string { + const parts: string[] = []; + + if (parsed.startTime) { + let dateStr = `📅 ${formatDatePreview(parsed.startTime)}`; + + if (!parsed.isAllDay && parsed.startTime.getHours() !== 0) { + dateStr += ` ${formatTimePreview({ + hours: parsed.startTime.getHours(), + minutes: parsed.startTime.getMinutes(), + })}`; + + // Add duration if end time differs + if (parsed.endTime) { + const durationMs = parsed.endTime.getTime() - parsed.startTime.getTime(); + const durationMins = Math.round(durationMs / 60000); + if (durationMins > 0 && durationMins !== 60) { + if (durationMins >= 60) { + const hours = Math.floor(durationMins / 60); + const mins = durationMins % 60; + dateStr += mins > 0 ? ` (${hours}h ${mins}min)` : ` (${hours}h)`; + } else { + dateStr += ` (${durationMins}min)`; + } + } + } + } + + if (parsed.isAllDay) { + dateStr += ' (Ganztägig)'; + } + + parts.push(dateStr); + } + + if (parsed.location) { + parts.push(`📍 ${parsed.location}`); + } + + if (parsed.calendarName) { + parts.push(`📆 ${parsed.calendarName}`); + } + + if (parsed.tagNames.length > 0) { + parts.push(`🏷️ ${parsed.tagNames.join(', ')}`); + } + + return parts.join(' · '); +} diff --git a/apps/calendar/apps/web/src/routes/(app)/+layout.svelte b/apps/calendar/apps/web/src/routes/(app)/+layout.svelte index 4b63872f0..d97272905 100644 --- a/apps/calendar/apps/web/src/routes/(app)/+layout.svelte +++ b/apps/calendar/apps/web/src/routes/(app)/+layout.svelte @@ -9,12 +9,15 @@ PillDropdownItem, CommandBarItem, QuickAction, + CreatePreview, } from '@manacore/shared-ui'; import { theme } from '$lib/stores/theme'; import { authStore } from '$lib/stores/auth.svelte'; import { userSettings } from '$lib/stores/user-settings.svelte'; import { viewStore } from '$lib/stores/view.svelte'; import { calendarsStore } from '$lib/stores/calendars.svelte'; + import { eventsStore } from '$lib/stores/events.svelte'; + import { eventTagsStore } from '$lib/stores/event-tags.svelte'; import { settingsStore } from '$lib/stores/settings.svelte'; import { THEME_DEFINITIONS, @@ -22,6 +25,7 @@ EXTENDED_THEME_VARIANTS, } from '@manacore/shared-theme'; import type { ThemeVariant } from '@manacore/shared-theme'; + import { filterHiddenNavItems } from '@manacore/shared-theme'; import { isSidebarMode as sidebarModeStore, isNavCollapsed as collapsedStore, @@ -32,6 +36,11 @@ import { searchEvents } from '$lib/api/events'; import { format } from 'date-fns'; import { de } from 'date-fns/locale'; + import { + parseEventInput, + resolveEventIds, + formatParsedEventPreview, + } from '$lib/utils/event-parser'; // App switcher items const appItems = getPillAppItems('calendar'); @@ -51,6 +60,7 @@ onclick: () => viewStore.goToToday(), }, { id: 'agenda', label: 'Agenda anzeigen', icon: 'list', href: '/agenda' }, + { id: 'tasks', label: 'Aufgaben anzeigen', icon: 'check-square', href: '/tasks' }, { id: 'settings', label: 'Einstellungen', icon: 'settings', href: '/settings' }, ]; @@ -72,6 +82,54 @@ goto(`/event/${item.id}`); } + // CommandBar Quick-Create handlers + function handleCommandBarParseCreate(query: string): CreatePreview | null { + if (!query.trim()) return null; + + const parsed = parseEventInput(query); + if (!parsed.title) return null; + + return { + title: parsed.title, + subtitle: formatParsedEventPreview(parsed), + }; + } + + async function handleCommandBarCreate(query: string): Promise { + const parsed = parseEventInput(query); + if (!parsed.title) return; + + // Resolve calendar and tag names to IDs + const calendars = calendarsStore.calendars.map((c) => ({ id: c.id, name: c.name })); + const tags = eventTagsStore.tags.map((t) => ({ id: t.id, name: t.name })); + const resolved = resolveEventIds(parsed, calendars, tags); + + // Ensure we have a calendar + if (!resolved.calendarId) { + console.error('No calendar available'); + return; + } + + // Ensure we have start and end times + if (!resolved.startTime) { + // Default to now + 1 hour + const now = new Date(); + resolved.startTime = now.toISOString(); + const end = new Date(now.getTime() + 60 * 60 * 1000); + resolved.endTime = end.toISOString(); + } + + await eventsStore.createEvent({ + calendarId: resolved.calendarId, + title: resolved.title, + startTime: resolved.startTime, + endTime: resolved.endTime || resolved.startTime, + isAllDay: resolved.isAllDay, + location: resolved.location, + tagIds: resolved.tagIds, + }); + } + let isSidebarMode = $state(false); let isCollapsed = $state(false); @@ -122,18 +180,25 @@ // User email for user dropdown let userEmail = $derived(authStore.user?.email || 'Menü'); - // Navigation items for Calendar - const navItems: PillNavItem[] = [ + // Base navigation items for Calendar + const baseNavItems: PillNavItem[] = [ { href: '/', label: 'Kalender', icon: 'calendar' }, { href: '/agenda', label: 'Agenda', icon: 'list' }, + { href: '/tasks', label: 'Aufgaben', icon: 'check-square' }, { href: '/tags', label: 'Tags', icon: 'tag' }, + { href: '/statistics', label: 'Statistiken', icon: 'bar-chart-3' }, { href: '/network', label: 'Netzwerk', icon: 'share-2' }, { href: '/settings', label: 'Einstellungen', icon: 'settings' }, { href: '/feedback', label: 'Feedback', icon: 'chat' }, ]; - // Navigation shortcuts (Ctrl+1-4) - const navRoutes = navItems.map((item) => item.href); + // Navigation items filtered by visibility settings + const navItems = $derived( + filterHiddenNavItems('calendar', baseNavItems, userSettings.nav.hiddenNavItems) + ); + + // Navigation shortcuts (Ctrl+1-4) - use base items for consistent shortcuts + const navRoutes = baseNavItems.map((item) => item.href); function handleKeydown(event: KeyboardEvent) { const target = event.target as HTMLElement; @@ -200,8 +265,9 @@ // Initialize view state viewStore.initialize(); - // Load calendars and user settings + // Load calendars, tags, and user settings await calendarsStore.fetchCalendars(); + await eventTagsStore.fetchTags(); await userSettings.load(); // Redirect to start page if on root and a custom start page is set @@ -283,9 +349,13 @@ onSearch={handleCommandBarSearch} onSelect={handleCommandBarSelect} quickActions={commandBarQuickActions} - placeholder="Termin suchen..." + placeholder="Termin suchen oder erstellen..." emptyText="Keine Termine gefunden" searchingText="Suche..." + onCreate={handleCommandBarCreate} + onParseCreate={handleCommandBarParseCreate} + createText="Als Termin erstellen" + createShortcut="⌘↵" />
diff --git a/apps/calendar/apps/web/src/routes/(app)/+page.svelte b/apps/calendar/apps/web/src/routes/(app)/+page.svelte index 3918aab21..6707d9259 100644 --- a/apps/calendar/apps/web/src/routes/(app)/+page.svelte +++ b/apps/calendar/apps/web/src/routes/(app)/+page.svelte @@ -16,6 +16,7 @@ import YearView from '$lib/components/calendar/YearView.svelte'; import MiniCalendar from '$lib/components/calendar/MiniCalendar.svelte'; import CalendarSidebar from '$lib/components/calendar/CalendarSidebar.svelte'; + import TodoSidebarSection from '$lib/components/calendar/TodoSidebarSection.svelte'; import QuickEventOverlay from '$lib/components/event/QuickEventOverlay.svelte'; import EventDetailModal from '$lib/components/event/EventDetailModal.svelte'; import { CalendarViewSkeleton } from '$lib/components/skeletons'; @@ -130,6 +131,8 @@ + + diff --git a/apps/calendar/apps/web/src/routes/(app)/statistics/+page.svelte b/apps/calendar/apps/web/src/routes/(app)/statistics/+page.svelte new file mode 100644 index 000000000..231dcd38c --- /dev/null +++ b/apps/calendar/apps/web/src/routes/(app)/statistics/+page.svelte @@ -0,0 +1,287 @@ + + + + Statistiken - Kalender + + +
+ + + {#if loading} + + {:else} + +
+ +
+ + +
+ +
+ +
+ + +
+
+ +
+ +
+ +
+
+ + +
+ +
+
+ + +
+
+ Ganztägige Events + + {calendarStatisticsStore.allDayRatio.allDay} + ({calendarStatisticsStore.allDayRatio.allDayPercentage}%) + +
+ +
+ Wiederkehrende Events + {calendarStatisticsStore.recurringEventsCount} +
+ +
+ Events gesamt + {calendarStatisticsStore.totalEvents} +
+
+ {/if} +
+ + diff --git a/apps/calendar/apps/web/src/routes/(app)/tasks/+page.svelte b/apps/calendar/apps/web/src/routes/(app)/tasks/+page.svelte new file mode 100644 index 000000000..403dbd875 --- /dev/null +++ b/apps/calendar/apps/web/src/routes/(app)/tasks/+page.svelte @@ -0,0 +1,486 @@ + + + + Aufgaben | Kalender + + +
+ + + + + + +
+ {#if showQuickAdd} + (showQuickAdd = false)} + oncancel={() => (showQuickAdd = false)} + /> + {:else} + + {/if} +
+ + + {#if loading} + + {:else if !todosStore.serviceAvailable} +
+ +

Todo-Service ist nicht erreichbar

+

Bitte versuchen Sie es später erneut

+
+ {:else if groupedItems.length === 0} +
+ +

Keine Einträge gefunden

+

+ {#if !showEvents && !showTodos} + Aktivieren Sie mindestens einen Filter + {:else} + Erstellen Sie eine neue Aufgabe oder ändern Sie den Zeitraum + {/if} +

+
+ {:else} +
+ {#each groupedItems as group} +
+

+ {formatDateHeader(group.date)} + ({group.items.length}) +

+ +
+ {#each group.items as item} + {#if item.type === 'event' && item.event} + handleEventClick(item.event!.id)} + /> + {:else if item.type === 'todo' && item.todo} + handleTodoClick(item.todo!)} + /> + {/if} + {/each} +
+
+ {/each} +
+ {/if} +
+ + +{#if selectedTask} + +{/if} + + diff --git a/apps/chat/apps/web/src/routes/(protected)/+layout.svelte b/apps/chat/apps/web/src/routes/(protected)/+layout.svelte index 71202a1b3..275071d82 100644 --- a/apps/chat/apps/web/src/routes/(protected)/+layout.svelte +++ b/apps/chat/apps/web/src/routes/(protected)/+layout.svelte @@ -12,6 +12,7 @@ EXTENDED_THEME_VARIANTS, } from '@manacore/shared-theme'; import type { ThemeVariant } from '@manacore/shared-theme'; + import { filterHiddenNavItems } from '@manacore/shared-theme'; import { isSidebarMode as sidebarModeStore, isNavCollapsed as collapsedStore, @@ -78,8 +79,8 @@ ); let currentLanguageLabel = $derived(getCurrentLanguageLabel(currentLocale)); - // Navigation items for Chat (settings moved to user dropdown) - const navItems: PillNavItem[] = [ + // Base navigation items for Chat (settings moved to user dropdown) + const baseNavItems: PillNavItem[] = [ { href: '/chat', label: 'Chat', icon: 'home' }, { href: '/templates', label: 'Templates', icon: 'document' }, { href: '/spaces', label: 'Spaces', icon: 'building' }, @@ -88,14 +89,19 @@ { href: '/feedback', label: 'Feedback', icon: 'chat' }, ]; + // Navigation items filtered by visibility settings + const navItems = $derived( + filterHiddenNavItems('chat', baseNavItems, userSettings.nav.hiddenNavItems) + ); + // User email for user dropdown let userEmail = $derived(authStore.user?.email); // Check if current page is a chat page (needs full-width layout) let isChatPage = $derived($page.url.pathname.startsWith('/chat')); - // Navigation shortcuts (Ctrl+1-5) - const navRoutes = navItems.map((item) => item.href); + // Navigation shortcuts (Ctrl+1-5) - use base items for consistent shortcuts + const navRoutes = baseNavItems.map((item) => item.href); function handleKeydown(event: KeyboardEvent) { const target = event.target as HTMLElement; diff --git a/apps/clock/apps/web/src/routes/(app)/+layout.svelte b/apps/clock/apps/web/src/routes/(app)/+layout.svelte index 46e5273ba..d58c91855 100644 --- a/apps/clock/apps/web/src/routes/(app)/+layout.svelte +++ b/apps/clock/apps/web/src/routes/(app)/+layout.svelte @@ -19,6 +19,7 @@ EXTENDED_THEME_VARIANTS, } from '@manacore/shared-theme'; import type { ThemeVariant } from '@manacore/shared-theme'; + import { filterHiddenNavItems } from '@manacore/shared-theme'; import { isSidebarMode as sidebarModeStore, isNavCollapsed as collapsedStore, @@ -161,8 +162,8 @@ // User email for user dropdown let userEmail = $derived(authStore.user?.email || 'Menü'); - // Navigation items for Clock - const navItems: PillNavItem[] = [ + // Base navigation items for Clock + const baseNavItems: PillNavItem[] = [ { href: '/', label: 'Übersicht', icon: 'home' }, { href: '/alarms', label: 'Wecker', icon: 'bell' }, { href: '/timers', label: 'Timer', icon: 'timer' }, @@ -174,8 +175,13 @@ { href: '/feedback', label: 'Feedback', icon: 'chat' }, ]; - // Navigation shortcuts (Ctrl+1-9) - const navRoutes = navItems.map((item) => item.href); + // Navigation items filtered by visibility settings + const navItems = $derived( + filterHiddenNavItems('clock', baseNavItems, userSettings.nav.hiddenNavItems) + ); + + // Navigation shortcuts (Ctrl+1-9) - use base items for consistent shortcuts + const navRoutes = baseNavItems.map((item) => item.href); function handleKeydown(event: KeyboardEvent) { const target = event.target as HTMLElement; diff --git a/apps/contacts/apps/web/src/lib/stores/statistics.svelte.ts b/apps/contacts/apps/web/src/lib/stores/statistics.svelte.ts new file mode 100644 index 000000000..4c2a112f6 --- /dev/null +++ b/apps/contacts/apps/web/src/lib/stores/statistics.svelte.ts @@ -0,0 +1,275 @@ +/** + * Contacts Statistics Store - Calculates contact statistics using Svelte 5 runes + */ + +import type { Contact } from '$lib/api/contacts'; +import { subDays, format, parseISO, isWithinInterval, getMonth, eachDayOfInterval } from 'date-fns'; +import { de } from 'date-fns/locale'; +import type { + HeatmapDataPoint, + TrendDataPoint, + DonutSegment, + ProgressItem, +} from '@manacore/shared-ui'; + +// Types +export interface ContactTag { + id: string; + name: string; + color: string; +} + +// State +let contacts = $state([]); +let tags = $state([]); + +export const contactsStatisticsStore = { + // Setters + setContacts(newContacts: Contact[]) { + contacts = newContacts; + }, + + setTags(newTags: ContactTag[]) { + tags = newTags; + }, + + // Quick Stats + get totalContacts() { + return contacts.length; + }, + + get favoriteContacts() { + return contacts.filter((c) => c.isFavorite).length; + }, + + get archivedContacts() { + return contacts.filter((c) => c.isArchived).length; + }, + + get activeContacts() { + return contacts.filter((c) => !c.isArchived).length; + }, + + get recentlyAdded() { + const weekAgo = subDays(new Date(), 7); + return contacts.filter((c) => { + const createdAt = + typeof c.createdAt === 'string' ? parseISO(c.createdAt) : new Date(c.createdAt); + return createdAt >= weekAgo; + }).length; + }, + + get birthdaysThisMonth() { + const currentMonth = getMonth(new Date()); + return contacts.filter((c) => { + if (!c.birthday) return false; + const birthday = typeof c.birthday === 'string' ? parseISO(c.birthday) : new Date(c.birthday); + return getMonth(birthday) === currentMonth; + }).length; + }, + + get contactsWithEmail() { + return contacts.filter((c) => c.email).length; + }, + + get contactsWithPhone() { + return contacts.filter((c) => c.phone || c.mobile).length; + }, + + // Completeness rate (contacts with email AND phone) + get completenessRate() { + if (contacts.length === 0) return 0; + const complete = contacts.filter((c) => c.email && (c.phone || c.mobile)).length; + return Math.round((complete / contacts.length) * 100); + }, + + // Activity Heatmap (last 6 months) - based on contact creation + get activityHeatmap(): HeatmapDataPoint[] { + const endDate = new Date(); + const startDate = subDays(endDate, 180); + + // Count contacts created per day + const creationMap = new Map(); + + contacts.forEach((c) => { + const createdAt = + typeof c.createdAt === 'string' ? parseISO(c.createdAt) : new Date(c.createdAt); + if (createdAt >= startDate && createdAt <= endDate) { + const dateKey = format(createdAt, 'yyyy-MM-dd'); + creationMap.set(dateKey, (creationMap.get(dateKey) || 0) + 1); + } + }); + + // Generate all days + const days = eachDayOfInterval({ start: startDate, end: endDate }); + + return days.map((day) => { + const dateKey = format(day, 'yyyy-MM-dd'); + return { + date: dateKey, + count: creationMap.get(dateKey) || 0, + dayOfWeek: day.getDay(), + }; + }); + }, + + // Weekly Trend (last 4 weeks) + get weeklyTrend(): TrendDataPoint[] { + const endDate = new Date(); + const startDate = subDays(endDate, 27); + + const creationMap = new Map(); + + contacts.forEach((c) => { + const createdAt = + typeof c.createdAt === 'string' ? parseISO(c.createdAt) : new Date(c.createdAt); + if (createdAt >= startDate && createdAt <= endDate) { + const dateKey = format(createdAt, 'yyyy-MM-dd'); + creationMap.set(dateKey, (creationMap.get(dateKey) || 0) + 1); + } + }); + + const days = eachDayOfInterval({ start: startDate, end: endDate }); + + return days.map((day) => { + const dateKey = format(day, 'yyyy-MM-dd'); + return { + date: dateKey, + count: creationMap.get(dateKey) || 0, + label: format(day, 'EEE', { locale: de }), + }; + }); + }, + + // Contact Status Breakdown (Donut Chart) - Favorites / Active / Archived + get statusBreakdown(): DonutSegment[] { + const total = contacts.length; + if (total === 0) return []; + + const favorites = contacts.filter((c) => c.isFavorite && !c.isArchived).length; + const archived = contacts.filter((c) => c.isArchived).length; + const regular = contacts.filter((c) => !c.isFavorite && !c.isArchived).length; + + return [ + { + id: 'favorites', + label: 'Favoriten', + count: favorites, + percentage: Math.round((favorites / total) * 100), + color: '#F59E0B', // amber + }, + { + id: 'regular', + label: 'Aktiv', + count: regular, + percentage: Math.round((regular / total) * 100), + color: '#10B981', // green + }, + { + id: 'archived', + label: 'Archiviert', + count: archived, + percentage: Math.round((archived / total) * 100), + color: '#6B7280', // gray + }, + ]; + }, + + // Tags Progress (Progress Bars) + get tagProgress(): ProgressItem[] { + // Count contacts per tag + const tagCountMap = new Map(); + + // This requires contacts to have a tags array - we'll estimate from the tag data + // For now, we'll show tags with placeholder counts + // In a real implementation, we'd need contactTags relation data + + const result: ProgressItem[] = tags.map((tag) => ({ + id: tag.id, + name: tag.name, + color: tag.color || '#6B7280', + total: contacts.length, // Total contacts as reference + completed: 0, // Would need contact-tag relation to calculate + percentage: 0, + })); + + return result.sort((a, b) => b.completed - a.completed); + }, + + // Info completeness breakdown + get infoBreakdown(): DonutSegment[] { + const total = contacts.length; + if (total === 0) return []; + + const withEmail = contacts.filter((c) => c.email).length; + const withPhone = contacts.filter((c) => c.phone || c.mobile).length; + const withCompany = contacts.filter((c) => c.company).length; + const withBirthday = contacts.filter((c) => c.birthday).length; + + return [ + { + id: 'email', + label: 'Mit E-Mail', + count: withEmail, + percentage: Math.round((withEmail / total) * 100), + color: '#3B82F6', // blue + }, + { + id: 'phone', + label: 'Mit Telefon', + count: withPhone, + percentage: Math.round((withPhone / total) * 100), + color: '#10B981', // green + }, + { + id: 'company', + label: 'Mit Firma', + count: withCompany, + percentage: Math.round((withCompany / total) * 100), + color: '#8B5CF6', // violet + }, + { + id: 'birthday', + label: 'Mit Geburtstag', + count: withBirthday, + percentage: Math.round((withBirthday / total) * 100), + color: '#EC4899', // pink + }, + ]; + }, + + // Country breakdown + get countryBreakdown(): ProgressItem[] { + const countryMap = new Map(); + + contacts.forEach((c) => { + const country = c.country || 'Unbekannt'; + countryMap.set(country, (countryMap.get(country) || 0) + 1); + }); + + const result: ProgressItem[] = []; + const colors = ['#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899', '#6B7280']; + let colorIndex = 0; + + countryMap.forEach((count, country) => { + if (country !== 'Unbekannt' || count > 0) { + result.push({ + id: country, + name: country, + color: colors[colorIndex % colors.length], + total: contacts.length, + completed: count, + percentage: Math.round((count / contacts.length) * 100), + }); + colorIndex++; + } + }); + + return result.sort((a, b) => b.completed - a.completed).slice(0, 8); + }, + + // Total tags count + get totalTags() { + return tags.length; + }, +}; diff --git a/apps/contacts/apps/web/src/lib/utils/contact-parser.ts b/apps/contacts/apps/web/src/lib/utils/contact-parser.ts new file mode 100644 index 000000000..58cd8b1b9 --- /dev/null +++ b/apps/contacts/apps/web/src/lib/utils/contact-parser.ts @@ -0,0 +1,227 @@ +/** + * Contact Parser for Contacts App + * + * Extends the base parser with contact-specific patterns: + * - Company: @CompanyName or bei CompanyName + * - Email: Recognizes email addresses + * - Phone: Recognizes phone numbers + * - Name: First and last name extraction + */ + +import { extractTags, extractAtReference } from '@manacore/shared-utils'; + +export interface ParsedContact { + displayName: string; + firstName?: string; + lastName?: string; + company?: string; + email?: string; + phone?: string; + tagNames: string[]; +} + +interface Tag { + id: string; + name: string; +} + +export interface ParsedContactWithIds { + displayName: string; + firstName?: string; + lastName?: string; + company?: string; + email?: string; + phone?: string; + tagIds: string[]; +} + +// Email pattern +const EMAIL_PATTERN = /\b([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})\b/; + +// Phone patterns (various formats) +const PHONE_PATTERNS: RegExp[] = [ + // International format: +49 123 456789, +49-123-456789 + /\+\d{1,3}[-\s]?\d{2,4}[-\s]?\d{3,}[-\s]?\d*/, + // German format: 0123 456789, 0123/456789 + /\b0\d{2,4}[-\s/]?\d{3,}[-\s]?\d*/, + // Simple format: 123456789 (at least 6 digits) + /\b\d{6,}\b/, +]; + +// Company patterns (alternative to @company) +const COMPANY_PATTERNS: RegExp[] = [ + /\bbei\s+([^@#]+?)(?=\s+(?:@|#|\+|[a-zA-Z0-9._%+-]+@)|$)/i, + /\bvon\s+([^@#]+?)(?=\s+(?:@|#|\+|[a-zA-Z0-9._%+-]+@)|$)/i, +]; + +/** + * Extract email from text + */ +function extractEmail(text: string): { email?: string; remaining: string } { + const match = text.match(EMAIL_PATTERN); + if (match) { + return { + email: match[1], + remaining: text.replace(EMAIL_PATTERN, '').trim(), + }; + } + return { email: undefined, remaining: text }; +} + +/** + * Extract phone number from text + */ +function extractPhone(text: string): { phone?: string; remaining: string } { + for (const pattern of PHONE_PATTERNS) { + const match = text.match(pattern); + if (match) { + return { + phone: match[0].trim(), + remaining: text.replace(pattern, '').trim(), + }; + } + } + return { phone: undefined, remaining: text }; +} + +/** + * Extract company from text (bei/von patterns) + */ +function extractCompanyPattern(text: string): { company?: string; remaining: string } { + for (const pattern of COMPANY_PATTERNS) { + const match = text.match(pattern); + if (match) { + return { + company: match[1].trim(), + remaining: text.replace(pattern, '').trim(), + }; + } + } + return { company: undefined, remaining: text }; +} + +/** + * Extract first and last name from display name + */ +function parseNames(displayName: string): { firstName?: string; lastName?: string } { + const parts = displayName.trim().split(/\s+/); + + if (parts.length === 0) { + return {}; + } + + if (parts.length === 1) { + return { firstName: parts[0] }; + } + + // First part is first name, rest is last name + return { + firstName: parts[0], + lastName: parts.slice(1).join(' '), + }; +} + +/** + * Parse natural language contact input + * + * Examples: + * - "Max Mustermann @ACME Corp max@example.com #kunde #wichtig" + * - "Anna Schmidt bei Google +49 123 456789" + * - "Peter Müller peter@mail.de #privat" + */ +export function parseContactInput(input: string): ParsedContact { + let text = input.trim(); + + // Extract tags first (#tag1 #tag2) + const tagsResult = extractTags(text); + text = tagsResult.remaining; + const tagNames = tagsResult.value || []; + + // Extract company via @CompanyName + const atRefResult = extractAtReference(text); + text = atRefResult.remaining; + let company = atRefResult.value; + + // If no @company, try bei/von patterns + if (!company) { + const companyPatternResult = extractCompanyPattern(text); + text = companyPatternResult.remaining; + company = companyPatternResult.company; + } + + // Extract email + const emailResult = extractEmail(text); + text = emailResult.remaining; + const email = emailResult.email; + + // Extract phone + const phoneResult = extractPhone(text); + text = phoneResult.remaining; + const phone = phoneResult.phone; + + // Clean up multiple spaces and get display name + const displayName = text.replace(/\s+/g, ' ').trim(); + + // Parse first and last name + const { firstName, lastName } = parseNames(displayName); + + return { + displayName, + firstName, + lastName, + company, + email, + phone, + tagNames, + }; +} + +/** + * Resolve tag names to IDs + */ +export function resolveContactIds(parsed: ParsedContact, tags: Tag[]): ParsedContactWithIds { + const tagIds: string[] = []; + + // Find tags by name (case-insensitive) + for (const tagName of parsed.tagNames) { + const tag = tags.find((t) => t.name.toLowerCase() === tagName.toLowerCase()); + if (tag) { + tagIds.push(tag.id); + } + } + + return { + displayName: parsed.displayName, + firstName: parsed.firstName, + lastName: parsed.lastName, + company: parsed.company, + email: parsed.email, + phone: parsed.phone, + tagIds, + }; +} + +/** + * Format parsed contact for preview display + */ +export function formatParsedContactPreview(parsed: ParsedContact): string { + const parts: string[] = []; + + if (parsed.company) { + parts.push(`🏢 ${parsed.company}`); + } + + if (parsed.email) { + parts.push(`📧 ${parsed.email}`); + } + + if (parsed.phone) { + parts.push(`📞 ${parsed.phone}`); + } + + if (parsed.tagNames.length > 0) { + parts.push(`🏷️ ${parsed.tagNames.join(', ')}`); + } + + return parts.join(' · '); +} diff --git a/apps/contacts/apps/web/src/routes/(app)/+layout.svelte b/apps/contacts/apps/web/src/routes/(app)/+layout.svelte index fe7339282..0a8871350 100644 --- a/apps/contacts/apps/web/src/routes/(app)/+layout.svelte +++ b/apps/contacts/apps/web/src/routes/(app)/+layout.svelte @@ -9,6 +9,7 @@ PillDropdownItem, CommandBarItem, QuickAction, + CreatePreview, } from '@manacore/shared-ui'; import { theme } from '$lib/stores/theme'; import { authStore } from '$lib/stores/auth.svelte'; @@ -19,6 +20,7 @@ EXTENDED_THEME_VARIANTS, } from '@manacore/shared-theme'; import type { ThemeVariant } from '@manacore/shared-theme'; + import { filterHiddenNavItems } from '@manacore/shared-theme'; import { isSidebarMode as sidebarModeStore, isNavCollapsed as collapsedStore, @@ -28,13 +30,21 @@ import { setLocale, supportedLocales } from '$lib/i18n'; import ContactDetailModal from '$lib/components/ContactDetailModal.svelte'; import { contactsStore } from '$lib/stores/contacts.svelte'; - import { contactsApi } from '$lib/api/contacts'; + import { contactsApi, tagsApi } from '$lib/api/contacts'; import { viewModeStore } from '$lib/stores/view-mode.svelte'; import { contactsSettings } from '$lib/stores/settings.svelte'; + import { + parseContactInput, + resolveContactIds, + formatParsedContactPreview, + } from '$lib/utils/contact-parser'; // Search modal state let searchModalOpen = $state(false); + // Tags state for Quick-Create + let availableTags = $state<{ id: string; name: string }[]>([]); + // Check if we're on a contact detail route const contactDetailMatch = $derived($page.url.pathname.match(/^\/contacts\/([0-9a-f-]{36})$/i)); const showContactModal = $derived(!!contactDetailMatch); @@ -97,19 +107,25 @@ // User email for user dropdown (fallback to 'Menü' when not logged in) let userEmail = $derived(authStore.user?.email || 'Menü'); - // Navigation items for Contacts - const navItems: PillNavItem[] = [ + // Base navigation items for Contacts + const baseNavItems: PillNavItem[] = [ { href: '/', label: 'Kontakte', icon: 'users' }, { href: '/tags', label: 'Tags', icon: 'tag' }, { href: '/favorites', label: 'Favoriten', icon: 'heart' }, + { href: '/statistics', label: 'Statistiken', icon: 'bar-chart-3' }, { href: '/network', label: 'Netzwerk', icon: 'share-2' }, { href: '/settings', label: 'Einstellungen', icon: 'settings' }, { href: '/feedback', label: 'Feedback', icon: 'chat' }, { href: '/help', label: 'Hilfe', icon: 'help-circle' }, ]; - // Navigation shortcuts (Ctrl+1-5) - const navRoutes = navItems.map((item) => item.href); + // Navigation items filtered by visibility settings + const navItems = $derived( + filterHiddenNavItems('contacts', baseNavItems, userSettings.nav.hiddenNavItems) + ); + + // Navigation shortcuts (Ctrl+1-5) - use base items for consistent shortcuts + const navRoutes = baseNavItems.map((item) => item.href); function handleKeydown(event: KeyboardEvent) { const target = event.target as HTMLElement; @@ -193,6 +209,47 @@ goto(`/contacts/${item.id}`); } + // CommandBar Quick-Create handlers + function handleCommandBarParseCreate(query: string): CreatePreview | null { + if (!query.trim()) return null; + + const parsed = parseContactInput(query); + if (!parsed.displayName) return null; + + return { + title: parsed.displayName, + subtitle: formatParsedContactPreview(parsed), + }; + } + + async function handleCommandBarCreate(query: string): Promise { + const parsed = parseContactInput(query); + if (!parsed.displayName) return; + + // Resolve tag names to IDs + const resolved = resolveContactIds(parsed, availableTags); + + try { + const contact = await contactsStore.createContact({ + displayName: resolved.displayName, + firstName: resolved.firstName, + lastName: resolved.lastName, + company: resolved.company, + email: resolved.email, + phone: resolved.phone, + }); + + // Add tags to the created contact + if (resolved.tagIds.length > 0 && contact) { + for (const tagId of resolved.tagIds) { + await tagsApi.addToContact(tagId, contact.id); + } + } + } catch (e) { + console.error('Failed to create contact:', e); + } + } + // CommandBar quick actions const commandBarQuickActions: QuickAction[] = [ { @@ -214,9 +271,17 @@ return; } - // Load user settings + // Load user settings and tags await userSettings.load(); + // Load tags for Quick-Create + try { + const tagsResult = await tagsApi.list(); + availableTags = (tagsResult.tags || []).map((t) => ({ id: t.id, name: t.name })); + } catch (e) { + console.error('Failed to load tags:', e); + } + // Initialize contacts settings and view mode contactsSettings.initialize(); viewModeStore.initialize(); @@ -302,9 +367,13 @@ onSearch={handleCommandBarSearch} onSelect={handleCommandBarSelect} quickActions={commandBarQuickActions} - placeholder="Kontakt suchen..." + placeholder="Kontakt suchen oder erstellen..." emptyText="Keine Kontakte gefunden" searchingText="Suche..." + onCreate={handleCommandBarCreate} + onParseCreate={handleCommandBarParseCreate} + createText="Als Kontakt erstellen" + createShortcut="⌘↵" />
diff --git a/apps/contacts/apps/web/src/routes/(app)/statistics/+page.svelte b/apps/contacts/apps/web/src/routes/(app)/statistics/+page.svelte new file mode 100644 index 000000000..f288dbd02 --- /dev/null +++ b/apps/contacts/apps/web/src/routes/(app)/statistics/+page.svelte @@ -0,0 +1,280 @@ + + + + Statistiken - Kontakte + + +
+ + + {#if loading} + + {:else} + +
+ +
+ + +
+ +
+ +
+ + +
+
+ +
+ +
+ +
+
+ + +
+
+ +
+ +
+ +
+
+
+ + +
+
+ Aktive Kontakte + {contactsStatisticsStore.activeContacts} +
+ +
+ Archivierte Kontakte + {contactsStatisticsStore.archivedContacts} +
+ +
+ Tags + {contactsStatisticsStore.totalTags} +
+
+ {/if} +
+ + diff --git a/apps/manadeck/apps/web/src/routes/(app)/+layout.svelte b/apps/manadeck/apps/web/src/routes/(app)/+layout.svelte index c4f82f189..a199c8d44 100644 --- a/apps/manadeck/apps/web/src/routes/(app)/+layout.svelte +++ b/apps/manadeck/apps/web/src/routes/(app)/+layout.svelte @@ -18,6 +18,7 @@ EXTENDED_THEME_VARIANTS, } from '@manacore/shared-theme'; import type { ThemeVariant } from '@manacore/shared-theme'; + import { filterHiddenNavItems } from '@manacore/shared-theme'; import { getLanguageDropdownItems, getCurrentLanguageLabel } from '@manacore/shared-i18n'; import { getPillAppItems } from '@manacore/shared-branding'; import { setLocale, supportedLocales } from '$lib/i18n'; @@ -33,13 +34,18 @@ // Get theme state let isDark = $derived(theme.isDark); - // Navigation items for ManaDeck (Mana and Profile are in user dropdown) - const navItems: PillNavItem[] = [ + // Base navigation items for ManaDeck (Mana and Profile are in user dropdown) + const baseNavItems: PillNavItem[] = [ { href: '/decks', label: 'Decks', icon: 'archive' }, { href: '/explore', label: 'Explore', icon: 'search' }, { href: '/progress', label: 'Progress', icon: 'chart' }, ]; + // Navigation items filtered by visibility settings + const navItems = $derived( + filterHiddenNavItems('manadeck', baseNavItems, userSettings.nav.hiddenNavItems) + ); + // Get pinned themes from user settings (extended themes only) let pinnedThemes = $derived( (userSettings.theme?.pinnedThemes || []).filter((t): t is ThemeVariant => diff --git a/apps/picture/apps/web/src/routes/app/+layout.svelte b/apps/picture/apps/web/src/routes/app/+layout.svelte index 99db8a558..447101cca 100644 --- a/apps/picture/apps/web/src/routes/app/+layout.svelte +++ b/apps/picture/apps/web/src/routes/app/+layout.svelte @@ -12,6 +12,7 @@ EXTENDED_THEME_VARIANTS, } from '@manacore/shared-theme'; import type { ThemeVariant } from '@manacore/shared-theme'; + import { filterHiddenNavItems } from '@manacore/shared-theme'; import { getLanguageDropdownItems, getCurrentLanguageLabel } from '@manacore/shared-i18n'; import { getPillAppItems } from '@manacore/shared-branding'; import { setLocale, supportedLocales } from '$lib/i18n'; @@ -93,8 +94,8 @@ } }); - // Navigation items (Mana is in user dropdown via manaHref) - const navItems: PillNavItem[] = [ + // Base navigation items (Mana is in user dropdown via manaHref) + const baseNavItems: PillNavItem[] = [ { href: '/app/gallery', label: 'Galerie', icon: 'home' }, { href: '/app/board', label: 'Moodboards', icon: 'grid' }, { href: '/app/explore', label: 'Entdecken', icon: 'search' }, @@ -104,6 +105,11 @@ { href: '/app/archive', label: 'Archiv', icon: 'archive' }, ]; + // Navigation items filtered by visibility settings + const navItems = $derived( + filterHiddenNavItems('picture', baseNavItems, userSettings.nav.hiddenNavItems) + ); + // View mode options for tab group const viewModeOptions = [ { id: 'single', icon: 'list', title: 'Liste (1)' }, diff --git a/apps/todo/apps/backend/jest.config.js b/apps/todo/apps/backend/jest.config.js new file mode 100644 index 000000000..dcb95fd63 --- /dev/null +++ b/apps/todo/apps/backend/jest.config.js @@ -0,0 +1,16 @@ +/** @type {import('jest').Config} */ +module.exports = { + moduleFileExtensions: ['js', 'json', 'ts'], + rootDir: 'src', + testRegex: '.*\\.spec\\.ts$', + transform: { + '^.+\\.(t|j)s$': 'ts-jest', + }, + collectCoverageFrom: ['**/*.(t|j)s'], + coverageDirectory: '../coverage', + testEnvironment: 'node', + moduleNameMapper: { + '^@todo/shared$': '/../../packages/shared/src', + '^@manacore/shared-nestjs-auth$': '/../../../../../packages/shared-nestjs-auth/src', + }, +}; diff --git a/apps/todo/apps/backend/package.json b/apps/todo/apps/backend/package.json index 59bdc6dcd..fdbcad75d 100644 --- a/apps/todo/apps/backend/package.json +++ b/apps/todo/apps/backend/package.json @@ -9,33 +9,41 @@ "start": "nest start", "start:prod": "node dist/main", "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", + "test": "jest", + "test:watch": "jest --watch", + "test:cov": "jest --coverage", "db:push": "drizzle-kit push", "db:studio": "drizzle-kit studio", "db:seed": "tsx src/db/seed.ts", "db:generate": "drizzle-kit generate" }, "dependencies": { - "@todo/shared": "workspace:*", "@manacore/shared-nestjs-auth": "workspace:*", "@nestjs/common": "^10.4.9", "@nestjs/config": "^3.3.0", "@nestjs/core": "^10.4.9", "@nestjs/platform-express": "^10.4.9", "@nestjs/schedule": "^4.1.2", + "@todo/shared": "workspace:*", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", "dotenv": "^16.4.7", "drizzle-orm": "^0.38.3", "postgres": "^3.4.5", "reflect-metadata": "^0.2.2", + "rrule": "^2.8.1", "rxjs": "^7.8.1" }, "devDependencies": { "@nestjs/cli": "^10.4.9", "@nestjs/schematics": "^10.2.3", + "@nestjs/testing": "^11.1.9", "@types/express": "^5.0.1", + "@types/jest": "^30.0.0", "@types/node": "^22.15.21", "drizzle-kit": "^0.30.2", + "jest": "^30.2.0", + "ts-jest": "^29.2.5", "tsx": "^4.19.4", "typescript": "^5.9.3" } diff --git a/apps/todo/apps/backend/src/label/dto/create-label.dto.ts b/apps/todo/apps/backend/src/label/dto/create-label.dto.ts index 5efeb87f3..920537e9a 100644 --- a/apps/todo/apps/backend/src/label/dto/create-label.dto.ts +++ b/apps/todo/apps/backend/src/label/dto/create-label.dto.ts @@ -1,8 +1,10 @@ -import { IsString, IsOptional, MaxLength } from 'class-validator'; +import { IsString, IsOptional, MaxLength, MinLength, IsNotEmpty } from 'class-validator'; export class CreateLabelDto { @IsString() - @MaxLength(100) + @IsNotEmpty({ message: 'Name darf nicht leer sein' }) + @MinLength(1, { message: 'Name muss mindestens 1 Zeichen haben' }) + @MaxLength(100, { message: 'Name darf maximal 100 Zeichen haben' }) name: string; @IsOptional() diff --git a/apps/todo/apps/backend/src/network/network.service.ts b/apps/todo/apps/backend/src/network/network.service.ts index 8b5d26524..1c67c19f0 100644 --- a/apps/todo/apps/backend/src/network/network.service.ts +++ b/apps/todo/apps/backend/src/network/network.service.ts @@ -1,5 +1,5 @@ import { Injectable, Inject } from '@nestjs/common'; -import { eq } from 'drizzle-orm'; +import { eq, inArray } from 'drizzle-orm'; import { DATABASE_CONNECTION } from '../db/database.module'; import { Database } from '../db/connection'; import { tasks, labels, taskLabels, projects } from '../db/schema'; @@ -54,21 +54,32 @@ export class NetworkService { const projectMap = new Map(userProjects.map((p) => [p.id, p.name])); - // 3. Get labels for each task + // 3. Get all labels for all tasks in a single batch query (fix N+1) + const taskIds = userTasks.map(({ task }) => task.id); const taskLabelsMap = new Map(); - for (const { task } of userTasks) { - const taskLabelRows = await this.db + if (taskIds.length > 0) { + const allTaskLabels = await this.db .select({ - id: labels.id, - name: labels.name, - color: labels.color, + taskId: taskLabels.taskId, + labelId: labels.id, + labelName: labels.name, + labelColor: labels.color, }) .from(taskLabels) .innerJoin(labels, eq(taskLabels.labelId, labels.id)) - .where(eq(taskLabels.taskId, task.id)); + .where(inArray(taskLabels.taskId, taskIds)); - taskLabelsMap.set(task.id, taskLabelRows); + // Group labels by taskId + for (const row of allTaskLabels) { + const existing = taskLabelsMap.get(row.taskId) || []; + existing.push({ + id: row.labelId, + name: row.labelName, + color: row.labelColor, + }); + taskLabelsMap.set(row.taskId, existing); + } } // 4. Filter tasks that have at least one label diff --git a/apps/todo/apps/backend/src/project/dto/create-project.dto.ts b/apps/todo/apps/backend/src/project/dto/create-project.dto.ts index cf13948b0..266bd7f00 100644 --- a/apps/todo/apps/backend/src/project/dto/create-project.dto.ts +++ b/apps/todo/apps/backend/src/project/dto/create-project.dto.ts @@ -1,9 +1,19 @@ -import { IsString, IsOptional, IsBoolean, MaxLength, IsObject } from 'class-validator'; +import { + IsString, + IsOptional, + IsBoolean, + MaxLength, + MinLength, + IsObject, + IsNotEmpty, +} from 'class-validator'; import type { ProjectSettings } from '../../db/schema/projects.schema'; export class CreateProjectDto { @IsString() - @MaxLength(255) + @IsNotEmpty({ message: 'Name darf nicht leer sein' }) + @MinLength(1, { message: 'Name muss mindestens 1 Zeichen haben' }) + @MaxLength(255, { message: 'Name darf maximal 255 Zeichen haben' }) name: string; @IsOptional() diff --git a/apps/todo/apps/backend/src/task/__tests__/task.service.spec.ts b/apps/todo/apps/backend/src/task/__tests__/task.service.spec.ts new file mode 100644 index 000000000..b5d9756fd --- /dev/null +++ b/apps/todo/apps/backend/src/task/__tests__/task.service.spec.ts @@ -0,0 +1,515 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { NotFoundException } from '@nestjs/common'; +import { TaskService } from '../task.service'; +import { ProjectService } from '../../project/project.service'; +import { DATABASE_CONNECTION } from '../../db/database.module'; + +// Mock database +const mockSelectFrom = jest.fn().mockReturnThis(); +const mockSelectWhere = jest.fn(); + +const mockDb = { + query: { + tasks: { + findMany: jest.fn(), + findFirst: jest.fn(), + }, + taskLabels: { + findMany: jest.fn(), + }, + labels: { + findMany: jest.fn(), + }, + }, + select: jest.fn().mockReturnValue({ + from: mockSelectFrom, + where: mockSelectWhere, + }), + insert: jest.fn().mockReturnThis(), + update: jest.fn().mockReturnThis(), + delete: jest.fn().mockReturnThis(), + values: jest.fn().mockReturnThis(), + set: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + returning: jest.fn(), +}; + +// Mock ProjectService +const mockProjectService = { + findByIdOrThrow: jest.fn(), +}; + +describe('TaskService', () => { + let service: TaskService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + TaskService, + { + provide: DATABASE_CONNECTION, + useValue: mockDb, + }, + { + provide: ProjectService, + useValue: mockProjectService, + }, + ], + }).compile(); + + service = module.get(TaskService); + + // Reset all mocks before each test + jest.clearAllMocks(); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('findAll', () => { + it('should return all tasks for a user', async () => { + const userId = 'user-123'; + const mockTasks = [ + { id: 'task-1', title: 'Task 1', userId }, + { id: 'task-2', title: 'Task 2', userId }, + ]; + + mockDb.query.tasks.findMany.mockResolvedValue(mockTasks); + mockDb.query.taskLabels.findMany.mockResolvedValue([]); + + const result = await service.findAll(userId); + + expect(result).toHaveLength(2); + expect(result[0].labels).toEqual([]); + expect(result[1].labels).toEqual([]); + }); + + it('should filter by projectId when provided', async () => { + const userId = 'user-123'; + const projectId = 'project-1'; + + mockDb.query.tasks.findMany.mockResolvedValue([]); + mockDb.query.taskLabels.findMany.mockResolvedValue([]); + + await service.findAll(userId, { projectId }); + + expect(mockDb.query.tasks.findMany).toHaveBeenCalled(); + }); + + it('should filter by priority when provided', async () => { + const userId = 'user-123'; + + mockDb.query.tasks.findMany.mockResolvedValue([]); + mockDb.query.taskLabels.findMany.mockResolvedValue([]); + + await service.findAll(userId, { priority: 'high' }); + + expect(mockDb.query.tasks.findMany).toHaveBeenCalled(); + }); + }); + + describe('findById', () => { + it('should return a task when found', async () => { + const userId = 'user-123'; + const taskId = 'task-1'; + const mockTask = { id: taskId, title: 'Test Task', userId }; + + mockDb.query.tasks.findFirst.mockResolvedValue(mockTask); + mockDb.query.taskLabels.findMany.mockResolvedValue([]); + + const result = await service.findById(taskId, userId); + + expect(result).toBeDefined(); + expect(result?.id).toBe(taskId); + expect(result?.labels).toEqual([]); + }); + + it('should return null when task not found', async () => { + mockDb.query.tasks.findFirst.mockResolvedValue(null); + + const result = await service.findById('non-existent', 'user-123'); + + expect(result).toBeNull(); + }); + }); + + describe('findByIdOrThrow', () => { + it('should return a task when found', async () => { + const userId = 'user-123'; + const taskId = 'task-1'; + const mockTask = { id: taskId, title: 'Test Task', userId }; + + mockDb.query.tasks.findFirst.mockResolvedValue(mockTask); + mockDb.query.taskLabels.findMany.mockResolvedValue([]); + + const result = await service.findByIdOrThrow(taskId, userId); + + expect(result.id).toBe(taskId); + }); + + it('should throw NotFoundException when task not found', async () => { + mockDb.query.tasks.findFirst.mockResolvedValue(null); + + await expect(service.findByIdOrThrow('non-existent', 'user-123')).rejects.toThrow( + NotFoundException + ); + }); + }); + + describe('create', () => { + it('should create a task with basic fields', async () => { + const userId = 'user-123'; + const dto = { title: 'New Task' }; + const createdTask = { id: 'task-new', title: 'New Task', userId, order: 0 }; + + mockDb.query.tasks.findMany.mockResolvedValue([]); + mockDb.query.taskLabels.findMany.mockResolvedValue([]); + mockDb.returning.mockResolvedValue([createdTask]); + + const result = await service.create(userId, dto); + + expect(result.title).toBe('New Task'); + expect(mockDb.insert).toHaveBeenCalled(); + }); + + it('should verify project belongs to user when projectId is provided', async () => { + const userId = 'user-123'; + const projectId = 'project-1'; + const dto = { title: 'New Task', projectId }; + const createdTask = { id: 'task-new', title: 'New Task', userId, projectId, order: 0 }; + + mockProjectService.findByIdOrThrow.mockResolvedValue({ id: projectId, userId }); + mockDb.query.tasks.findMany.mockResolvedValue([]); + mockDb.query.taskLabels.findMany.mockResolvedValue([]); + mockDb.returning.mockResolvedValue([createdTask]); + + await service.create(userId, dto); + + expect(mockProjectService.findByIdOrThrow).toHaveBeenCalledWith(projectId, userId); + }); + + it('should calculate order based on existing tasks', async () => { + const userId = 'user-123'; + const dto = { title: 'New Task' }; + const existingTasks = [ + { id: 'task-1', order: 0 }, + { id: 'task-2', order: 1 }, + { id: 'task-3', order: 2 }, + ]; + const createdTask = { id: 'task-new', title: 'New Task', userId, order: 3 }; + + mockDb.query.tasks.findMany.mockResolvedValue(existingTasks); + mockDb.query.taskLabels.findMany.mockResolvedValue([]); + mockDb.returning.mockResolvedValue([createdTask]); + + const result = await service.create(userId, dto); + + expect(result.order).toBe(3); + }); + }); + + describe('update', () => { + it('should update a task', async () => { + const userId = 'user-123'; + const taskId = 'task-1'; + const dto = { title: 'Updated Title' }; + const existingTask = { id: taskId, title: 'Original', userId }; + const updatedTask = { id: taskId, title: 'Updated Title', userId }; + + mockDb.query.tasks.findFirst.mockResolvedValue(existingTask); + mockDb.query.taskLabels.findMany.mockResolvedValue([]); + mockDb.returning.mockResolvedValue([updatedTask]); + + const result = await service.update(taskId, userId, dto); + + expect(result.title).toBe('Updated Title'); + }); + + it('should throw when task does not exist', async () => { + mockDb.query.tasks.findFirst.mockResolvedValue(null); + + await expect(service.update('non-existent', 'user-123', { title: 'Test' })).rejects.toThrow( + NotFoundException + ); + }); + }); + + describe('delete', () => { + it('should delete a task', async () => { + const userId = 'user-123'; + const taskId = 'task-1'; + const existingTask = { id: taskId, userId }; + + mockDb.query.tasks.findFirst.mockResolvedValue(existingTask); + mockDb.query.taskLabels.findMany.mockResolvedValue([]); + + await service.delete(taskId, userId); + + expect(mockDb.delete).toHaveBeenCalled(); + }); + + it('should throw when task does not exist', async () => { + mockDb.query.tasks.findFirst.mockResolvedValue(null); + + await expect(service.delete('non-existent', 'user-123')).rejects.toThrow(NotFoundException); + }); + }); + + describe('complete', () => { + it('should mark a task as completed', async () => { + const userId = 'user-123'; + const taskId = 'task-1'; + const existingTask = { id: taskId, title: 'Test', userId, recurrenceRule: null }; + const completedTask = { ...existingTask, isCompleted: true, status: 'completed' }; + + mockDb.query.tasks.findFirst.mockResolvedValue(existingTask); + mockDb.query.taskLabels.findMany.mockResolvedValue([]); + mockDb.returning.mockResolvedValue([completedTask]); + + const result = await service.complete(taskId, userId); + + expect(result.isCompleted).toBe(true); + expect(result.status).toBe('completed'); + }); + + it('should create next occurrence for recurring task', async () => { + const userId = 'user-123'; + const taskId = 'task-1'; + const tomorrow = new Date(); + tomorrow.setDate(tomorrow.getDate() + 1); + + const existingTask = { + id: taskId, + title: 'Daily Task', + userId, + recurrenceRule: 'FREQ=DAILY', + dueDate: new Date(), + labels: [], + }; + + const completedTask = { + ...existingTask, + isCompleted: true, + status: 'completed', + completedAt: new Date(), + lastOccurrence: new Date(), + }; + + const newTask = { + id: 'task-new', + title: 'Daily Task', + userId, + recurrenceRule: 'FREQ=DAILY', + dueDate: tomorrow, + isCompleted: false, + status: 'pending', + }; + + // First call for findByIdOrThrow + mockDb.query.tasks.findFirst.mockResolvedValue(existingTask); + mockDb.query.taskLabels.findMany.mockResolvedValue([]); + + // For completing the task + mockDb.returning + .mockResolvedValueOnce([newTask]) // For creating new occurrence + .mockResolvedValueOnce([completedTask]); // For completing original + + const result = await service.complete(taskId, userId); + + expect(result.isCompleted).toBe(true); + // Verify that a new task was created + expect(mockDb.insert).toHaveBeenCalled(); + }); + }); + + describe('uncomplete', () => { + it('should mark a task as not completed', async () => { + const userId = 'user-123'; + const taskId = 'task-1'; + const existingTask = { id: taskId, title: 'Test', userId, isCompleted: true }; + const uncompletedTask = { ...existingTask, isCompleted: false, status: 'pending' }; + + mockDb.query.tasks.findFirst.mockResolvedValue(existingTask); + mockDb.query.taskLabels.findMany.mockResolvedValue([]); + mockDb.returning.mockResolvedValue([uncompletedTask]); + + const result = await service.uncomplete(taskId, userId); + + expect(result.isCompleted).toBe(false); + expect(result.status).toBe('pending'); + }); + }); + + describe('move', () => { + it('should move a task to a different project', async () => { + const userId = 'user-123'; + const taskId = 'task-1'; + const newProjectId = 'project-2'; + const existingTask = { id: taskId, title: 'Test', userId, projectId: 'project-1' }; + const movedTask = { ...existingTask, projectId: newProjectId }; + + mockProjectService.findByIdOrThrow.mockResolvedValue({ id: newProjectId, userId }); + mockDb.query.tasks.findFirst.mockResolvedValue(existingTask); + mockDb.query.tasks.findMany.mockResolvedValue([]); + mockDb.query.taskLabels.findMany.mockResolvedValue([]); + mockDb.returning.mockResolvedValue([movedTask]); + + const result = await service.move(taskId, userId, newProjectId); + + expect(result.projectId).toBe(newProjectId); + expect(mockProjectService.findByIdOrThrow).toHaveBeenCalledWith(newProjectId, userId); + }); + + it('should move a task to inbox (null project)', async () => { + const userId = 'user-123'; + const taskId = 'task-1'; + const existingTask = { id: taskId, title: 'Test', userId, projectId: 'project-1' }; + const movedTask = { ...existingTask, projectId: null }; + + mockDb.query.tasks.findFirst.mockResolvedValue(existingTask); + mockDb.query.tasks.findMany.mockResolvedValue([]); + mockDb.query.taskLabels.findMany.mockResolvedValue([]); + mockDb.returning.mockResolvedValue([movedTask]); + + const result = await service.move(taskId, userId, null); + + expect(result.projectId).toBeNull(); + expect(mockProjectService.findByIdOrThrow).not.toHaveBeenCalled(); + }); + }); + + describe('getInboxTasks', () => { + it('should return incomplete tasks', async () => { + const userId = 'user-123'; + const mockTasks = [ + { id: 'task-1', title: 'Task 1', userId, isCompleted: false }, + { id: 'task-2', title: 'Task 2', userId, isCompleted: false }, + ]; + + mockDb.query.tasks.findMany.mockResolvedValue(mockTasks); + mockDb.query.taskLabels.findMany.mockResolvedValue([]); + + const result = await service.getInboxTasks(userId); + + expect(result).toHaveLength(2); + expect(result.every((t) => t.isCompleted === false)).toBe(true); + }); + }); + + describe('getTodayTasks', () => { + it('should return tasks due today', async () => { + const userId = 'user-123'; + const today = new Date(); + const mockTasks = [{ id: 'task-1', title: 'Today Task', userId, dueDate: today }]; + + mockDb.query.tasks.findMany.mockResolvedValue(mockTasks); + mockDb.query.taskLabels.findMany.mockResolvedValue([]); + + const result = await service.getTodayTasks(userId); + + expect(result).toHaveLength(1); + }); + }); + + describe('getCompletedTasks', () => { + it('should return completed tasks with pagination info', async () => { + const userId = 'user-123'; + const mockTasks = Array(50) + .fill(null) + .map((_, i) => ({ + id: `task-${i}`, + title: `Task ${i}`, + userId, + isCompleted: true, + })); + + mockDb.query.tasks.findMany.mockResolvedValue(mockTasks); + mockDb.query.taskLabels.findMany.mockResolvedValue([]); + mockSelectWhere.mockResolvedValue([{ count: 75 }]); + + const result = await service.getCompletedTasks(userId); + + expect(result.tasks).toHaveLength(50); + expect(result.total).toBe(75); + expect(result.hasMore).toBe(true); + }); + + it('should respect custom limit and offset', async () => { + const userId = 'user-123'; + const mockTasks = Array(10) + .fill(null) + .map((_, i) => ({ + id: `task-${i}`, + title: `Task ${i}`, + userId, + isCompleted: true, + })); + + mockDb.query.tasks.findMany.mockResolvedValue(mockTasks); + mockDb.query.taskLabels.findMany.mockResolvedValue([]); + mockSelectWhere.mockResolvedValue([{ count: 25 }]); + + const result = await service.getCompletedTasks(userId, 10, 10); + + expect(result.tasks).toHaveLength(10); + expect(result.total).toBe(25); + expect(result.hasMore).toBe(true); // offset 10 + limit 10 = 20 < 25 + }); + + it('should enforce max limit of 100', async () => { + const userId = 'user-123'; + const mockTasks = Array(100) + .fill(null) + .map((_, i) => ({ + id: `task-${i}`, + title: `Task ${i}`, + userId, + isCompleted: true, + })); + + mockDb.query.tasks.findMany.mockResolvedValue(mockTasks); + mockDb.query.taskLabels.findMany.mockResolvedValue([]); + mockSelectWhere.mockResolvedValue([{ count: 200 }]); + + // Request 500 tasks, should be capped at 100 + const result = await service.getCompletedTasks(userId, 500, 0); + + expect(result.tasks).toHaveLength(100); + expect(result.hasMore).toBe(true); + }); + }); + + describe('loadTaskLabelsBatch', () => { + it('should batch load labels for multiple tasks', async () => { + const userId = 'user-123'; + const mockTasks = [ + { id: 'task-1', title: 'Task 1', userId }, + { id: 'task-2', title: 'Task 2', userId }, + ]; + + const mockTaskLabels = [ + { taskId: 'task-1', labelId: 'label-1' }, + { taskId: 'task-1', labelId: 'label-2' }, + { taskId: 'task-2', labelId: 'label-1' }, + ]; + + const mockLabels = [ + { id: 'label-1', name: 'Important', color: '#ff0000' }, + { id: 'label-2', name: 'Work', color: '#0000ff' }, + ]; + + mockDb.query.tasks.findMany.mockResolvedValue(mockTasks); + mockDb.query.taskLabels.findMany.mockResolvedValue(mockTaskLabels); + mockDb.query.labels.findMany.mockResolvedValue(mockLabels); + + const result = await service.findAll(userId); + + expect(result[0].labels).toHaveLength(2); + expect(result[1].labels).toHaveLength(1); + // Should only make 2 queries for labels (taskLabels + labels), not N+1 + expect(mockDb.query.taskLabels.findMany).toHaveBeenCalledTimes(1); + expect(mockDb.query.labels.findMany).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/apps/todo/apps/backend/src/task/dto/create-task.dto.ts b/apps/todo/apps/backend/src/task/dto/create-task.dto.ts index 32e7fdd22..2cebd0bdd 100644 --- a/apps/todo/apps/backend/src/task/dto/create-task.dto.ts +++ b/apps/todo/apps/backend/src/task/dto/create-task.dto.ts @@ -4,15 +4,22 @@ import { IsUUID, IsEnum, IsArray, - IsObject, MaxLength, + MinLength, IsDateString, + IsNotEmpty, + ValidateNested, } from 'class-validator'; -import type { TaskPriority, Subtask, TaskMetadata } from '../../db/schema/tasks.schema'; +import { Type } from 'class-transformer'; +import type { TaskPriority } from '../../db/schema/tasks.schema'; +import { CreateSubtaskDto } from './subtask.dto'; +import { TaskMetadataDto } from './metadata.dto'; export class CreateTaskDto { @IsString() - @MaxLength(500) + @IsNotEmpty({ message: 'Titel darf nicht leer sein' }) + @MinLength(1, { message: 'Titel muss mindestens 1 Zeichen haben' }) + @MaxLength(500, { message: 'Titel darf maximal 500 Zeichen haben' }) title: string; @IsOptional() @@ -54,7 +61,9 @@ export class CreateTaskDto { @IsOptional() @IsArray() - subtasks?: Omit[]; + @ValidateNested({ each: true }) + @Type(() => CreateSubtaskDto) + subtasks?: CreateSubtaskDto[]; @IsOptional() @IsArray() @@ -62,6 +71,7 @@ export class CreateTaskDto { labelIds?: string[]; @IsOptional() - @IsObject() - metadata?: TaskMetadata; + @ValidateNested() + @Type(() => TaskMetadataDto) + metadata?: TaskMetadataDto; } diff --git a/apps/todo/apps/backend/src/task/dto/metadata.dto.ts b/apps/todo/apps/backend/src/task/dto/metadata.dto.ts new file mode 100644 index 000000000..f6ba27ba8 --- /dev/null +++ b/apps/todo/apps/backend/src/task/dto/metadata.dto.ts @@ -0,0 +1,58 @@ +import { + IsString, + IsOptional, + IsNumber, + IsArray, + IsUUID, + IsEnum, + Min, + Max, + MaxLength, + ValidateNested, +} from 'class-validator'; +import { Type } from 'class-transformer'; + +export class EffectiveDurationDto { + @IsNumber() + @Min(1, { message: 'Dauer muss mindestens 1 sein' }) + @Max(9999, { message: 'Dauer darf maximal 9999 sein' }) + value: number; + + @IsEnum(['minutes', 'hours', 'days'], { message: 'Ungültige Zeiteinheit' }) + unit: 'minutes' | 'hours' | 'days'; +} + +export class TaskMetadataDto { + @IsOptional() + @IsString() + @MaxLength(10000, { message: 'Notizen dürfen maximal 10000 Zeichen haben' }) + notes?: string; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + @MaxLength(500, { each: true }) + attachments?: string[]; + + @IsOptional() + @IsUUID() + linkedCalendarEventId?: string | null; + + @IsOptional() + @IsNumber() + @IsEnum([1, 2, 3, 5, 8, 13, 21], { + message: 'Storypoints müssen Fibonacci-Zahlen sein (1,2,3,5,8,13,21)', + }) + storyPoints?: number | null; + + @IsOptional() + @ValidateNested() + @Type(() => EffectiveDurationDto) + effectiveDuration?: EffectiveDurationDto | null; + + @IsOptional() + @IsNumber() + @Min(1, { message: 'Spaß-Faktor muss mindestens 1 sein' }) + @Max(10, { message: 'Spaß-Faktor darf maximal 10 sein' }) + funRating?: number | null; +} diff --git a/apps/todo/apps/backend/src/task/dto/subtask.dto.ts b/apps/todo/apps/backend/src/task/dto/subtask.dto.ts new file mode 100644 index 000000000..1cdce1a78 --- /dev/null +++ b/apps/todo/apps/backend/src/task/dto/subtask.dto.ts @@ -0,0 +1,48 @@ +import { + IsString, + IsBoolean, + IsOptional, + IsNumber, + MinLength, + MaxLength, + Min, + IsDateString, +} from 'class-validator'; + +export class SubtaskDto { + @IsOptional() + @IsString() + id?: string; + + @IsString() + @MinLength(1, { message: 'Subtask-Titel darf nicht leer sein' }) + @MaxLength(500, { message: 'Subtask-Titel darf maximal 500 Zeichen haben' }) + title: string; + + @IsBoolean() + isCompleted: boolean; + + @IsOptional() + @IsDateString() + completedAt?: string | null; + + @IsNumber() + @Min(0) + order: number; +} + +export class CreateSubtaskDto { + @IsString() + @MinLength(1, { message: 'Subtask-Titel darf nicht leer sein' }) + @MaxLength(500, { message: 'Subtask-Titel darf maximal 500 Zeichen haben' }) + title: string; + + @IsOptional() + @IsBoolean() + isCompleted?: boolean; + + @IsOptional() + @IsNumber() + @Min(0) + order?: number; +} diff --git a/apps/todo/apps/backend/src/task/task.controller.ts b/apps/todo/apps/backend/src/task/task.controller.ts index 579923aa8..01060596a 100644 --- a/apps/todo/apps/backend/src/task/task.controller.ts +++ b/apps/todo/apps/backend/src/task/task.controller.ts @@ -33,9 +33,13 @@ export class TaskController { } @Get('completed') - async getCompleted(@CurrentUser() user: CurrentUserData, @Query('limit') limit?: number) { - const tasks = await this.taskService.getCompletedTasks(user.userId, limit ?? 50); - return { tasks }; + async getCompleted( + @CurrentUser() user: CurrentUserData, + @Query('limit') limit?: number, + @Query('offset') offset?: number + ) { + const result = await this.taskService.getCompletedTasks(user.userId, limit ?? 50, offset ?? 0); + return result; } @Get(':id') diff --git a/apps/todo/apps/backend/src/task/task.service.ts b/apps/todo/apps/backend/src/task/task.service.ts index 4f394bf6d..747990a56 100644 --- a/apps/todo/apps/backend/src/task/task.service.ts +++ b/apps/todo/apps/backend/src/task/task.service.ts @@ -1,11 +1,15 @@ import { Injectable, Inject, NotFoundException } from '@nestjs/common'; -import { eq, and, or, gte, lte, ilike, asc, desc, isNull, SQL } from 'drizzle-orm'; +import { eq, and, or, gte, lte, ilike, asc, desc, isNull, SQL, sql } from 'drizzle-orm'; +import { RRule, RRuleSet, rrulestr } from 'rrule'; import { DATABASE_CONNECTION } from '../db/database.module'; import { type Database } from '../db/connection'; import { tasks, taskLabels, labels, type Task, type NewTask, type Subtask } from '../db/schema'; import { ProjectService } from '../project/project.service'; import { CreateTaskDto, UpdateTaskDto, QueryTasksDto } from './dto'; +// Extended Task type that includes labels (populated after loading from DB) +type TaskWithLabels = Task & { labels: (typeof labels.$inferSelect)[] }; + @Injectable() export class TaskService { constructor( @@ -13,7 +17,7 @@ export class TaskService { private projectService: ProjectService ) {} - async findAll(userId: string, query: QueryTasksDto = {}): Promise { + async findAll(userId: string, query: QueryTasksDto = {}): Promise { const conditions: SQL[] = [eq(tasks.userId, userId)]; if (query.projectId) { @@ -73,11 +77,11 @@ export class TaskService { offset: query.offset, }); - // Load labels for each task - return Promise.all(result.map((task) => this.loadTaskLabels(task))); + // Batch load labels for all tasks (2 queries instead of N+1) + return this.loadTaskLabelsBatch(result); } - async findById(id: string, userId: string): Promise { + async findById(id: string, userId: string): Promise { const result = await this.db.query.tasks.findFirst({ where: and(eq(tasks.id, id), eq(tasks.userId, userId)), }); @@ -86,7 +90,7 @@ export class TaskService { return this.loadTaskLabels(result); } - async findByIdOrThrow(id: string, userId: string): Promise { + async findByIdOrThrow(id: string, userId: string): Promise { const task = await this.findById(id, userId); if (!task) { throw new NotFoundException(`Task with id ${id} not found`); @@ -94,7 +98,7 @@ export class TaskService { return task; } - async create(userId: string, dto: CreateTaskDto): Promise { + async create(userId: string, dto: CreateTaskDto): Promise { // Verify project belongs to user if provided if (dto.projectId) { await this.projectService.findByIdOrThrow(dto.projectId, userId); @@ -139,7 +143,7 @@ export class TaskService { return this.loadTaskLabels(created); } - async update(id: string, userId: string, dto: UpdateTaskDto): Promise { + async update(id: string, userId: string, dto: UpdateTaskDto): Promise { await this.findByIdOrThrow(id, userId); // Verify project belongs to user if changing project @@ -185,13 +189,28 @@ export class TaskService { await this.db.delete(tasks).where(and(eq(tasks.id, id), eq(tasks.userId, userId))); } - async complete(id: string, userId: string): Promise { + async complete(id: string, userId: string): Promise { const task = await this.findByIdOrThrow(id, userId); // If task has recurrence, create next occurrence instead of completing if (task.recurrenceRule) { - // TODO: Implement recurrence handling - // For now, just mark as complete + const nextOccurrence = await this.createNextOccurrence(task, userId); + if (nextOccurrence) { + // Mark current task as completed and update lastOccurrence + const [completed] = await this.db + .update(tasks) + .set({ + isCompleted: true, + status: 'completed', + completedAt: new Date(), + lastOccurrence: new Date(), + updatedAt: new Date(), + }) + .where(and(eq(tasks.id, id), eq(tasks.userId, userId))) + .returning(); + + return this.loadTaskLabels(completed); + } } return this.update(id, userId, { @@ -200,14 +219,155 @@ export class TaskService { }); } - async uncomplete(id: string, userId: string): Promise { + /** + * Validates an RRULE string to prevent abuse (DoS, excessive occurrences). + * Returns true if valid, false if invalid or too complex. + */ + private validateRRule(rruleString: string): boolean { + // Basic length check + if (!rruleString || rruleString.length > 500) { + return false; + } + + try { + const rule = rrulestr(rruleString); + + // Get occurrences for the next 10 years with a limit + // Daily tasks = ~3650/10yrs, hourly would be ~87600 (reject) + const maxOccurrences = 5000; + const tenYearsFromNow = new Date(); + tenYearsFromNow.setFullYear(tenYearsFromNow.getFullYear() + 10); + + const occurrences = rule.between(new Date(), tenYearsFromNow, true, (_, count) => { + // Stop iteration early if we exceed limit + return count < maxOccurrences; + }); + + // Reject if too many occurrences (prevents hourly/minutely abuse) + if (occurrences.length >= maxOccurrences) { + console.warn(`RRULE rejected: too many occurrences (${occurrences.length})`); + return false; + } + + return true; + } catch { + return false; + } + } + + /** + * Creates the next occurrence of a recurring task based on its RRULE. + * Returns the newly created task, or null if no more occurrences should be created. + */ + private async createNextOccurrence( + task: TaskWithLabels, + userId: string + ): Promise { + if (!task.recurrenceRule) return null; + + // Validate RRULE complexity before parsing + if (!this.validateRRule(task.recurrenceRule)) { + console.warn(`Invalid or too complex RRULE for task ${task.id}`); + return null; + } + + try { + // Parse the RRULE string + const rule = rrulestr(task.recurrenceRule); + const now = new Date(); + + // Get the next occurrence after now + const nextDate = rule.after(now, false); + + // Check if we've exceeded the recurrence end date + if (task.recurrenceEndDate) { + const endDate = new Date(task.recurrenceEndDate); + if (!nextDate || nextDate > endDate) { + return null; // No more occurrences + } + } + + if (!nextDate) { + return null; // No more occurrences according to RRULE + } + + // Reset subtasks (mark all as incomplete) + const resetSubtasks: Subtask[] | undefined = task.subtasks?.map((s) => ({ + ...s, + isCompleted: false, + completedAt: null, + })); + + // Create new task for the next occurrence + const newTask: NewTask = { + userId, + projectId: task.projectId, + parentTaskId: task.parentTaskId, + title: task.title, + description: task.description, + dueDate: nextDate, + dueTime: task.dueTime, + startDate: task.startDate + ? this.calculateNextStartDate(task.startDate, task.dueDate, nextDate) + : null, + priority: task.priority ?? 'medium', + status: 'pending', + isCompleted: false, + recurrenceRule: task.recurrenceRule, + recurrenceEndDate: task.recurrenceEndDate, + subtasks: resetSubtasks, + metadata: task.metadata, + order: task.order, + columnId: task.columnId, + columnOrder: task.columnOrder, + }; + + const [created] = await this.db.insert(tasks).values(newTask).returning(); + + // Copy labels from original task + if (task.labels && task.labels.length > 0) { + await this.db.insert(taskLabels).values( + task.labels.map((label) => ({ + taskId: created.id, + labelId: label.id, + })) + ); + } + + return this.loadTaskLabels(created); + } catch (error) { + // If RRULE parsing fails, log and return null + console.error('Failed to parse recurrence rule:', error); + return null; + } + } + + /** + * Calculates the new start date based on the offset between original start and due dates. + */ + private calculateNextStartDate( + originalStartDate: Date | string | null, + originalDueDate: Date | string | null, + nextDueDate: Date + ): Date | null { + if (!originalStartDate || !originalDueDate) return null; + + const start = new Date(originalStartDate); + const due = new Date(originalDueDate); + const diffMs = due.getTime() - start.getTime(); + + // New start date maintains the same offset from the new due date + return new Date(nextDueDate.getTime() - diffMs); + } + + async uncomplete(id: string, userId: string): Promise { return this.update(id, userId, { isCompleted: false, status: 'pending', }); } - async move(id: string, userId: string, projectId: string | null): Promise { + async move(id: string, userId: string, projectId: string | null): Promise { // Verify new project if provided if (projectId) { await this.projectService.findByIdOrThrow(projectId, userId); @@ -247,11 +407,11 @@ export class TaskService { } } - async getInboxTasks(userId: string): Promise { + async getInboxTasks(userId: string): Promise { return this.findAll(userId, { isCompleted: false }); } - async getTodayTasks(userId: string): Promise { + async getTodayTasks(userId: string): Promise { const today = new Date(); today.setHours(0, 0, 0, 0); const tomorrow = new Date(today); @@ -270,10 +430,10 @@ export class TaskService { orderBy: [asc(tasks.dueDate), asc(tasks.order)], }); - return Promise.all(result.map((task) => this.loadTaskLabels(task))); + return this.loadTaskLabelsBatch(result); } - async getUpcomingTasks(userId: string, days: number = 7): Promise { + async getUpcomingTasks(userId: string, days: number = 7): Promise { const today = new Date(); today.setHours(0, 0, 0, 0); const endDate = new Date(today); @@ -289,20 +449,45 @@ export class TaskService { orderBy: [asc(tasks.dueDate), asc(tasks.order)], }); - return Promise.all(result.map((task) => this.loadTaskLabels(task))); + return this.loadTaskLabelsBatch(result); } - async getCompletedTasks(userId: string, limit: number = 50): Promise { - const result = await this.db.query.tasks.findMany({ - where: and(eq(tasks.userId, userId), eq(tasks.isCompleted, true)), - orderBy: [desc(tasks.completedAt)], - limit, - }); + async getCompletedTasks( + userId: string, + limit: number = 50, + offset: number = 0 + ): Promise<{ tasks: TaskWithLabels[]; total: number; hasMore: boolean }> { + // Enforce max limit to prevent abuse + const safeLimit = Math.min(limit, 100); - return Promise.all(result.map((task) => this.loadTaskLabels(task))); + const [result, countResult] = await Promise.all([ + this.db.query.tasks.findMany({ + where: and(eq(tasks.userId, userId), eq(tasks.isCompleted, true)), + orderBy: [desc(tasks.completedAt)], + limit: safeLimit, + offset, + }), + this.db + .select({ count: sql`count(*)::int` }) + .from(tasks) + .where(and(eq(tasks.userId, userId), eq(tasks.isCompleted, true))), + ]); + + const total = countResult[0]?.count ?? 0; + const tasksWithLabels = await this.loadTaskLabelsBatch(result); + + return { + tasks: tasksWithLabels, + total, + hasMore: offset + safeLimit < total, + }; } - async reorder(userId: string, taskIds: string[], projectId?: string | null): Promise { + async reorder( + userId: string, + taskIds: string[], + projectId?: string | null + ): Promise { // Update order for each task const updates = taskIds.map((id, index) => this.db @@ -316,22 +501,66 @@ export class TaskService { return this.findAll(userId, { projectId: projectId ?? undefined }); } + /** + * Loads labels for a single task (used for single task operations). + * For multiple tasks, use loadTaskLabelsBatch instead. + */ private async loadTaskLabels( task: Task ): Promise { - const taskLabelRows = await this.db.query.taskLabels.findMany({ - where: eq(taskLabels.taskId, task.id), - }); + const [result] = await this.loadTaskLabelsBatch([task]); + return result; + } - if (taskLabelRows.length === 0) { - return { ...task, labels: [] }; + /** + * Batch loads labels for multiple tasks in just 2 queries (instead of N+1). + * This significantly improves performance when loading task lists. + */ + private async loadTaskLabelsBatch( + taskList: Task[] + ): Promise<(Task & { labels: (typeof labels.$inferSelect)[] })[]> { + if (taskList.length === 0) { + return []; } - const labelIds = taskLabelRows.map((tl) => tl.labelId); - const taskLabelsData = await this.db.query.labels.findMany({ - where: or(...labelIds.map((id) => eq(labels.id, id))), + const taskIds = taskList.map((t) => t.id); + + // Single query to get all task-label relationships + const allTaskLabels = await this.db.query.taskLabels.findMany({ + where: or(...taskIds.map((id) => eq(taskLabels.taskId, id))), }); - return { ...task, labels: taskLabelsData }; + if (allTaskLabels.length === 0) { + // No labels for any task - return tasks with empty labels array + return taskList.map((task) => ({ ...task, labels: [] })); + } + + // Get unique label IDs + const uniqueLabelIds = [...new Set(allTaskLabels.map((tl) => tl.labelId))]; + + // Single query to get all labels + const allLabels = await this.db.query.labels.findMany({ + where: or(...uniqueLabelIds.map((id) => eq(labels.id, id))), + }); + + // Create a map of labelId -> label for fast lookup + const labelMap = new Map(allLabels.map((l) => [l.id, l])); + + // Create a map of taskId -> labelIds for fast lookup + const taskLabelMap = new Map(); + for (const tl of allTaskLabels) { + const existing = taskLabelMap.get(tl.taskId) || []; + existing.push(tl.labelId); + taskLabelMap.set(tl.taskId, existing); + } + + // Combine tasks with their labels + return taskList.map((task) => { + const labelIds = taskLabelMap.get(task.id) || []; + const taskLabelsData = labelIds + .map((id) => labelMap.get(id)) + .filter((l): l is typeof labels.$inferSelect => l !== undefined); + return { ...task, labels: taskLabelsData }; + }); } } diff --git a/apps/todo/apps/web/package.json b/apps/todo/apps/web/package.json index 4d6119404..5a4f6211b 100644 --- a/apps/todo/apps/web/package.json +++ b/apps/todo/apps/web/package.json @@ -30,6 +30,7 @@ }, "dependencies": { "@manacore/shared-auth": "workspace:*", + "@manacore/shared-utils": "workspace:*", "@manacore/shared-tags": "workspace:*", "@manacore/shared-auth-ui": "workspace:*", "@manacore/shared-branding": "workspace:*", diff --git a/apps/todo/apps/web/src/app.html b/apps/todo/apps/web/src/app.html index 076d6148e..95592e23e 100644 --- a/apps/todo/apps/web/src/app.html +++ b/apps/todo/apps/web/src/app.html @@ -15,8 +15,8 @@ - - + + diff --git a/apps/todo/apps/web/src/lib/components/CollapsibleSection.svelte b/apps/todo/apps/web/src/lib/components/CollapsibleSection.svelte index e7ac1bb11..ad8dc1014 100644 --- a/apps/todo/apps/web/src/lib/components/CollapsibleSection.svelte +++ b/apps/todo/apps/web/src/lib/components/CollapsibleSection.svelte @@ -39,7 +39,7 @@
diff --git a/apps/todo/apps/web/src/lib/components/form/DurationPicker.svelte b/apps/todo/apps/web/src/lib/components/form/DurationPicker.svelte new file mode 100644 index 000000000..c0be0f92a --- /dev/null +++ b/apps/todo/apps/web/src/lib/components/form/DurationPicker.svelte @@ -0,0 +1,238 @@ + + +
+
+ {#each quickOptions as opt} + + {/each} + + {#if value !== null} + + {/if} +
+ + {#if showCustom} +
+ + +
+ {/if} +
+ + diff --git a/apps/todo/apps/web/src/lib/components/form/FunRatingPicker.svelte b/apps/todo/apps/web/src/lib/components/form/FunRatingPicker.svelte new file mode 100644 index 000000000..2d2fbbbd6 --- /dev/null +++ b/apps/todo/apps/web/src/lib/components/form/FunRatingPicker.svelte @@ -0,0 +1,127 @@ + + +
+
+ {#each Array(10) as _, i} + {@const rating = i + 1} + + {/each} + {#if value !== null} + + {/if} +
+
+ 1 + 5 + 10 +
+
+ + diff --git a/apps/todo/apps/web/src/lib/components/form/PrioritySelector.svelte b/apps/todo/apps/web/src/lib/components/form/PrioritySelector.svelte new file mode 100644 index 000000000..b0bea04b0 --- /dev/null +++ b/apps/todo/apps/web/src/lib/components/form/PrioritySelector.svelte @@ -0,0 +1,70 @@ + + +
+ {#each PRIORITY_OPTIONS as p} + + {/each} +
+ + diff --git a/apps/todo/apps/web/src/lib/components/form/StorypointsSelector.svelte b/apps/todo/apps/web/src/lib/components/form/StorypointsSelector.svelte new file mode 100644 index 000000000..553bc7305 --- /dev/null +++ b/apps/todo/apps/web/src/lib/components/form/StorypointsSelector.svelte @@ -0,0 +1,105 @@ + + +
+ {#each options as sp} + + {/each} + {#if value !== null} + + {/if} +
+ + diff --git a/apps/todo/apps/web/src/lib/components/form/TagSelector.svelte b/apps/todo/apps/web/src/lib/components/form/TagSelector.svelte new file mode 100644 index 000000000..64c3ceff3 --- /dev/null +++ b/apps/todo/apps/web/src/lib/components/form/TagSelector.svelte @@ -0,0 +1,223 @@ + + + + +
+ + + {#if showDropdown} +
e.stopPropagation()} role="listbox"> + {#each labelsStore.labels as tag} + + {/each} + {#if labelsStore.labels.length === 0} +
Keine Tags vorhanden
+ {/if} +
+ {/if} +
+ + diff --git a/apps/todo/apps/web/src/lib/components/form/index.ts b/apps/todo/apps/web/src/lib/components/form/index.ts new file mode 100644 index 000000000..dddf9df4c --- /dev/null +++ b/apps/todo/apps/web/src/lib/components/form/index.ts @@ -0,0 +1,5 @@ +export { default as PrioritySelector } from './PrioritySelector.svelte'; +export { default as StorypointsSelector } from './StorypointsSelector.svelte'; +export { default as DurationPicker } from './DurationPicker.svelte'; +export { default as FunRatingPicker } from './FunRatingPicker.svelte'; +export { default as TagSelector } from './TagSelector.svelte'; diff --git a/apps/todo/apps/web/src/lib/components/kanban/KanbanBoard.svelte b/apps/todo/apps/web/src/lib/components/kanban/KanbanBoard.svelte index 4be0ccf54..a2c8d394f 100644 --- a/apps/todo/apps/web/src/lib/components/kanban/KanbanBoard.svelte +++ b/apps/todo/apps/web/src/lib/components/kanban/KanbanBoard.svelte @@ -1,6 +1,7 @@ @@ -155,7 +187,13 @@ variant="warning" defaultOpen={true} > - + {/if} @@ -167,13 +205,30 @@ variant="default" defaultOpen={true} > - {#if todayTasks.length === 0} -
-

Keine Aufgaben für heute

-
- {:else} - - {/if} + + + + + + @@ -184,39 +239,49 @@ variant="default" defaultOpen={true} > - {#if upcomingCount === 0} -
-

Keine anstehenden Aufgaben

-
- {:else} -
- {#each groupedUpcomingTasks() as group} -
-

- {group.label} ({group.tasks.length}) -

- -
- {/each} -
- {/if} +
+ {#each groupedUpcomingTasks() as group} +
+

+ {group.label} ({group.tasks.length}) +

+ +
+ {/each} + {#if upcomingCount === 0} + + + {/if} +
- + - {#if completedTasks.length === 0} -
-

Noch keine erledigten Aufgaben

-
- {:else} - - {/if} +
{/if} diff --git a/apps/todo/apps/web/src/routes/(app)/labels/+page.svelte b/apps/todo/apps/web/src/routes/(app)/labels/+page.svelte deleted file mode 100644 index f95d71371..000000000 --- a/apps/todo/apps/web/src/routes/(app)/labels/+page.svelte +++ /dev/null @@ -1,314 +0,0 @@ - - - - Labels - Todo - - -
- -
- - - -

Labels

- -
- - -
- - -
- - {#if labelsStore.error} - - {/if} - - - - - {#if !labelsStore.loading && labelsStore.labels.length > 0} -

- {labelsStore.labels.length} - {labelsStore.labels.length === 1 ? 'Label' : 'Labels'} -

- {/if} - - {#if !labelsStore.loading && labelsStore.labels.length === 0 && !searchQuery} -
- -
- {/if} -
- - - - - diff --git a/apps/todo/apps/web/src/routes/(app)/settings/+page.svelte b/apps/todo/apps/web/src/routes/(app)/settings/+page.svelte index 44600448a..91c1daa27 100644 --- a/apps/todo/apps/web/src/routes/(app)/settings/+page.svelte +++ b/apps/todo/apps/web/src/routes/(app)/settings/+page.svelte @@ -6,6 +6,7 @@ import { todoSettings, type TodoView, type KanbanCardSize } from '$lib/stores/settings.svelte'; import { projectsStore } from '$lib/stores/projects.svelte'; import type { TaskPriority } from '@todo/shared'; + import { PRIORITY_OPTIONS } from '@todo/shared'; import { SettingsPage, SettingsSection, @@ -20,13 +21,8 @@ GlobalSettingsSection, } from '@manacore/shared-ui'; - // Options for selects - const priorityOptions = [ - { value: 'low', label: 'Niedrig' }, - { value: 'medium', label: 'Mittel' }, - { value: 'high', label: 'Hoch' }, - { value: 'urgent', label: 'Dringend' }, - ]; + // Use shared priority options (without color) + const priorityOptions = PRIORITY_OPTIONS.map((p) => ({ value: p.value, label: p.label })); const viewOptions = [ { value: 'inbox', label: 'Inbox' }, @@ -129,7 +125,20 @@ - + diff --git a/apps/todo/apps/web/src/routes/(app)/label/[id]/+page.svelte b/apps/todo/apps/web/src/routes/(app)/tag/[id]/+page.svelte similarity index 81% rename from apps/todo/apps/web/src/routes/(app)/label/[id]/+page.svelte rename to apps/todo/apps/web/src/routes/(app)/tag/[id]/+page.svelte index 6acf4d681..57741e8a4 100644 --- a/apps/todo/apps/web/src/routes/(app)/label/[id]/+page.svelte +++ b/apps/todo/apps/web/src/routes/(app)/tag/[id]/+page.svelte @@ -15,16 +15,16 @@ let editingTask = $state(null); let showEditModal = $state(false); - // Get label ID from URL - const labelId = $derived($page.params.id ?? ''); + // Get tag ID from URL + const tagId = $derived($page.params.id ?? ''); - // Get label from store - const label = $derived(labelsStore.getById(labelId)); + // Get tag from store + const tag = $derived(labelsStore.getById(tagId)); - // Get tasks with this label - const labelTasks = $derived(labelId ? tasksStore.getTasksByLabel(labelId) : []); - const incompleteTasks = $derived(labelTasks.filter((t) => !t.isCompleted)); - const completedTasks = $derived(labelTasks.filter((t) => t.isCompleted)); + // Get tasks with this tag + const tagTasks = $derived(tagId ? tasksStore.getTasksByLabel(tagId) : []); + const incompleteTasks = $derived(tagTasks.filter((t) => !t.isCompleted)); + const completedTasks = $derived(tagTasks.filter((t) => t.isCompleted)); onMount(async () => { if (!authStore.isAuthenticated) { @@ -33,12 +33,12 @@ } try { - // Ensure labels are loaded + // Ensure tags are loaded if (labelsStore.labels.length === 0) { await labelsStore.fetchLabels(); } - // Fetch all tasks to filter by label + // Fetch all tasks to filter by tag await tasksStore.fetchAllTasks(); } catch (error) { console.error('Failed to load data:', error); @@ -88,7 +88,7 @@ - {label?.name || 'Label'} - Todo + {tag?.name || 'Tag'} - Todo
@@ -98,39 +98,39 @@
- {#if label} -
-
+ {#if tag} +
+
-

{label.name}

+

{tag.name}

{:else} -

Label

+

Tag

{/if}
- + {#if isLoading} - {:else if !label} + {:else if !tag}
-

Label nicht gefunden

-

Dieses Label existiert nicht mehr.

- Zu den Labels +

Tag nicht gefunden

+

Dieser Tag existiert nicht mehr.

+ Zu den Tags
- {:else if labelTasks.length === 0} + {:else if tagTasks.length === 0}
-
- +
+

Keine Aufgaben

- Es gibt keine Aufgaben mit dem Label "{label.name}". + Es gibt keine Aufgaben mit dem Tag "{tag.name}".

Aufgabe erstellen
@@ -156,8 +156,8 @@ {/if}

- {labelTasks.length} - {labelTasks.length === 1 ? 'Aufgabe' : 'Aufgaben'} + {tagTasks.length} + {tagTasks.length === 1 ? 'Aufgabe' : 'Aufgaben'}

{/if}
@@ -196,7 +196,7 @@ gap: 0.75rem; } - .label-icon { + .tag-icon { width: 2.5rem; height: 2.5rem; border-radius: 0.625rem; @@ -205,7 +205,7 @@ justify-content: center; } - .label-dot { + .tag-dot { width: 1rem; height: 1rem; border-radius: 50%; diff --git a/apps/todo/apps/web/src/routes/(app)/tags/+page.svelte b/apps/todo/apps/web/src/routes/(app)/tags/+page.svelte new file mode 100644 index 000000000..6eda4a9cb --- /dev/null +++ b/apps/todo/apps/web/src/routes/(app)/tags/+page.svelte @@ -0,0 +1,502 @@ + + + + Tags - Todo + + +
+ +
+ + + +

Tags

+
+ + +
+
+ + + + + +
+ + +
+
+ {#each colorPalette as color} + + {/each} +
+ + + {#if newTagName.trim()} +
+ + {newTagName} + +
+ {/if} +
+
+ + +
+ + +
+ + {#if labelsStore.error} + + {/if} + + + + + {#if !labelsStore.loading && labelsStore.labels.length > 0} +

+ {labelsStore.labels.length} + {labelsStore.labels.length === 1 ? 'Tag' : 'Tags'} +

+ {/if} +
+ + + + + + { + showDeleteConfirm = false; + labelToDelete = null; + }} + onConfirm={confirmDeleteLabel} + variant="danger" + title="Tag löschen?" + message={`Der Tag "${labelToDelete?.name ?? ''}" wird unwiderruflich gelöscht.`} + confirmLabel="Löschen" + cancelLabel="Abbrechen" +/> + + diff --git a/apps/todo/apps/web/static/sw.js b/apps/todo/apps/web/static/sw.js index 7992ed8ec..f2af67d36 100644 --- a/apps/todo/apps/web/static/sw.js +++ b/apps/todo/apps/web/static/sw.js @@ -1,4 +1,4 @@ -const CACHE_NAME = 'todo-v1'; +const CACHE_NAME = 'todo-v2'; const OFFLINE_URL = '/offline.html'; // Assets, die immer gecacht werden sollen @@ -8,23 +8,16 @@ const STATIC_CACHE_URLS = ['/', '/offline.html', '/icons/icon.svg', '/manifest.j const CACHE_STRATEGIES = { // Netzwerk zuerst, dann Cache (für HTML/Navigation) networkFirst: [/\/$/, /\.html$/, /^\/kanban/, /^\/settings/, /^\/mana/, /^\/feedback/], - // Cache zuerst, dann Netzwerk (für Assets) + // Cache zuerst, dann Netzwerk (für Assets) - nur für gebaute Assets, nicht /src/ cacheFirst: [ - /\.css$/, - /\.js$/, + /\/_app\//, // SvelteKit gebaute Assets /\.woff2?$/, /\.ttf$/, /\.otf$/, - /\.svg$/, - /\.png$/, - /\.jpg$/, - /\.jpeg$/, - /\.webp$/, /\.ico$/, - /\/_app\//, ], - // Nur Netzwerk (für API-Calls) - networkOnly: [/\/api\//, /localhost:3018/], + // Nur Netzwerk (für API-Calls und Dev-Server) + networkOnly: [/\/api\//, /localhost:3018/, /^\/src\//, /^\/@/, /^\/node_modules\//], }; // Service Worker Installation diff --git a/apps/todo/packages/shared/src/constants/index.ts b/apps/todo/packages/shared/src/constants/index.ts index ca2bb18bc..784948270 100644 --- a/apps/todo/packages/shared/src/constants/index.ts +++ b/apps/todo/packages/shared/src/constants/index.ts @@ -69,6 +69,9 @@ export const REMINDER_PRESETS = [ { label: '1 week before', minutes: 10080 }, ] as const; +// Re-export task-specific constants (German localized versions) +export * from './task'; + // View types export type ViewType = | 'inbox' diff --git a/apps/todo/packages/shared/src/constants/task.ts b/apps/todo/packages/shared/src/constants/task.ts new file mode 100644 index 000000000..abd33ff9d --- /dev/null +++ b/apps/todo/packages/shared/src/constants/task.ts @@ -0,0 +1,55 @@ +import type { TaskPriority, TaskStatus } from '../types/task'; + +export interface PriorityOption { + value: TaskPriority; + label: string; + color: string; +} + +export interface StatusOption { + value: TaskStatus; + label: string; +} + +export interface RecurrenceOption { + value: string; + label: string; +} + +export const PRIORITY_OPTIONS: PriorityOption[] = [ + { value: 'low', label: 'Später', color: '#22c55e' }, + { value: 'medium', label: 'Normal', color: '#eab308' }, + { value: 'high', label: 'Wichtig', color: '#f97316' }, + { value: 'urgent', label: 'Dringend', color: '#ef4444' }, +]; + +export const STATUS_OPTIONS: StatusOption[] = [ + { value: 'pending', label: 'Offen' }, + { value: 'in_progress', label: 'In Arbeit' }, + { value: 'completed', label: 'Erledigt' }, + { value: 'cancelled', label: 'Abgebrochen' }, +]; + +export const RECURRENCE_OPTIONS: RecurrenceOption[] = [ + { value: '', label: 'Keine Wiederholung' }, + { value: 'FREQ=DAILY', label: 'Täglich' }, + { value: 'FREQ=WEEKLY', label: 'Wöchentlich' }, + { value: 'FREQ=WEEKLY;INTERVAL=2', label: 'Alle 2 Wochen' }, + { value: 'FREQ=MONTHLY', label: 'Monatlich' }, + { value: 'FREQ=YEARLY', label: 'Jährlich' }, +]; + +// Fibonacci sequence for story points +export const STORYPOINT_OPTIONS = [1, 2, 3, 5, 8, 13, 21] as const; + +// Helper to get priority label +export function getPriorityLabel(priority: TaskPriority): string { + const option = PRIORITY_OPTIONS.find((p) => p.value === priority); + return option?.label ?? priority; +} + +// Helper to get status label +export function getStatusLabel(status: TaskStatus): string { + const option = STATUS_OPTIONS.find((s) => s.value === status); + return option?.label ?? status; +} diff --git a/apps/todo/packages/shared/src/types/task.ts b/apps/todo/packages/shared/src/types/task.ts index 74c97cd97..66ea523d4 100644 --- a/apps/todo/packages/shared/src/types/task.ts +++ b/apps/todo/packages/shared/src/types/task.ts @@ -108,6 +108,7 @@ export interface UpdateTaskInput { recurrenceEndDate?: string | null; subtasks?: Subtask[] | null; metadata?: TaskMetadata | null; + labelIds?: string[]; } export interface QueryTasksInput { diff --git a/apps/zitare/apps/web/src/routes/(app)/+layout.svelte b/apps/zitare/apps/web/src/routes/(app)/+layout.svelte index d568b38af..77d74aa2f 100644 --- a/apps/zitare/apps/web/src/routes/(app)/+layout.svelte +++ b/apps/zitare/apps/web/src/routes/(app)/+layout.svelte @@ -14,6 +14,7 @@ EXTENDED_THEME_VARIANTS, } from '@manacore/shared-theme'; import type { ThemeVariant } from '@manacore/shared-theme'; + import { filterHiddenNavItems } from '@manacore/shared-theme'; import { isSidebarMode as sidebarModeStore, isNavCollapsed as collapsedStore, @@ -77,8 +78,8 @@ // User email for user dropdown let userEmail = $derived(authStore.user?.email || 'Menü'); - // Navigation items for Zitare - const navItems: PillNavItem[] = [ + // Base navigation items for Zitare + const baseNavItems: PillNavItem[] = [ { href: '/', label: 'Zitate', icon: 'document' }, { href: '/search', label: 'Suche', icon: 'search' }, { href: '/authors', label: 'Autoren', icon: 'users' }, @@ -87,8 +88,13 @@ { href: '/feedback', label: 'Feedback', icon: 'chat' }, ]; - // Navigation shortcuts (Ctrl+1-6) - const navRoutes = navItems.map((item) => item.href); + // Navigation items filtered by visibility settings + const navItems = $derived( + filterHiddenNavItems('zitare', baseNavItems, userSettings.nav.hiddenNavItems) + ); + + // Navigation shortcuts (Ctrl+1-6) - use base items for consistent shortcuts + const navRoutes = baseNavItems.map((item) => item.href); function handleKeydown(event: KeyboardEvent) { const target = event.target as HTMLElement; diff --git a/docs/GIT_WORKFLOW.md b/docs/GIT_WORKFLOW.md index cdb6be794..33780e35b 100644 --- a/docs/GIT_WORKFLOW.md +++ b/docs/GIT_WORKFLOW.md @@ -4,11 +4,11 @@ Dokumentation des Git-Workflows für das ManaCore Monorepo. ## Branch-Struktur -| Branch | Zweck | -|--------|-------| -| `main` | Produktion - stabile Releases | -| `dev` | Entwicklung - Integration aller Features | -| `till-dev`, `{name}-dev` | Persönliche Entwicklungs-Branches | +| Branch | Zweck | +| ------------------------------ | ---------------------------------------- | +| `main` | Produktion - stabile Releases | +| `dev` | Entwicklung - Integration aller Features | +| `till-dev`, `{name}-dev` | Persönliche Entwicklungs-Branches | ## Workflow-Übersicht @@ -19,7 +19,7 @@ main (Produktion) │ dev (Integration) ↑ - │ PR (squashed) + │ PR (einzelne Commits behalten) │ till-dev (Feature-Entwicklung) ``` @@ -32,37 +32,16 @@ till-dev (Feature-Entwicklung) # Sicherstellen, dass du auf deinem Branch bist git checkout till-dev -# Änderungen committen (viele kleine Commits sind OK) +# Änderungen committen - kleine, aussagekräftige Commits git add . git commit -m "feat(app): add feature X" git commit -m "fix(app): fix bug Y" git commit -m "refactor(app): cleanup Z" ``` -### 2. Vor dem PR: Commits squashen +### 2. Regelmäßig mit dev synchronisieren -Wenn viele Commits angesammelt sind, sollten diese vor dem PR gequasht werden, um: -- Merge-Konflikte zu minimieren -- Die Git-History sauber zu halten -- Code-Reviews zu vereinfachen - -```bash -# Anzahl der Commits seit dev zählen -git log --oneline origin/dev..HEAD | wc -l - -# Alle Commits seit dev zu einem squashen -git reset --soft origin/dev - -# Einen neuen, zusammengefassten Commit erstellen -git commit -m "feat: descriptive summary of all changes" - -# Force-Push zum Remote (überschreibt alte Commits) -git push --force-with-lease -``` - -### 3. Rebase mit dev (falls nötig) - -Falls `dev` sich geändert hat, muss rebased werden: +Halte deinen Branch aktuell, um große Konflikte zu vermeiden: ```bash # Neuesten Stand von dev holen @@ -71,16 +50,17 @@ git fetch origin dev # Rebase durchführen git rebase origin/dev -# Bei Konflikten: -# 1. Konflikte lösen +# Bei Konflikten: Jeden Commit einzeln lösen +# 1. Konflikte in den angezeigten Dateien lösen # 2. git add # 3. git rebase --continue +# 4. Wiederholen bis alle Commits durchlaufen sind # Nach erfolgreichem Rebase pushen git push --force-with-lease ``` -### 4. Pull Request erstellen +### 3. Pull Request erstellen ```bash gh pr create --base dev --head till-dev \ @@ -94,45 +74,37 @@ gh pr create --base dev --head till-dev \ - [ ] Test case 2" ``` -## Squash-Strategie +## Konflikt-Lösung beim Rebase -### Wann squashen? +### Allgemeiner Ablauf -| Situation | Aktion | -|-----------|--------| -| Vor jedem PR | Immer squashen | -| Bei vielen kleinen Commits (10+) | Squashen empfohlen | -| Bei Rebase-Konflikten | Erst squashen, dann rebasen | -| Tägliche Arbeit | Kleine Commits OK | +Bei einem Rebase werden Commits einzeln auf den neuen Base-Branch angewendet. Konflikte müssen für jeden Commit separat gelöst werden - das gibt mehr Kontext und macht die Lösung einfacher. -### Squash-Commit-Message Format +```bash +# Rebase starten +git rebase origin/dev +# Bei Konflikt: Status prüfen +git status # Zeigt konfliktbehaftete Dateien + +# Konflikte lösen, dann: +git add +git rebase --continue + +# Nächster Commit wird angewendet... +# Wiederholen bis fertig ``` -feat: kurze Zusammenfassung (max 50 Zeichen) - -## Neue Features -- Feature 1: Beschreibung -- Feature 2: Beschreibung - -## Bug Fixes -- Fix 1: Beschreibung - -## Breaking Changes (falls vorhanden) -- Breaking Change 1 - -🤖 Generated with [Claude Code](https://claude.com/claude-code) - -Co-Authored-By: Claude Opus 4.5 -``` - -## Konflikt-Lösung ### Einfache Konflikte ```bash -# Konflikt in einer Datei -git checkout --ours path/to/file # Unsere Version behalten -git checkout --theirs path/to/file # Ihre Version behalten +# Unsere Version behalten (die aus dem Feature-Branch) +git checkout --ours path/to/file + +# Ihre Version behalten (die aus dev) +git checkout --theirs path/to/file + +# Nach der Wahl: git add path/to/file git rebase --continue ``` @@ -142,7 +114,7 @@ git rebase --continue Diese Datei sollte nie manuell gemerged werden: ```bash -# Ihre Version nehmen und neu installieren +# Version aus dev nehmen und neu installieren git checkout --theirs pnpm-lock.yaml pnpm install --frozen-lockfile=false git add pnpm-lock.yaml @@ -169,15 +141,15 @@ git rebase --abort # Zurück zum Zustand vor dem Rebase Verwende [Conventional Commits](https://www.conventionalcommits.org/): -| Prefix | Verwendung | -|--------|------------| -| `feat` | Neue Features | -| `fix` | Bug Fixes | -| `docs` | Dokumentation | -| `style` | Formatting (kein Code-Change) | -| `refactor` | Code-Refactoring | -| `test` | Tests hinzufügen/ändern | -| `chore` | Build, CI, Dependencies | +| Prefix | Verwendung | +| ---------- | ----------------------------- | +| `feat` | Neue Features | +| `fix` | Bug Fixes | +| `docs` | Dokumentation | +| `style` | Formatting (kein Code-Change) | +| `refactor` | Code-Refactoring | +| `test` | Tests hinzufügen/ändern | +| `chore` | Build, CI, Dependencies | ### Scope (optional) @@ -187,6 +159,13 @@ fix(calendar): fix event drag and drop docs(readme): update installation guide ``` +### Kleine, fokussierte Commits + +- Ein Commit = eine logische Änderung +- Aussagekräftige Commit Messages +- Leichter zu reviewen und bei Problemen zu debuggen +- Einzelne Commits können bei Bedarf reverted werden + ### Branch-Hygiene ```bash @@ -202,23 +181,23 @@ git fetch --prune ```bash # 1. Feature entwickeln git checkout till-dev -# ... viele Commits über mehrere Tage ... +git commit -m "feat(network): add D3 force simulation" +git commit -m "feat(network): add zoom and pan controls" +git commit -m "fix(network): fix node positioning on load" +git commit -m "docs(network): add keyboard shortcuts help" -# 2. Vor PR: Status prüfen +# 2. Vor PR: Mit dev synchronisieren git fetch origin dev -git log --oneline origin/dev..HEAD # 54 commits +git rebase origin/dev +# Konflikte einzeln lösen falls nötig... -# 3. Squashen -git reset --soft origin/dev -git commit -m "feat: major update with network graphs, themes, and more" - -# 4. Pushen +# 3. Pushen git push --force-with-lease -# 5. PR erstellen -gh pr create --base dev --head till-dev --title "feat: major update" +# 4. PR erstellen +gh pr create --base dev --head till-dev --title "feat(network): add network graph visualization" -# 6. Nach Merge: Branch aktualisieren +# 5. Nach Merge: Branch aktualisieren git fetch origin dev git checkout till-dev git reset --hard origin/dev @@ -301,6 +280,14 @@ git reflog git reset --hard HEAD@{2} ``` +### Viele Konflikte beim Rebase + +Wenn zu viele Konflikte auftreten: + +1. `git rebase --abort` - Rebase abbrechen +2. Regelmäßiger rebasen (täglich/wöchentlich) +3. Bei sehr alten Branches: Mit dem Team absprechen + --- *Zuletzt aktualisiert: 10.12.2025* diff --git a/docs/pr-reviews/PR-014-major-update.md b/docs/pr-reviews/PR-014-major-update.md new file mode 100644 index 000000000..6bb024b2d --- /dev/null +++ b/docs/pr-reviews/PR-014-major-update.md @@ -0,0 +1,272 @@ +# Code Review: PR #14 + +**Title:** feat: major update with network graphs, themes, todo extensions, and more +**Author:** Till-JS +**Date:** 2025-12-10 +**Status:** OPEN +**URL:** https://github.com/Memo-2023/manacore-monorepo/pull/14 + +--- + +## Summary + +| Metric | Value | +|--------|-------| +| Files Changed | 382 | +| Additions | +39,514 | +| Deletions | -6,251 | + +--- + +## Overview + +This is a **major feature release** introducing: + +1. **Network Graph Visualization** - D3.js force-directed graphs for Contacts, Calendar, and Todo apps +2. **Central Tags API** - Unified tagging system in mana-core-auth +3. **Custom Themes System** - Theme editor, community gallery, and sharing +4. **Todo App Extensions** - Kanban boards, statistics, settings page, PWA support +5. **Contacts App Features** - Duplicate detection, photo upload, batch operations, favorites views +6. **Help System** - Shared packages for content, UI, and types (`shared-help-content`, `shared-help-ui`, `shared-help-types`, `shared-help-mobile`) +7. **Skeleton Loaders** - Better loading states across apps +8. **CommandBar** - Global search (Cmd+K) +9. **Bug Fixes** - Network graph simulation fixes, database schema TEXT for user_id + +--- + +## Code Quality Analysis + +### Strengths + +#### 1. Excellent Architecture +- Clean separation of concerns with shared packages (`shared-ui`, `shared-theme`, `shared-tags`, `shared-help-*`) +- Proper Svelte 5 runes usage (`$state`, `$derived`, `$effect`) +- Good TypeScript typing throughout + +#### 2. NetworkGraph Component (`packages/shared-ui/src/organisms/network/`) +- Well-structured D3.js integration with `d3-zoom` and `d3-selection` +- Proper zoom/pan handling +- Keyboard shortcuts implemented: + - `+`/`-` for zoom in/out + - `0` to reset zoom + - `Esc` to deselect + - `F` to focus on selected node + - `/` to focus search +- Accessible with `role="button"`, `aria-label`, `tabindex` +- Efficient re-rendering with proper state management + +#### 3. Tags Service (`services/mana-core-auth/src/tags/`) +- Proper validation (duplicate name check before create/update) +- Good use of Drizzle's `returning()` for immediate results +- User-scoped queries with proper authorization (`userId` checks) +- Default tags created for new users + +#### 4. Custom Themes Store (`packages/shared-theme/src/custom-themes-store.svelte.ts`) +- Clean API design with factory function pattern +- Proper state management with Svelte 5 runes +- Good separation of public/authenticated API calls +- CSS variable application for runtime theming + +--- + +### Suggestions for Improvement + +#### 1. Hardcoded German Strings + +**Location:** `packages/shared-ui/src/organisms/network/NetworkGraph.svelte:440-442` + +```svelte +

Keine Verbindungen gefunden

+

Elemente werden verbunden, wenn sie gemeinsame Tags haben.

+``` + +**Recommendation:** Use i18n for user-facing strings to maintain consistency across the monorepo. + +--- + +#### 2. Default Tags in German Only + +**Location:** `services/mana-core-auth/src/tags/tags.service.ts:10-15` + +```typescript +const DEFAULT_TAGS = [ + { name: 'Arbeit', color: '#3B82F6', icon: 'Briefcase' }, + { name: 'Persönlich', color: '#10B981', icon: 'User' }, + { name: 'Familie', color: '#EC4899', icon: 'Heart' }, + { name: 'Wichtig', color: '#EF4444', icon: 'Star' }, +]; +``` + +**Recommendation:** Consider locale-aware default tags or use English defaults that users can customize. + +--- + +#### 3. Database Connection Pattern + +**Location:** `services/mana-core-auth/src/tags/tags.service.ts:21-24` + +```typescript +private getDb() { + const databaseUrl = this.configService.get('database.url'); + return getDb(databaseUrl!); +} +``` + +**Issue:** Using `!` assertion is less safe. + +**Recommendation:** Inject the database connection via NestJS dependency injection instead of calling `getDb()` on every method call. + +--- + +#### 4. Missing Error Boundary Handling + +The NetworkGraph component handles empty states but doesn't have explicit error handling for malformed node/link data. + +**Recommendation:** Add defensive checks for invalid data structures. + +--- + +### Potential Issues + +#### 1. PR Size + +- 382 files is extremely large for a single PR +- Makes code review difficult and increases risk +- Consider splitting into feature branches for easier review and rollback + +#### 2. Database Schema Consistency + +**Location:** `services/mana-core-auth/src/db/schema/tags.schema.ts:11` + +```typescript +userId: text('user_id').notNull(), +``` + +This uses `TEXT` type for user_id. Verify this aligns with how user IDs are stored in other tables (some use `UUID`). + +#### 3. Missing Test Coverage + +This major PR adds significant functionality without visible test changes. Consider adding: +- Unit tests for `TagsService` +- Component tests for `NetworkGraph` +- Integration tests for the themes API + +--- + +### Security Considerations + +#### Authorization Checks ✅ + +- Tag operations properly scope by `userId` +- Custom themes store requires authentication for write operations +- Community theme browsing allows public access (appropriate) + +#### Input Validation + +- DTOs should be reviewed for proper validation (max lengths, format checks) +- Tag color field accepts any 7-char string - consider validating hex format (`/^#[0-9A-Fa-f]{6}$/`) + +#### Environment Files ✅ + +- `.env.development` only removes 6 lines, no secrets added +- No credentials exposed in the diff + +--- + +## New Shared Packages + +| Package | Purpose | +|---------|---------| +| `@manacore/shared-tags` | Client for central tags API | +| `@manacore/shared-help-content` | Markdown content loader and search | +| `@manacore/shared-help-ui` | Svelte help page components | +| `@manacore/shared-help-types` | TypeScript types for help system | +| `@manacore/shared-help-mobile` | React Native help components | +| `@manacore/shared-theme-ui` | Theme editor and community gallery | + +--- + +## Files Changed by Category + +| Category | Count | Notable Changes | +|----------|-------|-----------------| +| Contacts App | ~40 | Duplicates, batch ops, network, favorites | +| Todo App | ~30 | Kanban, statistics, settings, PWA | +| Calendar App | ~25 | Event tags, network graph | +| Shared UI | ~30 | NetworkGraph, skeleton loaders, tags | +| Shared Theme | ~15 | Custom themes store, editor | +| Shared Help | ~35 | Content, UI, types, mobile | +| mana-core-auth | ~15 | Tags API, themes API | +| Archived Apps | ~100 | Documentation cleanup | + +--- + +## Recommendations + +### 1. Split Future PRs + +Consider creating separate PRs for major features: +- Network Graph feature +- Central Tags API +- Custom Themes System +- Help System packages + +### 2. Add i18n + +Replace hardcoded German strings in shared components. + +### 3. Add Tests + +At minimum, add unit tests for: +- `TagsService` (create, update, delete, defaults) +- `ThemesService` (publish, download, rate) +- `NetworkGraph` (props, events, accessibility) + +### 4. Database Migration Plan + +Ensure `tags` and `themes` table migrations are coordinated across environments. + +### 5. Documentation Updates + +The CLAUDE.md files are helpful. Ensure README updates for new packages. + +--- + +## Rating Summary + +| Aspect | Rating | Notes | +|--------|--------|-------| +| Code Quality | ⭐⭐⭐⭐ | Clean, well-structured code | +| Architecture | ⭐⭐⭐⭐⭐ | Excellent use of shared packages | +| Test Coverage | ⭐⭐ | Missing tests for new features | +| PR Size | ⭐⭐ | Too large for single review | +| Security | ⭐⭐⭐⭐ | Good authorization patterns | +| i18n | ⭐⭐⭐ | Some hardcoded German strings | + +--- + +## Conclusion + +This is solid, well-architected code with good separation of concerns. The main concerns are: + +1. **PR size** - Makes review difficult +2. **Missing tests** - New features lack test coverage +3. **Hardcoded strings** - Some German text in shared components + +Consider splitting future releases of this scale into smaller, focused PRs. + +--- + +## Test Plan Checklist + +From the PR description: + +- [ ] Verify network graph loads correctly in Contacts, Calendar, Todo +- [ ] Test theme editor and community themes page +- [ ] Check Todo app new features (kanban, statistics, settings) +- [ ] Verify contacts duplicate detection and batch operations +- [ ] Test skeleton loaders appear during loading states + +--- + +*Review by Claude Code - 2025-12-10* diff --git a/packages/mana-core-nestjs-integration/package.json b/packages/mana-core-nestjs-integration/package.json index 9db58f099..ed33d057d 100644 --- a/packages/mana-core-nestjs-integration/package.json +++ b/packages/mana-core-nestjs-integration/package.json @@ -25,7 +25,8 @@ "build": "tsc", "dev": "tsc --watch", "clean": "rm -rf dist", - "lint": "eslint ." + "lint": "eslint .", + "type-check": "tsc --noEmit" }, "dependencies": { "@nestjs/common": "^10.0.0 || ^11.0.0", diff --git a/packages/shared-theme/src/app-routes.ts b/packages/shared-theme/src/app-routes.ts index dcfa573c1..7ace3fb16 100644 --- a/packages/shared-theme/src/app-routes.ts +++ b/packages/shared-theme/src/app-routes.ts @@ -6,15 +6,17 @@ */ /** - * Route definition with i18n label + * Route definition with label */ export interface AppRoute { /** Route path (e.g., '/stopwatch') */ path: string; - /** i18n key for the label (e.g., 'nav.stopwatch') */ - labelKey: string; + /** Display label for the route (e.g., 'Stoppuhr') */ + label: string; /** Optional icon name */ icon?: string; + /** If true, this route cannot be hidden (e.g., Settings, Home) */ + alwaysVisible?: boolean; } /** @@ -37,13 +39,14 @@ export const APP_ROUTES: Record = { appId: 'clock', defaultRoute: '/', availableRoutes: [ - { path: '/', labelKey: 'nav.dashboard', icon: 'home' }, - { path: '/alarms', labelKey: 'nav.alarms', icon: 'alarm' }, - { path: '/timers', labelKey: 'nav.timers', icon: 'timer' }, - { path: '/stopwatch', labelKey: 'nav.stopwatch', icon: 'stopwatch' }, - { path: '/pomodoro', labelKey: 'nav.pomodoro', icon: 'target' }, - { path: '/world-clock', labelKey: 'nav.worldClock', icon: 'globe' }, - { path: '/life', labelKey: 'nav.lifeClock', icon: 'heart' }, + { path: '/', label: 'Dashboard', icon: 'home', alwaysVisible: true }, + { path: '/alarms', label: 'Wecker', icon: 'alarm' }, + { path: '/timers', label: 'Timer', icon: 'timer' }, + { path: '/stopwatch', label: 'Stoppuhr', icon: 'stopwatch' }, + { path: '/pomodoro', label: 'Pomodoro', icon: 'target' }, + { path: '/world-clock', label: 'Weltuhr', icon: 'globe' }, + { path: '/life', label: 'Lebenszeit', icon: 'heart' }, + { path: '/settings', label: 'Einstellungen', icon: 'settings', alwaysVisible: true }, ], }, @@ -51,8 +54,11 @@ export const APP_ROUTES: Record = { appId: 'calendar', defaultRoute: '/', availableRoutes: [ - { path: '/', labelKey: 'nav.month', icon: 'calendar' }, - { path: '/agenda', labelKey: 'nav.agenda', icon: 'list' }, + { path: '/', label: 'Kalender', icon: 'calendar', alwaysVisible: true }, + { path: '/agenda', label: 'Agenda', icon: 'list' }, + { path: '/tags', label: 'Tags', icon: 'tag' }, + { path: '/network', label: 'Netzwerk', icon: 'share' }, + { path: '/settings', label: 'Einstellungen', icon: 'settings', alwaysVisible: true }, ], }, @@ -60,20 +66,14 @@ export const APP_ROUTES: Record = { appId: 'contacts', defaultRoute: '/', availableRoutes: [ - { path: '/', labelKey: 'nav.contacts', icon: 'users' }, - { path: '/groups', labelKey: 'nav.groups', icon: 'folder' }, - { path: '/favorites', labelKey: 'nav.favorites', icon: 'star' }, - ], - }, - - mail: { - appId: 'mail', - defaultRoute: '/', - availableRoutes: [ - { path: '/', labelKey: 'nav.inbox', icon: 'inbox' }, - { path: '/sent', labelKey: 'nav.sent', icon: 'send' }, - { path: '/drafts', labelKey: 'nav.drafts', icon: 'file' }, - { path: '/starred', labelKey: 'nav.starred', icon: 'star' }, + { path: '/', label: 'Kontakte', icon: 'users', alwaysVisible: true }, + { path: '/favorites', label: 'Favoriten', icon: 'star' }, + { path: '/tags', label: 'Tags', icon: 'tag' }, + { path: '/archive', label: 'Archiv', icon: 'archive' }, + { path: '/duplicates', label: 'Duplikate', icon: 'copy' }, + { path: '/data', label: 'Import/Export', icon: 'download' }, + { path: '/network', label: 'Netzwerk', icon: 'share' }, + { path: '/settings', label: 'Einstellungen', icon: 'settings', alwaysVisible: true }, ], }, @@ -81,21 +81,12 @@ export const APP_ROUTES: Record = { appId: 'todo', defaultRoute: '/', availableRoutes: [ - { path: '/', labelKey: 'nav.all', icon: 'list' }, - { path: '/today', labelKey: 'nav.today', icon: 'calendar' }, - { path: '/upcoming', labelKey: 'nav.upcoming', icon: 'clock' }, - { path: '/completed', labelKey: 'nav.completed', icon: 'check' }, - ], - }, - - storage: { - appId: 'storage', - defaultRoute: '/', - availableRoutes: [ - { path: '/', labelKey: 'nav.home', icon: 'home' }, - { path: '/files', labelKey: 'nav.files', icon: 'folder' }, - { path: '/favorites', labelKey: 'nav.favorites', icon: 'star' }, - { path: '/shared', labelKey: 'nav.shared', icon: 'share' }, + { path: '/', label: 'Aufgaben', icon: 'list', alwaysVisible: true }, + { path: '/kanban', label: 'Kanban', icon: 'grid' }, + { path: '/labels', label: 'Labels', icon: 'tag' }, + { path: '/statistics', label: 'Statistiken', icon: 'chart' }, + { path: '/network', label: 'Netzwerk', icon: 'share' }, + { path: '/settings', label: 'Einstellungen', icon: 'settings', alwaysVisible: true }, ], }, @@ -103,10 +94,13 @@ export const APP_ROUTES: Record = { appId: 'chat', defaultRoute: '/chat', availableRoutes: [ - { path: '/chat', labelKey: 'nav.chat', icon: 'message' }, - { path: '/spaces', labelKey: 'nav.spaces', icon: 'folder' }, - { path: '/templates', labelKey: 'nav.templates', icon: 'file' }, - { path: '/documents', labelKey: 'nav.documents', icon: 'document' }, + { path: '/chat', label: 'Chat', icon: 'message', alwaysVisible: true }, + { path: '/templates', label: 'Vorlagen', icon: 'file' }, + { path: '/spaces', label: 'Spaces', icon: 'folder' }, + { path: '/documents', label: 'Dokumente', icon: 'document' }, + { path: '/archive', label: 'Archiv', icon: 'archive' }, + { path: '/feedback', label: 'Feedback', icon: 'chat' }, + { path: '/settings', label: 'Einstellungen', icon: 'settings', alwaysVisible: true }, ], }, @@ -114,10 +108,14 @@ export const APP_ROUTES: Record = { appId: 'picture', defaultRoute: '/app/gallery', availableRoutes: [ - { path: '/app/gallery', labelKey: 'nav.gallery', icon: 'image' }, - { path: '/app/generate', labelKey: 'nav.generate', icon: 'sparkle' }, - { path: '/app/board', labelKey: 'nav.board', icon: 'grid' }, - { path: '/app/explore', labelKey: 'nav.explore', icon: 'compass' }, + { path: '/app/gallery', label: 'Galerie', icon: 'image', alwaysVisible: true }, + { path: '/app/board', label: 'Moodboards', icon: 'grid' }, + { path: '/app/explore', label: 'Entdecken', icon: 'compass' }, + { path: '/app/generate', label: 'Generieren', icon: 'sparkle' }, + { path: '/app/upload', label: 'Upload', icon: 'upload' }, + { path: '/app/tags', label: 'Tags', icon: 'tag' }, + { path: '/app/archive', label: 'Archiv', icon: 'archive' }, + { path: '/app/settings', label: 'Einstellungen', icon: 'settings', alwaysVisible: true }, ], }, @@ -125,9 +123,10 @@ export const APP_ROUTES: Record = { appId: 'manadeck', defaultRoute: '/decks', availableRoutes: [ - { path: '/decks', labelKey: 'nav.decks', icon: 'layers' }, - { path: '/explore', labelKey: 'nav.explore', icon: 'compass' }, - { path: '/progress', labelKey: 'nav.progress', icon: 'trending' }, + { path: '/decks', label: 'Decks', icon: 'layers', alwaysVisible: true }, + { path: '/explore', label: 'Entdecken', icon: 'compass' }, + { path: '/progress', label: 'Fortschritt', icon: 'trending' }, + { path: '/settings', label: 'Einstellungen', icon: 'settings', alwaysVisible: true }, ], }, @@ -135,24 +134,23 @@ export const APP_ROUTES: Record = { appId: 'zitare', defaultRoute: '/', availableRoutes: [ - { path: '/', labelKey: 'nav.home', icon: 'home' }, - { path: '/quotes', labelKey: 'nav.quotes', icon: 'quote' }, - { path: '/favorites', labelKey: 'nav.favorites', icon: 'star' }, - { path: '/authors', labelKey: 'nav.authors', icon: 'users' }, - { path: '/lists', labelKey: 'nav.lists', icon: 'list' }, + { path: '/', label: 'Zitate', icon: 'quote', alwaysVisible: true }, + { path: '/search', label: 'Suche', icon: 'search' }, + { path: '/authors', label: 'Autoren', icon: 'users' }, + { path: '/favorites', label: 'Favoriten', icon: 'star' }, + { path: '/lists', label: 'Listen', icon: 'list' }, + { path: '/feedback', label: 'Feedback', icon: 'chat' }, + { path: '/settings', label: 'Einstellungen', icon: 'settings', alwaysVisible: true }, ], }, - presi: { - appId: 'presi', - defaultRoute: '/', - availableRoutes: [{ path: '/', labelKey: 'nav.home', icon: 'home' }], - }, - manacore: { appId: 'manacore', defaultRoute: '/', - availableRoutes: [{ path: '/', labelKey: 'nav.dashboard', icon: 'home' }], + availableRoutes: [ + { path: '/', label: 'Dashboard', icon: 'home', alwaysVisible: true }, + { path: '/settings', label: 'Einstellungen', icon: 'settings', alwaysVisible: true }, + ], }, }; @@ -199,3 +197,46 @@ export function getAvailableRoutes(appId: string): AppRoute[] { export function getDefaultRoute(appId: string): string { return APP_ROUTES[appId]?.defaultRoute ?? '/'; } + +/** + * Filter hidden navigation items from a list of nav items + * @param appId The app identifier + * @param items Array of nav items with href property + * @param hiddenNavItems Hidden items config (appId -> hidden paths) + * @returns Filtered array with hidden items removed + */ +export function filterHiddenNavItems( + appId: string, + items: T[], + hiddenNavItems: Record = {} +): T[] { + const hidden = hiddenNavItems[appId] || []; + return items.filter((item) => !hidden.includes(item.href)); +} + +/** + * Get routes that can be hidden for a specific app + * (excludes routes marked as alwaysVisible) + * @param appId The app identifier + * @returns Array of routes that can be hidden + */ +export function getHideableRoutes(appId: string): AppRoute[] { + const config = APP_ROUTES[appId]; + return config?.availableRoutes.filter((r) => !r.alwaysVisible) || []; +} + +/** + * Check if a route is hidden for a specific app + * @param appId The app identifier + * @param path The route path + * @param hiddenNavItems Hidden items config + * @returns True if the route is hidden + */ +export function isRouteHidden( + appId: string, + path: string, + hiddenNavItems: Record = {} +): boolean { + const hidden = hiddenNavItems[appId] || []; + return hidden.includes(path); +} diff --git a/packages/shared-theme/src/index.ts b/packages/shared-theme/src/index.ts index 090d9659b..9a5e51864 100644 --- a/packages/shared-theme/src/index.ts +++ b/packages/shared-theme/src/index.ts @@ -117,4 +117,12 @@ export { // App Routes export type { AppRoute, AppRouteConfig } from './app-routes'; -export { APP_ROUTES, getStartPage, getAvailableRoutes, getDefaultRoute } from './app-routes'; +export { + APP_ROUTES, + getStartPage, + getAvailableRoutes, + getDefaultRoute, + filterHiddenNavItems, + getHideableRoutes, + isRouteHidden, +} from './app-routes'; diff --git a/packages/shared-theme/src/types.ts b/packages/shared-theme/src/types.ts index d8eed66a7..964771bec 100644 --- a/packages/shared-theme/src/types.ts +++ b/packages/shared-theme/src/types.ts @@ -240,6 +240,8 @@ export interface NavSettings { desktopPosition: NavPosition; /** Whether sidebar is collapsed */ sidebarCollapsed: boolean; + /** Hidden navigation items per app (appId -> list of hidden paths) */ + hiddenNavItems?: Record; } /** @@ -323,7 +325,7 @@ export const DEFAULT_GENERAL_SETTINGS: GeneralSettings = { * Default global settings */ export const DEFAULT_GLOBAL_SETTINGS: GlobalSettings = { - nav: { desktopPosition: 'top', sidebarCollapsed: false }, + nav: { desktopPosition: 'top', sidebarCollapsed: false, hiddenNavItems: {} }, theme: { mode: 'system', colorScheme: 'ocean', pinnedThemes: [] }, locale: 'de', general: DEFAULT_GENERAL_SETTINGS, @@ -364,6 +366,12 @@ export interface UserSettingsStore { setStartPage: (appId: string, path: string) => Promise; /** Update general settings */ updateGeneral: (settings: Partial) => Promise; + /** Get hidden nav items for a specific app */ + getHiddenNavItemsForApp: (appId: string) => string[]; + /** Toggle visibility of a navigation item */ + toggleNavItemVisibility: (appId: string, href: string) => Promise; + /** Set hidden nav items for an app */ + setHiddenNavItems: (appId: string, hiddenHrefs: string[]) => Promise; } /** diff --git a/packages/shared-theme/src/user-settings-store.svelte.ts b/packages/shared-theme/src/user-settings-store.svelte.ts index 7a9ee94f6..c2ecabd16 100644 --- a/packages/shared-theme/src/user-settings-store.svelte.ts +++ b/packages/shared-theme/src/user-settings-store.svelte.ts @@ -314,6 +314,46 @@ export function createUserSettingsStore(config: UserSettingsStoreConfig): UserSe } } + /** + * Get hidden nav items for a specific app + */ + function getHiddenNavItemsForApp(targetAppId: string): string[] { + return globalSettings.nav.hiddenNavItems?.[targetAppId] || []; + } + + /** + * Toggle visibility of a navigation item for an app + */ + async function toggleNavItemVisibility(targetAppId: string, href: string): Promise { + const currentHidden = getHiddenNavItemsForApp(targetAppId); + const isHidden = currentHidden.includes(href); + + const newHidden = isHidden ? currentHidden.filter((h) => h !== href) : [...currentHidden, href]; + + await setHiddenNavItems(targetAppId, newHidden); + } + + /** + * Set hidden nav items for an app + */ + async function setHiddenNavItems(targetAppId: string, hiddenHrefs: string[]): Promise { + const newHiddenNavItems = { + ...globalSettings.nav.hiddenNavItems, + [targetAppId]: hiddenHrefs, + }; + + // Remove empty arrays + if (hiddenHrefs.length === 0) { + delete newHiddenNavItems[targetAppId]; + } + + await updateGlobal({ + nav: { + hiddenNavItems: newHiddenNavItems, + }, + } as Partial); + } + return { get nav() { return nav; @@ -349,5 +389,8 @@ export function createUserSettingsStore(config: UserSettingsStoreConfig): UserSe removeAppOverride, setStartPage, updateGeneral, + getHiddenNavItemsForApp, + toggleNavItemVisibility, + setHiddenNavItems, }; } diff --git a/packages/shared-ui/package.json b/packages/shared-ui/package.json index d43549996..f366d440f 100644 --- a/packages/shared-ui/package.json +++ b/packages/shared-ui/package.json @@ -42,6 +42,7 @@ "d3-selection": "^3.0.0", "d3-transition": "^3.0.0", "d3-zoom": "^3.0.0", + "date-fns": "^4.1.0", "lucide-svelte": "^0.468.0" }, "devDependencies": { diff --git a/packages/shared-ui/src/charts/ActivityHeatmap.svelte b/packages/shared-ui/src/charts/ActivityHeatmap.svelte new file mode 100644 index 000000000..852539046 --- /dev/null +++ b/packages/shared-ui/src/charts/ActivityHeatmap.svelte @@ -0,0 +1,294 @@ + + +
+

{title}

+ +
+ + + {#each monthLabels as label} + + {label.month} + + {/each} + + + {#each DAY_LABELS as label, i} + {#if label} + + {label} + + {/if} + {/each} + + + {#each weeks as week, weekIndex} + {#each week as day, dayIndex} + {#if day.date} + + {formatTooltip(day)} + + {:else} + + {/if} + {/each} + {/each} + +
+ + +
+ Weniger +
+
+
+
+
+
+
+ Mehr +
+
+ + diff --git a/packages/shared-ui/src/charts/DonutChart.svelte b/packages/shared-ui/src/charts/DonutChart.svelte new file mode 100644 index 000000000..643db2d61 --- /dev/null +++ b/packages/shared-ui/src/charts/DonutChart.svelte @@ -0,0 +1,260 @@ + + +
+

{title}

+ +
+
+ + {#each arcs as arc} + (hoveredSegment = arc.id)} + onmouseleave={() => (hoveredSegment = null)} + role="graphics-symbol" + aria-label="{arc.label}: {arc.count}" + > + {arc.label}: {arc.count} ({arc.percentage}%) + + {/each} + + + + {total} + + + {centerLabel} + + +
+ + + {#if showLegend} +
+ {#each data as item} +
(hoveredSegment = item.id)} + onmouseleave={() => (hoveredSegment = null)} + role="button" + tabindex="0" + > + + {item.label} + {item.count} +
+ {/each} +
+ {/if} +
+
+ + diff --git a/packages/shared-ui/src/charts/ProgressBars.svelte b/packages/shared-ui/src/charts/ProgressBars.svelte new file mode 100644 index 000000000..21692ec04 --- /dev/null +++ b/packages/shared-ui/src/charts/ProgressBars.svelte @@ -0,0 +1,192 @@ + + +
+

{title}

+ + {#if sortedData.length === 0} +

{emptyMessage}

+ {:else} +
+ {#each sortedData as item (item.id)} +
+
+
+ + {item.name} +
+ + {item.completed}/{item.total} + +
+ +
+
+ + {#if item.completed > 0} +
+ {/if} + + + {#if item.inProgress && item.inProgress > 0} +
+ {/if} +
+ + {item.percentage}% +
+
+ {/each} +
+ {/if} +
+ + diff --git a/packages/shared-ui/src/charts/StatisticsSkeleton.svelte b/packages/shared-ui/src/charts/StatisticsSkeleton.svelte new file mode 100644 index 000000000..e50ea20dd --- /dev/null +++ b/packages/shared-ui/src/charts/StatisticsSkeleton.svelte @@ -0,0 +1,272 @@ + + +
+ +
+ {#each Array(statCards) as _, i} +
+ +
+ + +
+
+ {/each} +
+ + +
+ +
+
+ +
+
+ {#each Array(7) as _} +
+ {#each Array(12) as _} + + {/each} +
+ {/each} +
+
+ + +
+ +
+
+ +
+
+ {#each Array(7) as _, i} +
+ + +
+ {/each} +
+
+ + +
+
+ +
+
+ +
+
+ {#each Array(legendItems) as _} +
+ + +
+ {/each} +
+
+
+ + +
+
+ +
+
+ {#each Array(progressItems) as _, i} +
+
+ + +
+ +
+ {/each} +
+
+
+ + + {#if showAdditionalStats} +
+ {#each Array(3) as _} +
+ + +
+ {/each} +
+ {/if} +
+ + diff --git a/packages/shared-ui/src/charts/StatsGrid.svelte b/packages/shared-ui/src/charts/StatsGrid.svelte new file mode 100644 index 000000000..49d1ffea9 --- /dev/null +++ b/packages/shared-ui/src/charts/StatsGrid.svelte @@ -0,0 +1,136 @@ + + +
+ {#each visibleItems as item (item.id)} +
+
+ +
+
+ {item.value} + {item.label} +
+
+ {/each} +
+ + diff --git a/packages/shared-ui/src/charts/TrendLineChart.svelte b/packages/shared-ui/src/charts/TrendLineChart.svelte new file mode 100644 index 000000000..0615c490b --- /dev/null +++ b/packages/shared-ui/src/charts/TrendLineChart.svelte @@ -0,0 +1,240 @@ + + +
+

{title}

+ + + + {#each yTicks as tick} + + {/each} + + + + + + + + + + + + + + + + {#each data as point, i} + + {formatTooltip(point)} + + {/each} + + + {#each yTicks as tick} + + {tick} + + {/each} + + + {#each xLabels as label} + + {label.label} + + {/each} + +
+ + diff --git a/packages/shared-ui/src/charts/index.ts b/packages/shared-ui/src/charts/index.ts new file mode 100644 index 000000000..6246fd8a8 --- /dev/null +++ b/packages/shared-ui/src/charts/index.ts @@ -0,0 +1,20 @@ +// Charts - Statistics Visualization Components +export { default as StatsGrid } from './StatsGrid.svelte'; +export { default as ActivityHeatmap } from './ActivityHeatmap.svelte'; +export { default as TrendLineChart } from './TrendLineChart.svelte'; +export { default as DonutChart } from './DonutChart.svelte'; +export { default as ProgressBars } from './ProgressBars.svelte'; +export { default as StatisticsSkeleton } from './StatisticsSkeleton.svelte'; + +// Types +export type { + StatVariant, + StatItem, + HeatmapDataPoint, + TrendDataPoint, + DonutSegment, + ProgressItem, +} from './types'; + +// Constants +export { STAT_VARIANT_COLORS } from './types'; diff --git a/packages/shared-ui/src/charts/types.ts b/packages/shared-ui/src/charts/types.ts new file mode 100644 index 000000000..774b0d993 --- /dev/null +++ b/packages/shared-ui/src/charts/types.ts @@ -0,0 +1,62 @@ +/** + * Shared Types for Chart Components + */ + +import type { Component } from 'svelte'; + +// Stat card variant colors +export type StatVariant = 'success' | 'primary' | 'neutral' | 'danger' | 'info' | 'accent'; + +export const STAT_VARIANT_COLORS: Record = { + success: { bg: 'rgba(16, 185, 129, 0.15)', color: '#10B981' }, + primary: { bg: 'rgba(139, 92, 246, 0.15)', color: '#8B5CF6' }, + neutral: { bg: 'rgba(107, 114, 128, 0.15)', color: '#6B7280' }, + danger: { bg: 'rgba(239, 68, 68, 0.15)', color: '#EF4444' }, + info: { bg: 'rgba(59, 130, 246, 0.15)', color: '#3B82F6' }, + accent: { bg: 'rgba(236, 72, 153, 0.15)', color: '#EC4899' }, +}; + +// StatsGrid types +export interface StatItem { + id: string; + label: string; + value: number | string; + icon: Component; + variant: StatVariant; + /** Optional: only show this stat if condition is true */ + showCondition?: boolean; +} + +// ActivityHeatmap types +export interface HeatmapDataPoint { + date: string; // YYYY-MM-DD format + count: number; + dayOfWeek: number; // 0-6 (Sunday-Saturday) +} + +// TrendLineChart types +export interface TrendDataPoint { + date: string; // YYYY-MM-DD format + count: number; + label?: string; +} + +// DonutChart types +export interface DonutSegment { + id: string; + label: string; + count: number; + percentage: number; + color: string; +} + +// ProgressBars types +export interface ProgressItem { + id: string; + name: string; + color: string; + total: number; + completed: number; + inProgress?: number; + percentage: number; +} diff --git a/packages/shared-ui/src/command-bar/CommandBar.svelte b/packages/shared-ui/src/command-bar/CommandBar.svelte index 71822e8a7..623e1f3c0 100644 --- a/packages/shared-ui/src/command-bar/CommandBar.svelte +++ b/packages/shared-ui/src/command-bar/CommandBar.svelte @@ -1,6 +1,47 @@ + +{#if hasRoutes} +
+
+

+ Navigation anpassen +

+

+ Versteckte Seiten bleiben über die URL erreichbar +

+
+ +
+ {#each hideableItems as item (item.href)} + {@const hidden = isRouteHidden(item.href)} + {@const iconPath = item.icon ? getIconPath(item.icon) : ''} + + {/each} +
+
+{/if} diff --git a/packages/shared-ui/src/settings/index.ts b/packages/shared-ui/src/settings/index.ts index f7daa394c..06db8bce2 100644 --- a/packages/shared-ui/src/settings/index.ts +++ b/packages/shared-ui/src/settings/index.ts @@ -10,3 +10,4 @@ export { default as SettingsTimeInput } from './SettingsTimeInput.svelte'; export { default as SettingsDangerZone } from './SettingsDangerZone.svelte'; export { default as SettingsDangerButton } from './SettingsDangerButton.svelte'; export { default as GlobalSettingsSection } from './GlobalSettingsSection.svelte'; +export { default as NavVisibilitySettings } from './NavVisibilitySettings.svelte'; diff --git a/packages/shared-utils/src/index.ts b/packages/shared-utils/src/index.ts index f77937d38..611e1384d 100644 --- a/packages/shared-utils/src/index.ts +++ b/packages/shared-utils/src/index.ts @@ -22,3 +22,6 @@ export * from './keyboard'; // IndexedDB Cache export * from './cache'; + +// Natural Language Parsers +export * from './parsers'; diff --git a/packages/shared-utils/src/parsers/base-parser.ts b/packages/shared-utils/src/parsers/base-parser.ts new file mode 100644 index 000000000..bb5c02fcc --- /dev/null +++ b/packages/shared-utils/src/parsers/base-parser.ts @@ -0,0 +1,320 @@ +/** + * Base Natural Language Parser + * + * Shared parsing utilities for date, time, and tags across all apps. + * App-specific parsers (task-parser, event-parser, contact-parser) extend this. + */ + +import { + addDays, + nextMonday, + nextTuesday, + nextWednesday, + nextThursday, + nextFriday, + nextSaturday, + nextSunday, + setHours, + setMinutes, +} from 'date-fns'; + +export interface BaseParsedInput { + title: string; + date?: Date; + time?: { hours: number; minutes: number }; + tagNames: string[]; + rawInput: string; +} + +export interface ExtractResult { + value: T | undefined; + remaining: string; +} + +// ============================================================================ +// Date Extraction +// ============================================================================ + +interface DatePattern { + pattern: RegExp; + getDate: (match?: RegExpMatchArray) => Date; +} + +const DATE_PATTERNS: DatePattern[] = [ + { pattern: /\bheute\b/i, getDate: () => new Date() }, + { pattern: /\bmorgen\b/i, getDate: () => addDays(new Date(), 1) }, + { pattern: /\bübermorgen\b/i, getDate: () => addDays(new Date(), 2) }, + { pattern: /\bnächste[nr]?\s*woche\b/i, getDate: () => addDays(new Date(), 7) }, + { pattern: /\bnächste[nr]?\s*montag\b/i, getDate: () => nextMonday(new Date()) }, + { pattern: /\bnächste[nr]?\s*dienstag\b/i, getDate: () => nextTuesday(new Date()) }, + { pattern: /\bnächste[nr]?\s*mittwoch\b/i, getDate: () => nextWednesday(new Date()) }, + { pattern: /\bnächste[nr]?\s*donnerstag\b/i, getDate: () => nextThursday(new Date()) }, + { pattern: /\bnächste[nr]?\s*freitag\b/i, getDate: () => nextFriday(new Date()) }, + { pattern: /\bnächste[nr]?\s*samstag\b/i, getDate: () => nextSaturday(new Date()) }, + { pattern: /\bnächste[nr]?\s*sonntag\b/i, getDate: () => nextSunday(new Date()) }, + { pattern: /\bmontag\b/i, getDate: () => nextMonday(new Date()) }, + { pattern: /\bdienstag\b/i, getDate: () => nextTuesday(new Date()) }, + { pattern: /\bmittwoch\b/i, getDate: () => nextWednesday(new Date()) }, + { pattern: /\bdonnerstag\b/i, getDate: () => nextThursday(new Date()) }, + { pattern: /\bfreitag\b/i, getDate: () => nextFriday(new Date()) }, + { pattern: /\bsamstag\b/i, getDate: () => nextSaturday(new Date()) }, + { pattern: /\bsonntag\b/i, getDate: () => nextSunday(new Date()) }, +]; + +// Pattern for "in X Tagen" +const IN_DAYS_PATTERN = /\bin\s*(\d+)\s*tage?n?\b/i; + +// Pattern for specific date (DD.MM. or DD.MM.YYYY) +const SPECIFIC_DATE_PATTERN = /\b(\d{1,2})\.(\d{1,2})\.?(\d{2,4})?\b/; + +/** + * Extract date from text + */ +export function extractDate(text: string): ExtractResult { + let remaining = text; + let date: Date | undefined; + + // Try "in X Tagen" pattern first + const inDaysMatch = remaining.match(IN_DAYS_PATTERN); + if (inDaysMatch) { + const days = parseInt(inDaysMatch[1], 10); + date = addDays(new Date(), days); + remaining = remaining.replace(IN_DAYS_PATTERN, '').trim(); + return { value: date, remaining }; + } + + // Try specific date (DD.MM. or DD.MM.YYYY) + const specificDateMatch = remaining.match(SPECIFIC_DATE_PATTERN); + if (specificDateMatch) { + const day = parseInt(specificDateMatch[1], 10); + const month = parseInt(specificDateMatch[2], 10) - 1; + const year = specificDateMatch[3] + ? parseInt(specificDateMatch[3], 10) < 100 + ? 2000 + parseInt(specificDateMatch[3], 10) + : parseInt(specificDateMatch[3], 10) + : new Date().getFullYear(); + + date = new Date(year, month, day); + remaining = remaining.replace(SPECIFIC_DATE_PATTERN, '').trim(); + return { value: date, remaining }; + } + + // Try relative date patterns + for (const { pattern, getDate } of DATE_PATTERNS) { + if (pattern.test(remaining)) { + date = getDate(); + remaining = remaining.replace(pattern, '').trim(); + return { value: date, remaining }; + } + } + + return { value: undefined, remaining }; +} + +// ============================================================================ +// Time Extraction +// ============================================================================ + +// Pattern for time (um 14 Uhr, 14:00, etc.) +const TIME_PATTERN = /\b(?:um\s*)?(\d{1,2})(?::(\d{2}))?\s*(?:uhr)?\b/i; + +/** + * Extract time from text + */ +export function extractTime(text: string): ExtractResult<{ hours: number; minutes: number }> { + const match = text.match(TIME_PATTERN); + + if (match) { + const hours = parseInt(match[1], 10); + const minutes = match[2] ? parseInt(match[2], 10) : 0; + + // Validate time + if (hours >= 0 && hours <= 23 && minutes >= 0 && minutes <= 59) { + const remaining = text.replace(TIME_PATTERN, '').trim(); + return { value: { hours, minutes }, remaining }; + } + } + + return { value: undefined, remaining: text }; +} + +// ============================================================================ +// Tag Extraction +// ============================================================================ + +/** + * Extract tags (#tag1 #tag2) from text + */ +export function extractTags(text: string): ExtractResult { + const tags: string[] = []; + const tagRegex = /#(\S+)/g; + let match; + + while ((match = tagRegex.exec(text)) !== null) { + tags.push(match[1]); + } + + const remaining = text.replace(/#\S+/g, '').trim(); + return { value: tags, remaining }; +} + +// ============================================================================ +// @ Reference Extraction (Projects, Calendars, Companies) +// ============================================================================ + +/** + * Extract @reference from text + */ +export function extractAtReference(text: string): ExtractResult { + const match = text.match(/@(\S+)/); + + if (match) { + const remaining = text.replace(/@\S+/, '').trim(); + return { value: match[1], remaining }; + } + + return { value: undefined, remaining: text }; +} + +// ============================================================================ +// Combined Date + Time +// ============================================================================ + +/** + * Combine date and time into a single Date object + */ +export function combineDateAndTime( + date?: Date, + time?: { hours: number; minutes: number } +): Date | undefined { + if (!date) return undefined; + + if (time) { + return setHours(setMinutes(date, time.minutes), time.hours); + } + + return date; +} + +// ============================================================================ +// Preview Formatting +// ============================================================================ + +/** + * Format date for preview display + */ +export function formatDatePreview(date: Date): string { + const now = new Date(); + const tomorrow = addDays(now, 1); + + if (date.toDateString() === now.toDateString()) { + return 'Heute'; + } + if (date.toDateString() === tomorrow.toDateString()) { + return 'Morgen'; + } + + return date.toLocaleDateString('de-DE', { + weekday: 'short', + day: 'numeric', + month: 'short', + }); +} + +/** + * Format time for preview display + */ +export function formatTimePreview(time: { hours: number; minutes: number }): string { + return `${time.hours.toString().padStart(2, '0')}:${time.minutes.toString().padStart(2, '0')}`; +} + +/** + * Format date and time for preview + */ +export function formatDateTimePreview( + date?: Date, + time?: { hours: number; minutes: number } +): string { + if (!date) return ''; + + let result = formatDatePreview(date); + + if (time) { + result += ` ${formatTimePreview(time)}`; + } + + return result; +} + +// ============================================================================ +// Main Parser Function +// ============================================================================ + +/** + * Parse base input - extracts common patterns (date, time, tags, @reference) + * + * App-specific parsers should call this first, then extract their own patterns. + */ +export function parseBaseInput(input: string): BaseParsedInput { + let text = input.trim(); + const rawInput = text; + + // Extract tags first (they're clearly delimited) + const tagsResult = extractTags(text); + text = tagsResult.remaining; + const tagNames = tagsResult.value || []; + + // Extract date + const dateResult = extractDate(text); + text = dateResult.remaining; + const date = dateResult.value; + + // Extract time + const timeResult = extractTime(text); + text = timeResult.remaining; + const time = timeResult.value; + + // If we got time but no date, assume today + const finalDate = time && !date ? new Date() : date; + + // Clean up multiple spaces + const title = text.replace(/\s+/g, ' ').trim(); + + return { + title, + date: finalDate, + time, + tagNames, + rawInput, + }; +} + +// ============================================================================ +// Utility: Clean title from all patterns +// ============================================================================ + +/** + * Remove all recognized patterns from text to get clean title + */ +export function cleanTitle(text: string): string { + let result = text; + + // Remove tags + result = result.replace(/#\S+/g, ''); + + // Remove @references + result = result.replace(/@\S+/g, ''); + + // Remove dates + result = result.replace(IN_DAYS_PATTERN, ''); + result = result.replace(SPECIFIC_DATE_PATTERN, ''); + for (const { pattern } of DATE_PATTERNS) { + result = result.replace(pattern, ''); + } + + // Remove time + result = result.replace(TIME_PATTERN, ''); + + // Clean up + return result.replace(/\s+/g, ' ').trim(); +} diff --git a/packages/shared-utils/src/parsers/index.ts b/packages/shared-utils/src/parsers/index.ts new file mode 100644 index 000000000..b209804ef --- /dev/null +++ b/packages/shared-utils/src/parsers/index.ts @@ -0,0 +1,26 @@ +/** + * Natural Language Parsers + * + * Base parser with common patterns, extended by app-specific parsers. + */ + +export { + // Types + type BaseParsedInput, + type ExtractResult, + // Extraction functions + extractDate, + extractTime, + extractTags, + extractAtReference, + // Combination + combineDateAndTime, + // Preview formatting + formatDatePreview, + formatTimePreview, + formatDateTimePreview, + // Main parser + parseBaseInput, + // Utilities + cleanTitle, +} from './base-parser'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1d7dc9fca..24a1a1fd2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -116,7 +116,7 @@ importers: devDependencies: '@nestjs/cli': specifier: ^10.4.9 - version: 10.4.9(esbuild@0.27.0) + version: 10.4.9(esbuild@0.19.12) '@nestjs/schematics': specifier: ^10.2.3 version: 10.2.3(chokidar@3.6.0)(typescript@5.9.3) @@ -149,7 +149,7 @@ importers: version: 0.5.21 ts-loader: specifier: ^9.5.1 - version: 9.5.4(typescript@5.9.3)(webpack@5.100.2(esbuild@0.27.0)) + version: 9.5.4(typescript@5.9.3)(webpack@5.97.1(esbuild@0.19.12)) ts-node: specifier: ^10.9.2 version: 10.9.2(@types/node@22.19.1)(typescript@5.9.3) @@ -173,14 +173,14 @@ importers: version: link:../../../../packages/shared-landing-ui astro: specifier: ^5.16.0 - version: 5.16.0(@netlify/blobs@10.4.1)(@types/node@20.19.25)(ioredis@5.8.2)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.53.3)(terser@5.44.1)(tsx@4.20.6)(typescript@5.9.3)(yaml@2.8.1) + version: 5.16.0(@netlify/blobs@10.4.1)(@types/node@20.19.25)(ioredis@5.8.2)(jiti@1.21.7)(lightningcss@1.30.2)(rollup@4.53.3)(terser@5.44.1)(tsx@4.20.6)(typescript@5.9.3)(yaml@2.8.1) typescript: specifier: ^5.9.2 version: 5.9.3 devDependencies: '@astrojs/tailwind': specifier: ^6.0.2 - version: 6.0.2(astro@5.16.0(@netlify/blobs@10.4.1)(@types/node@20.19.25)(ioredis@5.8.2)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.53.3)(terser@5.44.1)(tsx@4.20.6)(typescript@5.9.3)(yaml@2.8.1))(tailwindcss@3.4.18(tsx@4.20.6)(yaml@2.8.1))(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3)) + version: 6.0.2(astro@5.16.0(@netlify/blobs@10.4.1)(@types/node@20.19.25)(ioredis@5.8.2)(jiti@1.21.7)(lightningcss@1.30.2)(rollup@4.53.3)(terser@5.44.1)(tsx@4.20.6)(typescript@5.9.3)(yaml@2.8.1))(tailwindcss@3.4.18(tsx@4.20.6)(yaml@2.8.1))(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3)) '@tailwindcss/typography': specifier: ^0.5.18 version: 0.5.19(tailwindcss@3.4.18(tsx@4.20.6)(yaml@2.8.1)) @@ -189,13 +189,13 @@ importers: version: 20.19.25 eslint: specifier: ^9.0.0 - version: 9.39.1(jiti@2.6.1) + version: 9.39.1(jiti@1.21.7) eslint-config-prettier: specifier: ^9.1.0 - version: 9.1.2(eslint@9.39.1(jiti@2.6.1)) + version: 9.1.2(eslint@9.39.1(jiti@1.21.7)) eslint-plugin-astro: specifier: ^1.0.0 - version: 1.5.0(eslint@9.39.1(jiti@2.6.1)) + version: 1.5.0(eslint@9.39.1(jiti@1.21.7)) prettier: specifier: ^3.6.2 version: 3.6.2 @@ -256,6 +256,9 @@ importers: '@manacore/shared-ui': specifier: workspace:* version: link:../../../../packages/shared-ui + '@manacore/shared-utils': + specifier: workspace:* + version: link:../../../../packages/shared-utils '@neodrag/svelte': specifier: ^2.3.3 version: 2.3.3(svelte@5.44.0) @@ -265,6 +268,9 @@ importers: date-fns: specifier: ^4.1.0 version: 4.1.0 + lucide-svelte: + specifier: ^0.559.0 + version: 0.559.0(svelte@5.44.0) svelte-dnd-action: specifier: ^0.9.68 version: 0.9.68(svelte@5.44.0) @@ -537,19 +543,19 @@ importers: version: 18.3.27 '@typescript-eslint/eslint-plugin': specifier: ^7.7.0 - version: 7.18.0(@typescript-eslint/parser@7.18.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3))(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3) + version: 7.18.0(@typescript-eslint/parser@7.18.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3) '@typescript-eslint/parser': specifier: ^7.7.0 - version: 7.18.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3) + version: 7.18.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3) dotenv: specifier: ^16.4.7 version: 16.6.1 eslint: specifier: ^9.39.1 - version: 9.39.1(jiti@1.21.7) + version: 9.39.1(jiti@2.6.1) eslint-config-universe: specifier: ^12.0.1 - version: 12.1.0(@types/eslint@9.6.1)(eslint@9.39.1(jiti@1.21.7))(prettier@3.6.2)(typescript@5.3.3) + version: 12.1.0(@types/eslint@9.6.1)(eslint@9.39.1(jiti@2.6.1))(prettier@3.6.2)(typescript@5.3.3) prettier: specifier: ^3.2.5 version: 3.6.2 @@ -2072,7 +2078,7 @@ importers: devDependencies: '@nestjs/cli': specifier: ^10.4.9 - version: 10.4.9(esbuild@0.19.12) + version: 10.4.9(esbuild@0.27.0) '@nestjs/schematics': specifier: ^10.2.3 version: 10.2.3(chokidar@3.6.0)(typescript@5.9.3) @@ -2108,7 +2114,7 @@ importers: version: 0.5.21 ts-loader: specifier: ^9.5.1 - version: 9.5.4(typescript@5.9.3)(webpack@5.97.1(esbuild@0.19.12)) + version: 9.5.4(typescript@5.9.3)(webpack@5.100.2(esbuild@0.27.0)) ts-node: specifier: ^10.9.2 version: 10.9.2(@types/node@22.19.1)(typescript@5.9.3) @@ -2567,6 +2573,9 @@ importers: reflect-metadata: specifier: ^0.2.2 version: 0.2.2 + rrule: + specifier: ^2.8.1 + version: 2.8.1 rxjs: specifier: ^7.8.1 version: 7.8.2 @@ -2577,15 +2586,27 @@ importers: '@nestjs/schematics': specifier: ^10.2.3 version: 10.2.3(chokidar@3.6.0)(typescript@5.9.3) + '@nestjs/testing': + specifier: ^11.1.9 + version: 11.1.9(@nestjs/common@10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.20)(@nestjs/platform-express@10.4.20) '@types/express': specifier: ^5.0.1 version: 5.0.5 + '@types/jest': + specifier: ^30.0.0 + version: 30.0.0 '@types/node': specifier: ^22.15.21 version: 22.19.1 drizzle-kit: specifier: ^0.30.2 version: 0.30.6 + jest: + specifier: ^30.2.0 + version: 30.2.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.0)) + ts-jest: + specifier: ^29.2.5 + version: 29.4.5(@babel/core@7.28.5)(@jest/transform@30.2.0)(@jest/types@30.2.0)(babel-jest@30.2.0(@babel/core@7.28.5))(esbuild@0.27.0)(jest-util@30.2.0)(jest@30.2.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.0)))(typescript@5.9.3) tsx: specifier: ^4.19.4 version: 4.20.6 @@ -2637,6 +2658,9 @@ importers: '@manacore/shared-ui': specifier: workspace:* version: link:../../../../packages/shared-ui + '@manacore/shared-utils': + specifier: workspace:* + version: link:../../../../packages/shared-utils '@todo/shared': specifier: workspace:* version: link:../../packages/shared @@ -4277,6 +4301,9 @@ importers: d3-zoom: specifier: ^3.0.0 version: 3.0.0 + date-fns: + specifier: ^4.1.0 + version: 4.1.0 lucide-svelte: specifier: ^0.468.0 version: 0.468.0(svelte@5.44.0) @@ -6718,7 +6745,7 @@ packages: '@expo/bunyan@4.0.1': resolution: {integrity: sha512-+Lla7nYSiHZirgK+U/uYzsLv/X+HaJienbD5AKX1UQZHYfWaP+9uuQluRB4GrEVWF0GZ7vEVp/jzaOT9k/SQlg==} - engines: {node: '>=0.10.0'} + engines: {'0': node >=0.10.0} '@expo/cli@0.22.26': resolution: {integrity: sha512-I689wc8Fn/AX7aUGiwrh3HnssiORMJtR2fpksX+JIe8Cj/EDleblYMSwRPd0025wrwOV9UN1KM/RuEt/QjCS3Q==} @@ -15252,6 +15279,11 @@ packages: peerDependencies: svelte: ^3 || ^4 || ^5.0.0-next.42 + lucide-svelte@0.559.0: + resolution: {integrity: sha512-/R/4nywMW3yk+OCYF27IocERb+Cf6F/P7CdWL446U+MNb5Pa5OvcA4x9WZgSMGgs4ZM42xUDXB2BfKBzv+u1mg==} + peerDependencies: + svelte: ^3 || ^4 || ^5.0.0-next.42 + luxon@3.5.0: resolution: {integrity: sha512-rh+Zjr6DNfUYR3bPwJEnuwDdqMbxZW7LOQfUN4B54+Cl+0o5zaU9RJ6bcidfDtC1cWCZXQ+nvX8bf6bAji37QQ==} engines: {node: '>=12'} @@ -17508,6 +17540,9 @@ packages: resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} engines: {node: '>= 18'} + rrule@2.8.1: + resolution: {integrity: sha512-hM3dHSBMeaJ0Ktp7W38BJZ7O1zOgaFEsn41PDk+yHoEtfLV+PoJt9E9xAlZiWgf/iqEqionN0ebHFZIDAp+iGw==} + rtl-detect@1.1.2: resolution: {integrity: sha512-PGMBq03+TTG/p/cRB7HCLKJ1MgDIi07+QU1faSjiYRfmY5UsAttV9Hs08jDAHVwcOwmVLcSJkpwyfXszVjWfIQ==} @@ -19894,6 +19929,16 @@ snapshots: transitivePeerDependencies: - ts-node + '@astrojs/tailwind@6.0.2(astro@5.16.0(@netlify/blobs@10.4.1)(@types/node@20.19.25)(ioredis@5.8.2)(jiti@1.21.7)(lightningcss@1.30.2)(rollup@4.53.3)(terser@5.44.1)(tsx@4.20.6)(typescript@5.9.3)(yaml@2.8.1))(tailwindcss@3.4.18(tsx@4.20.6)(yaml@2.8.1))(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3))': + dependencies: + astro: 5.16.0(@netlify/blobs@10.4.1)(@types/node@20.19.25)(ioredis@5.8.2)(jiti@1.21.7)(lightningcss@1.30.2)(rollup@4.53.3)(terser@5.44.1)(tsx@4.20.6)(typescript@5.9.3)(yaml@2.8.1) + autoprefixer: 10.4.22(postcss@8.5.6) + postcss: 8.5.6 + postcss-load-config: 4.0.2(postcss@8.5.6)(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3)) + tailwindcss: 3.4.18(tsx@4.20.6)(yaml@2.8.1) + transitivePeerDependencies: + - ts-node + '@astrojs/tailwind@6.0.2(astro@5.16.0(@netlify/blobs@10.4.1)(@types/node@20.19.25)(ioredis@5.8.2)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.53.3)(terser@5.44.1)(tsx@4.20.6)(typescript@5.9.3)(yaml@2.8.1))(tailwindcss@3.4.18(tsx@4.20.6)(yaml@2.8.1))(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3))': dependencies: astro: 5.16.0(@netlify/blobs@10.4.1)(@types/node@20.19.25)(ioredis@5.8.2)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.53.3)(terser@5.44.1)(tsx@4.20.6)(typescript@5.9.3)(yaml@2.8.1) @@ -22407,7 +22452,7 @@ snapshots: wrap-ansi: 7.0.0 ws: 8.18.3 optionalDependencies: - expo-router: 6.0.15(jiucxy5ca3jdtbnulaxuc46jdq) + expo-router: 6.0.15(5e7ih2rh6mb55wruwvjljgzihq) react-native: 0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0) transitivePeerDependencies: - '@modelcontextprotocol/sdk' @@ -24639,6 +24684,14 @@ snapshots: optionalDependencies: '@nestjs/platform-express': 10.4.20(@nestjs/common@10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.20) + '@nestjs/testing@11.1.9(@nestjs/common@10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.20)(@nestjs/platform-express@10.4.20)': + dependencies: + '@nestjs/common': 10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 10.4.20(@nestjs/common@10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.20)(@nestjs/websockets@10.4.20)(reflect-metadata@0.2.2)(rxjs@7.8.2) + tslib: 2.8.1 + optionalDependencies: + '@nestjs/platform-express': 10.4.20(@nestjs/common@10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.20) + '@nestjs/testing@11.1.9(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.9)(@nestjs/platform-express@11.1.9)': dependencies: '@nestjs/common': 11.1.9(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) @@ -27274,19 +27327,6 @@ snapshots: react-test-renderer: 19.1.0(react@19.1.0) redent: 3.0.0 - '@testing-library/react-native@13.3.3(jest@30.2.0(@types/node@20.19.25)(esbuild-register@3.6.0(esbuild@0.27.0)))(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react-test-renderer@19.1.0(react@19.1.0))(react@19.1.0)': - dependencies: - jest-matcher-utils: 30.2.0 - picocolors: 1.1.1 - pretty-format: 30.2.0 - react: 19.1.0 - react-native: 0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0) - react-test-renderer: 19.1.0(react@19.1.0) - redent: 3.0.0 - optionalDependencies: - jest: 30.2.0(@types/node@20.19.25)(esbuild-register@3.6.0(esbuild@0.27.0)) - optional: true - '@testing-library/react-native@13.3.3(jest@30.2.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.19.12))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react-test-renderer@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: jest-matcher-utils: 30.2.0 @@ -27829,16 +27869,16 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3))(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3)': + '@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 6.21.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3) + '@typescript-eslint/parser': 6.21.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3) '@typescript-eslint/scope-manager': 6.21.0 - '@typescript-eslint/type-utils': 6.21.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3) - '@typescript-eslint/utils': 6.21.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3) + '@typescript-eslint/type-utils': 6.21.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3) + '@typescript-eslint/utils': 6.21.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3) '@typescript-eslint/visitor-keys': 6.21.0 debug: 4.4.3 - eslint: 9.39.1(jiti@1.21.7) + eslint: 9.39.1(jiti@2.6.1) graphemer: 1.4.0 ignore: 5.3.2 natural-compare: 1.4.0 @@ -27887,15 +27927,15 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/eslint-plugin@7.18.0(@typescript-eslint/parser@7.18.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3))(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3)': + '@typescript-eslint/eslint-plugin@7.18.0(@typescript-eslint/parser@7.18.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 7.18.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3) + '@typescript-eslint/parser': 7.18.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3) '@typescript-eslint/scope-manager': 7.18.0 - '@typescript-eslint/type-utils': 7.18.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3) - '@typescript-eslint/utils': 7.18.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3) + '@typescript-eslint/type-utils': 7.18.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3) + '@typescript-eslint/utils': 7.18.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3) '@typescript-eslint/visitor-keys': 7.18.0 - eslint: 9.39.1(jiti@1.21.7) + eslint: 9.39.1(jiti@2.6.1) graphemer: 1.4.0 ignore: 5.3.2 natural-compare: 1.4.0 @@ -27987,14 +28027,14 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@6.21.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3)': + '@typescript-eslint/parser@6.21.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3)': dependencies: '@typescript-eslint/scope-manager': 6.21.0 '@typescript-eslint/types': 6.21.0 '@typescript-eslint/typescript-estree': 6.21.0(typescript@5.3.3) '@typescript-eslint/visitor-keys': 6.21.0 debug: 4.4.3 - eslint: 9.39.1(jiti@1.21.7) + eslint: 9.39.1(jiti@2.6.1) optionalDependencies: typescript: 5.3.3 transitivePeerDependencies: @@ -28026,14 +28066,14 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@7.18.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3)': + '@typescript-eslint/parser@7.18.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3)': dependencies: '@typescript-eslint/scope-manager': 7.18.0 '@typescript-eslint/types': 7.18.0 '@typescript-eslint/typescript-estree': 7.18.0(typescript@5.3.3) '@typescript-eslint/visitor-keys': 7.18.0 debug: 4.4.3 - eslint: 9.39.1(jiti@1.21.7) + eslint: 9.39.1(jiti@2.6.1) optionalDependencies: typescript: 5.3.3 transitivePeerDependencies: @@ -28159,12 +28199,12 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/type-utils@6.21.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3)': + '@typescript-eslint/type-utils@6.21.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3)': dependencies: '@typescript-eslint/typescript-estree': 6.21.0(typescript@5.3.3) - '@typescript-eslint/utils': 6.21.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3) + '@typescript-eslint/utils': 6.21.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3) debug: 4.4.3 - eslint: 9.39.1(jiti@1.21.7) + eslint: 9.39.1(jiti@2.6.1) ts-api-utils: 1.4.3(typescript@5.3.3) optionalDependencies: typescript: 5.3.3 @@ -28195,12 +28235,12 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/type-utils@7.18.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3)': + '@typescript-eslint/type-utils@7.18.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3)': dependencies: '@typescript-eslint/typescript-estree': 7.18.0(typescript@5.3.3) - '@typescript-eslint/utils': 7.18.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3) + '@typescript-eslint/utils': 7.18.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3) debug: 4.4.3 - eslint: 9.39.1(jiti@1.21.7) + eslint: 9.39.1(jiti@2.6.1) ts-api-utils: 1.4.3(typescript@5.3.3) optionalDependencies: typescript: 5.3.3 @@ -28382,15 +28422,15 @@ snapshots: - supports-color - typescript - '@typescript-eslint/utils@6.21.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3)': + '@typescript-eslint/utils@6.21.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3)': dependencies: - '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.1(jiti@1.21.7)) + '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.1(jiti@2.6.1)) '@types/json-schema': 7.0.15 '@types/semver': 7.7.1 '@typescript-eslint/scope-manager': 6.21.0 '@typescript-eslint/types': 6.21.0 '@typescript-eslint/typescript-estree': 6.21.0(typescript@5.3.3) - eslint: 9.39.1(jiti@1.21.7) + eslint: 9.39.1(jiti@2.6.1) semver: 7.7.3 transitivePeerDependencies: - supports-color @@ -28421,13 +28461,13 @@ snapshots: - supports-color - typescript - '@typescript-eslint/utils@7.18.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3)': + '@typescript-eslint/utils@7.18.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3)': dependencies: - '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.1(jiti@1.21.7)) + '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.1(jiti@2.6.1)) '@typescript-eslint/scope-manager': 7.18.0 '@typescript-eslint/types': 7.18.0 '@typescript-eslint/typescript-estree': 7.18.0(typescript@5.3.3) - eslint: 9.39.1(jiti@1.21.7) + eslint: 9.39.1(jiti@2.6.1) transitivePeerDependencies: - supports-color - typescript @@ -29228,6 +29268,108 @@ snapshots: transitivePeerDependencies: - supports-color + astro@5.16.0(@netlify/blobs@10.4.1)(@types/node@20.19.25)(ioredis@5.8.2)(jiti@1.21.7)(lightningcss@1.30.2)(rollup@4.53.3)(terser@5.44.1)(tsx@4.20.6)(typescript@5.9.3)(yaml@2.8.1): + dependencies: + '@astrojs/compiler': 2.13.0 + '@astrojs/internal-helpers': 0.7.5 + '@astrojs/markdown-remark': 6.3.9 + '@astrojs/telemetry': 3.3.0 + '@capsizecss/unpack': 3.0.1 + '@oslojs/encoding': 1.1.0 + '@rollup/pluginutils': 5.3.0(rollup@4.53.3) + acorn: 8.15.0 + aria-query: 5.3.2 + axobject-query: 4.1.0 + boxen: 8.0.1 + ci-info: 4.3.1 + clsx: 2.1.1 + common-ancestor-path: 1.0.1 + cookie: 1.1.0 + cssesc: 3.0.0 + debug: 4.4.3 + deterministic-object-hash: 2.0.2 + devalue: 5.5.0 + diff: 5.2.0 + dlv: 1.1.3 + dset: 3.1.4 + es-module-lexer: 1.7.0 + esbuild: 0.25.12 + estree-walker: 3.0.3 + flattie: 1.1.1 + fontace: 0.3.1 + github-slugger: 2.0.0 + html-escaper: 3.0.3 + http-cache-semantics: 4.2.0 + import-meta-resolve: 4.2.0 + js-yaml: 4.1.1 + magic-string: 0.30.21 + magicast: 0.5.1 + mrmime: 2.0.1 + neotraverse: 0.6.18 + p-limit: 6.2.0 + p-queue: 8.1.1 + package-manager-detector: 1.5.0 + piccolore: 0.1.3 + picomatch: 4.0.3 + prompts: 2.4.2 + rehype: 13.0.2 + semver: 7.7.3 + shiki: 3.15.0 + smol-toml: 1.5.2 + svgo: 4.0.0 + tinyexec: 1.0.2 + tinyglobby: 0.2.15 + tsconfck: 3.1.6(typescript@5.9.3) + ultrahtml: 1.6.0 + unifont: 0.6.0 + unist-util-visit: 5.0.0 + unstorage: 1.17.3(@netlify/blobs@10.4.1)(ioredis@5.8.2) + vfile: 6.0.3 + vite: 6.4.1(@types/node@20.19.25)(jiti@1.21.7)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1) + vitefu: 1.1.1(vite@6.4.1(@types/node@20.19.25)(jiti@1.21.7)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1)) + xxhash-wasm: 1.1.0 + yargs-parser: 21.1.1 + yocto-spinner: 0.2.3 + zod: 3.25.76 + zod-to-json-schema: 3.25.0(zod@3.25.76) + zod-to-ts: 1.2.0(typescript@5.9.3)(zod@3.25.76) + optionalDependencies: + sharp: 0.34.5 + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@netlify/blobs' + - '@planetscale/database' + - '@types/node' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - aws4fetch + - db0 + - idb-keyval + - ioredis + - jiti + - less + - lightningcss + - rollup + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - typescript + - uploadthing + - yaml + astro@5.16.0(@netlify/blobs@10.4.1)(@types/node@20.19.25)(ioredis@5.8.2)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.53.3)(terser@5.44.1)(tsx@4.20.6)(typescript@5.9.3)(yaml@2.8.1): dependencies: '@astrojs/compiler': 2.13.0 @@ -31513,6 +31655,11 @@ snapshots: optionalDependencies: source-map: 0.6.1 + eslint-compat-utils@0.6.5(eslint@9.39.1(jiti@1.21.7)): + dependencies: + eslint: 9.39.1(jiti@1.21.7) + semver: 7.7.3 + eslint-compat-utils@0.6.5(eslint@9.39.1(jiti@2.6.1)): dependencies: eslint: 9.39.1(jiti@2.6.1) @@ -31523,9 +31670,9 @@ snapshots: '@typescript-eslint/eslint-plugin': 8.48.1(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) '@typescript-eslint/parser': 8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) eslint: 9.39.1(jiti@2.6.1) - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.1(jiti@2.6.1)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1)) eslint-plugin-expo: 1.0.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@2.6.1)) eslint-plugin-react: 7.37.5(eslint@9.39.1(jiti@2.6.1)) eslint-plugin-react-hooks: 5.2.0(eslint@9.39.1(jiti@2.6.1)) globals: 16.5.0 @@ -31540,9 +31687,9 @@ snapshots: '@typescript-eslint/eslint-plugin': 8.48.1(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.8.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.8.3) '@typescript-eslint/parser': 8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.8.3) eslint: 9.39.1(jiti@2.6.1) - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.1(jiti@2.6.1)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.8.3))(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1)) eslint-plugin-expo: 0.1.4(eslint@9.39.1(jiti@2.6.1))(typescript@5.8.3) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.8.3))(eslint@9.39.1(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.8.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@2.6.1)) eslint-plugin-react: 7.37.5(eslint@9.39.1(jiti@2.6.1)) eslint-plugin-react-hooks: 5.2.0(eslint@9.39.1(jiti@2.6.1)) globals: 16.5.0 @@ -31560,14 +31707,14 @@ snapshots: dependencies: eslint: 8.57.1 - eslint-config-prettier@8.10.2(eslint@9.39.1(jiti@1.21.7)): - dependencies: - eslint: 9.39.1(jiti@1.21.7) - eslint-config-prettier@8.10.2(eslint@9.39.1(jiti@2.6.1)): dependencies: eslint: 9.39.1(jiti@2.6.1) + eslint-config-prettier@9.1.2(eslint@9.39.1(jiti@1.21.7)): + dependencies: + eslint: 9.39.1(jiti@1.21.7) + eslint-config-prettier@9.1.2(eslint@9.39.1(jiti@2.6.1)): dependencies: eslint: 9.39.1(jiti@2.6.1) @@ -31592,17 +31739,17 @@ snapshots: - supports-color - typescript - eslint-config-universe@12.1.0(@types/eslint@9.6.1)(eslint@9.39.1(jiti@1.21.7))(prettier@3.6.2)(typescript@5.3.3): + eslint-config-universe@12.1.0(@types/eslint@9.6.1)(eslint@9.39.1(jiti@2.6.1))(prettier@3.6.2)(typescript@5.3.3): dependencies: - '@typescript-eslint/eslint-plugin': 6.21.0(@typescript-eslint/parser@6.21.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3))(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3) - '@typescript-eslint/parser': 6.21.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3) - eslint: 9.39.1(jiti@1.21.7) - eslint-config-prettier: 8.10.2(eslint@9.39.1(jiti@1.21.7)) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@6.21.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3))(eslint@9.39.1(jiti@1.21.7)) - eslint-plugin-node: 11.1.0(eslint@9.39.1(jiti@1.21.7)) - eslint-plugin-prettier: 5.5.4(@types/eslint@9.6.1)(eslint-config-prettier@8.10.2(eslint@9.39.1(jiti@1.21.7)))(eslint@9.39.1(jiti@1.21.7))(prettier@3.6.2) - eslint-plugin-react: 7.37.5(eslint@9.39.1(jiti@1.21.7)) - eslint-plugin-react-hooks: 4.6.2(eslint@9.39.1(jiti@1.21.7)) + '@typescript-eslint/eslint-plugin': 6.21.0(@typescript-eslint/parser@6.21.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3) + '@typescript-eslint/parser': 6.21.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3) + eslint: 9.39.1(jiti@2.6.1) + eslint-config-prettier: 8.10.2(eslint@9.39.1(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@6.21.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3))(eslint@9.39.1(jiti@2.6.1)) + eslint-plugin-node: 11.1.0(eslint@9.39.1(jiti@2.6.1)) + eslint-plugin-prettier: 5.5.4(@types/eslint@9.6.1)(eslint-config-prettier@8.10.2(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1))(prettier@3.6.2) + eslint-plugin-react: 7.37.5(eslint@9.39.1(jiti@2.6.1)) + eslint-plugin-react-hooks: 4.6.2(eslint@9.39.1(jiti@2.6.1)) optionalDependencies: prettier: 3.6.2 transitivePeerDependencies: @@ -31640,7 +31787,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.1(jiti@2.6.1)): + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.8.3))(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1)): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.3 @@ -31651,7 +31798,22 @@ snapshots: tinyglobby: 0.2.15 unrs-resolver: 1.11.1 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.8.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@2.6.1)) + transitivePeerDependencies: + - supports-color + + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1)): + dependencies: + '@nolyfill/is-core-module': 1.0.39 + debug: 4.4.3 + eslint: 9.39.1(jiti@2.6.1) + get-tsconfig: 4.13.0 + is-bun-module: 2.0.0 + stable-hash: 0.0.5 + tinyglobby: 0.2.15 + unrs-resolver: 1.11.1 + optionalDependencies: + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@2.6.1)) transitivePeerDependencies: - supports-color @@ -31665,12 +31827,12 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@6.21.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.1(jiti@1.21.7)): + eslint-module-utils@2.12.1(@typescript-eslint/parser@6.21.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.1(jiti@2.6.1)): dependencies: debug: 3.2.7 optionalDependencies: - '@typescript-eslint/parser': 6.21.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3) - eslint: 9.39.1(jiti@1.21.7) + '@typescript-eslint/parser': 6.21.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3) + eslint: 9.39.1(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 transitivePeerDependencies: - supports-color @@ -31685,25 +31847,39 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@2.6.1)): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.8.3))(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1)): dependencies: debug: 3.2.7 optionalDependencies: '@typescript-eslint/parser': 8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.8.3) eslint: 9.39.1(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.1(jiti@2.6.1)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.8.3))(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1)) transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@2.6.1)): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1)): dependencies: debug: 3.2.7 optionalDependencies: '@typescript-eslint/parser': 8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) eslint: 9.39.1(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.1(jiti@2.6.1)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1)) + transitivePeerDependencies: + - supports-color + + eslint-plugin-astro@1.5.0(eslint@9.39.1(jiti@1.21.7)): + dependencies: + '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.1(jiti@1.21.7)) + '@jridgewell/sourcemap-codec': 1.5.5 + '@typescript-eslint/types': 8.48.0 + astro-eslint-parser: 1.2.2 + eslint: 9.39.1(jiti@1.21.7) + eslint-compat-utils: 0.6.5(eslint@9.39.1(jiti@1.21.7)) + globals: 16.5.0 + postcss: 8.5.6 + postcss-selector-parser: 7.1.0 transitivePeerDependencies: - supports-color @@ -31727,12 +31903,6 @@ snapshots: eslint-utils: 2.1.0 regexpp: 3.2.0 - eslint-plugin-es@3.0.1(eslint@9.39.1(jiti@1.21.7)): - dependencies: - eslint: 9.39.1(jiti@1.21.7) - eslint-utils: 2.1.0 - regexpp: 3.2.0 - eslint-plugin-es@3.0.1(eslint@9.39.1(jiti@2.6.1)): dependencies: eslint: 9.39.1(jiti@2.6.1) @@ -31786,7 +31956,7 @@ snapshots: - eslint-import-resolver-webpack - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@6.21.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3))(eslint@9.39.1(jiti@1.21.7)): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@6.21.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3))(eslint@9.39.1(jiti@2.6.1)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -31795,9 +31965,9 @@ snapshots: array.prototype.flatmap: 1.3.3 debug: 3.2.7 doctrine: 2.1.0 - eslint: 9.39.1(jiti@1.21.7) + eslint: 9.39.1(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@6.21.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.1(jiti@1.21.7)) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@6.21.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.1(jiti@2.6.1)) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -31809,7 +31979,7 @@ snapshots: string.prototype.trimend: 1.0.9 tsconfig-paths: 3.15.0 optionalDependencies: - '@typescript-eslint/parser': 6.21.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3) + '@typescript-eslint/parser': 6.21.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3) transitivePeerDependencies: - eslint-import-resolver-typescript - eslint-import-resolver-webpack @@ -31844,7 +32014,7 @@ snapshots: - eslint-import-resolver-webpack - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.8.3))(eslint@9.39.1(jiti@2.6.1)): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.8.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@2.6.1)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -31855,7 +32025,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.39.1(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@2.6.1)) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.8.3))(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1)) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -31873,7 +32043,7 @@ snapshots: - eslint-import-resolver-webpack - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1)): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@2.6.1)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -31884,7 +32054,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.39.1(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@2.6.1)) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1)) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -31912,16 +32082,6 @@ snapshots: resolve: 1.22.11 semver: 6.3.1 - eslint-plugin-node@11.1.0(eslint@9.39.1(jiti@1.21.7)): - dependencies: - eslint: 9.39.1(jiti@1.21.7) - eslint-plugin-es: 3.0.1(eslint@9.39.1(jiti@1.21.7)) - eslint-utils: 2.1.0 - ignore: 5.3.2 - minimatch: 3.1.2 - resolve: 1.22.11 - semver: 6.3.1 - eslint-plugin-node@11.1.0(eslint@9.39.1(jiti@2.6.1)): dependencies: eslint: 9.39.1(jiti@2.6.1) @@ -31952,16 +32112,6 @@ snapshots: '@types/eslint': 9.6.1 eslint-config-prettier: 8.10.2(eslint@8.57.1) - eslint-plugin-prettier@5.5.4(@types/eslint@9.6.1)(eslint-config-prettier@8.10.2(eslint@9.39.1(jiti@1.21.7)))(eslint@9.39.1(jiti@1.21.7))(prettier@3.6.2): - dependencies: - eslint: 9.39.1(jiti@1.21.7) - prettier: 3.6.2 - prettier-linter-helpers: 1.0.0 - synckit: 0.11.11 - optionalDependencies: - '@types/eslint': 9.6.1 - eslint-config-prettier: 8.10.2(eslint@9.39.1(jiti@1.21.7)) - eslint-plugin-prettier@5.5.4(@types/eslint@9.6.1)(eslint-config-prettier@8.10.2(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1))(prettier@3.6.2): dependencies: eslint: 9.39.1(jiti@2.6.1) @@ -31986,10 +32136,6 @@ snapshots: dependencies: eslint: 8.57.1 - eslint-plugin-react-hooks@4.6.2(eslint@9.39.1(jiti@1.21.7)): - dependencies: - eslint: 9.39.1(jiti@1.21.7) - eslint-plugin-react-hooks@4.6.2(eslint@9.39.1(jiti@2.6.1)): dependencies: eslint: 9.39.1(jiti@2.6.1) @@ -32020,28 +32166,6 @@ snapshots: string.prototype.matchall: 4.0.12 string.prototype.repeat: 1.0.0 - eslint-plugin-react@7.37.5(eslint@9.39.1(jiti@1.21.7)): - dependencies: - array-includes: 3.1.9 - array.prototype.findlast: 1.2.5 - array.prototype.flatmap: 1.3.3 - array.prototype.tosorted: 1.1.4 - doctrine: 2.1.0 - es-iterator-helpers: 1.2.1 - eslint: 9.39.1(jiti@1.21.7) - estraverse: 5.3.0 - hasown: 2.0.2 - jsx-ast-utils: 3.3.5 - minimatch: 3.1.2 - object.entries: 1.1.9 - object.fromentries: 2.0.8 - object.values: 1.2.1 - prop-types: 15.8.1 - resolve: 2.0.0-next.5 - semver: 6.3.1 - string.prototype.matchall: 4.0.12 - string.prototype.repeat: 1.0.0 - eslint-plugin-react@7.37.5(eslint@9.39.1(jiti@2.6.1)): dependencies: array-includes: 3.1.9 @@ -33261,53 +33385,6 @@ snapshots: - supports-color optional: true - expo-router@6.0.15(jiucxy5ca3jdtbnulaxuc46jdq): - dependencies: - '@expo/metro-runtime': 6.1.2(expo@54.0.25)(react-dom@19.1.0(react@19.1.0))(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0) - '@expo/schema-utils': 0.1.7 - '@radix-ui/react-slot': 1.2.0(@types/react@19.2.7)(react@19.1.0) - '@radix-ui/react-tabs': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@react-navigation/bottom-tabs': 7.8.6(@react-navigation/native@7.1.21(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native-safe-area-context@5.6.2(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native-screens@4.16.0(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0) - '@react-navigation/native': 7.1.21(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0) - '@react-navigation/native-stack': 7.8.0(@react-navigation/native@7.1.21(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native-safe-area-context@5.6.2(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native-screens@4.16.0(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0) - client-only: 0.0.1 - debug: 4.4.3 - escape-string-regexp: 4.0.0 - expo: 54.0.25(@babel/core@7.28.5)(@expo/metro-runtime@6.1.2)(expo-router@6.0.15)(react-native-webview@13.12.2(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0) - expo-constants: 18.0.10(expo@54.0.25)(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0)) - expo-linking: 8.0.9(expo@54.0.25)(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0) - expo-server: 1.0.4 - fast-deep-equal: 3.1.3 - invariant: 2.2.4 - nanoid: 3.3.11 - query-string: 7.1.3 - react: 19.1.0 - react-fast-compare: 3.2.2 - react-native: 0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0) - react-native-is-edge-to-edge: 1.2.1(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0) - react-native-safe-area-context: 5.6.2(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0) - react-native-screens: 4.16.0(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0) - semver: 7.6.3 - server-only: 0.0.1 - sf-symbols-typescript: 2.1.0 - shallowequal: 1.1.0 - use-latest-callback: 0.2.6(react@19.1.0) - vaul: 1.1.2(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - optionalDependencies: - '@react-navigation/drawer': 7.7.4(@react-navigation/native@7.1.21(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native-gesture-handler@2.28.0(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native-reanimated@4.1.5(@babel/core@7.28.5)(react-native-worklets@0.6.1(@babel/core@7.28.5)(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native-safe-area-context@5.6.2(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native-screens@4.16.0(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0) - '@testing-library/react-native': 13.3.3(jest@30.2.0(@types/node@20.19.25)(esbuild-register@3.6.0(esbuild@0.27.0)))(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react-test-renderer@19.1.0(react@19.1.0))(react@19.1.0) - react-dom: 19.1.0(react@19.1.0) - react-native-gesture-handler: 2.28.0(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0) - react-native-reanimated: 4.1.5(@babel/core@7.28.5)(react-native-worklets@0.6.1(@babel/core@7.28.5)(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0) - react-native-web: 0.21.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - react-server-dom-webpack: 19.0.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(webpack@5.100.2(esbuild@0.27.0)) - transitivePeerDependencies: - - '@react-native-masked-view/masked-view' - - '@types/react' - - '@types/react-dom' - - supports-color - optional: true - expo-router@6.0.15(nttrd3tw67nnyhowcwgdzipb5e): dependencies: '@expo/metro-runtime': 6.1.2(expo@54.0.25)(react-dom@19.1.0(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0) @@ -35497,26 +35574,6 @@ snapshots: - supports-color - ts-node - jest-cli@30.2.0(@types/node@20.19.25)(esbuild-register@3.6.0(esbuild@0.27.0)): - dependencies: - '@jest/core': 30.2.0(esbuild-register@3.6.0(esbuild@0.27.0)) - '@jest/test-result': 30.2.0 - '@jest/types': 30.2.0 - chalk: 4.1.2 - exit-x: 0.2.2 - import-local: 3.2.0 - jest-config: 30.2.0(@types/node@20.19.25)(esbuild-register@3.6.0(esbuild@0.27.0)) - jest-util: 30.2.0 - jest-validate: 30.2.0 - yargs: 17.7.2 - transitivePeerDependencies: - - '@types/node' - - babel-plugin-macros - - esbuild-register - - supports-color - - ts-node - optional: true - jest-cli@30.2.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.19.12))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)): dependencies: '@jest/core': 30.2.0(esbuild-register@3.6.0(esbuild@0.19.12))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)) @@ -35537,6 +35594,25 @@ snapshots: - ts-node optional: true + jest-cli@30.2.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.0)): + dependencies: + '@jest/core': 30.2.0(esbuild-register@3.6.0(esbuild@0.27.0)) + '@jest/test-result': 30.2.0 + '@jest/types': 30.2.0 + chalk: 4.1.2 + exit-x: 0.2.2 + import-local: 3.2.0 + jest-config: 30.2.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.0)) + jest-util: 30.2.0 + jest-validate: 30.2.0 + yargs: 17.7.2 + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - esbuild-register + - supports-color + - ts-node + jest-cli@30.2.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.0))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)): dependencies: '@jest/core': 30.2.0(esbuild-register@3.6.0(esbuild@0.27.0))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)) @@ -35688,40 +35764,6 @@ snapshots: - babel-plugin-macros - supports-color - jest-config@30.2.0(@types/node@20.19.25)(esbuild-register@3.6.0(esbuild@0.27.0)): - dependencies: - '@babel/core': 7.28.5 - '@jest/get-type': 30.1.0 - '@jest/pattern': 30.0.1 - '@jest/test-sequencer': 30.2.0 - '@jest/types': 30.2.0 - babel-jest: 30.2.0(@babel/core@7.28.5) - chalk: 4.1.2 - ci-info: 4.3.1 - deepmerge: 4.3.1 - glob: 10.5.0 - graceful-fs: 4.2.11 - jest-circus: 30.2.0 - jest-docblock: 30.2.0 - jest-environment-node: 30.2.0 - jest-regex-util: 30.0.1 - jest-resolve: 30.2.0 - jest-runner: 30.2.0 - jest-util: 30.2.0 - jest-validate: 30.2.0 - micromatch: 4.0.8 - parse-json: 5.2.0 - pretty-format: 30.2.0 - slash: 3.0.0 - strip-json-comments: 3.1.1 - optionalDependencies: - '@types/node': 20.19.25 - esbuild-register: 3.6.0(esbuild@0.27.0) - transitivePeerDependencies: - - babel-plugin-macros - - supports-color - optional: true - jest-config@30.2.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.19.12))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)): dependencies: '@babel/core': 7.28.5 @@ -36412,20 +36454,6 @@ snapshots: - supports-color - ts-node - jest@30.2.0(@types/node@20.19.25)(esbuild-register@3.6.0(esbuild@0.27.0)): - dependencies: - '@jest/core': 30.2.0(esbuild-register@3.6.0(esbuild@0.27.0)) - '@jest/types': 30.2.0 - import-local: 3.2.0 - jest-cli: 30.2.0(@types/node@20.19.25)(esbuild-register@3.6.0(esbuild@0.27.0)) - transitivePeerDependencies: - - '@types/node' - - babel-plugin-macros - - esbuild-register - - supports-color - - ts-node - optional: true - jest@30.2.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.19.12))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)): dependencies: '@jest/core': 30.2.0(esbuild-register@3.6.0(esbuild@0.19.12))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)) @@ -36440,6 +36468,19 @@ snapshots: - ts-node optional: true + jest@30.2.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.0)): + dependencies: + '@jest/core': 30.2.0(esbuild-register@3.6.0(esbuild@0.27.0)) + '@jest/types': 30.2.0 + import-local: 3.2.0 + jest-cli: 30.2.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.0)) + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - esbuild-register + - supports-color + - ts-node + jest@30.2.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.0))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)): dependencies: '@jest/core': 30.2.0(esbuild-register@3.6.0(esbuild@0.27.0))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)) @@ -36994,6 +37035,10 @@ snapshots: dependencies: svelte: 5.44.0 + lucide-svelte@0.559.0(svelte@5.44.0): + dependencies: + svelte: 5.44.0 + luxon@3.5.0: {} lz-string@1.5.0: @@ -40841,6 +40886,10 @@ snapshots: transitivePeerDependencies: - supports-color + rrule@2.8.1: + dependencies: + tslib: 2.8.1 + rtl-detect@1.1.2: {} run-async@2.4.1: {} @@ -41924,6 +41973,27 @@ snapshots: esbuild: 0.27.0 jest-util: 30.2.0 + ts-jest@29.4.5(@babel/core@7.28.5)(@jest/transform@30.2.0)(@jest/types@30.2.0)(babel-jest@30.2.0(@babel/core@7.28.5))(esbuild@0.27.0)(jest-util@30.2.0)(jest@30.2.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.0)))(typescript@5.9.3): + dependencies: + bs-logger: 0.2.6 + fast-json-stable-stringify: 2.1.0 + handlebars: 4.7.8 + jest: 30.2.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.0)) + json5: 2.2.3 + lodash.memoize: 4.1.2 + make-error: 1.3.6 + semver: 7.7.3 + type-fest: 4.41.0 + typescript: 5.9.3 + yargs-parser: 21.1.1 + optionalDependencies: + '@babel/core': 7.28.5 + '@jest/transform': 30.2.0 + '@jest/types': 30.2.0 + babel-jest: 30.2.0(@babel/core@7.28.5) + esbuild: 0.27.0 + jest-util: 30.2.0 + ts-jest@29.4.5(@babel/core@7.28.5)(@jest/transform@30.2.0)(@jest/types@30.2.0)(babel-jest@30.2.0(@babel/core@7.28.5))(jest-util@30.2.0)(jest@29.7.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)))(typescript@5.9.3): dependencies: bs-logger: 0.2.6 @@ -42580,6 +42650,23 @@ snapshots: lightningcss: 1.30.2 terser: 5.44.1 + vite@6.4.1(@types/node@20.19.25)(jiti@1.21.7)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1): + dependencies: + esbuild: 0.25.12 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.6 + rollup: 4.53.3 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 20.19.25 + fsevents: 2.3.3 + jiti: 1.21.7 + lightningcss: 1.30.2 + terser: 5.44.1 + tsx: 4.20.6 + yaml: 2.8.1 + vite@6.4.1(@types/node@20.19.25)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1): dependencies: esbuild: 0.25.12 @@ -42682,6 +42769,10 @@ snapshots: tsx: 4.20.6 yaml: 2.8.1 + vitefu@1.1.1(vite@6.4.1(@types/node@20.19.25)(jiti@1.21.7)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1)): + optionalDependencies: + vite: 6.4.1(@types/node@20.19.25)(jiti@1.21.7)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1) + vitefu@1.1.1(vite@6.4.1(@types/node@20.19.25)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1)): optionalDependencies: vite: 6.4.1(@types/node@20.19.25)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1) diff --git a/scripts/generate-env.mjs b/scripts/generate-env.mjs index ea7a3fc2e..6f66d295b 100644 --- a/scripts/generate-env.mjs +++ b/scripts/generate-env.mjs @@ -426,6 +426,8 @@ const APP_CONFIGS = [ vars: { PUBLIC_BACKEND_URL: (env) => `http://localhost:${env.CALENDAR_BACKEND_PORT || '3014'}`, PUBLIC_MANA_CORE_AUTH_URL: (env) => env.MANA_CORE_AUTH_URL, + PUBLIC_TODO_BACKEND_URL: (env) => + env.TODO_BACKEND_URL || `http://localhost:${env.TODO_BACKEND_PORT || '3018'}`, }, }, diff --git a/services/mana-core-auth/src/settings/dto/index.ts b/services/mana-core-auth/src/settings/dto/index.ts index bc8ee0e3d..2f315c1fe 100644 --- a/services/mana-core-auth/src/settings/dto/index.ts +++ b/services/mana-core-auth/src/settings/dto/index.ts @@ -18,6 +18,10 @@ export class NavSettingsDto { @IsOptional() @IsBoolean() sidebarCollapsed?: boolean; + + @IsOptional() + @IsObject() + hiddenNavItems?: Record; } // Theme settings @@ -70,6 +74,7 @@ export class UpdateAppOverrideDto { export interface NavSettings { desktopPosition: 'top' | 'bottom'; sidebarCollapsed: boolean; + hiddenNavItems?: Record; } export interface ThemeSettings { diff --git a/turbo.json b/turbo.json index 96b9b5869..787dd0cf2 100644 --- a/turbo.json +++ b/turbo.json @@ -22,7 +22,7 @@ "outputs": [] }, "type-check": { - "dependsOn": ["^type-check"], + "dependsOn": ["^type-check", "^build"], "outputs": [] }, "test": {