B3.1: app shell tracks lastLaunchAt; >7-day gap opens a 24h fresh-start window. While the window is active: a "Welcome back. This week starts fresh." banner offers Archive old Inbox (Inbox-only via archive_old_inbox_cmd, system-attributable copy on success), the morning-triage modal is suppressed, the momentum sparkline is hidden, and the nudge bus stays quiet. Banner is per-session dismissable. B3.4: MicroSteps shows a "Last completed: X. Next: Y." re-orientation banner when re-opening a parent task whose decomposition has been idle >=30 min OR sat across a session boundary. Speaker button reads the banner aloud via existing TTS. Per-parent last-activity timestamps are persisted to a single localStorage key. Banner self-dismisses on first interaction. Shared inFreshStartWindow gate extracted to src/lib/utils/freshStart.ts so ShutdownRitualPage (B3.14), TasksPage, MorningTriageModal, and nudgeBus all read the same source of truth. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1039 lines
42 KiB
Svelte
1039 lines
42 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 { isAndroid } from "$lib/utils/runtime.js";
|
|
import {
|
|
tasks, addTask, completeTask, uncompleteTask, deleteTask, updateTask,
|
|
setTaskEnergy,
|
|
taskLists, addTaskList, renameTaskList, deleteTaskList,
|
|
settings, saveSettings, profiles, saveProfiles,
|
|
} from "$lib/stores/page.svelte.js";
|
|
import { page } from "$lib/stores/page.svelte.js";
|
|
import type { TaskDto } from "$lib/types/app";
|
|
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, Archive, ArchiveRestore } from 'lucide-svelte';
|
|
import Card from "$lib/components/Card.svelte";
|
|
import { formatTimestamp, todayLocalDate } from "$lib/utils/time.js";
|
|
import { resolveEnergyLabels } from "$lib/utils/energyLabels";
|
|
import { inFreshStartWindow } from "$lib/utils/freshStart.js";
|
|
import { BUCKET_COLORS, EFFORT_LABELS, EFFORT_ORDER } from "$lib/utils/constants.js";
|
|
|
|
// B3.1 — fresh-start gate. While the 24h re-entry window is open, the
|
|
// momentum sparkline is hidden so the header doesn't push a "look at
|
|
// your last 7 days" frame onto a user the app has just told "this
|
|
// week starts fresh". Same gate the shell banner / morning triage /
|
|
// nudge bus consult.
|
|
const inFreshStartNow = $derived(
|
|
inFreshStartWindow(settings.reentryFreshStartUntil),
|
|
);
|
|
|
|
// PR 1.2: default landing bucket is Today rather than All. The cold-open
|
|
// surface area for "what should I do now?" is now scoped to today's
|
|
// commitments, with All still reachable as a one-click filter.
|
|
let activeBucket = $state("today");
|
|
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);
|
|
|
|
// B2a: Archived view. The pill is hidden when there are no archived
|
|
// rows. The list is fetched on demand (and refreshed each time the
|
|
// user toggles into the view) so it doesn't block the main task load.
|
|
let showArchived = $state(false);
|
|
let archivedTasks = $state<TaskDto[]>([]);
|
|
let archivedTasksCount = $state(0);
|
|
|
|
async function refreshArchivedTasks() {
|
|
try {
|
|
const rows = await invoke<TaskDto[]>("list_archived_tasks_cmd");
|
|
archivedTasks = Array.isArray(rows) ? rows : [];
|
|
archivedTasksCount = archivedTasks.length;
|
|
} catch (err) {
|
|
console.error("list_archived_tasks_cmd failed", err);
|
|
archivedTasks = [];
|
|
archivedTasksCount = 0;
|
|
}
|
|
}
|
|
|
|
// Initial archived count so the pill knows whether to render.
|
|
$effect(() => {
|
|
refreshArchivedTasks();
|
|
});
|
|
|
|
async function archiveTaskRow(id: string) {
|
|
try {
|
|
await invoke("archive_task_cmd", { id });
|
|
// Optimistic local removal — the backend has already moved the
|
|
// row out of list_tasks, so the next loadTasks would do this
|
|
// anyway; doing it here avoids a refetch round-trip.
|
|
const idx = tasks.findIndex((t) => t.id === id);
|
|
if (idx >= 0) tasks.splice(idx, 1);
|
|
archivedTasksCount++;
|
|
} catch (err) {
|
|
console.error("archive_task_cmd failed", err);
|
|
}
|
|
}
|
|
|
|
async function unarchiveTaskRow(id: string) {
|
|
try {
|
|
const restored = await invoke<TaskDto>("unarchive_task_cmd", { id });
|
|
// Drop from the archived list so the view updates without a
|
|
// round-trip; refresh the count to stay in sync.
|
|
const aidx = archivedTasks.findIndex((t) => t.id === id);
|
|
if (aidx >= 0) {
|
|
archivedTasks = archivedTasks.filter((_, i) => i !== aidx);
|
|
archivedTasksCount = archivedTasks.length;
|
|
}
|
|
// Push the restored row into the in-memory tasks store so it
|
|
// shows up in the active bucket without a full reload. The DTO
|
|
// shape matches what mapTaskRow expects in the store; we mirror
|
|
// its defaults inline since mapTaskRow isn't exported.
|
|
tasks.unshift({
|
|
id: restored.id,
|
|
text: restored.text ?? "",
|
|
bucket: (restored.bucket ?? "inbox") as TaskBucket,
|
|
listId: restored.listId ?? null,
|
|
effort: restored.effort ?? "",
|
|
notes: restored.notes ?? "",
|
|
done: !!restored.done,
|
|
doneAt: restored.doneAt ?? null,
|
|
createdAt: restored.createdAt ?? new Date().toISOString(),
|
|
sourceTranscriptId: restored.sourceTranscriptId ?? null,
|
|
parentTaskId: restored.parentTaskId ?? null,
|
|
energy: restored.energy ?? null,
|
|
archived: false,
|
|
archivedAt: null,
|
|
});
|
|
} catch (err) {
|
|
console.error("unarchive_task_cmd failed", err);
|
|
}
|
|
}
|
|
|
|
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;
|
|
});
|
|
|
|
// PR 1.2: the Now lane consumes the same filtered set as the bucket
|
|
// list so its rows respect the active bucket / list / search / sort.
|
|
// We compute the rendered slice here (top-level, capped at wipLimit)
|
|
// and exclude those IDs from the bucket list — no row appears twice.
|
|
// Computing in the parent (rather than via callback) avoids a
|
|
// first-paint flash where both panels briefly render the same row
|
|
// before an effect reconciles them.
|
|
const NOW_LANE_LIMIT = 3;
|
|
let nowLaneTasks = $derived(
|
|
filteredTasks.filter((t) => !t.parentTaskId).slice(0, NOW_LANE_LIMIT),
|
|
);
|
|
let nowLaneIds = $derived(new Set(nowLaneTasks.map((t) => t.id)));
|
|
let bucketListTasks = $derived(filteredTasks.filter((t) => !nowLaneIds.has(t.id)));
|
|
|
|
// B3.6 — resolve labels from per-profile override or global settings.
|
|
// The active profile is the localStorage Profile entry whose name
|
|
// matches page.activeProfile (a string, "None" when unset).
|
|
let activeProfile = $derived(
|
|
page.activeProfile === "None"
|
|
? null
|
|
: profiles.find((p) => p.name === page.activeProfile) ?? null,
|
|
);
|
|
let energyLabels = $derived(resolveEnergyLabels(activeProfile, settings));
|
|
|
|
// B3.6 — first-tap energy prompt state. The actual gate (per-day +
|
|
// first-Zero one-shot) lives in `maybeShowEnergyPrompt` below.
|
|
let energyPromptOpen = $state(false);
|
|
let energyPromptDismissTimer: ReturnType<typeof setTimeout> | null = null;
|
|
|
|
function clearPromptTimer() {
|
|
if (energyPromptDismissTimer) {
|
|
clearTimeout(energyPromptDismissTimer);
|
|
energyPromptDismissTimer = null;
|
|
}
|
|
}
|
|
|
|
function dismissEnergyPrompt() {
|
|
if (!energyPromptOpen) return;
|
|
energyPromptOpen = false;
|
|
clearPromptTimer();
|
|
// Mark today as "shown" only when the prompt is actually dismissed.
|
|
settings.energyPromptLastShownDate = todayLocalDate();
|
|
saveSettings();
|
|
}
|
|
|
|
function maybeShowEnergyPrompt(next: EnergyLevel | null) {
|
|
if (!settings.energyPromptsEnabled) return;
|
|
// Don't prompt on a clear-to-null tap — only on a meaningful pick.
|
|
if (next === null) return;
|
|
const today = todayLocalDate();
|
|
const isFirstZero = next === "brain_dead" && !settings.energyPromptZeroSeen;
|
|
const dailyGateOpen = settings.energyPromptLastShownDate !== today;
|
|
if (!isFirstZero && !dailyGateOpen) return;
|
|
|
|
energyPromptOpen = true;
|
|
if (isFirstZero) {
|
|
settings.energyPromptZeroSeen = true;
|
|
saveSettings();
|
|
}
|
|
// Auto-hide after 30s so the prompt never feels modal — a calm
|
|
// affordance, not a demand.
|
|
clearPromptTimer();
|
|
energyPromptDismissTimer = setTimeout(() => {
|
|
dismissEnergyPrompt();
|
|
}, 30000);
|
|
}
|
|
|
|
// B3.6 — example chips. Optionally pre-fill the "Zero" description
|
|
// when the user picks one, with a confirm so we never overwrite their
|
|
// own copy without permission.
|
|
const ZERO_EXAMPLE_CHIPS = ["rest only", "low-touch admin", "quiet thinking"];
|
|
function applyZeroExample(text: string) {
|
|
const current = settings.energyLabels.brain_dead.description ?? "";
|
|
if (current.trim().length > 0) {
|
|
const ok = typeof confirm === "function"
|
|
? confirm(`Replace your current Zero description (\"${current}\") with \"${text}\"?`)
|
|
: true;
|
|
if (!ok) {
|
|
dismissEnergyPrompt();
|
|
return;
|
|
}
|
|
}
|
|
settings.energyLabels.brain_dead.description = text;
|
|
saveSettings();
|
|
dismissEnergyPrompt();
|
|
}
|
|
|
|
function cycleCurrentEnergy(next: EnergyLevel | null) {
|
|
settings.currentEnergy = next;
|
|
saveSettings();
|
|
maybeShowEnergyPrompt(next);
|
|
}
|
|
|
|
function toggleMatchMyEnergy() {
|
|
settings.matchMyEnergy = !settings.matchMyEnergy;
|
|
saveSettings();
|
|
}
|
|
|
|
function energyLabel(level: EnergyLevel | null): string {
|
|
switch (level) {
|
|
case "high": return energyLabels.high.label;
|
|
case "medium": return energyLabels.medium.label;
|
|
case "brain_dead": return energyLabels.brain_dead.label;
|
|
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.
|
|
//
|
|
// B3.6: derived from the resolved labels so renaming "Zero" propagates
|
|
// here too. The compact "Med" label drops to a 3-char prefix of the
|
|
// user's medium label so the segmented control stays narrow.
|
|
type EnergyOption = { value: EnergyLevel | null; label: string };
|
|
let energyOptions = $derived<EnergyOption[]>([
|
|
{ value: null, label: "—" },
|
|
{ value: "high", label: energyLabels.high.label },
|
|
{ value: "medium", label: energyLabels.medium.label.length > 4
|
|
? energyLabels.medium.label.slice(0, 3)
|
|
: energyLabels.medium.label },
|
|
{ value: "brain_dead", label: energyLabels.brain_dead.label },
|
|
]);
|
|
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 && !inFreshStartNow}
|
|
<CompletionSparkline data={recentCompletions} />
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
|
|
<p class="text-[11px] text-text-tertiary mt-1">Add tasks manually, or extract them from a transcript in Dictation.</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/15 text-accent'
|
|
: 'text-text-tertiary hover:text-text-secondary'}"
|
|
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/15 text-accent border border-accent/30'
|
|
: 'text-text-tertiary 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>
|
|
|
|
{#if !isAndroid()}
|
|
<!-- Multi-window pop-out is desktop-only — the Tauri Android stub
|
|
for open_task_window returns an error, so the button would just
|
|
surface a toast on a mobile build. Hide it instead. -->
|
|
<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>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- B3.6 first-tap energy prompt. Inline (never modal) — same shape
|
|
as the PR 1.4 draft-recovery banner so the affordance feels
|
|
familiar. Opt-in chips can pre-fill the user's Zero description;
|
|
Dismiss closes without changes. -->
|
|
{#if energyPromptOpen}
|
|
<div class="px-7 pt-1 pb-2 animate-fade-in">
|
|
<div
|
|
class="px-4 py-2 rounded-lg bg-accent-subtle border border-accent/20 text-[12px]"
|
|
role="status"
|
|
aria-live="polite"
|
|
>
|
|
<div class="flex items-center gap-3 flex-wrap">
|
|
<span class="text-text">
|
|
What does <span class="font-medium">{energyLabels.brain_dead.label}</span> mean for you today?
|
|
</span>
|
|
<div class="flex items-center gap-1.5 flex-wrap">
|
|
{#each ZERO_EXAMPLE_CHIPS as chip}
|
|
<button
|
|
class="text-[11px] px-2 py-0.5 rounded-md border border-accent/30 text-accent hover:bg-accent/10"
|
|
style="transition-duration: var(--duration-ui)"
|
|
onclick={() => applyZeroExample(chip)}
|
|
title={`Use \"${chip}\" as your ${energyLabels.brain_dead.label} description`}
|
|
>{chip}</button>
|
|
{/each}
|
|
</div>
|
|
<div class="flex-1"></div>
|
|
<button
|
|
class="btn-md rounded-md text-text-tertiary hover:bg-hover hover:text-text whitespace-nowrap"
|
|
onclick={dismissEnergyPrompt}
|
|
aria-label="Dismiss energy prompt"
|
|
>Dismiss</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- 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 focus:outline-none"
|
|
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 focus:outline-none"
|
|
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">
|
|
<!-- Bucket tabs render as a horizontal pill row. The nav element is
|
|
block-level by default, which previously made its button children
|
|
stack vertically — the outer flex container only governs siblings,
|
|
not children of the nav. Adding flex/gap here puts the tabs
|
|
side-by-side as intended. -->
|
|
<nav aria-label="Task filters" class="flex items-center gap-1 flex-wrap">
|
|
{#each buckets as bucket}
|
|
<button
|
|
class="flex items-center gap-1.5 btn-md rounded-lg
|
|
{activeBucket === bucket.id && !showArchived
|
|
? 'bg-nav-active text-text font-medium'
|
|
: 'text-text-secondary hover:bg-hover hover:text-text'}"
|
|
style="transition-duration: var(--duration-ui)"
|
|
onclick={() => { showArchived = false; 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}
|
|
<!-- B2a: Archived pill. Hidden until at least one row is archived
|
|
so we don't add visual noise for users who never use the
|
|
feature. Toggling refreshes the list to catch other-window
|
|
archive activity. -->
|
|
{#if archivedTasksCount > 0}
|
|
<button
|
|
class="flex items-center gap-1.5 btn-md rounded-lg
|
|
{showArchived
|
|
? 'bg-nav-active text-text font-medium'
|
|
: 'text-text-secondary hover:bg-hover hover:text-text'}"
|
|
style="transition-duration: var(--duration-ui)"
|
|
onclick={async () => {
|
|
showArchived = !showArchived;
|
|
if (showArchived) await refreshArchivedTasks();
|
|
}}
|
|
>
|
|
<Archive size={12} aria-hidden="true" />
|
|
Archived
|
|
<span class="text-[10px] px-1.5 py-0.5 rounded-full bg-bg-elevated text-text-tertiary">
|
|
{archivedTasksCount}
|
|
</span>
|
|
</button>
|
|
{/if}
|
|
</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 items-start">
|
|
<!-- List sidebar — `self-start` + `max-h-full` so the panel sizes to
|
|
its content rather than stretching to the full task-area height.
|
|
A nearly-empty list with three items used to draw a column that
|
|
ran the full height of the window even though it had nothing in
|
|
it; this keeps the surface honest. The inner items list keeps
|
|
its scroll affordance for users with many lists. -->
|
|
<div
|
|
class="flex flex-col bg-bg-elevated rounded-2xl border border-border-subtle overflow-hidden self-start max-h-full
|
|
{sidebarCollapsed ? 'w-[40px] min-w-[40px]' : 'w-[160px] min-w-[160px]'}"
|
|
style="transition: width var(--duration-ui)"
|
|
>
|
|
<!-- 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 focus:outline-none"
|
|
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:outline-none 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>
|
|
|
|
<!-- Task list -->
|
|
<div class="flex-1 overflow-y-auto min-h-0">
|
|
{#if showArchived}
|
|
<!-- B2a: Archived view. Read-only-ish list with an Unarchive
|
|
control per row. Hides the Now lane / completed sections
|
|
entirely so the surface stays focused. -->
|
|
{#if archivedTasks.length === 0}
|
|
<EmptyState
|
|
icon={Archive}
|
|
message="No archived tasks. Use the archive button on a task to hide it without losing it."
|
|
/>
|
|
{:else}
|
|
<div class="flex flex-col gap-1">
|
|
{#each archivedTasks 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 opacity-80 hover:opacity-100"
|
|
style="transition-duration: var(--duration-ui)">
|
|
<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">
|
|
{#if task.archivedAt}
|
|
<span class="text-[10px] text-text-tertiary">Archived {formatTimestamp(task.archivedAt)}</span>
|
|
{/if}
|
|
{#if task.bucket}
|
|
<span class="text-[10px] text-text-tertiary">· {task.bucket}</span>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
<button
|
|
aria-label="Unarchive task"
|
|
title="Restore to its bucket"
|
|
class="mt-0.5 flex items-center gap-1 px-2 py-1 rounded text-[11px] text-text-secondary hover:text-text hover:bg-hover"
|
|
style="transition: background var(--duration-ui)"
|
|
onclick={() => unarchiveTaskRow(task.id)}
|
|
>
|
|
<ArchiveRestore size={14} aria-hidden="true" />
|
|
Unarchive
|
|
</button>
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
{:else}
|
|
<!-- PR 1.2: Now lane sits above the bucket list and shares its
|
|
filtered set. The bucket list excludes whatever IDs the lane
|
|
rendered, so a task is never shown in both. The lane is only
|
|
mounted when it has something to show — when the filter is
|
|
empty, the EmptyState below carries onboarding copy alone. -->
|
|
{#if nowLaneTasks.length > 0}
|
|
<div class="mb-3">
|
|
<WipTaskList tasks={nowLaneTasks} wipLimit={NOW_LANE_LIMIT} />
|
|
</div>
|
|
{/if}
|
|
{#if filteredTasks.length === 0}
|
|
<EmptyState
|
|
icon={SquareCheck}
|
|
message={searchQuery
|
|
? "No matching tasks"
|
|
: "Add tasks manually. Automatic extraction from your transcripts is coming."}
|
|
/>
|
|
{:else if bucketListTasks.length > 0}
|
|
<div class="flex flex-col gap-1">
|
|
{#each bucketListTasks 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>
|
|
|
|
<!-- Row controls (PR 1.3 baseline-opacity affordances). -->
|
|
<div class="flex items-start gap-1">
|
|
<!-- B2a: per-row archive. Lower contrast than delete
|
|
because archive is the calmer, recoverable action. -->
|
|
<button
|
|
aria-label="Archive task"
|
|
title="Archive (hide from list, recoverable)"
|
|
class="mt-0.5 text-text-tertiary hover:text-text-secondary opacity-30 group-hover:opacity-100 focus-visible:opacity-100"
|
|
style="transition: opacity var(--duration-ui)"
|
|
onclick={() => archiveTaskRow(task.id)}
|
|
>
|
|
<Archive size={14} aria-hidden="true" />
|
|
</button>
|
|
<!-- 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>
|
|
</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}
|
|
{/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>
|