Mechanical fixes from `npx impeccable detect`: - Sidebar.svelte: replace `transition: width, min-width` on the aside with a wrapping CSS-grid container animating `grid-template-columns`. Avoids per-frame layout cost from animating `width` directly. - MorningTriageModal.svelte: swap pure `bg-black/50` overlay for the brand deep-neutral `rgba(26, 24, 22, 0.5)` (#1a1816 @ 50%). TODO left in source to promote this to a `--color-overlay` token in app.css. - Toggle.svelte: drop bouncy `cubic-bezier(0.34, 1.56, 0.64, 1)` (1.56 overshoot) for ease-out-quart `cubic-bezier(0.16, 1, 0.3, 1)`. Still snappy, no overshoot — better fit for a toggle. - TasksPage.svelte: same grid-template-columns refactor as Sidebar for the list-sidebar `transition: width` declaration. Verification: - npm run check: 1 pre-existing error (vite.config.js:5), 1 unrelated warning in SettingsGroup (out of scope, owned by parallel subagent). - npm run build: clean. - cargo clippy --all-targets -- -D warnings: clean (with LIBCLANG_PATH). - cargo test --workspace: 283 passed, 0 failed. - cargo fmt --check: pre-existing diffs in main.rs / lib.rs (no Rust files were touched in this commit). Manual smoke deferred — `npm run dev` is in use by the SettingsPage subagent, so the build-clean signal is the proxy. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
726 lines
28 KiB
Svelte
726 lines
28 KiB
Svelte
<script lang="ts">
|
|
import { tick } from "svelte";
|
|
import type { EnergyLevel, TaskBucket, TaskEntry, TaskList } from "$lib/types/app";
|
|
import { invoke } from "@tauri-apps/api/core";
|
|
import {
|
|
tasks, addTask, completeTask, uncompleteTask, deleteTask, updateTask,
|
|
setTaskEnergy,
|
|
taskLists, addTaskList, renameTaskList, deleteTaskList,
|
|
settings, saveSettings,
|
|
} from "$lib/stores/page.svelte.js";
|
|
import { recentCompletions, todayCount } from "$lib/stores/completionStats.svelte";
|
|
import WipTaskList from '$lib/components/WipTaskList.svelte';
|
|
import EmptyState from '$lib/components/EmptyState.svelte';
|
|
import CompletionSparkline from "$lib/components/CompletionSparkline.svelte";
|
|
import EnergyChip from '$lib/components/EnergyChip.svelte';
|
|
import { SquareCheck, Search, ExternalLink, ChevronLeft, ArrowUpDown, Plus, X, ChevronRight, Zap } from 'lucide-svelte';
|
|
import Card from "$lib/components/Card.svelte";
|
|
import { formatTimestamp } from "$lib/utils/time.js";
|
|
import { BUCKET_COLORS, EFFORT_LABELS, EFFORT_ORDER } from "$lib/utils/constants.js";
|
|
|
|
let activeBucket = $state("all");
|
|
let activeListId = $state("all");
|
|
let showCompleted = $state(false);
|
|
let quickInput = $state("");
|
|
let searchQuery = $state("");
|
|
let sidebarCollapsed = $state(false);
|
|
let showNewList = $state(false);
|
|
let newListName = $state("");
|
|
let sortMode = $state("date");
|
|
let showSortMenu = $state(false);
|
|
let contextMenuListId = $state<string | null>(null);
|
|
let editingListId = $state<string | null>(null);
|
|
let editingName = $state("");
|
|
let editingInputEl = $state<HTMLInputElement | null>(null);
|
|
let newListInputEl = $state<HTMLInputElement | null>(null);
|
|
|
|
const buckets: Array<{ id: "all" | TaskBucket; label: string }> = [
|
|
{ id: "all", label: "All" },
|
|
{ id: "inbox", label: "Inbox" },
|
|
{ id: "today", label: "Today" },
|
|
{ id: "soon", label: "Soon" },
|
|
{ id: "later", label: "Later" },
|
|
];
|
|
const taskBucketOptions: TaskBucket[] = ["today", "soon", "later"];
|
|
const effortOptions = ["quick", "medium", "deep"];
|
|
|
|
function effortRank(effort: string): number {
|
|
return EFFORT_ORDER[effort as keyof typeof EFFORT_ORDER] || 4;
|
|
}
|
|
|
|
function effortLabel(effort: string): string {
|
|
return EFFORT_LABELS[effort as keyof typeof EFFORT_LABELS] || effort;
|
|
}
|
|
|
|
// Phase 3 energy sort: when the user has opted in and declared a
|
|
// current energy, tasks matching that energy sort to the top. Tasks
|
|
// with no energy tag are treated as Medium-equivalent, per the brief's
|
|
// framing that unset is the normal case. Nothing is ever hidden —
|
|
// "reserves" in the spec means de-prioritise, not filter.
|
|
function energyMatchRank(task: TaskEntry, currentEnergy: EnergyLevel | null): number {
|
|
if (!currentEnergy) return 0;
|
|
const effective = task.energy ?? "medium";
|
|
return effective === currentEnergy ? 0 : 1;
|
|
}
|
|
|
|
let filteredTasks = $derived.by(() => {
|
|
let list = tasks.filter((t) => !t.done);
|
|
if (activeBucket !== "all") {
|
|
list = list.filter((t) => t.bucket === activeBucket);
|
|
}
|
|
if (activeListId !== "all") {
|
|
if (activeListId === "inbox") {
|
|
list = list.filter((t) => !t.listId);
|
|
} else {
|
|
list = list.filter((t) => t.listId === activeListId);
|
|
}
|
|
}
|
|
if (searchQuery.trim()) {
|
|
const q = searchQuery.toLowerCase();
|
|
list = list.filter((t) => t.text.toLowerCase().includes(q));
|
|
}
|
|
if (sortMode === "quick-first") {
|
|
list.sort((a, b) => effortRank(a.effort) - effortRank(b.effort));
|
|
} else if (sortMode === "deep-first") {
|
|
list.sort((a, b) => effortRank(b.effort) - effortRank(a.effort));
|
|
}
|
|
// Match-my-energy sort runs last (stable) so it reorders the result
|
|
// of the effort-based sort rather than replacing it.
|
|
if (settings.matchMyEnergy && settings.currentEnergy) {
|
|
const energy = settings.currentEnergy;
|
|
list.sort((a, b) => energyMatchRank(a, energy) - energyMatchRank(b, energy));
|
|
}
|
|
return list;
|
|
});
|
|
|
|
function cycleCurrentEnergy(next: EnergyLevel | null) {
|
|
settings.currentEnergy = next;
|
|
saveSettings();
|
|
}
|
|
|
|
function toggleMatchMyEnergy() {
|
|
settings.matchMyEnergy = !settings.matchMyEnergy;
|
|
saveSettings();
|
|
}
|
|
|
|
function energyLabel(level: EnergyLevel | null): string {
|
|
switch (level) {
|
|
case "high": return "High";
|
|
case "medium": return "Medium";
|
|
case "brain_dead": return "Brain-Dead";
|
|
default: return "Not set";
|
|
}
|
|
}
|
|
|
|
// ARIA radiogroup keyboard handling for the energy segmented control.
|
|
// Full W3C APG Radio Group pattern: arrow keys cycle, Home / End jump
|
|
// to ends, focus and selection move together. The options array lives
|
|
// here so the keyboard handler and the render loop share one source
|
|
// of truth — desynchronising them is the usual way these patterns
|
|
// rot over time.
|
|
type EnergyOption = { value: EnergyLevel | null; label: string };
|
|
const energyOptions: EnergyOption[] = [
|
|
{ value: null, label: "—" },
|
|
{ value: "high", label: "High" },
|
|
{ value: "medium", label: "Med" },
|
|
{ value: "brain_dead", label: "Low" },
|
|
];
|
|
let energyRadioGroupEl = $state<HTMLDivElement | null>(null);
|
|
|
|
function selectEnergyByIndex(idx: number) {
|
|
const clamped = Math.max(0, Math.min(energyOptions.length - 1, idx));
|
|
const opt = energyOptions[clamped];
|
|
cycleCurrentEnergy(opt.value);
|
|
// Move focus to the newly-checked button so keyboard nav tracks
|
|
// selection, per the ARIA radio pattern.
|
|
const btn = energyRadioGroupEl?.querySelectorAll<HTMLButtonElement>('[role="radio"]')[clamped];
|
|
btn?.focus();
|
|
}
|
|
|
|
function energyRadioKeydown(e: KeyboardEvent) {
|
|
const current = energyOptions.findIndex((o) => o.value === settings.currentEnergy);
|
|
const here = current < 0 ? 0 : current;
|
|
switch (e.key) {
|
|
case "ArrowRight":
|
|
case "ArrowDown":
|
|
e.preventDefault();
|
|
selectEnergyByIndex((here + 1) % energyOptions.length);
|
|
break;
|
|
case "ArrowLeft":
|
|
case "ArrowUp":
|
|
e.preventDefault();
|
|
selectEnergyByIndex((here - 1 + energyOptions.length) % energyOptions.length);
|
|
break;
|
|
case "Home":
|
|
e.preventDefault();
|
|
selectEnergyByIndex(0);
|
|
break;
|
|
case "End":
|
|
e.preventDefault();
|
|
selectEnergyByIndex(energyOptions.length - 1);
|
|
break;
|
|
}
|
|
}
|
|
|
|
let completedTasks = $derived.by(() => {
|
|
let list = tasks.filter((t) => t.done);
|
|
if (activeBucket !== "all") {
|
|
list = list.filter((t) => t.bucket === activeBucket);
|
|
}
|
|
if (activeListId !== "all") {
|
|
if (activeListId === "inbox") {
|
|
list = list.filter((t) => !t.listId);
|
|
} else {
|
|
list = list.filter((t) => t.listId === activeListId);
|
|
}
|
|
}
|
|
if (searchQuery.trim()) {
|
|
const q = searchQuery.toLowerCase();
|
|
list = list.filter((t) => t.text.toLowerCase().includes(q));
|
|
}
|
|
return list.slice(0, 20);
|
|
});
|
|
|
|
let bucketCounts = $derived.by(() => {
|
|
const counts: Record<"all" | TaskBucket, number> = { all: 0, inbox: 0, today: 0, soon: 0, later: 0 };
|
|
for (const t of tasks) {
|
|
if (t.done) continue;
|
|
counts.all++;
|
|
if (counts[t.bucket] !== undefined) counts[t.bucket]++;
|
|
}
|
|
return counts;
|
|
});
|
|
|
|
function countForList(listId: string) {
|
|
if (listId === "all") return tasks.filter((t) => !t.done).length;
|
|
if (listId === "inbox") return tasks.filter((t) => !t.done && !t.listId).length;
|
|
return tasks.filter((t) => !t.done && t.listId === listId).length;
|
|
}
|
|
|
|
let activeListName = $derived(
|
|
activeListId === "all" ? "All Lists" : (taskLists.find((l) => l.id === activeListId)?.name || "Tasks")
|
|
);
|
|
|
|
function handleQuickAdd(e: KeyboardEvent) {
|
|
if (e.key === "Enter" && quickInput.trim()) {
|
|
const listId = (activeListId === "all" || activeListId === "inbox") ? null : activeListId;
|
|
addTask({
|
|
text: quickInput.trim(),
|
|
bucket: (activeBucket === "all" ? "inbox" : activeBucket) as TaskBucket,
|
|
listId,
|
|
});
|
|
quickInput = "";
|
|
}
|
|
}
|
|
|
|
function setBucket(taskId: string, bucket: TaskBucket) {
|
|
updateTask(taskId, { bucket });
|
|
}
|
|
|
|
function setEffort(taskId: string, effort: string) {
|
|
updateTask(taskId, { effort: effort === tasks.find((t) => t.id === taskId)?.effort ? "" : effort });
|
|
}
|
|
|
|
function getListName(listId: string | null) {
|
|
if (!listId) return null;
|
|
return taskLists.find((l) => l.id === listId)?.name || null;
|
|
}
|
|
|
|
async function popOutTasks() {
|
|
try {
|
|
await invoke("open_task_window");
|
|
} catch {
|
|
window.open("/float", "_blank", "width=380,height=520");
|
|
}
|
|
}
|
|
|
|
function handleCreateList(e: KeyboardEvent) {
|
|
if (e.key === "Enter" && newListName.trim()) {
|
|
addTaskList(newListName.trim());
|
|
newListName = "";
|
|
showNewList = false;
|
|
}
|
|
if (e.key === "Escape") { showNewList = false; newListName = ""; }
|
|
}
|
|
|
|
async function startRenaming(list: TaskList) {
|
|
editingListId = list.id;
|
|
editingName = list.name;
|
|
contextMenuListId = null;
|
|
await tick();
|
|
editingInputEl?.focus();
|
|
editingInputEl?.select();
|
|
}
|
|
|
|
function finishRenaming() {
|
|
if (editingListId && editingName.trim()) {
|
|
renameTaskList(editingListId, editingName.trim());
|
|
}
|
|
editingListId = null;
|
|
editingName = "";
|
|
}
|
|
|
|
function handleDeleteList(id: string) {
|
|
if (activeListId === id) activeListId = "all";
|
|
deleteTaskList(id);
|
|
contextMenuListId = null;
|
|
}
|
|
|
|
function toggleContextMenu(e: MouseEvent, listId: string) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
contextMenuListId = contextMenuListId === listId ? null : listId;
|
|
}
|
|
|
|
$effect(() => {
|
|
if (showNewList) {
|
|
tick().then(() => {
|
|
newListInputEl?.focus();
|
|
newListInputEl?.select();
|
|
});
|
|
}
|
|
});
|
|
</script>
|
|
|
|
<div class="flex flex-col h-full animate-fade-in">
|
|
<!-- Header -->
|
|
<div class="flex items-center px-7 pt-6 pb-2">
|
|
<div>
|
|
<div class="flex items-baseline gap-3">
|
|
<h2 class="text-xl font-display text-text italic">Tasks</h2>
|
|
|
|
<!-- Phase 8 badge + sparkline. aria-live scoped to just this
|
|
wrapper so screen readers announce completions without
|
|
re-reading the rest of the header. -->
|
|
<div
|
|
class="flex items-center gap-2"
|
|
aria-live="polite"
|
|
>
|
|
{#if todayCount() > 0}
|
|
<span
|
|
class="text-[11px] text-text-tertiary badge-today"
|
|
aria-label={`${todayCount()} ${todayCount() === 1 ? "task" : "tasks"} completed today`}
|
|
>
|
|
{todayCount()} today
|
|
</span>
|
|
{/if}
|
|
|
|
{#if settings.showMomentumSparkline}
|
|
<CompletionSparkline data={recentCompletions} />
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
|
|
<p class="text-[11px] text-text-tertiary mt-1">Add tasks manually. Automatic extraction from your transcripts is coming.</p>
|
|
</div>
|
|
<div class="flex-1"></div>
|
|
|
|
<!-- Phase 3 match-my-energy control. Three-state energy selector +
|
|
toggle for the sort. Sits in the header so it is always visible
|
|
from any bucket / list context. -->
|
|
<div class="flex items-center gap-2 mr-2">
|
|
<span class="text-[10px] text-text-tertiary hidden sm:inline" aria-hidden="true">I feel</span>
|
|
<div
|
|
bind:this={energyRadioGroupEl}
|
|
class="flex items-center gap-0.5 bg-bg-input border border-border-subtle rounded-lg p-0.5"
|
|
role="radiogroup"
|
|
aria-label="My current energy"
|
|
tabindex="-1"
|
|
onkeydown={energyRadioKeydown}
|
|
>
|
|
{#each energyOptions as opt}
|
|
{@const checked = settings.currentEnergy === opt.value}
|
|
<button
|
|
class="text-[10px] px-2 py-0.5 rounded-md
|
|
{checked
|
|
? 'bg-accent/25 text-accent border border-accent/30'
|
|
: 'text-text-secondary hover:text-text'}"
|
|
role="radio"
|
|
aria-checked={checked}
|
|
aria-label={opt.value ? `Set current energy to ${energyLabel(opt.value)}` : 'Clear current energy'}
|
|
tabindex={checked ? 0 : -1}
|
|
onclick={() => cycleCurrentEnergy(opt.value)}
|
|
>{opt.label}</button>
|
|
{/each}
|
|
</div>
|
|
<button
|
|
class="flex items-center gap-1 btn-md rounded-lg text-[10px]
|
|
{settings.matchMyEnergy
|
|
? 'bg-accent/25 text-accent border border-accent/30'
|
|
: 'text-text-secondary hover:bg-hover hover:text-text border border-transparent'}"
|
|
style="transition-duration: var(--duration-ui)"
|
|
onclick={toggleMatchMyEnergy}
|
|
aria-pressed={settings.matchMyEnergy}
|
|
aria-label={settings.matchMyEnergy ? 'Match my energy is on — click to turn off' : 'Match my energy is off — click to turn on'}
|
|
title="Sort matching tasks to the top (unset counts as Medium)"
|
|
>
|
|
<Zap size={12} aria-hidden="true" />
|
|
Match my energy
|
|
</button>
|
|
</div>
|
|
|
|
<button
|
|
class="flex items-center gap-1.5 btn-md rounded-lg text-text-secondary hover:bg-hover hover:text-text"
|
|
style="transition-duration: var(--duration-ui)"
|
|
onclick={popOutTasks}
|
|
aria-label="Pop out task window"
|
|
>
|
|
<ExternalLink size={14} aria-hidden="true" />
|
|
Pop out
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Search -->
|
|
<div class="px-7 pb-2">
|
|
<div class="flex items-center gap-2 bg-bg-input border border-border rounded-lg px-3 py-1.5 focus-within:border-accent"
|
|
style="transition-duration: var(--duration-ui)">
|
|
<Search size={14} class="text-text-tertiary flex-shrink-0" aria-hidden="true" />
|
|
<input
|
|
type="text"
|
|
class="flex-1 bg-transparent text-[12px] text-text placeholder:text-text-tertiary"
|
|
placeholder="Search tasks..."
|
|
bind:value={searchQuery}
|
|
data-no-transition
|
|
/>
|
|
{#if searchQuery}
|
|
<button class="text-[10px] text-text-tertiary hover:text-text" onclick={() => searchQuery = ""}>Clear</button>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Quick capture -->
|
|
<div class="px-7 pb-3">
|
|
<div class="flex items-center gap-2 bg-bg-input border border-border rounded-xl px-4 py-2.5 focus-within:border-accent"
|
|
style="transition-duration: var(--duration-ui)">
|
|
<Plus size={16} class="text-text-tertiary flex-shrink-0" aria-hidden="true" />
|
|
<input
|
|
type="text"
|
|
class="flex-1 bg-transparent text-[13px] text-text placeholder:text-text-tertiary"
|
|
placeholder="Add a task to {activeListName}{activeBucket !== 'all' ? ` (${activeBucket})` : ''}... (Enter to save)"
|
|
bind:value={quickInput}
|
|
onkeydown={handleQuickAdd}
|
|
/>
|
|
{#if quickInput.trim()}
|
|
<span class="text-[10px] text-text-tertiary px-1.5 py-0.5 rounded bg-bg-elevated border border-border-subtle">
|
|
→ {activeBucket === "all" ? "inbox" : activeBucket}
|
|
</span>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Bucket tabs + sort -->
|
|
<div class="flex items-center gap-1 px-7 pb-3">
|
|
<nav aria-label="Task filters">
|
|
{#each buckets as bucket}
|
|
<button
|
|
class="flex items-center gap-1.5 btn-md rounded-lg
|
|
{activeBucket === bucket.id
|
|
? 'bg-nav-active text-text font-medium'
|
|
: 'text-text-secondary hover:bg-hover hover:text-text'}"
|
|
style="transition-duration: var(--duration-ui)"
|
|
onclick={() => activeBucket = bucket.id}
|
|
>
|
|
{bucket.label}
|
|
{#if bucketCounts[bucket.id] > 0}
|
|
<span class="text-[10px] px-1.5 py-0.5 rounded-full bg-bg-elevated text-text-tertiary">
|
|
{bucketCounts[bucket.id]}
|
|
</span>
|
|
{/if}
|
|
</button>
|
|
{/each}
|
|
</nav>
|
|
|
|
<div class="flex-1"></div>
|
|
|
|
<!-- Sort dropdown -->
|
|
<div class="relative">
|
|
<button
|
|
class="flex items-center gap-1 px-2 py-1.5 rounded-lg text-[11px] text-text-tertiary hover:text-text-secondary hover:bg-hover"
|
|
onclick={() => showSortMenu = !showSortMenu}
|
|
aria-label="Sort tasks"
|
|
>
|
|
<ArrowUpDown size={14} aria-hidden="true" />
|
|
{sortMode === "date" ? "" : sortMode === "quick-first" ? "Quick first" : "Deep first"}
|
|
</button>
|
|
{#if showSortMenu}
|
|
<div class="absolute right-0 top-full mt-1 bg-bg-card border border-border rounded-lg shadow-lg py-1 z-20 animate-fade-in min-w-[120px]">
|
|
{#each [["date", "By date"], ["quick-first", "Quick first"], ["deep-first", "Deep first"]] as [mode, label]}
|
|
<button
|
|
class="w-full text-left px-3 py-1 text-[11px] hover:bg-hover
|
|
{sortMode === mode ? 'text-accent font-medium' : 'text-text-secondary hover:text-text'}"
|
|
onclick={() => { sortMode = mode; showSortMenu = false; }}
|
|
>{label}</button>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Main content: sidebar + tasks -->
|
|
<div class="flex flex-1 min-h-0 px-7 pb-4 gap-3">
|
|
<!--
|
|
Layout-transition perf fix: was animating `width` directly. Now wraps
|
|
the list sidebar in a CSS-grid container whose `grid-template-columns`
|
|
track-size transitions between collapsed/expanded widths. Same visual
|
|
effect, single transitioning property the browser can handle as a
|
|
track-spec change rather than a width re-flow each frame.
|
|
-->
|
|
<!-- List sidebar -->
|
|
<div
|
|
class="grid {sidebarCollapsed ? 'grid-cols-[40px]' : 'grid-cols-[160px]'}"
|
|
style="transition: grid-template-columns var(--duration-ui)"
|
|
>
|
|
<div
|
|
class="flex flex-col bg-bg-elevated rounded-2xl border border-border-subtle overflow-hidden min-w-0"
|
|
>
|
|
<!-- Collapse toggle -->
|
|
<button
|
|
class="flex items-center justify-center h-[32px] text-text-tertiary hover:text-text-secondary border-b border-border-subtle"
|
|
onclick={() => sidebarCollapsed = !sidebarCollapsed}
|
|
aria-label={sidebarCollapsed ? "Expand list sidebar" : "Collapse list sidebar"}
|
|
>
|
|
{#if sidebarCollapsed}
|
|
<ChevronRight size={12} aria-hidden="true" />
|
|
{:else}
|
|
<ChevronLeft size={12} aria-hidden="true" />
|
|
{/if}
|
|
</button>
|
|
|
|
<!-- List items -->
|
|
<div class="flex-1 overflow-y-auto py-1">
|
|
{#each taskLists as list (list.id)}
|
|
{#if editingListId === list.id && !sidebarCollapsed}
|
|
<div class="px-1.5 py-0.5">
|
|
<input
|
|
bind:this={editingInputEl}
|
|
type="text"
|
|
class="w-full bg-bg-input border border-accent rounded px-2 py-1 text-[10px] text-text"
|
|
bind:value={editingName}
|
|
onkeydown={(e) => { if (e.key === "Enter") finishRenaming(); if (e.key === "Escape") { editingListId = null; } }}
|
|
onblur={finishRenaming}
|
|
data-no-transition
|
|
/>
|
|
</div>
|
|
{:else}
|
|
<div class="relative">
|
|
<button
|
|
class="w-full flex items-center gap-1.5 px-2 py-1.5 text-left text-[11px]
|
|
{activeListId === list.id
|
|
? 'border-l-2 border-accent text-text font-medium bg-accent/5'
|
|
: 'border-l-2 border-transparent text-text-secondary hover:bg-hover hover:text-text'}"
|
|
onclick={() => { activeListId = list.id; contextMenuListId = null; }}
|
|
ondblclick={() => { if (!list.builtIn && !sidebarCollapsed) startRenaming(list); }}
|
|
oncontextmenu={(e) => { if (!list.builtIn) toggleContextMenu(e, list.id); }}
|
|
title={sidebarCollapsed ? `${list.name} (${countForList(list.id)})` : ""}
|
|
>
|
|
{#if sidebarCollapsed}
|
|
<span class="text-[9px] px-1 py-0 rounded-full bg-bg-card text-text-tertiary min-w-[16px] text-center mx-auto">
|
|
{countForList(list.id)}
|
|
</span>
|
|
{:else}
|
|
<span class="flex-1 truncate">{list.name}</span>
|
|
{#if countForList(list.id) > 0}
|
|
<span class="text-[9px] px-1 py-0 rounded-full bg-bg-card text-text-tertiary min-w-[16px] text-center">
|
|
{countForList(list.id)}
|
|
</span>
|
|
{/if}
|
|
{/if}
|
|
</button>
|
|
|
|
<!-- Context menu -->
|
|
{#if contextMenuListId === list.id && !sidebarCollapsed}
|
|
<div class="absolute left-2 top-full mt-0.5 bg-bg-card border border-border rounded-lg shadow-lg py-1 z-20 animate-fade-in min-w-[100px]">
|
|
<button
|
|
class="w-full text-left px-3 py-1 text-[10px] text-text-secondary hover:bg-hover hover:text-text"
|
|
onclick={() => startRenaming(list)}
|
|
>Rename</button>
|
|
<div class="my-1 h-px bg-border-subtle"></div>
|
|
<button
|
|
class="w-full text-left px-3 py-1 text-[10px] text-danger hover:bg-hover"
|
|
onclick={() => handleDeleteList(list.id)}
|
|
>Delete</button>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
{/each}
|
|
</div>
|
|
|
|
<!-- New list -->
|
|
{#if !sidebarCollapsed}
|
|
<div class="px-2 py-2 border-t border-border-subtle">
|
|
{#if showNewList}
|
|
<input
|
|
bind:this={newListInputEl}
|
|
type="text"
|
|
class="w-full bg-bg-input border border-border rounded px-2 py-1 text-[10px] text-text
|
|
placeholder:text-text-tertiary focus:border-accent"
|
|
placeholder="List name..."
|
|
bind:value={newListName}
|
|
onkeydown={handleCreateList}
|
|
onblur={() => { showNewList = false; newListName = ""; }}
|
|
data-no-transition
|
|
/>
|
|
{:else}
|
|
<button
|
|
class="w-full text-left text-[10px] text-accent hover:text-accent-hover px-1"
|
|
onclick={() => showNewList = true}
|
|
>+ New list</button>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Task list -->
|
|
<div class="flex-1 overflow-y-auto min-h-0">
|
|
{#if filteredTasks.length === 0}
|
|
<EmptyState
|
|
icon={SquareCheck}
|
|
message={searchQuery
|
|
? "No matching tasks"
|
|
: "Add tasks manually. Automatic extraction from your transcripts is coming."}
|
|
/>
|
|
{:else}
|
|
<div class="flex flex-col gap-1">
|
|
{#each filteredTasks as task (task.id)}
|
|
<div class="group flex items-start gap-3 p-3 rounded-xl bg-bg-card border border-border-subtle hover:border-border"
|
|
style="transition-duration: var(--duration-ui)">
|
|
<!-- Checkbox -->
|
|
<button
|
|
aria-label="Complete task"
|
|
class="mt-0.5 w-[18px] h-[18px] rounded-md border-2 border-border-subtle hover:border-accent flex-shrink-0 flex items-center justify-center"
|
|
style="transition-duration: var(--duration-ui)"
|
|
onclick={() => completeTask(task.id)}
|
|
></button>
|
|
|
|
<!-- Content -->
|
|
<div class="flex-1 min-w-0">
|
|
<p class="text-[13px] text-text leading-relaxed">{task.text}</p>
|
|
<div class="flex items-center gap-2 mt-1.5">
|
|
<!-- Bucket pills -->
|
|
{#each taskBucketOptions as b}
|
|
<button
|
|
class="text-[10px] px-2 py-0.5 rounded-full border
|
|
{task.bucket === b
|
|
? `${BUCKET_COLORS[b]} border-current bg-current/10 font-medium`
|
|
: 'text-text-tertiary border-transparent hover:border-border-subtle hover:text-text-secondary'}"
|
|
style="transition-duration: var(--duration-ui)"
|
|
onclick={() => setBucket(task.id, b)}
|
|
>{b}</button>
|
|
{/each}
|
|
<span class="text-border-subtle">·</span>
|
|
<!-- Effort pills -->
|
|
{#each effortOptions as e}
|
|
<button
|
|
class="text-[10px] px-2 py-0.5 rounded-full border
|
|
{task.effort === e
|
|
? 'text-accent border-accent/30 bg-accent/10 font-medium'
|
|
: 'text-text-tertiary border-transparent hover:border-border-subtle hover:text-text-secondary'}"
|
|
style="transition-duration: var(--duration-ui)"
|
|
onclick={() => setEffort(task.id, e)}
|
|
>{effortLabel(e)}</button>
|
|
{/each}
|
|
<span class="text-border-subtle">·</span>
|
|
<!-- Energy chip (Phase 3) -->
|
|
<EnergyChip
|
|
energy={task.energy}
|
|
onSelect={(next) => setTaskEnergy(task.id, next)}
|
|
size="md"
|
|
/>
|
|
<!-- Timestamp -->
|
|
{#if task.createdAt}
|
|
<span class="text-[10px] text-text-tertiary ml-auto">{formatTimestamp(task.createdAt)}</span>
|
|
{/if}
|
|
</div>
|
|
<!-- List name (only when viewing all lists) -->
|
|
{#if activeListId === "all" && getListName(task.listId)}
|
|
<p class="text-[10px] text-text-tertiary italic mt-1">{getListName(task.listId)}</p>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Delete -->
|
|
<button
|
|
aria-label="Delete task"
|
|
class="mt-0.5 text-text-tertiary hover:text-danger opacity-0 group-hover:opacity-100 focus:opacity-100"
|
|
style="transition: opacity var(--duration-ui)"
|
|
onclick={() => deleteTask(task.id)}
|
|
>
|
|
<X size={16} aria-hidden="true" />
|
|
</button>
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Completed section -->
|
|
{#if completedTasks.length > 0}
|
|
<div class="mt-6">
|
|
<button
|
|
class="flex items-center gap-2 text-[12px] text-text-tertiary hover:text-text-secondary mb-2"
|
|
onclick={() => showCompleted = !showCompleted}
|
|
>
|
|
<ChevronRight size={12} class={showCompleted ? "rotate-90" : ""} aria-hidden="true"
|
|
style="transition: transform var(--duration-ui)" />
|
|
Completed ({completedTasks.length})
|
|
</button>
|
|
{#if showCompleted}
|
|
<div class="flex flex-col gap-1 animate-fade-in">
|
|
{#each completedTasks as task (task.id)}
|
|
<div class="group flex items-start gap-3 px-4 py-2.5 rounded-xl opacity-50 hover:opacity-70"
|
|
style="transition: opacity var(--duration-ui)">
|
|
<button
|
|
aria-label="Uncomplete task"
|
|
class="mt-0.5 w-[18px] h-[18px] rounded-md border-2 border-accent bg-accent/20 flex-shrink-0 flex items-center justify-center"
|
|
onclick={() => uncompleteTask(task.id)}
|
|
>
|
|
<SquareCheck size={12} class="text-accent" aria-hidden="true" />
|
|
</button>
|
|
<div class="flex-1 min-w-0">
|
|
<p class="text-[13px] text-text-secondary line-through">{task.text}</p>
|
|
{#if task.doneAt}
|
|
<p class="text-[10px] text-text-tertiary mt-0.5">Completed {formatTimestamp(task.doneAt)}</p>
|
|
{/if}
|
|
</div>
|
|
<button
|
|
aria-label="Delete completed task"
|
|
class="mt-0.5 text-text-tertiary hover:text-danger opacity-0 group-hover:opacity-100 focus:opacity-100"
|
|
style="transition: opacity var(--duration-ui)"
|
|
onclick={() => deleteTask(task.id)}
|
|
>
|
|
<X size={16} aria-hidden="true" />
|
|
</button>
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<style>
|
|
/* Phase 9 polish: gentle entrance animation when the today-count
|
|
badge mounts (it is conditionally rendered, so each new render
|
|
re-fires the animation). prefers-reduced-motion disables. */
|
|
:global(.badge-today) {
|
|
animation: badge-pop 180ms ease both;
|
|
}
|
|
@media (prefers-reduced-motion: reduce) {
|
|
:global(.badge-today) {
|
|
animation: none;
|
|
}
|
|
}
|
|
@keyframes badge-pop {
|
|
from {
|
|
opacity: 0;
|
|
transform: translateY(2px);
|
|
}
|
|
to {
|
|
opacity: 1;
|
|
transform: translateY(0);
|
|
}
|
|
}
|
|
</style>
|