diff --git a/.env.example b/.env.example index 6de9bea..d0090b2 100644 --- a/.env.example +++ b/.env.example @@ -7,3 +7,10 @@ GOOGLE_API_KEY=your_google_api_key # Paste the full JSON key file contents, or base64-encode it # The calendar must be shared with the service account email (with "Make changes to events" permission) GOOGLE_SERVICE_ACCOUNT_KEY= + +# Matrix / Synapse integration +# The homeserver URL where your Synapse instance is running +MATRIX_HOMESERVER_URL=https://matrix.example.com +# Synapse Admin API shared secret or admin access token +# Used to auto-provision Matrix accounts for users +MATRIX_ADMIN_TOKEN= diff --git a/messages/en.json b/messages/en.json index 101b623..7fb989e 100644 --- a/messages/en.json +++ b/messages/en.json @@ -378,5 +378,14 @@ "overview_upcoming_events": "Upcoming Events", "overview_upcoming_empty": "No upcoming events. Create one to get started.", "overview_view_all_events": "View all events", - "overview_more_members": "+{count} more" + "overview_more_members": "+{count} more", + "chat_join_title": "Join Chat", + "chat_join_description": "Chat is powered by Matrix, an open standard for secure, decentralized communication.", + "chat_join_consent": "By joining, a Matrix account will be created for you using your current profile details (name, email, and avatar).", + "chat_join_learn_more": "Learn more about Matrix", + "chat_join_button": "Join Chat", + "chat_joining": "Setting up your account...", + "chat_join_success": "Chat account created! Welcome.", + "chat_join_error": "Failed to set up chat. Please try again.", + "chat_disconnect": "Disconnect from Chat" } \ No newline at end of file diff --git a/messages/et.json b/messages/et.json index ec3232a..5ef4ff8 100644 --- a/messages/et.json +++ b/messages/et.json @@ -378,5 +378,14 @@ "overview_upcoming_events": "Tulevased üritused", "overview_upcoming_empty": "Tulevasi üritusi pole. Loo üks alustamiseks.", "overview_view_all_events": "Vaata kõiki üritusi", - "overview_more_members": "+{count} veel" + "overview_more_members": "+{count} veel", + "chat_join_title": "Liitu vestlusega", + "chat_join_description": "Vestlus põhineb Matrixil — avatud standardil turvalise ja detsentraliseeritud suhtluse jaoks.", + "chat_join_consent": "Liitudes luuakse sulle Matrixi konto sinu praeguste profiiliandmete (nimi, e-post ja avatar) põhjal.", + "chat_join_learn_more": "Loe Matrixi kohta lähemalt", + "chat_join_button": "Liitu vestlusega", + "chat_joining": "Konto seadistamine...", + "chat_join_success": "Vestluskonto loodud! Tere tulemast.", + "chat_join_error": "Vestluse seadistamine ebaõnnestus. Proovi uuesti.", + "chat_disconnect": "Katkesta vestlusühendus" } \ No newline at end of file diff --git a/src/lib/api/event-tasks.ts b/src/lib/api/event-tasks.ts new file mode 100644 index 0000000..05dfa04 --- /dev/null +++ b/src/lib/api/event-tasks.ts @@ -0,0 +1,266 @@ +import type { SupabaseClient } from '@supabase/supabase-js'; +import type { Database, EventTaskColumn, EventTask } from '$lib/supabase/types'; +import { createLogger } from '$lib/utils/logger'; + +const log = createLogger('api.event-tasks'); + +export interface TaskColumnWithTasks extends EventTaskColumn { + cards: EventTask[]; +} + +// ============================================================ +// Columns +// ============================================================ + +export async function fetchTaskColumns( + supabase: SupabaseClient, + eventId: string +): Promise { + const { data: columns, error: colErr } = await supabase + .from('event_task_columns') + .select('*') + .eq('event_id', eventId) + .order('position'); + + if (colErr) { + log.error('Failed to fetch task columns', { error: colErr, data: { eventId } }); + throw colErr; + } + + const { data: tasks, error: taskErr } = await supabase + .from('event_tasks') + .select('*') + .eq('event_id', eventId) + .order('position'); + + if (taskErr) { + log.error('Failed to fetch tasks', { error: taskErr, data: { eventId } }); + throw taskErr; + } + + const tasksByColumn = new Map(); + for (const task of tasks ?? []) { + const arr = tasksByColumn.get(task.column_id) ?? []; + arr.push(task); + tasksByColumn.set(task.column_id, arr); + } + + return (columns ?? []).map((col) => ({ + ...col, + cards: tasksByColumn.get(col.id) ?? [], + })); +} + +export async function createTaskColumn( + supabase: SupabaseClient, + eventId: string, + name: string, + position?: number +): Promise { + if (position === undefined) { + const { count } = await supabase + .from('event_task_columns') + .select('*', { count: 'exact', head: true }) + .eq('event_id', eventId); + position = count ?? 0; + } + + const { data, error } = await supabase + .from('event_task_columns') + .insert({ event_id: eventId, name, position }) + .select() + .single(); + + if (error || !data) { + log.error('Failed to create task column', { error, data: { eventId, name } }); + throw error; + } + + return data; +} + +export async function renameTaskColumn( + supabase: SupabaseClient, + columnId: string, + name: string +): Promise { + const { error } = await supabase + .from('event_task_columns') + .update({ name }) + .eq('id', columnId); + + if (error) { + log.error('Failed to rename task column', { error, data: { columnId, name } }); + throw error; + } +} + +export async function deleteTaskColumn( + supabase: SupabaseClient, + columnId: string +): Promise { + const { error } = await supabase + .from('event_task_columns') + .delete() + .eq('id', columnId); + + if (error) { + log.error('Failed to delete task column', { error, data: { columnId } }); + throw error; + } +} + +// ============================================================ +// Tasks +// ============================================================ + +export async function createTask( + supabase: SupabaseClient, + eventId: string, + columnId: string, + title: string, + createdBy?: string +): Promise { + const { count } = await supabase + .from('event_tasks') + .select('*', { count: 'exact', head: true }) + .eq('column_id', columnId); + + const { data, error } = await supabase + .from('event_tasks') + .insert({ + event_id: eventId, + column_id: columnId, + title, + position: count ?? 0, + created_by: createdBy ?? null, + }) + .select() + .single(); + + if (error || !data) { + log.error('Failed to create task', { error, data: { eventId, columnId, title } }); + throw error; + } + + return data; +} + +export async function updateTask( + supabase: SupabaseClient, + taskId: string, + updates: Partial> +): Promise { + const { error } = await supabase + .from('event_tasks') + .update({ ...updates, updated_at: new Date().toISOString() }) + .eq('id', taskId); + + if (error) { + log.error('Failed to update task', { error, data: { taskId, updates } }); + throw error; + } +} + +export async function deleteTask( + supabase: SupabaseClient, + taskId: string +): Promise { + const { error } = await supabase + .from('event_tasks') + .delete() + .eq('id', taskId); + + if (error) { + log.error('Failed to delete task', { error, data: { taskId } }); + throw error; + } +} + +export async function moveTask( + supabase: SupabaseClient, + taskId: string, + newColumnId: string, + newPosition: number +): Promise { + // Fetch all tasks in the target column + const { data: colTasks, error: fetchErr } = await supabase + .from('event_tasks') + .select('id, position') + .eq('column_id', newColumnId) + .order('position'); + + if (fetchErr) { + log.error('Failed to fetch column tasks for reorder', { error: fetchErr }); + throw fetchErr; + } + + // Build the new order + const existing = (colTasks ?? []).filter((t) => t.id !== taskId); + existing.splice(newPosition, 0, { id: taskId, position: newPosition }); + + // Update positions + column for changed tasks + const updates = existing + .map((t, i) => ({ id: t.id, position: i, column_id: newColumnId })) + .filter((t, i) => { + const orig = colTasks?.find((c) => c.id === t.id); + return !orig || orig.position !== i || t.id === taskId; + }); + + if (updates.length > 0) { + await Promise.all( + updates.map((u) => + supabase + .from('event_tasks') + .update({ column_id: u.column_id, position: u.position, updated_at: new Date().toISOString() }) + .eq('id', u.id) + ) + ); + } +} + +// ============================================================ +// Realtime +// ============================================================ + +export interface RealtimeChangePayload> { + event: 'INSERT' | 'UPDATE' | 'DELETE'; + new: T; + old: Partial; +} + +export function subscribeToEventTasks( + supabase: SupabaseClient, + eventId: string, + columnIds: string[], + onColumnChange: (payload: RealtimeChangePayload) => void, + onTaskChange: (payload: RealtimeChangePayload) => void +) { + const channel = supabase.channel(`event-tasks:${eventId}`); + const columnIdSet = new Set(columnIds); + + channel + .on('postgres_changes', { event: '*', schema: 'public', table: 'event_task_columns', filter: `event_id=eq.${eventId}` }, + (payload) => onColumnChange({ + event: payload.eventType as 'INSERT' | 'UPDATE' | 'DELETE', + new: payload.new as EventTaskColumn, + old: payload.old as Partial, + }) + ) + .on('postgres_changes', { event: '*', schema: 'public', table: 'event_tasks', filter: `event_id=eq.${eventId}` }, + (payload) => { + const task = (payload.new ?? payload.old) as Partial; + const colId = task.column_id ?? (payload.old as Partial)?.column_id; + if (colId && !columnIdSet.has(colId)) return; + + onTaskChange({ + event: payload.eventType as 'INSERT' | 'UPDATE' | 'DELETE', + new: payload.new as EventTask, + old: payload.old as Partial, + }); + } + ) + .subscribe(); + + return channel; +} diff --git a/src/lib/components/kanban/KanbanBoard.svelte b/src/lib/components/kanban/KanbanBoard.svelte index 6148477..3442133 100644 --- a/src/lib/components/kanban/KanbanBoard.svelte +++ b/src/lib/components/kanban/KanbanBoard.svelte @@ -83,6 +83,13 @@ dragOverCardIndex = null; } + function dropIndicatorClass(card: KanbanCard, cardIndex: number, columnId: string, totalCards: number): string { + if (!draggedCard || draggedCard.id === card.id || dragOverCardIndex?.columnId !== columnId) return ''; + if (dragOverCardIndex.index === cardIndex) return 'shadow-[0_-3px_0_0_var(--color-primary)]'; + if (dragOverCardIndex.index === cardIndex + 1 && cardIndex === totalCards - 1) return 'shadow-[0_3px_0_0_var(--color-primary)]'; + return ''; + } + function handleCardDragOver(e: DragEvent, columnId: string, index: number) { e.preventDefault(); e.stopPropagation(); @@ -92,6 +99,12 @@ const midY = rect.top + rect.height / 2; const dropIndex = e.clientY < midY ? index : index + 1; + // Skip update if the index hasn't changed (prevents flicker) + if ( + dragOverCardIndex?.columnId === columnId && + dragOverCardIndex?.index === dropIndex + ) return; + dragOverColumn = columnId; dragOverCardIndex = { columnId, index: dropIndex }; } @@ -144,6 +157,7 @@ column.id ? 'ring-2 ring-primary' : ''}" + data-column-id={column.id} ondragover={(e) => handleColumnDragOver(e, column.id)} ondragleave={handleColumnDragLeave} ondrop={(e) => handleDrop(e, column.id)} @@ -238,14 +252,8 @@
{#each column.cards as card, cardIndex} - - {#if draggedCard && dragOverCardIndex?.columnId === column.id && dragOverCardIndex?.index === cardIndex && draggedCard.id !== card.id} -
- {/if}
handleCardDragOver(e, column.id, cardIndex)} > @@ -261,12 +269,6 @@ />
{/each} - - {#if draggedCard && dragOverCardIndex?.columnId === column.id && dragOverCardIndex?.index === column.cards.length} -
- {/if}
diff --git a/src/lib/components/kanban/KanbanCard.svelte b/src/lib/components/kanban/KanbanCard.svelte index b73c17e..4a87c47 100644 --- a/src/lib/components/kanban/KanbanCard.svelte +++ b/src/lib/components/kanban/KanbanCard.svelte @@ -21,6 +21,7 @@ ondelete?: (cardId: string) => void; draggable?: boolean; ondragstart?: (e: DragEvent) => void; + ondragend?: (e: DragEvent) => void; } let { @@ -30,6 +31,7 @@ ondelete, draggable = true, ondragstart, + ondragend, }: Props = $props(); function handleDelete(e: MouseEvent) { @@ -59,8 +61,10 @@ type="button" class="bg-night/80 border border-light/5 hover:border-light/10 rounded-xl px-3 py-2.5 cursor-pointer transition-all group w-full text-left flex flex-col gap-1.5 relative" class:opacity-50={isDragging} + data-card-id={card.id} {draggable} {ondragstart} + {ondragend} {onclick} > diff --git a/src/lib/components/ui/Logo.svelte b/src/lib/components/ui/Logo.svelte index 8b21308..52fceb4 100644 --- a/src/lib/components/ui/Logo.svelte +++ b/src/lib/components/ui/Logo.svelte @@ -1,54 +1,24 @@
- - - - - + + + + +
- {#if showText} - - Root - - {/if}
diff --git a/src/lib/supabase/types.ts b/src/lib/supabase/types.ts index 2ba25a0..e2bbb6e 100644 --- a/src/lib/supabase/types.ts +++ b/src/lib/supabase/types.ts @@ -517,6 +517,104 @@ export type Database = { }, ] } + event_task_columns: { + Row: { + color: string | null + created_at: string | null + event_id: string + id: string + name: string + position: number + } + Insert: { + color?: string | null + created_at?: string | null + event_id: string + id?: string + name: string + position?: number + } + Update: { + color?: string | null + created_at?: string | null + event_id?: string + id?: string + name?: string + position?: number + } + Relationships: [ + { + foreignKeyName: "event_task_columns_event_id_fkey" + columns: ["event_id"] + isOneToOne: false + referencedRelation: "events" + referencedColumns: ["id"] + }, + ] + } + event_tasks: { + Row: { + assignee_id: string | null + color: string | null + column_id: string + created_at: string | null + created_by: string | null + description: string | null + due_date: string | null + event_id: string + id: string + position: number + priority: string | null + title: string + updated_at: string | null + } + Insert: { + assignee_id?: string | null + color?: string | null + column_id: string + created_at?: string | null + created_by?: string | null + description?: string | null + due_date?: string | null + event_id: string + id?: string + position?: number + priority?: string | null + title: string + updated_at?: string | null + } + Update: { + assignee_id?: string | null + color?: string | null + column_id?: string + created_at?: string | null + created_by?: string | null + description?: string | null + due_date?: string | null + event_id?: string + id?: string + position?: number + priority?: string | null + title?: string + updated_at?: string | null + } + Relationships: [ + { + foreignKeyName: "event_tasks_column_id_fkey" + columns: ["column_id"] + isOneToOne: false + referencedRelation: "event_task_columns" + referencedColumns: ["id"] + }, + { + foreignKeyName: "event_tasks_event_id_fkey" + columns: ["event_id"] + isOneToOne: false + referencedRelation: "events" + referencedColumns: ["id"] + }, + ] + } events: { Row: { color: string | null @@ -1433,15 +1531,15 @@ export const Constants = { }, } as const -// ============================================================ -// Convenience type aliases (used across the codebase) -// ============================================================ -export type Profile = Tables<'profiles'> -export type Organization = Tables<'organizations'> -export type Document = Tables<'documents'> -export type KanbanBoard = Tables<'kanban_boards'> -export type KanbanColumn = Tables<'kanban_columns'> -export type KanbanCard = Tables<'kanban_cards'> -export type CalendarEvent = Tables<'calendar_events'> -export type OrgRole = Tables<'org_roles'> -export type MemberRole = string +// Convenience type aliases +export type Profile = Tables<'profiles'>; +export type Organization = Tables<'organizations'>; +export type Document = Tables<'documents'>; +export type KanbanBoard = Tables<'kanban_boards'>; +export type KanbanColumn = Tables<'kanban_columns'>; +export type KanbanCard = Tables<'kanban_cards'>; +export type CalendarEvent = Tables<'calendar_events'>; +export type OrgRole = Tables<'org_roles'>; +export type EventTaskColumn = Tables<'event_task_columns'>; +export type EventTask = Tables<'event_tasks'>; +export type MemberRole = string; diff --git a/src/routes/[orgSlug]/+layout.svelte b/src/routes/[orgSlug]/+layout.svelte index 6da719f..b7f6d18 100644 --- a/src/routes/[orgSlug]/+layout.svelte +++ b/src/routes/[orgSlug]/+layout.svelte @@ -1,5 +1,5 @@ @@ -230,7 +237,11 @@ ? 'opacity-0 max-w-0 overflow-hidden' : 'opacity-100 max-w-[200px]'}">{item.label} - {#if item.badge} + {#if isNavigatingTo(item.href)} + + + + {:else if item.badge} {item.badge} @@ -337,9 +348,7 @@ class="flex items-center justify-center" > + size={sidebarCollapsed ? "sm" : "md"} /> diff --git a/src/routes/[orgSlug]/chat/+page.svelte b/src/routes/[orgSlug]/chat/+page.svelte index 56638e3..80050fd 100644 --- a/src/routes/[orgSlug]/chat/+page.svelte +++ b/src/routes/[orgSlug]/chat/+page.svelte @@ -2,7 +2,8 @@ import { onMount, getContext } from "svelte"; import { browser } from "$app/environment"; import { page } from "$app/state"; - import { Avatar, Button, Input, Modal } from "$lib/components/ui"; + import { Avatar, Button, Modal } from "$lib/components/ui"; + import * as m from "$lib/paraglide/messages"; import { MessageList, MessageInput, @@ -51,13 +52,8 @@ // Matrix state let matrixClient = $state(null); let isInitializing = $state(true); - let showMatrixLogin = $state(false); - - // Matrix login form - let matrixHomeserver = $state("https://matrix.org"); - let matrixUsername = $state(""); - let matrixPassword = $state(""); - let isLoggingIn = $state(false); + let showJoinScreen = $state(false); + let isProvisioning = $state(false); // Chat UI state let showCreateRoomModal = $state(false); @@ -140,13 +136,13 @@ deviceId: result.credentials.device_id, }); } else { - // No stored credentials — show login form - showMatrixLogin = true; + // No stored credentials — show join screen + showJoinScreen = true; isInitializing = false; } } catch (e) { console.error("Failed to load Matrix credentials:", e); - showMatrixLogin = true; + showJoinScreen = true; isInitializing = false; } }); @@ -169,8 +165,8 @@ await ensureOrgSpace(credentials); } catch (e: unknown) { console.error("Failed to init Matrix client:", e); - toasts.error("Failed to connect to chat. Please re-login."); - showMatrixLogin = true; + toasts.error(m.chat_join_error()); + showJoinScreen = true; } finally { isInitializing = false; } @@ -204,41 +200,33 @@ } } - async function handleMatrixLogin() { - if (!matrixUsername.trim() || !matrixPassword.trim()) { - toasts.error("Please enter username and password"); - return; - } - - isLoggingIn = true; + async function handleJoinChat() { + isProvisioning = true; try { - const { loginWithPassword } = await import("$lib/matrix"); - const credentials = await loginWithPassword({ - homeserverUrl: matrixHomeserver, - username: matrixUsername.trim(), - password: matrixPassword, - }); - - // Save to Supabase - await fetch("/api/matrix-credentials", { + const res = await fetch("/api/matrix-provision", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - org_id: data.org.id, - homeserver_url: credentials.homeserverUrl, - matrix_user_id: credentials.userId, - access_token: credentials.accessToken, - device_id: credentials.deviceId, - }), + body: JSON.stringify({ org_id: data.org.id }), }); - showMatrixLogin = false; - await initFromCredentials(credentials); - toasts.success("Connected to chat!"); + const result = await res.json(); + if (!res.ok) throw new Error(result.error || "Provisioning failed"); + + showJoinScreen = false; + await initFromCredentials({ + homeserverUrl: result.credentials.homeserver_url, + userId: result.credentials.matrix_user_id, + accessToken: result.credentials.access_token, + deviceId: result.credentials.device_id, + }); + + if (result.provisioned) { + toasts.success(m.chat_join_success()); + } } catch (e: any) { - toasts.error(e.message || "Login failed"); + toasts.error(e.message || m.chat_join_error()); } finally { - isLoggingIn = false; + isProvisioning = false; } } @@ -255,7 +243,7 @@ }); matrixClient = null; - showMatrixLogin = true; + showJoinScreen = true; auth.set({ isLoggedIn: false, userId: null, @@ -389,47 +377,37 @@ } - -{#if showMatrixLogin} + +{#if showJoinScreen}
-
-

Connect to Chat

-

- Enter your Matrix credentials to enable messaging. +

+ chat +

{m.chat_join_title()}

+

+ {m.chat_join_description()}

- -
- - -
- - { - if (e.key === "Enter") handleMatrixLogin(); - }} - /> -
- -
+

