feat(fase-3): Tareas + Notas — TDD complete (148 tests green)

3.0 Tests primero
  pgTAP: 005_tasks_rls.sql (5 — completion CHECK), 006_notes_trash_purge.sql (4 — purge SQL inline + pin/archive CHECK)
  Vitest RLS: rls-tasks.test.ts (14), rls-notes.test.ts (15)
  Playwright: tasks.test.ts (3), notes.test.ts (4)

3.1 Tareas
  Migration 008_tasks.sql: task_lists + tasks + task_list_collective_id() helper
  CHECK constraint: is_completed ⇔ completed_by ⇔ completed_at
  RLS by role + cross-collective isolation
  Store apps/web/src/lib/stores/tasks.ts (optimistic CRUD + reorder)
  /tasks overview (grid + sticky create input + 5s polling)
  /tasks/[id] detail with svelte-dnd-action reorder, flip animation,
    inline edit (dblclick), "Completed by …" attribution via lazy-loaded
    collective_members

3.2 Notas
  Migration 009_notes.sql: notes + note_color enum (8) + pin/archive CHECK
    + 7d trash window in RLS + fn_notes_touch trigger + pg_cron purge job
  Store apps/web/src/lib/stores/notes.ts (CRUD + pin/archive/trash/duplicate)
  /notes board (pinned section testid notes-pinned + 30s polling)
  /notes/[id] editor (title + textarea, 500ms debounced autosave,
    color picker, pin/archive/duplicate/trash actions)
  /notes/archive (restore + send to trash)
  /notes/trash (restore + permanent delete)

3.Z Verification
  just test-all → 148 verdes, 2 skipped:
    25 pgTAP (was 16, +9)
    88 Vitest integration (was 59, +29; 2 presence skipped)
    6 Vitest unit (unchanged)
    29 Playwright (was 22, +3 tasks +4 notes)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-13 10:28:19 +02:00
parent e7a961a66d
commit 104eeba02e
23 changed files with 2563 additions and 81 deletions

View File

@@ -0,0 +1,147 @@
import { writable } from 'svelte/store';
import { getSupabase } from '$lib/supabase';
import type { Note, NoteColor } from '@colectivo/types';
export const notes = writable<Note[]>([]);
export const notesLoading = writable(false);
// ── Active board (not archived, not in trash) ────────────────────────────────
export async function loadNotes(collectiveId: string) {
notesLoading.set(true);
const { data, error } = await getSupabase()
.from('notes')
.select('*')
.eq('collective_id', collectiveId)
.eq('is_archived', false)
.is('deleted_at', null)
.order('is_pinned', { ascending: false })
.order('updated_at', { ascending: false });
if (!error && data) {
notes.set(data as Note[]);
}
notesLoading.set(false);
}
export async function loadArchivedNotes(collectiveId: string): Promise<Note[]> {
const { data } = await getSupabase()
.from('notes')
.select('*')
.eq('collective_id', collectiveId)
.eq('is_archived', true)
.is('deleted_at', null)
.order('updated_at', { ascending: false });
return (data as Note[]) ?? [];
}
export async function loadTrashedNotes(collectiveId: string): Promise<Note[]> {
const { data } = await getSupabase()
.from('notes')
.select('*')
.eq('collective_id', collectiveId)
.not('deleted_at', 'is', null)
.order('deleted_at', { ascending: false });
return (data as Note[]) ?? [];
}
export async function loadNote(id: string): Promise<Note | null> {
const { data } = await getSupabase().from('notes').select('*').eq('id', id).single();
return (data as Note) ?? null;
}
// ── Mutations ────────────────────────────────────────────────────────────────
export async function createNote(
collectiveId: string,
userId: string
): Promise<Note | null> {
const { data, error } = await getSupabase()
.from('notes')
.insert({
collective_id: collectiveId,
content: '',
created_by: userId,
updated_by: userId
})
.select()
.single();
if (error || !data) return null;
notes.update((ns) => [data as Note, ...ns]);
return data as Note;
}
export async function patchNote(
id: string,
userId: string,
patch: Partial<Pick<Note, 'title' | 'content' | 'color' | 'is_pinned' | 'is_archived' | 'deleted_at'>>
) {
const fullPatch = { ...patch, updated_by: userId };
await getSupabase().from('notes').update(fullPatch).eq('id', id);
notes.update((ns) =>
ns.map((n) =>
n.id === id ? { ...n, ...patch, updated_by: userId, updated_at: new Date().toISOString() } : n
)
);
}
export async function pinNote(id: string, userId: string) {
// Pinning forces archive=false (CHECK constraint forbids both true)
await patchNote(id, userId, { is_pinned: true, is_archived: false });
}
export async function unpinNote(id: string, userId: string) {
await patchNote(id, userId, { is_pinned: false });
}
export async function archiveNote(id: string, userId: string) {
await patchNote(id, userId, { is_archived: true, is_pinned: false });
notes.update((ns) => ns.filter((n) => n.id !== id));
}
export async function unarchiveNote(id: string, userId: string) {
await patchNote(id, userId, { is_archived: false });
}
export async function trashNote(id: string, userId: string) {
await patchNote(id, userId, { deleted_at: new Date().toISOString() });
notes.update((ns) => ns.filter((n) => n.id !== id));
}
export async function restoreNote(id: string, userId: string) {
await patchNote(id, userId, { deleted_at: null });
}
export async function permanentDeleteNote(id: string) {
await getSupabase().from('notes').delete().eq('id', id);
}
export async function duplicateNote(note: Note, userId: string): Promise<Note | null> {
const { data, error } = await getSupabase()
.from('notes')
.insert({
collective_id: note.collective_id,
title: note.title,
content: note.content,
color: note.color,
created_by: userId,
updated_by: userId
})
.select()
.single();
if (error || !data) return null;
notes.update((ns) => [data as Note, ...ns]);
return data as Note;
}
export const NOTE_COLORS: NoteColor[] = [
'yellow',
'green',
'blue',
'pink',
'purple',
'orange',
'gray',
'red'
];

