fix(tasks): match /lists + /notes create pattern

Two concrete problems the user hit:
  1) /tasks overview had no visible "create" button — only an enter-to-submit
     input hidden behind a decorative Plus icon.
  2) /tasks/[id] add-task form had no visible submit button either.

Both now mirror the /lists + /notes pattern:

  /tasks — new "New list" button in the masthead (bg-slate-900 primary
    style matching "New list" on /lists and "New note" on /notes). Click
    creates an empty task_list and navigates to /tasks/[id] where the
    user names it inline via the new big editable title input
    (autosaves after NAME_SAVE_DEBOUNCE_MS, 500 ms — same hook as
    /lists/[id] and /notes/[id]).
  /tasks/[id] — sticky add-task footer gains a filled "+" submit button
    on the right; clicking it or pressing Enter both fire handleAdd.
    Input is now wrapped in the same rounded-lg card used on /lists/[id]
    so the two screens look identical.

i18n
  tasks_new_list = "New list" / "Nueva lista" (en / es).

Tests
  tasks.test.ts helper updated to drive the new button-then-title flow
  (same shape as lists.test.ts). T-UI-01..03 all pass.

just test-all → exit 0, 228 green, 2 skipped.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-14 02:25:14 +02:00
parent 3dd4f64cb2
commit ab8ac08226
5 changed files with 101 additions and 46 deletions

View File

@@ -1,4 +1,5 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { currentCollective } from '$lib/stores/collective';
import { currentUser } from '$lib/stores/auth';
import {
@@ -12,9 +13,7 @@
import * as m from '$lib/paraglide/messages';
import ScreenMasthead from '$lib/components/ScreenMasthead.svelte';
let newName = $state('');
let creating = $state(false);
let nameInput: HTMLInputElement | undefined = $state();
$effect(() => {
if ($currentCollective) {
@@ -31,18 +30,14 @@
return () => clearInterval(id);
});
// Matches the /notes + /lists flow: masthead button creates an empty list
// and sends the user to the detail view where the name is set inline.
async function handleCreate() {
const name = newName.trim();
if (!name || !$currentCollective || !$currentUser) return;
if (!$currentCollective || !$currentUser || creating) return;
creating = true;
newName = '';
await createTaskList($currentCollective.id, name, $currentUser.id);
const created = await createTaskList($currentCollective.id, '', $currentUser.id);
creating = false;
nameInput?.focus();
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter') handleCreate();
if (created) await goto(`/tasks/${created.id}`);
}
async function handleDelete(id: string) {
@@ -54,7 +49,19 @@
<svelte:head><title>{m.tasks_title()} — Colectivo</title></svelte:head>
<div class="flex h-full flex-col overflow-hidden">
<ScreenMasthead label={m.masthead_tasks_label()} title={m.tasks_title()} />
<ScreenMasthead label={m.masthead_tasks_label()} title={m.tasks_title()}>
{#snippet actions()}
<button
type="button"
onclick={handleCreate}
disabled={creating || !$currentCollective}
class="ml-1 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 disabled:opacity-60 dark:bg-slate-100 dark:text-slate-900"
>
<Plus size={14} strokeWidth={2} />
{m.tasks_new_list()}
</button>
{/snippet}
</ScreenMasthead>
<div class="flex-1 overflow-y-auto px-4 py-4 md:px-6 md:py-6">
{#if $taskListsLoading && $taskLists.length === 0}
@@ -85,17 +92,4 @@
{/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

@@ -12,7 +12,8 @@
renameTask,
completeTask,
deleteTask,
reorderTasks
reorderTasks,
renameTaskList
} from '$lib/stores/tasks';
import { getSupabase } from '$lib/supabase';
import type { Task, TaskList } from '@colectivo/types';
@@ -25,6 +26,11 @@
let tasks = $state<Task[]>([]);
let loading = $state(true);
// Inline rename of the task list (same pattern as /lists/[id], /notes/[id]).
let listName = $state('');
let nameSaveTimer: ReturnType<typeof setTimeout> | null = null;
const NAME_SAVE_DEBOUNCE_MS = 500;
let newTitle = $state('');
let titleInput: HTMLInputElement | undefined = $state();
@@ -48,6 +54,7 @@
}
list = listRes.data as TaskList;
listName = list.name ?? '';
tasks = tasksRes;
loading = false;
@@ -91,10 +98,32 @@
}, 5_000);
});
onDestroy(() => {
onDestroy(async () => {
if (pollingId) clearInterval(pollingId);
if (nameSaveTimer) {
clearTimeout(nameSaveTimer);
await flushNameSave();
}
});
// ── Inline rename ──────────────────────────────────────────────────────────
function scheduleNameSave() {
if (nameSaveTimer) clearTimeout(nameSaveTimer);
nameSaveTimer = setTimeout(() => {
nameSaveTimer = null;
void flushNameSave();
}, NAME_SAVE_DEBOUNCE_MS);
}
async function flushNameSave() {
if (!list) return;
const next = listName.trim();
if (next === (list.name ?? '')) return;
list = { ...list, name: next };
await renameTaskList(list.id, next);
}
function nameFor(userId: string | null): string {
if (!userId) return '';
const member = $collectiveMembers.find((mm) => mm.user_id === userId);
@@ -196,10 +225,27 @@
<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>
<h1 class="truncate text-[15px] font-medium text-text-secondary md:hidden">
{list?.name || m.tasks_new_list_placeholder()}
</h1>
</header>
<div class="flex-1 overflow-y-auto px-6 py-4">
<div class="flex-1 overflow-y-auto px-4 py-4 md:px-6">
<!-- Inline rename title input (autosave on idle) -->
<div class="pt-2 pb-4 md:pt-4 md:pb-6">
<p class="mb-1 text-[13px] font-semibold uppercase tracking-[0.05em] text-text-secondary">
{m.masthead_tasks_label()}
</p>
<input
bind:value={listName}
oninput={scheduleNameSave}
onblur={flushNameSave}
placeholder={m.tasks_new_list_placeholder()}
class="w-full bg-transparent text-[26px] md:text-[32px] font-semibold tracking-[-0.02em] text-text-primary placeholder:text-text-secondary focus:outline-none"
aria-label={m.tasks_new_list_placeholder()}
/>
</div>
{#if loading}
<p class="text-sm text-text-secondary">{m.loading()}</p>
{:else if tasks.length === 0}
@@ -312,16 +358,26 @@
</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="border-t border-black/5 bg-surface-raised px-4 py-3 md:px-6 md: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 class="flex flex-1 items-center gap-2 rounded-lg bg-surface px-3 py-2 shadow-[0px_2px_8px_rgba(15,23,42,0.06)]">
<input
bind:this={titleInput}
bind:value={newTitle}
onkeydown={handleKeydown}
placeholder={m.tasks_new_task_placeholder()}
class="min-w-0 flex-1 bg-transparent text-sm text-text-primary placeholder:text-text-secondary focus:outline-none"
/>
</div>
<button
type="button"
onclick={handleAdd}
disabled={!newTitle.trim()}
aria-label={m.add()}
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-slate-900 text-white hover:opacity-90 disabled:opacity-40 dark:bg-slate-100 dark:text-slate-900 transition-opacity"
>
<Plus size={18} strokeWidth={2} />
</button>
</div>
</div>
</div>