+ {m.chat_join_consent()} +

+ + + {m.chat_join_learn_more()} → +
diff --git a/src/routes/[orgSlug]/documents/file/[id]/+page.svelte b/src/routes/[orgSlug]/documents/file/[id]/+page.svelte index 18353c5..dd1f510 100644 --- a/src/routes/[orgSlug]/documents/file/[id]/+page.svelte +++ b/src/routes/[orgSlug]/documents/file/[id]/+page.svelte @@ -10,6 +10,9 @@ deleteCard, deleteColumn, subscribeToBoard, + type RealtimeChangePayload, + type ColumnWithCards, + type BoardWithColumns, } from "$lib/api/kanban"; import { getLockInfo, @@ -24,8 +27,8 @@ RealtimeChannel, SupabaseClient, } from "@supabase/supabase-js"; - import type { Database, KanbanCard, Document } from "$lib/supabase/types"; - import type { BoardWithColumns } from "$lib/api/kanban"; + import type { Database, KanbanCard, KanbanColumn, Document } from "$lib/supabase/types"; + import { untrack } from "svelte"; const log = createLogger("page.file-viewer"); @@ -194,16 +197,68 @@ } }); - $effect(() => { + // Incremental realtime handlers (avoid full refetch) + function handleColumnRealtime(payload: RealtimeChangePayload) { if (!kanbanBoard) return; + const { event } = payload; + if (event === "INSERT") { + const col: ColumnWithCards = { ...payload.new, cards: [] }; + kanbanBoard = { ...kanbanBoard, columns: [...kanbanBoard.columns, col].sort((a: ColumnWithCards, b: ColumnWithCards) => a.position - b.position) }; + } else if (event === "UPDATE") { + kanbanBoard = { ...kanbanBoard, columns: kanbanBoard.columns.map((c: ColumnWithCards) => c.id === payload.new.id ? { ...c, ...payload.new } : c).sort((a: ColumnWithCards, b: ColumnWithCards) => a.position - b.position) }; + } else if (event === "DELETE") { + const deletedId = payload.old.id; + if (deletedId) { + kanbanBoard = { ...kanbanBoard, columns: kanbanBoard.columns.filter((c: ColumnWithCards) => c.id !== deletedId) }; + } + } + } - const colIds = kanbanBoard.columns.map((c) => c.id); + function handleCardRealtime(payload: RealtimeChangePayload) { + if (!kanbanBoard) return; + const { event } = payload; + + // Skip realtime updates for cards in an in-flight optimistic move + if (event === "UPDATE" && optimisticMoveIds.size > 0) { + const cardId = payload.new?.id ?? payload.old?.id; + if (cardId && optimisticMoveIds.has(cardId)) return; + } + + if (event === "INSERT") { + const card = payload.new; + if (!card.column_id) return; + kanbanBoard = { ...kanbanBoard, columns: kanbanBoard.columns.map((col: ColumnWithCards) => col.id === card.column_id ? { ...col, cards: [...col.cards, card].sort((a: KanbanCard, b: KanbanCard) => a.position - b.position) } : col) }; + } else if (event === "UPDATE") { + const card = payload.new; + kanbanBoard = { ...kanbanBoard, columns: kanbanBoard.columns.map((col: ColumnWithCards) => { + if (col.id === card.column_id) { + const exists = col.cards.some((c: KanbanCard) => c.id === card.id); + const updatedCards = exists ? col.cards.map((c: KanbanCard) => c.id === card.id ? { ...c, ...card } : c) : [...col.cards, card]; + return { ...col, cards: updatedCards.sort((a: KanbanCard, b: KanbanCard) => a.position - b.position) }; + } + return { ...col, cards: col.cards.filter((c: KanbanCard) => c.id !== card.id) }; + }) }; + } else if (event === "DELETE") { + const deletedId = payload.old.id; + if (deletedId) { + kanbanBoard = { ...kanbanBoard, columns: kanbanBoard.columns.map((col: ColumnWithCards) => ({ ...col, cards: col.cards.filter((c: KanbanCard) => c.id !== deletedId) })) }; + } + } + } + + let currentKanbanBoardId = $derived(kanbanBoard?.id ?? null); + + $effect(() => { + const boardId = currentKanbanBoardId; + if (!boardId) return; + + const colIds = (untrack(() => kanbanBoard)?.columns ?? []).map((c: ColumnWithCards) => c.id); const channel = subscribeToBoard( supabase, - kanbanBoard.id, + boardId, colIds, - () => loadKanbanBoard(), - () => loadKanbanBoard(), + handleColumnRealtime, + handleCardRealtime, ); realtimeChannel = channel; @@ -244,17 +299,77 @@ } }); - async function handleCardMove( + // Track card IDs with in-flight optimistic moves to suppress realtime echoes + let optimisticMoveIds = new Set(); + + function handleCardMove( cardId: string, toColumnId: string, toPosition: number, ) { - try { - await moveCard(supabase, cardId, toColumnId, toPosition); - } catch (err) { - log.error("Failed to move card", { error: err }); - toasts.error("Failed to move card"); - } + if (!kanbanBoard) return; + + const fromColId = kanbanBoard.columns.find((c: ColumnWithCards) => + c.cards.some((card: KanbanCard) => card.id === cardId), + )?.id; + if (!fromColId) return; + + // Build fully immutable new columns for instant Svelte reactivity + let movedCard: KanbanCard | undefined; + const newColumns = kanbanBoard.columns.map((col: ColumnWithCards) => { + if (col.id === fromColId && fromColId !== toColumnId) { + const filtered = col.cards.filter((c: KanbanCard) => { + if (c.id === cardId) { movedCard = { ...c, column_id: toColumnId }; return false; } + return true; + }); + return { ...col, cards: filtered }; + } + if (col.id === toColumnId && fromColId !== toColumnId) { + const cards = [...col.cards]; + return { ...col, cards }; + } + if (col.id === fromColId && fromColId === toColumnId) { + const card = col.cards.find((c: KanbanCard) => c.id === cardId); + if (!card) return col; + movedCard = { ...card }; + const without = col.cards.filter((c: KanbanCard) => c.id !== cardId); + const cards = [...without.slice(0, toPosition), movedCard, ...without.slice(toPosition)]; + return { ...col, cards }; + } + return col; + }); + + const finalColumns = (fromColId !== toColumnId && movedCard) + ? newColumns.map((col: ColumnWithCards) => { + if (col.id === toColumnId) { + const cards = [...col.cards]; + cards.splice(toPosition, 0, movedCard!); + return { ...col, cards }; + } + return col; + }) + : newColumns; + + // Track affected IDs for realtime suppression + const affectedIds = new Set(); + affectedIds.add(cardId); + const targetCol = finalColumns.find((c: ColumnWithCards) => c.id === toColumnId); + targetCol?.cards.forEach((c: KanbanCard) => affectedIds.add(c.id)); + affectedIds.forEach((id: string) => optimisticMoveIds.add(id)); + + // Instant optimistic update + kanbanBoard = { ...kanbanBoard, columns: finalColumns }; + + // Persist in background + moveCard(supabase, cardId, toColumnId, toPosition) + .catch((err) => { + log.error("Failed to move card", { error: err }); + toasts.error("Failed to move card"); + loadKanbanBoard(); + }) + .finally(() => { + affectedIds.forEach((id) => optimisticMoveIds.delete(id)); + }); } function handleCardClick(card: KanbanCard) { @@ -356,7 +471,7 @@ for (const col of json.columns) { // Check if column already exists by name let targetCol = kanbanBoard.columns.find( - (c) => + (c: ColumnWithCards) => c.name.toLowerCase() === (col.name || "").toLowerCase(), ); @@ -419,9 +534,9 @@ if (!kanbanBoard) return; const exportData = { board: kanbanBoard.name, - columns: kanbanBoard.columns.map((col) => ({ + columns: kanbanBoard.columns.map((col: ColumnWithCards) => ({ name: col.name, - cards: col.cards.map((card) => ({ + cards: col.cards.map((card: KanbanCard) => ({ title: card.title, description: card.description, priority: card.priority, @@ -545,9 +660,9 @@ if (kanbanBoard) { kanbanBoard = { ...kanbanBoard, - columns: kanbanBoard.columns.map((col) => ({ + columns: kanbanBoard.columns.map((col: ColumnWithCards) => ({ ...col, - cards: col.cards.map((c) => + cards: col.cards.map((c: KanbanCard) => c.id === updatedCard.id ? updatedCard : c, ), })), diff --git a/src/routes/[orgSlug]/events/[eventSlug]/+layout.svelte b/src/routes/[orgSlug]/events/[eventSlug]/+layout.svelte index 1a56d39..9c8a9c6 100644 --- a/src/routes/[orgSlug]/events/[eventSlug]/+layout.svelte +++ b/src/routes/[orgSlug]/events/[eventSlug]/+layout.svelte @@ -1,5 +1,5 @@ {m.events_mod_tasks()} | {data.event.name} | {data.org.name} -
- task_alt -

{m.events_mod_tasks()}

-

- {m.events_mod_tasks_desc()} -

- {m.module_coming_soon()} +
+ +
+

{m.events_mod_tasks()}

+
+ + +
+ {#if boardColumns.length > 0} + (showAddColumnModal = true)} + onDeleteCard={handleDeleteCard} + onDeleteColumn={handleDeleteColumn} + onRenameColumn={handleRenameColumn} + {canEdit} + /> + {:else} +
+
+ task_alt +

No task columns yet

+ {#if canEdit} + + {/if} +
+
+ {/if} +
+ + + (showAddColumnModal = false)} +> +
{ + e.preventDefault(); + handleAddColumn(); + }} + class="flex flex-col gap-4" + > + +
+ + +
+
+
+ + + (showAddCardModal = false)} +> +
{ + e.preventDefault(); + submitAddCard(); + }} + class="flex flex-col gap-4" + > + +
+ + +
+
+
diff --git a/src/routes/[orgSlug]/kanban/+page.svelte b/src/routes/[orgSlug]/kanban/+page.svelte index f2774cc..ced9651 100644 --- a/src/routes/[orgSlug]/kanban/+page.svelte +++ b/src/routes/[orgSlug]/kanban/+page.svelte @@ -73,6 +73,9 @@ let cardModalMode = $state<"edit" | "create">("edit"); let realtimeChannel = $state(null); + // Track card IDs with in-flight optimistic moves to suppress realtime echoes + let optimisticMoveIds = new Set(); + async function loadBoard(boardId: string) { selectedBoard = await fetchBoardWithColumns(supabase, boardId); } @@ -118,6 +121,12 @@ if (!selectedBoard) return; const { event } = payload; + // Skip realtime updates for cards that are part of an in-flight optimistic move + if (event === "UPDATE" && optimisticMoveIds.size > 0) { + const cardId = payload.new?.id ?? payload.old?.id; + if (cardId && optimisticMoveIds.has(cardId)) return; + } + if (event === "INSERT") { const card = payload.new; if (!card.column_id) return; @@ -412,37 +421,73 @@ } } - async function handleCardMove( + function handleCardMove( cardId: string, toColumnId: string, toPosition: number, ) { if (!selectedBoard) return; - // Optimistic UI update - move card immediately - const fromColumn = selectedBoard.columns.find((c) => + const fromColId = selectedBoard.columns.find((c) => c.cards.some((card) => card.id === cardId), - ); - const toColumn = selectedBoard.columns.find((c) => c.id === toColumnId); + )?.id; + if (!fromColId) return; - if (!fromColumn || !toColumn) return; - - const cardIndex = fromColumn.cards.findIndex((c) => c.id === cardId); - if (cardIndex === -1) return; - - const [movedCard] = fromColumn.cards.splice(cardIndex, 1); - movedCard.column_id = toColumnId; - toColumn.cards.splice(toPosition, 0, movedCard); - - // Trigger reactivity - selectedBoard = { ...selectedBoard }; - - // Persist to database in background - moveCard(supabase, cardId, toColumnId, toPosition).catch((err) => { - log.error("Failed to persist card move", { error: err }); - // Reload to sync state on error - loadBoard(selectedBoard!.id); + // Build fully immutable new columns so Svelte sees fresh references instantly + let movedCard: KanbanCard | undefined; + const newColumns = selectedBoard.columns.map((col) => { + if (col.id === fromColId && fromColId !== toColumnId) { + const filtered = col.cards.filter((c) => { + if (c.id === cardId) { movedCard = { ...c, column_id: toColumnId }; return false; } + return true; + }); + return { ...col, cards: filtered }; + } + if (col.id === toColumnId && fromColId !== toColumnId) { + const cards = [...col.cards]; + return { ...col, cards, _insertAt: toPosition } as typeof col & { _insertAt: number }; + } + if (col.id === fromColId && fromColId === toColumnId) { + const card = col.cards.find((c) => c.id === cardId); + if (!card) return col; + movedCard = { ...card }; + const without = col.cards.filter((c) => c.id !== cardId); + const cards = [...without.slice(0, toPosition), movedCard, ...without.slice(toPosition)]; + return { ...col, cards }; + } + return col; }); + + const finalColumns = (fromColId !== toColumnId && movedCard) + ? newColumns.map((col) => { + if (col.id === toColumnId) { + const cards = [...col.cards]; + cards.splice(toPosition, 0, movedCard!); + return { ...col, cards }; + } + return col; + }) + : newColumns; + + const affectedIds = new Set(); + affectedIds.add(cardId); + const targetCol = finalColumns.find((c) => c.id === toColumnId); + targetCol?.cards.forEach((c) => affectedIds.add(c.id)); + affectedIds.forEach((id) => optimisticMoveIds.add(id)); + + // Single synchronous assignment — Svelte picks this up instantly + selectedBoard = { ...selectedBoard, columns: finalColumns }; + + // Persist to database in background (fire-and-forget, NOT awaited) + moveCard(supabase, cardId, toColumnId, toPosition) + .catch((err) => { + log.error("Failed to persist card move", { error: err }); + loadBoard(selectedBoard!.id); + }) + .finally(() => { + affectedIds.forEach((id) => optimisticMoveIds.delete(id)); + }); + } function handleCardClick(card: KanbanCard) { diff --git a/src/routes/api/matrix-provision/+server.ts b/src/routes/api/matrix-provision/+server.ts new file mode 100644 index 0000000..c7040e8 --- /dev/null +++ b/src/routes/api/matrix-provision/+server.ts @@ -0,0 +1,234 @@ +import { json } from '@sveltejs/kit'; +import { env } from '$env/dynamic/private'; +import type { RequestHandler } from './$types'; + +/** + * POST /api/matrix-provision + * + * Auto-provisions a Matrix account for the authenticated Supabase user. + * Uses the Synapse Admin API to: + * 1. Register a new Matrix user (or retrieve existing) + * 2. Login to get a fresh access token + * 3. Set display name and avatar from the Supabase profile + * 4. Store credentials in matrix_credentials table + * + * Body: { org_id: string } + */ +export const POST: RequestHandler = async ({ request, locals }) => { + const session = await locals.safeGetSession(); + if (!session.user) { + return json({ error: 'Unauthorized' }, { status: 401 }); + } + + if (!env.MATRIX_HOMESERVER_URL || !env.MATRIX_ADMIN_TOKEN) { + return json({ error: 'Matrix integration not configured' }, { status: 503 }); + } + + const body = await request.json(); + const { org_id } = body; + + if (!org_id) { + return json({ error: 'org_id is required' }, { status: 400 }); + } + + // Check if credentials already exist + const { data: existing } = await locals.supabase + .from('matrix_credentials') + .select('homeserver_url, matrix_user_id, access_token, device_id') + .eq('user_id', session.user.id) + .eq('org_id', org_id) + .single(); + + if (existing) { + return json({ credentials: existing, provisioned: false }); + } + + // Fetch user profile from Supabase + const { data: profile } = await locals.supabase + .from('profiles') + .select('email, full_name, avatar_url') + .eq('id', session.user.id) + .single(); + + if (!profile) { + return json({ error: 'User profile not found' }, { status: 404 }); + } + + // After the guard above, these are guaranteed to be defined + const homeserverUrl = env.MATRIX_HOMESERVER_URL!; + const adminToken = env.MATRIX_ADMIN_TOKEN!; + + // Derive Matrix username from Supabase user ID (safe, unique, no collisions) + const matrixLocalpart = `root_${session.user.id.replace(/-/g, '')}`; + const password = generatePassword(); + + try { + // Step 1: Register user via Synapse Admin API + const registerRes = await fetch( + `${homeserverUrl}/_synapse/admin/v2/users/@${matrixLocalpart}:${getServerName(homeserverUrl)}`, + { + method: 'PUT', + headers: { + 'Authorization': `Bearer ${adminToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + password, + displayname: profile.full_name || profile.email, + admin: false, + deactivated: false, + }), + } + ); + + if (!registerRes.ok) { + const err = await registerRes.json().catch(() => ({})); + console.error('Matrix register failed:', registerRes.status, err); + return json({ error: 'Failed to create Matrix account' }, { status: 500 }); + } + + // Step 2: Login to get access token + const loginRes = await fetch(`${homeserverUrl}/_matrix/client/v3/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + type: 'm.login.password', + identifier: { + type: 'm.id.user', + user: matrixLocalpart, + }, + password, + initial_device_display_name: 'Root v2 Web', + }), + }); + + if (!loginRes.ok) { + const err = await loginRes.json().catch(() => ({})); + console.error('Matrix login failed:', loginRes.status, err); + return json({ error: 'Failed to login to Matrix account' }, { status: 500 }); + } + + const loginData = await loginRes.json(); + const matrixUserId = loginData.user_id; + const accessToken = loginData.access_token; + const deviceId = loginData.device_id; + + // Step 3: Set avatar if available + if (profile.avatar_url) { + try { + await setMatrixAvatar(homeserverUrl, accessToken, profile.avatar_url); + } catch (e) { + console.warn('Failed to set Matrix avatar:', e); + } + } + + // Step 4: Store credentials in Supabase + const { error: upsertError } = await locals.supabase + .from('matrix_credentials') + .upsert( + { + user_id: session.user.id, + org_id, + homeserver_url: homeserverUrl, + matrix_user_id: matrixUserId, + access_token: accessToken, + device_id: deviceId ?? null, + }, + { onConflict: 'user_id,org_id' } + ); + + if (upsertError) { + console.error('Failed to store Matrix credentials:', upsertError); + return json({ error: 'Failed to store credentials' }, { status: 500 }); + } + + return json({ + credentials: { + homeserver_url: homeserverUrl, + matrix_user_id: matrixUserId, + access_token: accessToken, + device_id: deviceId, + }, + provisioned: true, + }); + } catch (e) { + console.error('Matrix provisioning error:', e); + return json({ error: 'Matrix provisioning failed' }, { status: 500 }); + } +}; + +/** + * Extract the server name from the homeserver URL. + * e.g. "https://matrix.example.com" -> "matrix.example.com" + */ +function getServerName(homeserverUrl: string): string { + try { + return new URL(homeserverUrl).hostname; + } catch { + return homeserverUrl.replace(/^https?:\/\//, ''); + } +} + +/** + * Generate a random password for the Matrix account. + * The user never sees this — auth is token-based after provisioning. + */ +function generatePassword(): string { + const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*'; + let password = ''; + const array = new Uint8Array(32); + crypto.getRandomValues(array); + for (const byte of array) { + password += chars[byte % chars.length]; + } + return password; +} + +/** + * Download an avatar from a URL and upload it to Matrix, then set as profile avatar. + */ +async function setMatrixAvatar(homeserverUrl: string, accessToken: string, avatarUrl: string): Promise { + // Download the avatar + const imgRes = await fetch(avatarUrl); + if (!imgRes.ok) return; + + const blob = await imgRes.blob(); + const contentType = imgRes.headers.get('content-type') || 'image/png'; + + // Upload to Matrix content repository + const uploadRes = await fetch( + `${homeserverUrl}/_matrix/media/v3/upload?filename=avatar`, + { + method: 'POST', + headers: { + 'Authorization': `Bearer ${accessToken}`, + 'Content-Type': contentType, + }, + body: blob, + } + ); + + if (!uploadRes.ok) return; + + const { content_uri } = await uploadRes.json(); + + // Set as profile avatar + // We need the user ID from the access token — extract from a whoami call + const whoamiRes = await fetch(`${homeserverUrl}/_matrix/client/v3/account/whoami`, { + headers: { 'Authorization': `Bearer ${accessToken}` }, + }); + if (!whoamiRes.ok) return; + const { user_id } = await whoamiRes.json(); + + await fetch( + `${homeserverUrl}/_matrix/client/v3/profile/${encodeURIComponent(user_id)}/avatar_url`, + { + method: 'PUT', + headers: { + 'Authorization': `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ avatar_url: content_uri }), + } + ); +} diff --git a/supabase/migrations/025_event_tasks.sql b/supabase/migrations/025_event_tasks.sql new file mode 100644 index 0000000..0f6bf63 --- /dev/null +++ b/supabase/migrations/025_event_tasks.sql @@ -0,0 +1,99 @@ +-- Event Tasks: kanban-style task management scoped to events +-- Uses the same KanbanBoard component but with event-specific tables + +-- ============================================================ +-- 1. Task columns: kanban columns per event +-- ============================================================ +CREATE TABLE event_task_columns ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + event_id UUID NOT NULL REFERENCES events(id) ON DELETE CASCADE, + name TEXT NOT NULL, + position INT NOT NULL DEFAULT 0, + color TEXT, + created_at TIMESTAMPTZ DEFAULT now() +); + +CREATE INDEX idx_event_task_columns_event ON event_task_columns(event_id); + +-- ============================================================ +-- 2. Tasks: cards within columns +-- ============================================================ +CREATE TABLE event_tasks ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + column_id UUID NOT NULL REFERENCES event_task_columns(id) ON DELETE CASCADE, + event_id UUID NOT NULL REFERENCES events(id) ON DELETE CASCADE, + title TEXT NOT NULL, + description TEXT, + position INT NOT NULL DEFAULT 0, + priority TEXT CHECK (priority IN ('low', 'medium', 'high', 'urgent')), + due_date DATE, + color TEXT, + assignee_id UUID REFERENCES auth.users(id) ON DELETE SET NULL, + created_by UUID REFERENCES auth.users(id), + created_at TIMESTAMPTZ DEFAULT now(), + updated_at TIMESTAMPTZ DEFAULT now() +); + +CREATE INDEX idx_event_tasks_column ON event_tasks(column_id); +CREATE INDEX idx_event_tasks_event ON event_tasks(event_id); +CREATE INDEX idx_event_tasks_assignee ON event_tasks(assignee_id); + +-- ============================================================ +-- 3. RLS policies +-- ============================================================ +ALTER TABLE event_task_columns ENABLE ROW LEVEL SECURITY; +ALTER TABLE event_tasks ENABLE ROW LEVEL SECURITY; + +-- Task columns: org members can view, editors+ can manage +CREATE POLICY "Org members can view event task columns" ON event_task_columns FOR SELECT + USING (EXISTS ( + SELECT 1 FROM events e + JOIN org_members om ON e.org_id = om.org_id + WHERE e.id = event_task_columns.event_id AND om.user_id = auth.uid() + )); + +CREATE POLICY "Editors can manage event task columns" ON event_task_columns FOR ALL + USING (EXISTS ( + SELECT 1 FROM events e + JOIN org_members om ON e.org_id = om.org_id + WHERE e.id = event_task_columns.event_id AND om.user_id = auth.uid() AND om.role IN ('owner', 'admin', 'editor') + )); + +-- Tasks: org members can view, editors+ can manage +CREATE POLICY "Org members can view event tasks" ON event_tasks FOR SELECT + USING (EXISTS ( + SELECT 1 FROM events e + JOIN org_members om ON e.org_id = om.org_id + WHERE e.id = event_tasks.event_id AND om.user_id = auth.uid() + )); + +CREATE POLICY "Editors can manage event tasks" ON event_tasks FOR ALL + USING (EXISTS ( + SELECT 1 FROM events e + JOIN org_members om ON e.org_id = om.org_id + WHERE e.id = event_tasks.event_id AND om.user_id = auth.uid() AND om.role IN ('owner', 'admin', 'editor') + )); + +-- ============================================================ +-- 4. Enable realtime +-- ============================================================ +ALTER PUBLICATION supabase_realtime ADD TABLE event_task_columns; +ALTER PUBLICATION supabase_realtime ADD TABLE event_tasks; + +-- ============================================================ +-- 5. Auto-seed default columns when a new event is created +-- ============================================================ +CREATE OR REPLACE FUNCTION public.seed_event_task_columns() +RETURNS TRIGGER AS $$ +BEGIN + INSERT INTO public.event_task_columns (event_id, name, position) VALUES + (NEW.id, 'To Do', 0), + (NEW.id, 'In Progress', 1), + (NEW.id, 'Done', 2); + RETURN NEW; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +CREATE TRIGGER on_event_created_seed_task_columns + AFTER INSERT ON events + FOR EACH ROW EXECUTE FUNCTION public.seed_event_task_columns(); diff --git a/synapse/docker-compose.yml b/synapse/docker-compose.yml new file mode 100644 index 0000000..47d6a9b --- /dev/null +++ b/synapse/docker-compose.yml @@ -0,0 +1,12 @@ +services: + synapse: + image: matrixdotorg/synapse:latest + container_name: root-org-synapse + ports: + - "8008:8008" + volumes: + - ./data:/data + environment: + - SYNAPSE_SERVER_NAME=localhost + - SYNAPSE_REPORT_STATS=no + restart: unless-stopped diff --git a/tests/e2e/kanban-perf.spec.ts b/tests/e2e/kanban-perf.spec.ts new file mode 100644 index 0000000..a205af1 --- /dev/null +++ b/tests/e2e/kanban-perf.spec.ts @@ -0,0 +1,174 @@ +import { test, expect } from '@playwright/test'; +import { login, TEST_ORG_SLUG } from './helpers'; + +/** + * Measures the REAL end-to-end latency a user experiences when dragging + * a kanban card from one column to another using native mouse events. + * + * Uses MutationObserver set up BEFORE the drag starts, then performs + * a real mouse drag-and-drop sequence. Measures from mouse-up to + * card appearing in the target column DOM. + */ +test.describe('Kanban card move latency', () => { + test('real drag-drop: card should appear in target column fast', async ({ page }) => { + await login(page); + await page.goto(`/${TEST_ORG_SLUG}/kanban`, { waitUntil: 'networkidle' }); + + // Click into the first board if we're on the board selector + const anyBoard = page.locator('text=/board/i').first(); + if (await anyBoard.isVisible({ timeout: 3000 }).catch(() => false)) { + await anyBoard.click(); + } + + // Wait for columns + await page.waitForSelector('[data-column-id]', { timeout: 10000 }); + + // Ensure there's a card to move — check first column + const firstCard = page.locator('[data-column-id]').first().locator('[data-card-id]').first(); + if (!(await firstCard.isVisible({ timeout: 2000 }).catch(() => false))) { + const addBtn = page.locator('[data-column-id]').first().getByRole('button', { name: /add card/i }); + if (await addBtn.isVisible({ timeout: 2000 }).catch(() => false)) { + await addBtn.click(); + await page.waitForTimeout(300); + const titleInput = page.locator('input[placeholder="Card title"]'); + await titleInput.fill('Perf Test Card'); + await page.getByRole('button', { name: 'Add Card', exact: true }).click(); + await page.waitForSelector('[data-card-id]', { timeout: 5000 }); + } else { + console.log('Cannot create card, skipping'); + return; + } + } + + // Gather IDs + const cardId = await page.locator('[data-column-id]').first().locator('[data-card-id]').first().getAttribute('data-card-id'); + const columns = page.locator('[data-column-id]'); + if ((await columns.count()) < 2) { console.log('Need 2+ columns'); return; } + const dstColId = await columns.nth(1).getAttribute('data-column-id'); + + console.log(`Card: ${cardId}`); + console.log(`Target column: ${dstColId}`); + + // Set up a MutationObserver on the target column BEFORE the drag starts + // This will record the exact timestamp when the card DOM node appears + await page.evaluate(({ cardId, dstColId }) => { + const dstCol = document.querySelector(`[data-column-id="${dstColId}"]`); + if (!dstCol) return; + + (window as any).__cardAppearedAt = 0; + (window as any).__mouseUpAt = 0; + + const observer = new MutationObserver(() => { + if ((window as any).__cardAppearedAt > 0) return; + const found = dstCol.querySelector(`[data-card-id="${cardId}"]`); + if (found) { + (window as any).__cardAppearedAt = performance.now(); + observer.disconnect(); + } + }); + observer.observe(dstCol, { childList: true, subtree: true }); + + // Also hook into mouseup to record the exact drop moment + document.addEventListener('mouseup', () => { + if ((window as any).__mouseUpAt === 0) { + (window as any).__mouseUpAt = performance.now(); + } + }, { once: true, capture: true }); + }, { cardId, dstColId }); + + // Get bounding boxes + const card = page.locator('[data-column-id]').first().locator('[data-card-id]').first(); + const cardBox = await card.boundingBox(); + const targetBox = await columns.nth(1).boundingBox(); + if (!cardBox || !targetBox) { console.log('No bounding boxes'); return; } + + const srcX = cardBox.x + cardBox.width / 2; + const srcY = cardBox.y + cardBox.height / 2; + const dstX = targetBox.x + targetBox.width / 2; + const dstY = targetBox.y + 100; + + // Perform REAL mouse drag + await page.mouse.move(srcX, srcY); + await page.mouse.down(); + // Small move to trigger dragstart + await page.mouse.move(srcX + 5, srcY, { steps: 2 }); + await page.waitForTimeout(50); // Let browser register the drag + // Move to target + await page.mouse.move(dstX, dstY, { steps: 3 }); + await page.waitForTimeout(50); // Let dragover register + // Drop + await page.mouse.up(); + + // Wait a bit for everything to settle + await page.waitForTimeout(200); + + // Read the timestamps + const result = await page.evaluate(() => { + return { + mouseUpAt: (window as any).__mouseUpAt as number, + cardAppearedAt: (window as any).__cardAppearedAt as number, + }; + }); + + const dropToRender = result.cardAppearedAt > 0 && result.mouseUpAt > 0 + ? result.cardAppearedAt - result.mouseUpAt + : -1; + + console.log(`\n========================================`); + console.log(` mouseup timestamp: ${result.mouseUpAt.toFixed(1)}`); + console.log(` card appeared at: ${result.cardAppearedAt.toFixed(1)}`); + console.log(` DROP → RENDER LATENCY: ${dropToRender.toFixed(1)}ms`); + if (dropToRender < 0) { + console.log(' ⚠️ Card never appeared or mouseup not captured'); + console.log(' (HTML5 drag may not have fired — trying synthetic fallback)'); + } else if (dropToRender < 20) { + console.log(' ✅ INSTANT (<20ms)'); + } else if (dropToRender < 50) { + console.log(' ✅ VERY FAST (<50ms)'); + } else if (dropToRender < 100) { + console.log(' ⚠️ PERCEPTIBLE (50-100ms)'); + } else if (dropToRender < 500) { + console.log(' ❌ SLOW (100-500ms)'); + } else { + console.log(' ❌ VERY SLOW (>500ms)'); + } + console.log(`========================================\n`); + + // If native drag didn't work, fall back to synthetic events to at least measure Svelte + if (dropToRender < 0) { + console.log('Falling back to synthetic drag events...'); + const synthLatency = await page.evaluate(({ cardId, dstColId }) => { + return new Promise((resolve) => { + const cardEl = document.querySelector(`[data-card-id="${cardId}"]`); + const dstCol = document.querySelector(`[data-column-id="${dstColId}"]`); + if (!cardEl || !dstCol) { resolve(-1); return; } + + const observer = new MutationObserver(() => { + const found = dstCol.querySelector(`[data-card-id="${cardId}"]`); + if (found) { observer.disconnect(); resolve(performance.now() - t0); } + }); + observer.observe(dstCol, { childList: true, subtree: true }); + + const dt = new DataTransfer(); + dt.setData('text/plain', cardId!); + cardEl.dispatchEvent(new DragEvent('dragstart', { bubbles: true, cancelable: true, dataTransfer: dt })); + dstCol.dispatchEvent(new DragEvent('dragover', { bubbles: true, cancelable: true, dataTransfer: dt })); + const t0 = performance.now(); + dstCol.dispatchEvent(new DragEvent('drop', { bubbles: true, cancelable: true, dataTransfer: dt })); + + setTimeout(() => { + observer.disconnect(); + const found = dstCol.querySelector(`[data-card-id="${cardId}"]`); + resolve(found ? performance.now() - t0 : 9999); + }, 2000); + }); + }, { cardId, dstColId }); + console.log(` Synthetic drop→render: ${synthLatency.toFixed(1)}ms`); + } + + // Verify card moved + const cardInTarget = columns.nth(1).locator(`[data-card-id="${cardId}"]`); + const visible = await cardInTarget.isVisible({ timeout: 3000 }).catch(() => false); + console.log(` Card visible in target: ${visible}`); + }); +});