View File

@@ -0,0 +1,113 @@
import { writable } from 'svelte/store';
import { getSupabase } from '$lib/supabase';
import type { Task, TaskList } from '@colectivo/types';
// ── Task lists store (overview page) ──────────────────────────────────────────
export const taskLists = writable<TaskList[]>([]);
export const taskListsLoading = writable(false);
export async function loadTaskLists(collectiveId: string) {
taskListsLoading.set(true);
const { data, error } = await getSupabase()
.from('task_lists')
.select('*')
.eq('collective_id', collectiveId)
.order('created_at', { ascending: false });
if (!error && data) {
taskLists.set(data as TaskList[]);
}
taskListsLoading.set(false);
}
export async function createTaskList(
collectiveId: string,
name: string,
userId: string
): Promise<TaskList | null> {
const tempId = crypto.randomUUID();
const optimistic: TaskList = {
id: tempId,
collective_id: collectiveId,
name,
created_by: userId,
created_at: new Date().toISOString()
};
taskLists.update((ls) => [optimistic, ...ls]);
const { data, error } = await getSupabase()
.from('task_lists')
.insert({ collective_id: collectiveId, name, created_by: userId })
.select()
.single();
if (error || !data) {
taskLists.update((ls) => ls.filter((l) => l.id !== tempId));
return null;
}
const real = data as TaskList;
taskLists.update((ls) => ls.map((l) => (l.id === tempId ? real : l)));
return real;
}
export async function renameTaskList(id: string, name: string) {
taskLists.update((ls) => ls.map((l) => (l.id === id ? { ...l, name } : l)));
await getSupabase().from('task_lists').update({ name }).eq('id', id);
}
export async function deleteTaskList(id: string) {
taskLists.update((ls) => ls.filter((l) => l.id !== id));
await getSupabase().from('task_lists').delete().eq('id', id);
}
// ── Task operations (used by [id] page) ───────────────────────────────────────
export async function loadTasks(listId: string): Promise<Task[]> {
const { data, error } = await getSupabase()
.from('tasks')
.select('*')
.eq('list_id', listId)
.order('sort_order', { ascending: true });
if (error || !data) return [];
return data as Task[];
}
export async function addTask(
listId: string,
title: string,
userId: string,
sortOrder: number
): Promise<Task | null> {
const { data, error } = await getSupabase()
.from('tasks')
.insert({ list_id: listId, title, sort_order: sortOrder, created_by: userId })
.select()
.single();
if (error || !data) return null;
return data as Task;
}
export async function renameTask(id: string, title: string) {
await getSupabase().from('tasks').update({ title }).eq('id', id);
}
export async function completeTask(id: string, userId: string, completed: boolean) {
const patch = completed
? { is_completed: true, completed_by: userId, completed_at: new Date().toISOString() }
: { is_completed: false, completed_by: null, completed_at: null };
await getSupabase().from('tasks').update(patch).eq('id', id);
}
export async function deleteTask(id: string) {
await getSupabase().from('tasks').delete().eq('id', id);
}
export async function reorderTasks(tasks: Pick<Task, 'id'>[]) {
await Promise.all(
tasks.map((t, idx) => getSupabase().from('tasks').update({ sort_order: idx }).eq('id', t.id))
);
}

View File

