diff --git a/apps/mukke/apps/web/src/lib/stores/library.svelte.ts b/apps/mukke/apps/web/src/lib/stores/library.svelte.ts index 4b775b585..a6260fe59 100644 --- a/apps/mukke/apps/web/src/lib/stores/library.svelte.ts +++ b/apps/mukke/apps/web/src/lib/stores/library.svelte.ts @@ -128,14 +128,29 @@ function createLibraryStore() { } }, - /** Load albums from backend (aggregated view). */ + /** Load albums from IndexedDB (aggregated locally). */ async loadAlbums() { state.isLoading = true; state.error = null; try { - const data = await fetchApi<{ albums: Album[] }>('/library/albums'); - state.albums = data.albums; - const coverPaths = data.albums.map((a) => a.coverArtPath).filter((p): p is string => !!p); + const songs = await songCollection.getAll(); + const albumMap = new Map(); + for (const s of songs) { + const key = s.album || 'Unknown Album'; + if (!albumMap.has(key)) { + albumMap.set(key, { + album: key, + albumArtist: s.albumArtist || s.artist || 'Unknown', + year: s.year ?? null, + coverArtPath: s.coverArtPath ?? null, + songCount: 0, + } as Album); + } + const a = albumMap.get(key)!; + (a as any).songCount = ((a as any).songCount || 0) + 1; + } + state.albums = Array.from(albumMap.values()); + const coverPaths = state.albums.map((a) => a.coverArtPath).filter((p): p is string => !!p); if (coverPaths.length > 0) this.loadCoverUrls(coverPaths); } catch (e) { state.error = e instanceof Error ? e.message : 'Failed to load albums'; @@ -143,37 +158,72 @@ function createLibraryStore() { state.isLoading = false; }, - /** Load artists from backend (aggregated view). */ + /** Load artists from IndexedDB (aggregated locally). */ async loadArtists() { state.isLoading = true; state.error = null; try { - const data = await fetchApi<{ artists: Artist[] }>('/library/artists'); - state.artists = data.artists; + const songs = await songCollection.getAll(); + const artistMap = new Map< + string, + { artist: string; songCount: number; albumCount: number } + >(); + const artistAlbums = new Map>(); + for (const s of songs) { + const key = s.artist || 'Unknown'; + if (!artistMap.has(key)) { + artistMap.set(key, { artist: key, songCount: 0, albumCount: 0 }); + artistAlbums.set(key, new Set()); + } + artistMap.get(key)!.songCount++; + if (s.album) artistAlbums.get(key)!.add(s.album); + } + state.artists = Array.from(artistMap.values()).map((a) => ({ + ...a, + albumCount: artistAlbums.get(a.artist)?.size || 0, + })) as Artist[]; } catch (e) { state.error = e instanceof Error ? e.message : 'Failed to load artists'; } state.isLoading = false; }, - /** Load genres from backend (aggregated view). */ + /** Load genres from IndexedDB (aggregated locally). */ async loadGenres() { state.isLoading = true; state.error = null; try { - const data = await fetchApi<{ genres: Genre[] }>('/library/genres'); - state.genres = data.genres; + const songs = await songCollection.getAll(); + const genreMap = new Map(); + for (const s of songs) { + const key = s.genre || 'Unknown'; + genreMap.set(key, (genreMap.get(key) || 0) + 1); + } + state.genres = Array.from(genreMap.entries()).map(([genre, songCount]) => ({ + genre, + songCount, + })) as Genre[]; } catch (e) { state.error = e instanceof Error ? e.message : 'Failed to load genres'; } state.isLoading = false; }, - /** Load stats from backend. */ + /** Load stats from IndexedDB (computed locally). */ async loadStats() { try { - const data = await fetchApi<{ stats: LibraryStats }>('/library/stats'); - state.stats = data.stats; + const songs = await songCollection.getAll(); + const artists = new Set(songs.map((s) => s.artist).filter(Boolean)); + const albums = new Set(songs.map((s) => s.album).filter(Boolean)); + const genres = new Set(songs.map((s) => s.genre).filter(Boolean)); + state.stats = { + totalSongs: songs.length, + totalArtists: artists.size, + totalAlbums: albums.size, + totalGenres: genres.size, + totalDuration: songs.reduce((sum, s) => sum + (s.duration || 0), 0), + totalPlays: songs.reduce((sum, s) => sum + (s.playCount || 0), 0), + } as LibraryStats; } catch (e) { state.error = e instanceof Error ? e.message : 'Failed to load stats'; } diff --git a/apps/nutriphi/apps/web/src/lib/stores/meals.svelte.ts b/apps/nutriphi/apps/web/src/lib/stores/meals.svelte.ts index f520b333a..2c8970621 100644 --- a/apps/nutriphi/apps/web/src/lib/stores/meals.svelte.ts +++ b/apps/nutriphi/apps/web/src/lib/stores/meals.svelte.ts @@ -1,4 +1,11 @@ -import { apiClient } from '$lib/api/client'; +/** + * Meals Store — Local-First with @manacore/local-store + * + * All reads and writes go to IndexedDB first. + * When authenticated, changes sync to the server in the background. + */ + +import { mealCollection, type LocalMeal } from '$lib/data/local-store'; import type { Meal, MealNutrition, DailySummary } from '@nutriphi/shared'; import { NutriPhiEvents } from '@manacore/shared-utils/analytics'; @@ -6,6 +13,32 @@ interface MealWithNutrition extends Meal { nutrition: MealNutrition | null; } +function toMealWithNutrition(local: LocalMeal): MealWithNutrition { + return { + id: local.id, + userId: 'local', + date: new Date(local.date), + mealType: local.mealType as any, + inputType: local.inputType as any, + description: local.description, + portionSize: local.portionSize ?? undefined, + confidence: local.confidence, + createdAt: new Date(local.createdAt ?? Date.now()), + nutrition: local.nutrition + ? { + id: local.id, + mealId: local.id, + calories: local.nutrition.calories, + protein: local.nutrition.protein, + carbohydrates: local.nutrition.carbohydrates, + fat: local.nutrition.fat, + fiber: local.nutrition.fiber, + sugar: local.nutrition.sugar, + } + : null, + } as MealWithNutrition; +} + class MealsStore { meals = $state([]); loading = $state(false); @@ -20,7 +53,11 @@ class MealsStore { this.error = null; try { const today = new Date().toISOString().split('T')[0]; - this.meals = await apiClient.get(`/meals?date=${today}`); + const allMeals = await mealCollection.getAll(); + this.meals = allMeals + .filter((m) => m.date === today) + .map(toMealWithNutrition) + .sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()); } catch (err) { this.error = err instanceof Error ? err.message : 'Mahlzeiten konnten nicht geladen werden'; } finally { @@ -33,7 +70,26 @@ class MealsStore { this.summaryError = null; try { const dateStr = (date || new Date()).toISOString().split('T')[0]; - this.dailySummary = await apiClient.get(`/stats/daily?date=${dateStr}`); + const allMeals = await mealCollection.getAll(); + const dayMeals = allMeals.filter((m) => m.date === dateStr); + + const totalNutrition = dayMeals.reduce( + (acc, m) => ({ + calories: acc.calories + (m.nutrition?.calories || 0), + protein: acc.protein + (m.nutrition?.protein || 0), + carbohydrates: acc.carbohydrates + (m.nutrition?.carbohydrates || 0), + fat: acc.fat + (m.nutrition?.fat || 0), + fiber: acc.fiber + (m.nutrition?.fiber || 0), + sugar: acc.sugar + (m.nutrition?.sugar || 0), + }), + { calories: 0, protein: 0, carbohydrates: 0, fat: 0, fiber: 0, sugar: 0 } + ); + + this.dailySummary = { + date: new Date(dateStr), + meals: dayMeals.map(toMealWithNutrition), + totalNutrition, + } as DailySummary; } catch (err) { this.summaryError = err instanceof Error ? err.message : 'Zusammenfassung konnte nicht geladen werden'; @@ -57,7 +113,25 @@ class MealsStore { }) { this.error = null; try { - const meal = await apiClient.post('/meals', mealData); + const newMeal: LocalMeal = { + id: crypto.randomUUID(), + date: mealData.date, + mealType: mealData.mealType as any, + inputType: mealData.inputType as any, + description: mealData.description, + confidence: mealData.confidence, + nutrition: { + calories: mealData.calories, + protein: mealData.protein, + carbohydrates: mealData.carbohydrates, + fat: mealData.fat, + fiber: mealData.fiber || 0, + sugar: mealData.sugar || 0, + }, + }; + + const inserted = await mealCollection.insert(newMeal); + const meal = toMealWithNutrition(inserted); this.meals = [...this.meals, meal]; await this.fetchDailySummary(); NutriPhiEvents.mealAdded(mealData.mealType, mealData.inputType); @@ -73,7 +147,7 @@ class MealsStore { async deleteMeal(mealId: string) { this.deleteError = null; try { - await apiClient.delete(`/meals/${mealId}`); + await mealCollection.delete(mealId); this.meals = this.meals.filter((m) => m.id !== mealId); await this.fetchDailySummary(); NutriPhiEvents.mealDeleted(); diff --git a/apps/todo/apps/web/src/lib/api/kanban.ts b/apps/todo/apps/web/src/lib/api/kanban.ts deleted file mode 100644 index f2e04e1c2..000000000 --- a/apps/todo/apps/web/src/lib/api/kanban.ts +++ /dev/null @@ -1,176 +0,0 @@ -import { apiClient } from './client'; -import type { KanbanBoard, KanbanColumn, Task } from '@todo/shared'; - -// Response types - -interface BoardsResponse { - boards: KanbanBoard[]; -} - -interface BoardResponse { - board: KanbanBoard; -} - -interface ColumnsResponse { - columns: KanbanColumn[]; -} - -interface ColumnResponse { - column: KanbanColumn; -} - -interface KanbanTasksResponse { - columns: KanbanColumn[]; - tasksByColumn: Record; -} - -interface TaskResponse { - task: Task; -} - -interface TasksResponse { - tasks: Task[]; -} - -// DTO types - -interface CreateBoardDto { - name: string; - projectId?: string; - color?: string; - icon?: string; -} - -interface UpdateBoardDto { - name?: string; - color?: string; - icon?: string; -} - -interface CreateColumnDto { - name: string; - boardId: string; - color?: string; - isDefault?: boolean; - defaultStatus?: string; - autoComplete?: boolean; -} - -interface UpdateColumnDto { - name?: string; - color?: string; - defaultStatus?: string; - autoComplete?: boolean; -} - -// ===================== -// Board operations -// ===================== - -export async function getBoards(): Promise { - const response = await apiClient.get('/api/v1/kanban/boards'); - return response.boards; -} - -export async function getGlobalBoard(): Promise { - const response = await apiClient.get('/api/v1/kanban/boards/global'); - return response.board; -} - -export async function getBoard(id: string): Promise { - const response = await apiClient.get(`/api/v1/kanban/boards/${id}`); - return response.board; -} - -export async function createBoard(data: CreateBoardDto): Promise { - const response = await apiClient.post('/api/v1/kanban/boards', data); - return response.board; -} - -export async function updateBoard(id: string, data: UpdateBoardDto): Promise { - const response = await apiClient.put(`/api/v1/kanban/boards/${id}`, data); - return response.board; -} - -export async function deleteBoard(id: string): Promise { - await apiClient.delete(`/api/v1/kanban/boards/${id}`); -} - -export async function reorderBoards(boardIds: string[]): Promise { - const response = await apiClient.put('/api/v1/kanban/boards/reorder', { - boardIds, - }); - return response.boards; -} - -// ===================== -// Column operations -// ===================== - -export async function getColumns(boardId: string): Promise { - const response = await apiClient.get( - `/api/v1/kanban/columns?boardId=${boardId}` - ); - return response.columns; -} - -export async function createColumn(data: CreateColumnDto): Promise { - const response = await apiClient.post('/api/v1/kanban/columns', data); - return response.column; -} - -export async function updateColumn(id: string, data: UpdateColumnDto): Promise { - const response = await apiClient.put(`/api/v1/kanban/columns/${id}`, data); - return response.column; -} - -export async function deleteColumn(id: string): Promise { - await apiClient.delete(`/api/v1/kanban/columns/${id}`); -} - -export async function reorderColumns(columnIds: string[]): Promise { - const response = await apiClient.put('/api/v1/kanban/columns/reorder', { - columnIds, - }); - return response.columns; -} - -export async function initializeColumns(boardId: string): Promise { - const response = await apiClient.post( - `/api/v1/kanban/columns/init?boardId=${boardId}` - ); - return response.columns; -} - -// ===================== -// Task operations -// ===================== - -export async function getKanbanTasks( - boardId: string -): Promise<{ columns: KanbanColumn[]; tasksByColumn: Record }> { - const response = await apiClient.get( - `/api/v1/kanban/tasks?boardId=${boardId}` - ); - return response; -} - -export async function moveTaskToColumn( - taskId: string, - columnId: string, - order?: number -): Promise { - const response = await apiClient.post(`/api/v1/kanban/tasks/${taskId}/move`, { - columnId, - order, - }); - return response.task; -} - -export async function reorderTasksInColumn(columnId: string, taskIds: string[]): Promise { - const response = await apiClient.put('/api/v1/kanban/tasks/reorder', { - columnId, - taskIds, - }); - return response.tasks; -} diff --git a/apps/todo/apps/web/src/lib/components/board-views/BoardViewRenderer.svelte b/apps/todo/apps/web/src/lib/components/board-views/BoardViewRenderer.svelte new file mode 100644 index 000000000..ef8dbec82 --- /dev/null +++ b/apps/todo/apps/web/src/lib/components/board-views/BoardViewRenderer.svelte @@ -0,0 +1,82 @@ + + +{#if view.layout === 'grid'} + +{:else} + +{/if} diff --git a/apps/todo/apps/web/src/lib/components/board-views/GridLayout.svelte b/apps/todo/apps/web/src/lib/components/board-views/GridLayout.svelte new file mode 100644 index 000000000..953ae529c --- /dev/null +++ b/apps/todo/apps/web/src/lib/components/board-views/GridLayout.svelte @@ -0,0 +1,72 @@ + + +
+ {#each columns as column (column.id)} +
+ +
+ {/each} +
+ + diff --git a/apps/todo/apps/web/src/lib/components/board-views/KanbanLayout.svelte b/apps/todo/apps/web/src/lib/components/board-views/KanbanLayout.svelte new file mode 100644 index 000000000..b6eb2e83f --- /dev/null +++ b/apps/todo/apps/web/src/lib/components/board-views/KanbanLayout.svelte @@ -0,0 +1,77 @@ + + +
+ {#each columns as column (column.id)} +
+ +
+ {/each} +
+ + diff --git a/apps/todo/apps/web/src/lib/components/kanban/KanbanColumn.svelte b/apps/todo/apps/web/src/lib/components/board-views/ViewColumn.svelte similarity index 63% rename from apps/todo/apps/web/src/lib/components/kanban/KanbanColumn.svelte rename to apps/todo/apps/web/src/lib/components/board-views/ViewColumn.svelte index 8e366a5e2..f25561539 100644 --- a/apps/todo/apps/web/src/lib/components/kanban/KanbanColumn.svelte +++ b/apps/todo/apps/web/src/lib/components/board-views/ViewColumn.svelte @@ -1,37 +1,28 @@ -
+
- +
- {#if onAddTask} -
- -
- {/if} +
+ +
diff --git a/apps/todo/apps/web/src/lib/components/board-views/ViewSelector.svelte b/apps/todo/apps/web/src/lib/components/board-views/ViewSelector.svelte new file mode 100644 index 000000000..e25bc48f0 --- /dev/null +++ b/apps/todo/apps/web/src/lib/components/board-views/ViewSelector.svelte @@ -0,0 +1,160 @@ + + +
+
+
+ {#each views as view (view.id)} + + {/each} +
+
+
+ + diff --git a/apps/todo/apps/web/src/lib/components/board-views/index.ts b/apps/todo/apps/web/src/lib/components/board-views/index.ts new file mode 100644 index 000000000..02b1199ff --- /dev/null +++ b/apps/todo/apps/web/src/lib/components/board-views/index.ts @@ -0,0 +1,6 @@ +export { default as ViewColumnHeader } from './ViewColumnHeader.svelte'; +export { default as ViewColumn } from './ViewColumn.svelte'; +export { default as KanbanLayout } from './KanbanLayout.svelte'; +export { default as GridLayout } from './GridLayout.svelte'; +export { default as BoardViewRenderer } from './BoardViewRenderer.svelte'; +export { default as ViewSelector } from './ViewSelector.svelte'; diff --git a/apps/todo/apps/web/src/lib/components/kanban/AddColumnButton.svelte b/apps/todo/apps/web/src/lib/components/kanban/AddColumnButton.svelte deleted file mode 100644 index 375a3af25..000000000 --- a/apps/todo/apps/web/src/lib/components/kanban/AddColumnButton.svelte +++ /dev/null @@ -1,184 +0,0 @@ - - -
- {#if isAdding} -
-
-
- Neue Spalte -
- -
- - -
-
- {:else} - - {/if} -
- - diff --git a/apps/todo/apps/web/src/lib/components/kanban/BoardNavigation.svelte b/apps/todo/apps/web/src/lib/components/kanban/BoardNavigation.svelte deleted file mode 100644 index cec67969e..000000000 --- a/apps/todo/apps/web/src/lib/components/kanban/BoardNavigation.svelte +++ /dev/null @@ -1,243 +0,0 @@ - - -
-
- - - - -
- {#each boards as board (board.id)} - - {/each} -
-
-
- - diff --git a/apps/todo/apps/web/src/lib/components/kanban/KanbanBoard.svelte b/apps/todo/apps/web/src/lib/components/kanban/KanbanBoard.svelte deleted file mode 100644 index a2c8d394f..000000000 --- a/apps/todo/apps/web/src/lib/components/kanban/KanbanBoard.svelte +++ /dev/null @@ -1,254 +0,0 @@ - - -
- {#if kanbanStore.loading} - - {:else if kanbanStore.error} -
- - - - {kanbanStore.error} -
- {:else} -
- {#each localColumns.filter((c) => c.id !== SHADOW_PLACEHOLDER_ITEM_ID) as column (column.id)} -
- handleUpdateColumn(column.id, data)} - onDeleteColumn={() => handleDeleteColumn(column.id)} - onTasksReorder={(taskIds) => handleTasksReorder(column.id, taskIds)} - onTaskMove={(taskId, toColumnId, order) => handleTaskMove(taskId, toColumnId, order)} - onAddTask={(title) => handleAddTask(column.id, title)} - /> -
- {/each} - - -
- -
-
- {/if} -
- - - { - showDeleteConfirm = false; - columnToDelete = null; - }} - onConfirm={confirmDeleteColumn} - variant="danger" - title="Spalte löschen?" - message="Alle Aufgaben dieser Spalte werden in die erste Spalte verschoben." - confirmLabel="Löschen" - cancelLabel="Abbrechen" -/> - - diff --git a/apps/todo/apps/web/src/lib/components/kanban/KanbanColumnHeader.svelte b/apps/todo/apps/web/src/lib/components/kanban/KanbanColumnHeader.svelte deleted file mode 100644 index f9fcf16ce..000000000 --- a/apps/todo/apps/web/src/lib/components/kanban/KanbanColumnHeader.svelte +++ /dev/null @@ -1,255 +0,0 @@ - - -
-
- -
- - - {#if isEditing} - - {:else} - - {/if} - - - - {taskCount} - -
- - - {#if onUpdate || onDelete} -
- - - {#if showMenu} - - {/if} -
- {/if} -
- - -{#if showMenu} - -{/if} - - diff --git a/apps/todo/apps/web/src/lib/components/kanban/index.ts b/apps/todo/apps/web/src/lib/components/kanban/index.ts index 39f1e79b3..d51f455af 100644 --- a/apps/todo/apps/web/src/lib/components/kanban/index.ts +++ b/apps/todo/apps/web/src/lib/components/kanban/index.ts @@ -1,6 +1,2 @@ -export { default as KanbanBoard } from './KanbanBoard.svelte'; -export { default as KanbanColumn } from './KanbanColumn.svelte'; -export { default as KanbanColumnHeader } from './KanbanColumnHeader.svelte'; export { default as KanbanTaskCard } from './KanbanTaskCard.svelte'; -export { default as AddColumnButton } from './AddColumnButton.svelte'; -export { default as BoardNavigation } from './BoardNavigation.svelte'; +export { default as QuickAddTaskInline } from './QuickAddTaskInline.svelte'; diff --git a/apps/todo/apps/web/src/lib/data/guest-seed.ts b/apps/todo/apps/web/src/lib/data/guest-seed.ts index a9961edd7..d564aa319 100644 --- a/apps/todo/apps/web/src/lib/data/guest-seed.ts +++ b/apps/todo/apps/web/src/lib/data/guest-seed.ts @@ -5,7 +5,7 @@ * They serve as onboarding content that teaches the user how the app works. */ -import type { LocalTask, LocalProject, LocalLabel } from './local-store'; +import type { LocalTask, LocalProject, LocalLabel, LocalBoardView } from './local-store'; const ONBOARDING_PROJECT_ID = 'onboarding-project'; const PERSONAL_PROJECT_ID = 'personal-project'; @@ -44,6 +44,168 @@ export const guestLabels: LocalLabel[] = [ }, ]; +// ─── Board Views ──────────────────────────────────────────── + +export const guestBoardViews: LocalBoardView[] = [ + { + id: 'view-kanban', + name: 'Kanban', + icon: 'columns', + groupBy: 'status', + layout: 'kanban', + order: 0, + columns: [ + { + id: 'col-todo', + name: 'To Do', + color: '#6B7280', + match: { type: 'status', value: 'pending' }, + onDrop: { setCompleted: false }, + }, + { + id: 'col-done', + name: 'Erledigt', + color: '#22C55E', + match: { type: 'status', value: 'completed' }, + onDrop: { setCompleted: true }, + }, + ], + }, + { + id: 'view-eisenhower', + name: 'Eisenhower', + icon: 'grid-four', + groupBy: 'custom', + layout: 'grid', + order: 1, + columns: [ + { + id: 'col-eis-ui', + name: 'Wichtig & Dringend', + color: '#EF4444', + match: { type: 'custom', value: 'urgent-important' }, + onDrop: { setPriority: 'urgent' }, + }, + { + id: 'col-eis-i', + name: 'Wichtig', + color: '#F59E0B', + match: { type: 'custom', value: 'important' }, + onDrop: { setPriority: 'high' }, + }, + { + id: 'col-eis-u', + name: 'Dringend', + color: '#3B82F6', + match: { type: 'custom', value: 'urgent' }, + onDrop: { setPriority: 'medium' }, + }, + { + id: 'col-eis-ni', + name: 'Weder noch', + color: '#6B7280', + match: { type: 'custom', value: 'neither' }, + onDrop: { setPriority: 'low' }, + }, + ], + }, + { + id: 'view-priority', + name: 'Priorität', + icon: 'flag', + groupBy: 'priority', + layout: 'kanban', + order: 2, + columns: [ + { + id: 'col-pri-urgent', + name: 'Dringend', + color: '#EF4444', + match: { type: 'priority', value: 'urgent' }, + onDrop: { setPriority: 'urgent' }, + }, + { + id: 'col-pri-high', + name: 'Hoch', + color: '#F59E0B', + match: { type: 'priority', value: 'high' }, + onDrop: { setPriority: 'high' }, + }, + { + id: 'col-pri-medium', + name: 'Mittel', + color: '#3B82F6', + match: { type: 'priority', value: 'medium' }, + onDrop: { setPriority: 'medium' }, + }, + { + id: 'col-pri-low', + name: 'Niedrig', + color: '#6B7280', + match: { type: 'priority', value: 'low' }, + onDrop: { setPriority: 'low' }, + }, + ], + }, + { + id: 'view-project', + name: 'Projekte', + icon: 'folders', + groupBy: 'project', + layout: 'kanban', + order: 3, + columns: [], // dynamically generated from projects + }, + { + id: 'view-due', + name: 'Fälligkeit', + icon: 'calendar', + groupBy: 'dueDate', + layout: 'kanban', + order: 4, + columns: [ + { + id: 'col-due-overdue', + name: 'Überfällig', + color: '#EF4444', + match: { type: 'dueDate', value: 'overdue' }, + }, + { + id: 'col-due-today', + name: 'Heute', + color: '#F59E0B', + match: { type: 'dueDate', value: 'today' }, + }, + { + id: 'col-due-tomorrow', + name: 'Morgen', + color: '#3B82F6', + match: { type: 'dueDate', value: 'tomorrow' }, + }, + { + id: 'col-due-week', + name: 'Diese Woche', + color: '#8B5CF6', + match: { type: 'dueDate', value: 'week' }, + }, + { + id: 'col-due-later', + name: 'Später', + color: '#6B7280', + match: { type: 'dueDate', value: 'later' }, + }, + { + id: 'col-due-none', + name: 'Ohne Datum', + color: '#9CA3AF', + match: { type: 'dueDate', value: 'none' }, + }, + ], + }, +]; + +// ─── Task Seed Data ───────────────────────────────────────── + const now = new Date(); const tomorrow = new Date(now); tomorrow.setDate(tomorrow.getDate() + 1); @@ -85,7 +247,7 @@ export const guestTasks: LocalTask[] = [ }, { id: 'onboard-4', - title: 'Wechsle zur Kanban-Ansicht über die Navigation', + title: 'Wechsle zur Board-Ansicht über die Navigation', projectId: ONBOARDING_PROJECT_ID, priority: 'low', isCompleted: false, diff --git a/apps/todo/apps/web/src/lib/data/local-store.ts b/apps/todo/apps/web/src/lib/data/local-store.ts index 4eac3cadf..b45fc9e0e 100644 --- a/apps/todo/apps/web/src/lib/data/local-store.ts +++ b/apps/todo/apps/web/src/lib/data/local-store.ts @@ -7,7 +7,7 @@ import { createLocalStore, type BaseRecord } from '@manacore/local-store'; import type { Subtask as SharedSubtask } from '@todo/shared'; -import { guestProjects, guestTasks, guestLabels } from './guest-seed.js'; +import { guestProjects, guestTasks, guestLabels, guestBoardViews } from './guest-seed.js'; // ─── Types ────────────────────────────────────────────────── @@ -60,6 +60,45 @@ export interface LocalReminder extends BaseRecord { status: 'pending' | 'sent' | 'failed'; } +// ─── Board Views ──────────────────────────────────────────── + +export interface TaskMatcher { + type: 'status' | 'priority' | 'project' | 'tag' | 'dueDate' | 'custom'; + value?: string | null; + /** For 'custom' groupBy: manually assigned task IDs */ + taskIds?: string[]; +} + +export interface DropAction { + setCompleted?: boolean; + setPriority?: 'low' | 'medium' | 'high' | 'urgent'; + setProjectId?: string | null; +} + +export interface ViewColumn { + id: string; + name: string; + color: string; + match: TaskMatcher; + onDrop?: DropAction; +} + +export interface ViewFilter { + projectId?: string; + tagIds?: string[]; + priorities?: string[]; +} + +export interface LocalBoardView extends BaseRecord { + name: string; + icon: string; + groupBy: 'status' | 'priority' | 'project' | 'dueDate' | 'tag' | 'custom'; + columns: ViewColumn[]; + filter?: ViewFilter; + layout: 'kanban' | 'grid'; + order: number; +} + // ─── Store ────────────────────────────────────────────────── const SYNC_SERVER_URL = import.meta.env.PUBLIC_SYNC_SERVER_URL || 'http://localhost:3050'; @@ -98,6 +137,11 @@ export const todoStore = createLocalStore({ name: 'reminders', indexes: ['taskId'], }, + { + name: 'boardViews', + indexes: ['order', 'groupBy'], + guestSeed: guestBoardViews, + }, ], sync: { serverUrl: SYNC_SERVER_URL, @@ -110,3 +154,4 @@ export const projectCollection = todoStore.collection('projects'); export const labelCollection = todoStore.collection('labels'); export const taskLabelCollection = todoStore.collection('taskLabels'); export const reminderCollection = todoStore.collection('reminders'); +export const boardViewCollection = todoStore.collection('boardViews'); diff --git a/apps/todo/apps/web/src/lib/data/task-queries.ts b/apps/todo/apps/web/src/lib/data/task-queries.ts index a642563f6..25bf284ce 100644 --- a/apps/todo/apps/web/src/lib/data/task-queries.ts +++ b/apps/todo/apps/web/src/lib/data/task-queries.ts @@ -10,8 +10,10 @@ import { useLiveQueryWithDefault } from '@manacore/local-store/svelte'; import { taskCollection, projectCollection, + boardViewCollection, type LocalTask, type LocalProject, + type LocalBoardView, } from './local-store'; import type { Task, Project } from '@todo/shared'; import { isToday, isPast, isFuture, startOfDay, addDays } from 'date-fns'; @@ -81,6 +83,17 @@ export function useAllProjects() { }, [] as Project[]); } +/** All board views, sorted by order. Auto-updates on any change. */ +export function useAllBoardViews() { + return useLiveQueryWithDefault(async () => { + const locals = await boardViewCollection.getAll(undefined, { + sortBy: 'order', + sortDirection: 'asc', + }); + return locals; + }, [] as LocalBoardView[]); +} + // ─── Pure Filter Functions (for $derived) ────────────────── export function filterIncomplete(tasks: Task[]): Task[] { diff --git a/apps/todo/apps/web/src/lib/data/view-grouping.ts b/apps/todo/apps/web/src/lib/data/view-grouping.ts new file mode 100644 index 000000000..b114f40ad --- /dev/null +++ b/apps/todo/apps/web/src/lib/data/view-grouping.ts @@ -0,0 +1,268 @@ +/** + * View Grouping Engine — Pure Functions + * + * Groups tasks into columns based on a BoardView configuration. + * No side effects, no store dependencies — easy to test. + */ + +import type { Task, Project } from '@todo/shared'; +import type { LocalBoardView, ViewColumn, DropAction } from './local-store'; +import { isToday, isPast, isTomorrow, startOfDay, addDays, isFuture } from 'date-fns'; + +// ─── Output Type ─────────────────────────────────────────── + +export interface GroupedColumn { + id: string; + name: string; + color: string; + tasks: Task[]; + onDrop?: DropAction; +} + +// ─── Main Grouping Function ──────────────────────────────── + +export function groupTasksByView( + view: LocalBoardView, + tasks: Task[], + projects: Project[] +): GroupedColumn[] { + // Only group incomplete tasks (unless status view includes completed) + const activeTasks = view.groupBy === 'status' ? tasks : tasks.filter((t) => !t.isCompleted); + + // Apply view-level filter + const filtered = applyViewFilter(activeTasks, view.filter); + + switch (view.groupBy) { + case 'status': + return groupByStatus(filtered, view.columns); + case 'priority': + return groupByPriority(filtered, view.columns); + case 'project': + return groupByProject(filtered, view.columns, projects); + case 'dueDate': + return groupByDueDate(filtered, view.columns); + case 'tag': + return groupByTag(filtered, view.columns); + case 'custom': + return groupByCustom(filtered, view); + default: + return groupByStatus(filtered, view.columns); + } +} + +// ─── Group By Implementations ────────────────────────────── + +function groupByStatus(tasks: Task[], columns: ViewColumn[]): GroupedColumn[] { + return columns.map((col) => ({ + id: col.id, + name: col.name, + color: col.color, + onDrop: col.onDrop, + tasks: tasks.filter((t) => { + if (col.match.value === 'completed') return t.isCompleted; + if (col.match.value === 'pending') return !t.isCompleted; + return false; + }), + })); +} + +function groupByPriority(tasks: Task[], columns: ViewColumn[]): GroupedColumn[] { + return columns.map((col) => ({ + id: col.id, + name: col.name, + color: col.color, + onDrop: col.onDrop, + tasks: tasks.filter((t) => t.priority === col.match.value), + })); +} + +function groupByProject( + tasks: Task[], + columns: ViewColumn[], + projects: Project[] +): GroupedColumn[] { + // Dynamic: generate columns from projects + if (columns.length === 0) { + const activeProjects = projects.filter((p) => !p.isArchived); + const dynamicColumns: GroupedColumn[] = [ + { + id: 'col-inbox', + name: 'Inbox', + color: '#6B7280', + tasks: tasks.filter((t) => !t.projectId), + onDrop: { setProjectId: null }, + }, + ...activeProjects.map((p) => ({ + id: `col-proj-${p.id}`, + name: p.name, + color: p.color, + tasks: tasks.filter((t) => t.projectId === p.id), + onDrop: { setProjectId: p.id } as DropAction, + })), + ]; + return dynamicColumns; + } + // Static columns from config + return columns.map((col) => ({ + id: col.id, + name: col.name, + color: col.color, + onDrop: col.onDrop, + tasks: tasks.filter((t) => + col.match.value === null ? !t.projectId : t.projectId === col.match.value + ), + })); +} + +function groupByDueDate(tasks: Task[], columns: ViewColumn[]): GroupedColumn[] { + const today = startOfDay(new Date()); + const tomorrowDate = addDays(today, 1); + const weekEnd = addDays(today, 7); + + return columns.map((col) => ({ + id: col.id, + name: col.name, + color: col.color, + onDrop: col.onDrop, + tasks: tasks.filter((t) => { + if (!t.dueDate) return col.match.value === 'none'; + const d = new Date(t.dueDate); + const dayStart = startOfDay(d); + switch (col.match.value) { + case 'overdue': + return isPast(dayStart) && !isToday(d); + case 'today': + return isToday(d); + case 'tomorrow': + return isTomorrow(d); + case 'week': + return isFuture(d) && !isTomorrow(d) && d <= weekEnd; + case 'later': + return d > weekEnd; + default: + return false; + } + }), + })); +} + +function groupByTag(tasks: Task[], columns: ViewColumn[]): GroupedColumn[] { + return columns.map((col) => ({ + id: col.id, + name: col.name, + color: col.color, + onDrop: col.onDrop, + tasks: tasks.filter( + (t) => t.labels?.some((l) => l.id === col.match.value) ?? false + ), + })); +} + +function groupByCustom(tasks: Task[], view: LocalBoardView): GroupedColumn[] { + // Eisenhower matrix: priority + dueDate combination + if (view.id === 'view-eisenhower') { + return groupEisenhower(tasks, view.columns); + } + + // Generic custom: use taskIds per column + const assigned = new Set(); + const result = view.columns.map((col) => { + const colTaskIds = new Set(col.match.taskIds ?? []); + const colTasks = tasks.filter((t) => { + if (colTaskIds.has(t.id)) { + assigned.add(t.id); + return true; + } + return false; + }); + return { + id: col.id, + name: col.name, + color: col.color, + onDrop: col.onDrop, + tasks: colTasks, + }; + }); + + // Unassigned tasks go to last column + const unassigned = tasks.filter((t) => !assigned.has(t.id)); + if (unassigned.length > 0 && result.length > 0) { + result[result.length - 1].tasks = [...result[result.length - 1].tasks, ...unassigned]; + } + + return result; +} + +// ─── Eisenhower Matrix ───────────────────────────────────── + +function groupEisenhower(tasks: Task[], columns: ViewColumn[]): GroupedColumn[] { + const today = startOfDay(new Date()); + const soonThreshold = addDays(today, 3); + + function isImportant(t: Task): boolean { + return t.priority === 'urgent' || t.priority === 'high'; + } + + function isUrgent(t: Task): boolean { + if (!t.dueDate) return false; + const d = new Date(t.dueDate); + return isPast(startOfDay(d)) || d <= soonThreshold; + } + + const buckets: Record = { + 'urgent-important': [], + important: [], + urgent: [], + neither: [], + }; + + for (const t of tasks.filter((t) => !t.isCompleted)) { + const imp = isImportant(t); + const urg = isUrgent(t); + if (imp && urg) buckets['urgent-important'].push(t); + else if (imp) buckets['important'].push(t); + else if (urg) buckets['urgent'].push(t); + else buckets['neither'].push(t); + } + + return columns.map((col) => ({ + id: col.id, + name: col.name, + color: col.color, + onDrop: col.onDrop, + tasks: buckets[col.match.value ?? ''] ?? [], + })); +} + +// ─── Helpers ─────────────────────────────────────────────── + +function applyViewFilter(tasks: Task[], filter?: { projectId?: string; tagIds?: string[]; priorities?: string[] }): Task[] { + if (!filter) return tasks; + let result = tasks; + + if (filter.projectId) { + result = result.filter((t) => t.projectId === filter.projectId); + } + if (filter.priorities && filter.priorities.length > 0) { + result = result.filter((t) => filter.priorities!.includes(t.priority)); + } + if (filter.tagIds && filter.tagIds.length > 0) { + result = result.filter((t) => t.labels?.some((l) => filter.tagIds!.includes(l.id))); + } + + return result; +} + +/** + * Apply a column's drop action to a task — returns the update payload. + */ +export function getDropActionUpdate(action: DropAction): Record { + const update: Record = {}; + if (action.setCompleted !== undefined) { + update.isCompleted = action.setCompleted; + update.completedAt = action.setCompleted ? new Date().toISOString() : null; + } + if (action.setPriority) update.priority = action.setPriority; + if (action.setProjectId !== undefined) update.projectId = action.setProjectId; + return update; +} diff --git a/apps/todo/apps/web/src/lib/stores/board-views.svelte.ts b/apps/todo/apps/web/src/lib/stores/board-views.svelte.ts new file mode 100644 index 000000000..112e97f0b --- /dev/null +++ b/apps/todo/apps/web/src/lib/stores/board-views.svelte.ts @@ -0,0 +1,84 @@ +/** + * Board Views Store — Mutation-Only Service + * + * Reads via useLiveQuery (useAllBoardViews in task-queries.ts). + * This store only handles create, update, delete, reorder. + */ + +import { boardViewCollection, type LocalBoardView, type ViewColumn } from '$lib/data/local-store'; + +let error = $state(null); + +export const boardViewsStore = { + get error() { + return error; + }, + + async createView(data: Omit) { + error = null; + try { + const count = await boardViewCollection.count(); + const newView: LocalBoardView = { + ...data, + id: crypto.randomUUID(), + order: data.order ?? count, + }; + return await boardViewCollection.insert(newView); + } catch (e) { + error = e instanceof Error ? e.message : 'Failed to create view'; + throw e; + } + }, + + async updateView(id: string, data: Partial) { + error = null; + try { + return await boardViewCollection.update(id, data as Partial); + } catch (e) { + error = e instanceof Error ? e.message : 'Failed to update view'; + throw e; + } + }, + + async deleteView(id: string) { + error = null; + try { + await boardViewCollection.delete(id); + } catch (e) { + error = e instanceof Error ? e.message : 'Failed to delete view'; + throw e; + } + }, + + async reorderViews(viewIds: string[]) { + error = null; + try { + for (let i = 0; i < viewIds.length; i++) { + await boardViewCollection.update(viewIds[i], { order: i } as Partial); + } + } catch (e) { + error = e instanceof Error ? e.message : 'Failed to reorder views'; + } + }, + + /** Update a column's taskIds (for custom groupBy with manual task assignment) */ + async updateColumnTaskIds(viewId: string, columnId: string, taskIds: string[]) { + error = null; + try { + const view = await boardViewCollection.get(viewId); + if (!view) return; + + const updatedColumns = view.columns.map((col: ViewColumn) => + col.id === columnId + ? { ...col, match: { ...col.match, taskIds } } + : col + ); + await boardViewCollection.update(viewId, { + columns: updatedColumns, + } as Partial); + } catch (e) { + error = e instanceof Error ? e.message : 'Failed to update column'; + throw e; + } + }, +}; diff --git a/apps/todo/apps/web/src/lib/stores/kanban.svelte.ts b/apps/todo/apps/web/src/lib/stores/kanban.svelte.ts deleted file mode 100644 index cc026473f..000000000 --- a/apps/todo/apps/web/src/lib/stores/kanban.svelte.ts +++ /dev/null @@ -1,465 +0,0 @@ -/** - * Kanban Store - Manages kanban boards, columns, and tasks using Svelte 5 runes - */ - -import type { KanbanBoard, KanbanColumn, Task } from '@todo/shared'; -import * as kanbanApi from '$lib/api/kanban'; -import * as tasksApi from '$lib/api/tasks'; - -// Board state -let boards = $state([]); -let currentBoardId = $state(null); - -// Column & Task state -let columns = $state([]); -let tasksByColumn = $state>({}); - -// Loading & Error state -let loading = $state(false); -let boardsLoading = $state(false); -let error = $state(null); - -export const kanbanStore = { - // ===================== - // Board Getters - // ===================== - - get boards() { - return boards; - }, - get currentBoardId() { - return currentBoardId; - }, - get currentBoard() { - return boards.find((b) => b.id === currentBoardId) ?? null; - }, - get globalBoard() { - return boards.find((b) => b.isGlobal) ?? null; - }, - - // ===================== - // Column & Task Getters - // ===================== - - get columns() { - return columns; - }, - get tasksByColumn() { - return tasksByColumn; - }, - get loading() { - return loading; - }, - get boardsLoading() { - return boardsLoading; - }, - get error() { - return error; - }, - - // ===================== - // Board Operations - // ===================== - - /** - * Fetch all boards for the current user - */ - async fetchBoards() { - boardsLoading = true; - error = null; - try { - boards = await kanbanApi.getBoards(); - - // If no current board selected, select global board or first board - if (!currentBoardId && boards.length > 0) { - const globalBoard = boards.find((b) => b.isGlobal); - currentBoardId = globalBoard?.id ?? boards[0].id; - } - } catch (e) { - error = e instanceof Error ? e.message : 'Failed to fetch boards'; - console.error('Failed to fetch boards:', e); - } finally { - boardsLoading = false; - } - }, - - /** - * Get or create the global board - */ - async getOrCreateGlobalBoard() { - error = null; - try { - const globalBoard = await kanbanApi.getGlobalBoard(); - - // Update or add to boards list - const existingIndex = boards.findIndex((b) => b.id === globalBoard.id); - if (existingIndex >= 0) { - boards[existingIndex] = globalBoard; - } else { - boards = [globalBoard, ...boards]; - } - - return globalBoard; - } catch (e) { - error = e instanceof Error ? e.message : 'Failed to get global board'; - console.error('Failed to get global board:', e); - throw e; - } - }, - - /** - * Select a board and load its data - */ - async selectBoard(boardId: string) { - if (currentBoardId === boardId) return; - - currentBoardId = boardId; - await this.fetchKanbanData(boardId); - }, - - /** - * Create a new board - */ - async createBoard(data: { name: string; projectId?: string; color?: string; icon?: string }) { - error = null; - try { - const newBoard = await kanbanApi.createBoard(data); - boards = [...boards, newBoard]; - return newBoard; - } catch (e) { - error = e instanceof Error ? e.message : 'Failed to create board'; - console.error('Failed to create board:', e); - throw e; - } - }, - - /** - * Update a board - */ - async updateBoard(id: string, data: { name?: string; color?: string; icon?: string }) { - error = null; - try { - const updated = await kanbanApi.updateBoard(id, data); - boards = boards.map((b) => (b.id === id ? updated : b)); - return updated; - } catch (e) { - error = e instanceof Error ? e.message : 'Failed to update board'; - console.error('Failed to update board:', e); - throw e; - } - }, - - /** - * Delete a board - */ - async deleteBoard(id: string) { - error = null; - try { - await kanbanApi.deleteBoard(id); - boards = boards.filter((b) => b.id !== id); - - // If deleted board was current, switch to global board - if (currentBoardId === id) { - const globalBoard = boards.find((b) => b.isGlobal); - currentBoardId = globalBoard?.id ?? boards[0]?.id ?? null; - if (currentBoardId) { - await this.fetchKanbanData(currentBoardId); - } - } - } catch (e) { - error = e instanceof Error ? e.message : 'Failed to delete board'; - console.error('Failed to delete board:', e); - throw e; - } - }, - - /** - * Reorder boards (optimistic update) - */ - async reorderBoards(boardIds: string[]) { - error = null; - const previousBoards = [...boards]; - try { - // Optimistic update - boards = boardIds - .map((id) => boards.find((b) => b.id === id)) - .filter((b): b is KanbanBoard => b !== undefined); - - // Persist to server - const updated = await kanbanApi.reorderBoards(boardIds); - boards = updated; - } catch (e) { - // Rollback on error - boards = previousBoards; - error = e instanceof Error ? e.message : 'Failed to reorder boards'; - console.error('Failed to reorder boards:', e); - throw e; - } - }, - - // ===================== - // Column & Task Operations - // ===================== - - /** - * Fetch columns and tasks grouped by column for a board - */ - async fetchKanbanData(boardId: string) { - loading = true; - error = null; - try { - const data = await kanbanApi.getKanbanTasks(boardId); - columns = data.columns; - tasksByColumn = data.tasksByColumn; - } catch (e) { - error = e instanceof Error ? e.message : 'Failed to fetch kanban data'; - console.error('Failed to fetch kanban data:', e); - } finally { - loading = false; - } - }, - - /** - * Fetch only columns for a board - */ - async fetchColumns(boardId: string) { - loading = true; - error = null; - try { - columns = await kanbanApi.getColumns(boardId); - } catch (e) { - error = e instanceof Error ? e.message : 'Failed to fetch columns'; - console.error('Failed to fetch columns:', e); - } finally { - loading = false; - } - }, - - /** - * Create a new column - */ - async createColumn(data: { - name: string; - boardId: string; - color?: string; - defaultStatus?: string; - autoComplete?: boolean; - }) { - error = null; - try { - const newColumn = await kanbanApi.createColumn(data); - columns = [...columns, newColumn]; - tasksByColumn[newColumn.id] = []; - return newColumn; - } catch (e) { - error = e instanceof Error ? e.message : 'Failed to create column'; - console.error('Failed to create column:', e); - throw e; - } - }, - - /** - * Update a column - */ - async updateColumn( - id: string, - data: { - name?: string; - color?: string; - defaultStatus?: string; - autoComplete?: boolean; - } - ) { - error = null; - try { - const updated = await kanbanApi.updateColumn(id, data); - columns = columns.map((c) => (c.id === id ? updated : c)); - return updated; - } catch (e) { - error = e instanceof Error ? e.message : 'Failed to update column'; - console.error('Failed to update column:', e); - throw e; - } - }, - - /** - * Delete a column - */ - async deleteColumn(id: string) { - error = null; - try { - await kanbanApi.deleteColumn(id); - columns = columns.filter((c) => c.id !== id); - delete tasksByColumn[id]; - } catch (e) { - error = e instanceof Error ? e.message : 'Failed to delete column'; - console.error('Failed to delete column:', e); - throw e; - } - }, - - /** - * Reorder columns (optimistic update) - */ - async reorderColumns(columnIds: string[]) { - error = null; - const previousColumns = [...columns]; - try { - // Optimistic update - columns = columnIds - .map((id) => columns.find((c) => c.id === id)) - .filter((c): c is KanbanColumn => c !== undefined); - - // Persist to server - const updated = await kanbanApi.reorderColumns(columnIds); - columns = updated; - } catch (e) { - // Rollback on error - columns = previousColumns; - error = e instanceof Error ? e.message : 'Failed to reorder columns'; - console.error('Failed to reorder columns:', e); - throw e; - } - }, - - /** - * Move task to a different column (optimistic update) - */ - async moveTaskToColumn(taskId: string, fromColumnId: string, toColumnId: string, order?: number) { - error = null; - const previousTasksByColumn = { ...tasksByColumn }; - - try { - // Find the task - const task = tasksByColumn[fromColumnId]?.find((t) => t.id === taskId); - if (!task) { - throw new Error('Task not found'); - } - - // Optimistic update - tasksByColumn[fromColumnId] = tasksByColumn[fromColumnId].filter((t) => t.id !== taskId); - - if (!tasksByColumn[toColumnId]) { - tasksByColumn[toColumnId] = []; - } - - const insertIndex = order ?? tasksByColumn[toColumnId].length; - const updatedTask = { ...task, columnId: toColumnId, columnOrder: insertIndex }; - tasksByColumn[toColumnId] = [ - ...tasksByColumn[toColumnId].slice(0, insertIndex), - updatedTask, - ...tasksByColumn[toColumnId].slice(insertIndex), - ]; - - // Persist to server - await kanbanApi.moveTaskToColumn(taskId, toColumnId, order); - } catch (e) { - // Rollback on error - tasksByColumn = previousTasksByColumn; - error = e instanceof Error ? e.message : 'Failed to move task'; - console.error('Failed to move task:', e); - throw e; - } - }, - - /** - * Reorder tasks within a column (optimistic update) - */ - async reorderTasksInColumn(columnId: string, taskIds: string[]) { - error = null; - const previousTasks = [...(tasksByColumn[columnId] || [])]; - - try { - // Optimistic update - const columnTasks = tasksByColumn[columnId] || []; - tasksByColumn[columnId] = taskIds - .map((id) => columnTasks.find((t) => t.id === id)) - .filter((t): t is Task => t !== undefined); - - // Persist to server - await kanbanApi.reorderTasksInColumn(columnId, taskIds); - } catch (e) { - // Rollback on error - tasksByColumn[columnId] = previousTasks; - error = e instanceof Error ? e.message : 'Failed to reorder tasks'; - console.error('Failed to reorder tasks:', e); - throw e; - } - }, - - /** - * Initialize default columns if none exist - */ - async initializeDefaultColumns(boardId: string) { - error = null; - try { - const newColumns = await kanbanApi.initializeColumns(boardId); - columns = newColumns; - // Initialize empty task arrays for each column - for (const col of newColumns) { - if (!tasksByColumn[col.id]) { - tasksByColumn[col.id] = []; - } - } - return newColumns; - } catch (e) { - error = e instanceof Error ? e.message : 'Failed to initialize columns'; - console.error('Failed to initialize columns:', e); - throw e; - } - }, - - /** - * Get tasks for a specific column - */ - getTasksForColumn(columnId: string): Task[] { - return tasksByColumn[columnId] || []; - }, - - /** - * Create a new task in a specific column - */ - async createTaskInColumn(columnId: string, title: string, projectId?: string) { - error = null; - - try { - // Find the column to get its default status - const column = columns.find((c) => c.id === columnId); - - // Create the task - const newTask = await tasksApi.createTask({ - title, - projectId, - priority: 'medium', - }); - - // Move task to the column (this will set columnId and status) - const movedTask = await kanbanApi.moveTaskToColumn(newTask.id, columnId, 0); - - // Add to local state at the beginning of the column - if (!tasksByColumn[columnId]) { - tasksByColumn[columnId] = []; - } - tasksByColumn[columnId] = [movedTask, ...tasksByColumn[columnId]]; - - return movedTask; - } catch (e) { - error = e instanceof Error ? e.message : 'Failed to create task'; - console.error('Failed to create task in column:', e); - throw e; - } - }, - - /** - * Clear all state (for logout) - */ - clear() { - boards = []; - currentBoardId = null; - columns = []; - tasksByColumn = {}; - loading = false; - boardsLoading = false; - error = null; - }, -}; diff --git a/apps/todo/apps/web/src/routes/(app)/kanban/+page.svelte b/apps/todo/apps/web/src/routes/(app)/kanban/+page.svelte index a751e2646..0b855d608 100644 --- a/apps/todo/apps/web/src/routes/(app)/kanban/+page.svelte +++ b/apps/todo/apps/web/src/routes/(app)/kanban/+page.svelte @@ -1,10 +1,45 @@