@@ -1,17 +1,148 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { currentCollective } from '$lib/stores/collective';
import { currentUser } from '$lib/stores/auth';
import { notes, notesLoading, loadNotes, createNote } from '$lib/stores/notes';
import { FileText, Plus, Archive, Trash2 } from 'lucide-svelte';
import * as m from '$lib/paraglide/messages';
$effect(() => {
if ($currentCollective) loadNotes($currentCollective.id);
});
// 30s polling per analysis §6
$effect(() => {
if (!$currentCollective) return;
const id = setInterval(() => {
if ($currentCollective) loadNotes($currentCollective.id);
}, 30_000);
return () => clearInterval(id);
});
const pinned = $derived($notes.filter((n) => n.is_pinned));
const others = $derived($notes.filter((n) => !n.is_pinned));
async function handleCreate() {
if (!$currentCollective || !$currentUser) return;
const created = await createNote($currentCollective.id, $currentUser.id);
if (created) goto(`/notes/${created.id}`);
}
function colorClass(color: string | null): string {
switch (color) {
case 'yellow':
return 'bg-yellow-100 dark:bg-yellow-900/30';
case 'green':
return 'bg-emerald-100 dark:bg-emerald-900/30';
case 'blue':
return 'bg-sky-100 dark:bg-sky-900/30';
case 'pink':
return 'bg-pink-100 dark:bg-pink-900/30';
case 'purple':
return 'bg-purple-100 dark:bg-purple-900/30';
case 'orange':
return 'bg-orange-100 dark:bg-orange-900/30';
case 'gray':
return 'bg-slate-100 dark:bg-slate-800/50';
case 'red':
return 'bg-red-100 dark:bg-red-900/30';
default:
return 'bg-surface-raised';
}
}
</script>
<div class="flex flex-1 flex-col overflow-hidden">
<header class="px-8 pb-4 pt-8">
<p class="mb-1 text-[13px] font-semibold uppercase tracking-[0.05em] text-text-secondary">
{m.nav_notes()}
</p>
<h1 class="text-[20px] font-semibold tracking-[-0.01em] text-slate-900 dark:text-slate-50">
{m.notes_title()}
</h1>
<svelte:head><title>{m.notes_title()} — Colectivo</title></svelte:head>
<div class="flex h-full flex-col overflow-hidden">
<header class="flex h-14 items-center gap-2 border-b border-black/5 px-6 dark:border-white/5">
<FileText size={18} strokeWidth={1.5} />
<h1 class="text-base font-semibold text-text-primary">{m.notes_title()}</h1>
<div class="flex-1"></div>
<a
href="/notes/archive"
class="rounded p-1.5 text-text-secondary hover:bg-black/5 dark:hover:bg-white/5"
aria-label={m.notes_archive_view()}
title={m.notes_archive_view()}
>
<Archive size={16} strokeWidth={1.5} />
</a>
<a
href="/notes/trash"
class="rounded p-1.5 text-text-secondary hover:bg-black/5 dark:hover:bg-white/5"
aria-label={m.notes_trash_view()}
title={m.notes_trash_view()}
>
<Trash2 size={16} strokeWidth={1.5} />
</a>
<button
type="button"
onclick={handleCreate}
class="ml-2 inline-flex items-center gap-1 rounded-md bg-slate-900 px-3 py-1.5 text-sm font-medium text-white hover:bg-slate-800 dark:bg-slate-100 dark:text-slate-900"
>
<Plus size={14} strokeWidth={2} />
{m.notes_new()}
</button>
</header>
<div class="flex-1 overflow-y-auto px-8 py-6">
<!-- Fase 3: notes content -->
<div class="flex-1 overflow-y-auto px-6 py-6">
{#if $notesLoading && $notes.length === 0}
<p class="text-sm text-text-secondary">{m.loading()}</p>
{:else if $notes.length === 0}
<div class="rounded-lg border border-dashed border-black/10 p-12 text-center dark:border-white/10">
<p class="text-sm font-medium text-text-primary">{m.notes_empty_board()}</p>
<p class="mt-1 text-sm text-text-secondary">{m.notes_empty_board_hint()}</p>
</div>
{:else}
{#if pinned.length > 0}
<section data-testid="notes-pinned" class="mb-8">
<h2 class="mb-3 text-xs font-semibold uppercase tracking-wide text-text-secondary">
{m.notes_pinned_section()}
</h2>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{#each pinned as note (note.id)}
<a
href={`/notes/${note.id}`}
data-testid="note-card"
class={`block rounded-lg p-4 shadow-sm transition-shadow hover:shadow-md ${colorClass(note.color)}`}
>
{#if note.title}
<h3 class="mb-1 text-sm font-semibold text-text-primary">{note.title}</h3>
{/if}
<p class="line-clamp-6 whitespace-pre-wrap text-sm text-text-secondary">
{note.content}
</p>
</a>
{/each}
</div>
</section>
{/if}
{#if others.length > 0}
<section data-testid="notes-active">
{#if pinned.length > 0}
<h2 class="mb-3 text-xs font-semibold uppercase tracking-wide text-text-secondary">
{m.notes_active_section()}
</h2>
{/if}
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{#each others as note (note.id)}
<a
href={`/notes/${note.id}`}
data-testid="note-card"
class={`block rounded-lg p-4 shadow-sm transition-shadow hover:shadow-md ${colorClass(note.color)}`}
>
{#if note.title}
<h3 class="mb-1 text-sm font-semibold text-text-primary">{note.title}</h3>
{/if}
<p class="line-clamp-6 whitespace-pre-wrap text-sm text-text-secondary">
{note.content}
</p>
</a>
{/each}
</div>
</section>
{/if}
{/if}
</div>
</div>

View File

@@ -0,0 +1,271 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { currentUser } from '$lib/stores/auth';
import {
loadNote,
patchNote,
pinNote,
unpinNote,
archiveNote,
unarchiveNote,
trashNote,
restoreNote,
duplicateNote,
NOTE_COLORS
} from '$lib/stores/notes';
import type { Note, NoteColor } from '@colectivo/types';
import { ArrowLeft, Pin, PinOff, Archive, ArchiveRestore, Trash2, Copy, Palette } from 'lucide-svelte';
import * as m from '$lib/paraglide/messages';
const noteId = $page.params.id!;
let note = $state<Note | null>(null);
let title = $state('');
let content = $state('');
let saving = $state(false);
let savedAt = $state<number>(0);
let showColorPicker = $state(false);
let saveTimer: ReturnType<typeof setTimeout> | null = null;
const SAVE_DEBOUNCE_MS = 500;
onMount(async () => {
const n = await loadNote(noteId);
if (!n) {
goto('/notes');
return;
}
note = n;
title = n.title ?? '';
content = n.content;
});
onDestroy(() => {
if (saveTimer) {
clearTimeout(saveTimer);
void flushSave();
}
});
async function flushSave() {
if (!note || !$currentUser) return;
saving = true;
await patchNote(note.id, $currentUser.id, {
title: title.trim() || null,
content
});
saving = false;
savedAt = Date.now();
}
function scheduleSave() {
if (saveTimer) clearTimeout(saveTimer);
saveTimer = setTimeout(() => {
saveTimer = null;
void flushSave();
}, SAVE_DEBOUNCE_MS);
}
function onTitleInput() {
scheduleSave();
}
function onContentInput() {
scheduleSave();
}
async function togglePin() {
if (!note || !$currentUser) return;
if (note.is_pinned) {
await unpinNote(note.id, $currentUser.id);
note = { ...note, is_pinned: false };
} else {
await pinNote(note.id, $currentUser.id);
note = { ...note, is_pinned: true, is_archived: false };
}
}
async function toggleArchive() {
if (!note || !$currentUser) return;
if (note.is_archived) {
await unarchiveNote(note.id, $currentUser.id);
note = { ...note, is_archived: false };
} else {
await archiveNote(note.id, $currentUser.id);
goto('/notes');
}
}
async function sendToTrash() {
if (!note || !$currentUser) return;
await trashNote(note.id, $currentUser.id);
goto('/notes');
}
async function handleRestore() {
if (!note || !$currentUser) return;
await restoreNote(note.id, $currentUser.id);
note = { ...note, deleted_at: null };
}
async function handleDuplicate() {
if (!note || !$currentUser) return;
const dup = await duplicateNote(note, $currentUser.id);
if (dup) goto(`/notes/${dup.id}`);
}
async function setColor(color: NoteColor | null) {
if (!note || !$currentUser) return;
await patchNote(note.id, $currentUser.id, { color });
note = { ...note, color };
showColorPicker = false;
}
function colorBg(color: string | null): string {
switch (color) {
case 'yellow': return 'bg-yellow-50 dark:bg-yellow-900/20';
case 'green': return 'bg-emerald-50 dark:bg-emerald-900/20';
case 'blue': return 'bg-sky-50 dark:bg-sky-900/20';
case 'pink': return 'bg-pink-50 dark:bg-pink-900/20';
case 'purple': return 'bg-purple-50 dark:bg-purple-900/20';
case 'orange': return 'bg-orange-50 dark:bg-orange-900/20';
case 'gray': return 'bg-slate-100 dark:bg-slate-800/40';
case 'red': return 'bg-red-50 dark:bg-red-900/20';
default: return 'bg-background';
}
}
function colorSwatch(color: NoteColor): string {
switch (color) {
case 'yellow': return 'bg-yellow-300';
case 'green': return 'bg-emerald-400';
case 'blue': return 'bg-sky-400';
case 'pink': return 'bg-pink-300';
case 'purple': return 'bg-purple-400';
case 'orange': return 'bg-orange-400';
case 'gray': return 'bg-slate-400';
case 'red': return 'bg-red-400';
}
}
</script>
<svelte:head><title>{title || m.notes_title()} — Colectivo</title></svelte:head>
<div class={`flex h-full flex-col overflow-hidden ${colorBg(note?.color ?? null)}`}>
<header class="flex h-14 flex-shrink-0 items-center gap-2 border-b border-black/5 px-6 dark:border-white/5">
<a
href="/notes"
class="rounded p-1 text-text-secondary hover:bg-black/5 dark:hover:bg-white/5"
aria-label={m.notes_back_to_board()}
>
<ArrowLeft size={18} strokeWidth={1.5} />
</a>
<div class="flex-1"></div>
{#if note?.deleted_at}
<span class="text-xs text-text-secondary">{m.notes_trash_view()}</span>
<button
type="button"
onclick={handleRestore}
class="rounded p-1.5 text-text-secondary hover:bg-black/5 dark:hover:bg-white/5"
aria-label={m.notes_restore()}
title={m.notes_restore()}
>
<ArchiveRestore size={16} strokeWidth={1.5} />
</button>
{:else}
<span class="text-xs text-text-secondary">
{saving ? m.notes_saving() : savedAt ? m.notes_saved() : ''}
</span>
<!-- Color picker -->
<div class="relative">
<button
type="button"
onclick={() => (showColorPicker = !showColorPicker)}
class="rounded p-1.5 text-text-secondary hover:bg-black/5 dark:hover:bg-white/5"
aria-label={m.notes_color()}
title={m.notes_color()}
>
<Palette size={16} strokeWidth={1.5} />
</button>
{#if showColorPicker}
<div class="absolute right-0 top-full z-50 mt-1 flex gap-1 rounded-md bg-surface-raised p-2 shadow-lg">
<button
type="button"
onclick={() => setColor(null)}
class="h-6 w-6 rounded-full border-2 border-black/20 bg-background"
aria-label="default"
></button>
{#each NOTE_COLORS as c}
<button
type="button"
onclick={() => setColor(c)}
class={`h-6 w-6 rounded-full border-2 border-transparent hover:border-black/30 ${colorSwatch(c)}`}
aria-label={c}
></button>
{/each}
</div>
{/if}
</div>
<button
type="button"
onclick={togglePin}
class="rounded p-1.5 text-text-secondary hover:bg-black/5 dark:hover:bg-white/5"
aria-label={note?.is_pinned ? m.notes_unpin() : m.notes_pin()}
title={note?.is_pinned ? m.notes_unpin() : m.notes_pin()}
>
{#if note?.is_pinned}<PinOff size={16} strokeWidth={1.5} />{:else}<Pin size={16} strokeWidth={1.5} />{/if}
</button>
<button
type="button"
onclick={toggleArchive}
class="rounded p-1.5 text-text-secondary hover:bg-black/5 dark:hover:bg-white/5"
aria-label={note?.is_archived ? m.notes_unarchive() : m.notes_archive()}
title={note?.is_archived ? m.notes_unarchive() : m.notes_archive()}
>
{#if note?.is_archived}<ArchiveRestore size={16} strokeWidth={1.5} />{:else}<Archive size={16} strokeWidth={1.5} />{/if}
</button>
<button
type="button"
onclick={handleDuplicate}
class="rounded p-1.5 text-text-secondary hover:bg-black/5 dark:hover:bg-white/5"
aria-label={m.notes_duplicate()}
title={m.notes_duplicate()}
>
<Copy size={16} strokeWidth={1.5} />
</button>
<button
type="button"
onclick={sendToTrash}
class="rounded p-1.5 text-text-secondary hover:bg-black/5 dark:hover:bg-white/5"
aria-label={m.notes_send_to_trash()}
title={m.notes_send_to_trash()}
>
<Trash2 size={16} strokeWidth={1.5} />
</button>
{/if}
</header>
<div class="flex-1 overflow-y-auto px-8 py-6">
<input
bind:value={title}
oninput={onTitleInput}
placeholder={m.notes_title_placeholder()}
class="mb-4 w-full bg-transparent text-2xl font-semibold text-text-primary placeholder:text-text-secondary focus:outline-none"
/>
<textarea
bind:value={content}
oninput={onContentInput}
placeholder={m.notes_content_placeholder()}
aria-label={m.notes_content_aria()}
class="h-[calc(100%-4rem)] w-full resize-none bg-transparent text-base text-text-primary placeholder:text-text-secondary focus:outline-none"
></textarea>
</div>
</div>

View File

@@ -0,0 +1,81 @@
<script lang="ts">
import { currentCollective } from '$lib/stores/collective';
import { currentUser } from '$lib/stores/auth';
import { loadArchivedNotes, unarchiveNote, trashNote } from '$lib/stores/notes';
import type { Note } from '@colectivo/types';
import { ArrowLeft, ArchiveRestore, Trash2 } from 'lucide-svelte';
import * as m from '$lib/paraglide/messages';
let archived = $state<Note[]>([]);
let loading = $state(true);
$effect(() => {
if ($currentCollective) {
(async () => {
loading = true;
archived = await loadArchivedNotes($currentCollective.id);
loading = false;
})();
}
});
async function handleUnarchive(id: string) {
if (!$currentUser) return;
await unarchiveNote(id, $currentUser.id);
archived = archived.filter((n) => n.id !== id);
}
async function handleTrash(id: string) {
if (!$currentUser) return;
await trashNote(id, $currentUser.id);
archived = archived.filter((n) => n.id !== id);
}
</script>
<svelte:head><title>{m.notes_archive_view()} — Colectivo</title></svelte:head>
<div class="flex h-full flex-col overflow-hidden">
<header class="flex h-14 items-center gap-2 border-b border-black/5 px-6 dark:border-white/5">
<a href="/notes" class="rounded p-1 text-text-secondary hover:bg-black/5 dark:hover:bg-white/5" aria-label={m.notes_back_to_board()}>
<ArrowLeft size={18} strokeWidth={1.5} />
</a>
<h1 class="text-base font-semibold text-text-primary">{m.notes_archive_view()}</h1>
</header>
<div class="flex-1 overflow-y-auto px-6 py-6">
{#if loading}
<p class="text-sm text-text-secondary">{m.loading()}</p>
{:else if archived.length === 0}
<p class="text-sm text-text-secondary">{m.notes_archived_empty()}</p>
{:else}
<div class="space-y-2">
{#each archived as note (note.id)}
<div role="listitem" data-testid="note-card" class="flex items-center gap-3 rounded-md bg-surface-raised p-3">
<a href={`/notes/${note.id}`} class="min-w-0 flex-1">
{#if note.title}
<h3 class="truncate text-sm font-semibold text-text-primary">{note.title}</h3>
{/if}
<p class="truncate text-xs text-text-secondary">{note.content}</p>
</a>
<button
type="button"
onclick={() => handleUnarchive(note.id)}
aria-label={m.notes_unarchive()}
class="rounded p-1.5 text-text-secondary hover:bg-black/5 dark:hover:bg-white/5"
>
<ArchiveRestore size={16} strokeWidth={1.5} />
</button>
<button
type="button"
onclick={() => handleTrash(note.id)}
aria-label={m.notes_send_to_trash()}
class="rounded p-1.5 text-text-secondary hover:bg-black/5 dark:hover:bg-white/5"
>
<Trash2 size={16} strokeWidth={1.5} />
</button>
</div>
{/each}
</div>
{/if}
</div>
</div>

View File

@@ -0,0 +1,81 @@
<script lang="ts">
import { currentCollective } from '$lib/stores/collective';
import { currentUser } from '$lib/stores/auth';
import { loadTrashedNotes, restoreNote, permanentDeleteNote } from '$lib/stores/notes';
import type { Note } from '@colectivo/types';
import { ArrowLeft, ArchiveRestore, Trash2 } from 'lucide-svelte';
import * as m from '$lib/paraglide/messages';
let trashed = $state<Note[]>([]);
let loading = $state(true);
$effect(() => {
if ($currentCollective) {
(async () => {
loading = true;
trashed = await loadTrashedNotes($currentCollective.id);
loading = false;
})();
}
});
async function handleRestore(id: string) {
if (!$currentUser) return;
await restoreNote(id, $currentUser.id);
trashed = trashed.filter((n) => n.id !== id);
}
async function handlePurge(id: string) {
if (!confirm(m.confirm_delete())) return;
await permanentDeleteNote(id);
trashed = trashed.filter((n) => n.id !== id);
}
</script>
<svelte:head><title>{m.notes_trash_view()} — Colectivo</title></svelte:head>
<div class="flex h-full flex-col overflow-hidden">
<header class="flex h-14 items-center gap-2 border-b border-black/5 px-6 dark:border-white/5">
<a href="/notes" class="rounded p-1 text-text-secondary hover:bg-black/5 dark:hover:bg-white/5" aria-label={m.notes_back_to_board()}>
<ArrowLeft size={18} strokeWidth={1.5} />
</a>
<h1 class="text-base font-semibold text-text-primary">{m.notes_trash_view()}</h1>
</header>
<div class="flex-1 overflow-y-auto px-6 py-6">
{#if loading}
<p class="text-sm text-text-secondary">{m.loading()}</p>
{:else if trashed.length === 0}
<p class="text-sm text-text-secondary">{m.notes_trash_empty()}</p>
{:else}
<div class="space-y-2">
{#each trashed as note (note.id)}
<div role="listitem" data-testid="note-card" class="flex items-center gap-3 rounded-md bg-surface-raised p-3">
<div class="min-w-0 flex-1">
{#if note.title}
<h3 class="truncate text-sm font-semibold text-text-primary">{note.title}</h3>
{/if}
<p class="truncate text-xs text-text-secondary">{note.content}</p>
</div>
<button
type="button"
onclick={() => handleRestore(note.id)}
aria-label={m.notes_restore()}
class="rounded p-1.5 text-text-secondary hover:bg-black/5 dark:hover:bg-white/5"
>
<ArchiveRestore size={16} strokeWidth={1.5} />
</button>
<button
type="button"
onclick={() => handlePurge(note.id)}
aria-label={m.trash_delete_permanent()}
class="rounded p-1.5 text-text-secondary hover:bg-black/5 dark:hover:bg-white/5"
>
<Trash2 size={16} strokeWidth={1.5} />
</button>
</div>
{/each}
</div>
{/if}
</div>
</div>

View File

@@ -1,17 +1,103 @@
<script lang="ts">
import { currentCollective } from '$lib/stores/collective';
import { currentUser } from '$lib/stores/auth';
import {
taskLists,
taskListsLoading,
loadTaskLists,
createTaskList,
deleteTaskList
} from '$lib/stores/tasks';
import { CheckSquare, Plus, Trash2 } from 'lucide-svelte';
import * as m from '$lib/paraglide/messages';
let newName = $state('');
let creating = $state(false);
let nameInput: HTMLInputElement | undefined = $state();
$effect(() => {
if ($currentCollective) {
loadTaskLists($currentCollective.id);
}
});
// 5s polling per analysis §6
$effect(() => {
if (!$currentCollective) return;
const id = setInterval(() => {
if ($currentCollective) loadTaskLists($currentCollective.id);
}, 5_000);
return () => clearInterval(id);
});
async function handleCreate() {
const name = newName.trim();
if (!name || !$currentCollective || !$currentUser) return;
creating = true;
newName = '';
await createTaskList($currentCollective.id, name, $currentUser.id);
creating = false;
nameInput?.focus();
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter') handleCreate();
}
async function handleDelete(id: string) {
if (!confirm(m.tasks_delete_list_confirm())) return;
await deleteTaskList(id);
}
</script>
<div class="flex flex-1 flex-col overflow-hidden">
<header class="px-8 pb-4 pt-8">
<p class="mb-1 text-[13px] font-semibold uppercase tracking-[0.05em] text-text-secondary">
{m.nav_tasks()}
</p>
<h1 class="text-[20px] font-semibold tracking-[-0.01em] text-slate-900 dark:text-slate-50">
{m.tasks_title()}
</h1>
<svelte:head><title>{m.tasks_title()} — Colectivo</title></svelte:head>
<div class="flex h-full flex-col overflow-hidden">
<header class="flex h-14 items-center gap-2 border-b border-black/5 px-6 dark:border-white/5">
<CheckSquare size={18} strokeWidth={1.5} />
<h1 class="text-base font-semibold text-text-primary">{m.tasks_title()}</h1>
</header>
<div class="flex-1 overflow-y-auto px-8 py-6">
<!-- Fase 3: tasks content -->
<div class="flex-1 overflow-y-auto px-6 py-6">
{#if $taskListsLoading && $taskLists.length === 0}
<p class="text-sm text-text-secondary">{m.loading()}</p>
{:else if $taskLists.length === 0}
<div class="rounded-lg border border-dashed border-black/10 p-12 text-center dark:border-white/10">
<p class="text-sm font-medium text-text-primary">{m.tasks_no_lists()}</p>
<p class="mt-1 text-sm text-text-secondary">{m.tasks_create_first()}</p>
</div>
{:else}
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{#each $taskLists as list (list.id)}
<div class="group relative rounded-lg bg-surface-raised p-4 shadow-sm transition-shadow hover:shadow-md">
<a href={`/tasks/${list.id}`} class="block">
<h3 class="text-base font-semibold text-text-primary">{list.name}</h3>
</a>
<button
type="button"
class="absolute right-3 top-3 rounded p-1 text-text-secondary opacity-0 transition-opacity hover:bg-black/5 group-hover:opacity-100 dark:hover:bg-white/5"
aria-label={m.tasks_delete_list()}
onclick={() => handleDelete(list.id)}
>
<Trash2 size={14} strokeWidth={1.5} />
</button>
</div>
{/each}
</div>
{/if}
</div>
<div class="border-t border-black/5 bg-surface-raised px-6 py-4 dark:border-white/5">
<div class="flex items-center gap-2">
<Plus size={16} strokeWidth={1.5} class="text-text-secondary" />
<input
bind:this={nameInput}
bind:value={newName}
onkeydown={handleKeydown}
placeholder={m.tasks_new_list_placeholder()}
class="flex-1 bg-transparent text-sm text-text-primary placeholder:text-text-secondary focus:outline-none"
disabled={creating || !$currentCollective}
/>
</div>
</div>
</div>

View File

@@ -0,0 +1,327 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { flip } from 'svelte/animate';
import { dndzone } from 'svelte-dnd-action';
import { currentUser } from '$lib/stores/auth';
import { collectiveMembers, currentCollective } from '$lib/stores/collective';
import {
loadTasks,
addTask,
renameTask,
completeTask,
deleteTask,
reorderTasks
} from '$lib/stores/tasks';
import { getSupabase } from '$lib/supabase';
import type { Task, TaskList } from '@colectivo/types';
import { ArrowLeft, GripVertical, Plus, Trash2, Check } from 'lucide-svelte';
import * as m from '$lib/paraglide/messages';
const listId = $page.params.id!;
let list = $state<TaskList | null>(null);
let tasks = $state<Task[]>([]);
let loading = $state(true);
let newTitle = $state('');
let titleInput: HTMLInputElement | undefined = $state();
let editingId = $state<string | null>(null);
let editTitle = $state('');
const pendingTasks = $derived(tasks.filter((t) => !t.is_completed));
const completedTasks = $derived(tasks.filter((t) => t.is_completed));
let pollingId: ReturnType<typeof setInterval> | null = null;
onMount(async () => {
const [listRes, tasksRes] = await Promise.all([
getSupabase().from('task_lists').select('*').eq('id', listId).single(),
loadTasks(listId)
]);
if (listRes.error || !listRes.data) {
goto('/tasks');
return;
}
list = listRes.data as TaskList;
tasks = tasksRes;
loading = false;
// Lazily load collective members so we can attribute completed tasks to a name.
if ($currentCollective && $collectiveMembers.length === 0) {
const { data: members } = await getSupabase()
.from('collective_members')
.select('user_id, role, users:users(display_name, avatar_type, avatar_emoji, avatar_url)')
.eq('collective_id', $currentCollective.id);
if (members) {
type Row = {
user_id: string;
role: 'admin' | 'member' | 'guest';
users: {
display_name: string;
avatar_type: 'initials' | 'emoji' | 'upload';
avatar_emoji: string | null;
avatar_url: string | null;
} | null;
};
const rows = members as unknown as Row[];
collectiveMembers.set(
rows
.filter((r) => r.users)
.map((r) => ({
user_id: r.user_id,
collective_id: $currentCollective!.id,
role: r.role,
display_name: r.users!.display_name,
avatar_type: r.users!.avatar_type,
avatar_emoji: r.users!.avatar_emoji,
avatar_url: r.users!.avatar_url
}))
);
}
}
// 5s polling per analysis §6
pollingId = setInterval(async () => {
tasks = await loadTasks(listId);
}, 5_000);
});
onDestroy(() => {
if (pollingId) clearInterval(pollingId);
});
function nameFor(userId: string | null): string {
if (!userId) return '';
const member = $collectiveMembers.find((mm) => mm.user_id === userId);
return member?.display_name ?? '';
}
async function handleAdd() {
const title = newTitle.trim();
if (!title || !$currentUser) return;
const sortOrder = pendingTasks.length;
const tempId = crypto.randomUUID();
const optimistic: Task = {
id: tempId,
list_id: listId,
title,
is_completed: false,
completed_by: null,
completed_at: null,
sort_order: sortOrder,
created_by: $currentUser.id,
created_at: new Date().toISOString()
};
tasks = [...tasks, optimistic];
newTitle = '';
const real = await addTask(listId, title, $currentUser.id, sortOrder);
if (real) {
tasks = tasks.map((t) => (t.id === tempId ? real : t));
} else {
tasks = tasks.filter((t) => t.id !== tempId);
}
titleInput?.focus();
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter') handleAdd();
}
async function handleToggle(task: Task) {
if (!$currentUser) return;
const nowCompleted = !task.is_completed;
tasks = tasks.map((t) =>
t.id === task.id
? {
...t,
is_completed: nowCompleted,
completed_by: nowCompleted ? $currentUser!.id : null,
completed_at: nowCompleted ? new Date().toISOString() : null
}
: t
);
await completeTask(task.id, $currentUser.id, nowCompleted);
}
async function handleDelete(id: string) {
tasks = tasks.filter((t) => t.id !== id);
await deleteTask(id);
}
function startEdit(task: Task) {
editingId = task.id;
editTitle = task.title;
}
async function commitEdit() {
if (!editingId) return;
const id = editingId;
const title = editTitle.trim();
editingId = null;
if (!title) return;
tasks = tasks.map((t) => (t.id === id ? { ...t, title } : t));
await renameTask(id, title);
}
function handleEditKeydown(e: KeyboardEvent) {
if (e.key === 'Enter') commitEdit();
if (e.key === 'Escape') {
editingId = null;
}
}
function handleDnd(e: CustomEvent<{ items: Task[] }>) {
// Reorder is only valid among pending tasks (completed sink to the bottom).
const reordered = e.detail.items;
tasks = [...reordered, ...completedTasks];
}
async function handleDndFinalize(e: CustomEvent<{ items: Task[] }>) {
const reordered = e.detail.items;
tasks = [...reordered, ...completedTasks];
await reorderTasks(reordered);
}
</script>
<svelte:head><title>{list?.name ?? m.tasks_title()} — Colectivo</title></svelte:head>
<div class="flex h-full flex-col overflow-hidden">
<header class="flex h-14 items-center gap-2 border-b border-black/5 px-6 dark:border-white/5">
<a href="/tasks" class="rounded p-1 text-text-secondary hover:bg-black/5 dark:hover:bg-white/5" aria-label={m.notes_back_to_board()}>
<ArrowLeft size={18} strokeWidth={1.5} />
</a>
<h1 class="text-base font-semibold text-text-primary">{list?.name ?? ''}</h1>
</header>
<div class="flex-1 overflow-y-auto px-6 py-4">
{#if loading}
<p class="text-sm text-text-secondary">{m.loading()}</p>
{:else if tasks.length === 0}
<div class="rounded-lg border border-dashed border-black/10 p-12 text-center dark:border-white/10">
<p class="text-sm font-medium text-text-primary">{m.tasks_empty()}</p>
<p class="mt-1 text-sm text-text-secondary">{m.tasks_empty_hint()}</p>
</div>
{:else}
<!-- Pending (drag-and-drop reorderable) -->
{#if pendingTasks.length > 0}
<section
use:dndzone={{ items: pendingTasks, flipDurationMs: 200, dragDisabled: editingId !== null }}
onconsider={handleDnd}
onfinalize={handleDndFinalize}
class="space-y-1"
>
{#each pendingTasks as task (task.id)}
<div
role="listitem"
animate:flip={{ duration: 200 }}
class="group flex items-center gap-2 rounded-md bg-surface-raised px-3 py-2"
>
<button
type="button"
class="cursor-grab text-text-secondary opacity-0 group-hover:opacity-100"
aria-label="Drag handle"
tabindex="-1"
>
<GripVertical size={14} strokeWidth={1.5} />
</button>
<button
type="button"
onclick={() => handleToggle(task)}
aria-label={m.tasks_toggle()}
class="flex h-5 w-5 flex-shrink-0 items-center justify-center rounded border border-black/20 hover:border-black/40 dark:border-white/20"
></button>
{#if editingId === task.id}
<input
bind:value={editTitle}
onkeydown={handleEditKeydown}
onblur={commitEdit}
class="flex-1 bg-transparent text-sm text-text-primary focus:outline-none"
/>
{:else}
<button
type="button"
ondblclick={() => startEdit(task)}
class="flex-1 truncate text-left text-sm text-text-primary"
>
{task.title}
</button>
{/if}
<button
type="button"
onclick={() => handleDelete(task.id)}
aria-label={m.tasks_delete()}
class="rounded p-1 text-text-secondary opacity-0 hover:bg-black/5 group-hover:opacity-100 dark:hover:bg-white/5"
>
<Trash2 size={14} strokeWidth={1.5} />
</button>
</div>
{/each}
</section>
{/if}
<!-- Completed (sunk to bottom; not reorderable) -->
{#if completedTasks.length > 0}
<section class="mt-6">
<h2 class="mb-2 text-xs font-semibold uppercase tracking-wide text-text-secondary">
{m.tasks_completed_section()}
</h2>
<div class="space-y-1">
{#each completedTasks as task (task.id)}
<div
role="listitem"
animate:flip={{ duration: 200 }}
class="group flex items-center gap-2 rounded-md bg-surface-raised px-3 py-2"
>
<button
type="button"
onclick={() => handleToggle(task)}
aria-label={m.tasks_uncheck()}
title={task.completed_by
? m.tasks_completed_by({ name: nameFor(task.completed_by) })
: undefined}
class="flex h-5 w-5 flex-shrink-0 items-center justify-center rounded border border-emerald-500 bg-emerald-500 text-white"
>
<Check size={12} strokeWidth={2.5} />
</button>
<span class="flex-1 truncate text-sm text-text-secondary line-through">
{task.title}
</span>
<span class="text-xs text-text-secondary">
{m.tasks_completed_by({ name: nameFor(task.completed_by) })}
</span>
<button
type="button"
onclick={() => handleDelete(task.id)}
aria-label={m.tasks_delete()}
class="rounded p-1 text-text-secondary opacity-0 hover:bg-black/5 group-hover:opacity-100 dark:hover:bg-white/5"
>
<Trash2 size={14} strokeWidth={1.5} />
</button>
</div>
{/each}
</div>
</section>
{/if}
{/if}
</div>
<!-- Sticky add-task input -->
<div class="border-t border-black/5 bg-surface-raised px-6 py-4 dark:border-white/5">
<div class="flex items-center gap-2">
<Plus size={16} strokeWidth={1.5} class="text-text-secondary" />
<input
bind:this={titleInput}
bind:value={newTitle}
onkeydown={handleKeydown}
placeholder={m.tasks_new_task_placeholder()}
class="flex-1 bg-transparent text-sm text-text-primary placeholder:text-text-secondary focus:outline-none"
/>
</div>
</div>
</div>