feat(ui): add MicroSteps component with expandable micro-steps in WipTaskList
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
132
src/lib/components/MicroSteps.svelte
Normal file
132
src/lib/components/MicroSteps.svelte
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
<script>
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
import { Check, Timer, Loader2 } from 'lucide-svelte';
|
||||||
|
import { getPreferences } from '$lib/stores/preferences.svelte.js';
|
||||||
|
|
||||||
|
const prefs = getPreferences();
|
||||||
|
|
||||||
|
let { parentTaskId } = $props();
|
||||||
|
|
||||||
|
let steps = $state([]);
|
||||||
|
let loading = $state(false);
|
||||||
|
let error = $state('');
|
||||||
|
|
||||||
|
let reduceMotion = $derived(
|
||||||
|
prefs.accessibility?.reduceMotion === 'on'
|
||||||
|
|| (prefs.accessibility?.reduceMotion === 'system'
|
||||||
|
&& typeof window !== 'undefined'
|
||||||
|
&& window.matchMedia('(prefers-reduced-motion: reduce)').matches)
|
||||||
|
);
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
loadSteps();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadSteps() {
|
||||||
|
loading = true;
|
||||||
|
error = '';
|
||||||
|
try {
|
||||||
|
steps = await invoke('list_subtasks', { parentTaskId });
|
||||||
|
} catch (e) {
|
||||||
|
error = typeof e === 'string' ? e : 'Failed to load micro-steps';
|
||||||
|
console.error('Failed to load subtasks:', e);
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleStep(step) {
|
||||||
|
if (step.done) return; // Already done — no uncomplete for micro-steps
|
||||||
|
try {
|
||||||
|
const parentCompleted = await invoke('complete_subtask', { subtaskId: step.id });
|
||||||
|
// Update local state
|
||||||
|
const idx = steps.findIndex(s => s.id === step.id);
|
||||||
|
if (idx >= 0) {
|
||||||
|
steps[idx].done = true;
|
||||||
|
steps[idx].status = 'done';
|
||||||
|
}
|
||||||
|
// If parent was auto-completed, notify via event so task list refreshes
|
||||||
|
if (parentCompleted) {
|
||||||
|
window.dispatchEvent(new CustomEvent('kon:tasks-changed'));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to complete micro-step:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startTimer(step) {
|
||||||
|
window.dispatchEvent(new CustomEvent('kon:start-timer', {
|
||||||
|
detail: { id: step.id, text: step.text, duration: 120 }
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
let allDone = $derived(steps.length > 0 && steps.every(s => s.done));
|
||||||
|
let doneCount = $derived(steps.filter(s => s.done).length);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="pl-6 border-l-2 border-border-subtle ml-2 mt-1 mb-2"
|
||||||
|
class:animate-fade-in={!reduceMotion}>
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<div class="flex items-center gap-2 py-3 text-text-tertiary">
|
||||||
|
<Loader2 size={14} class="animate-spin" aria-hidden="true" />
|
||||||
|
<span class="text-[11px]">Breaking down task…</span>
|
||||||
|
</div>
|
||||||
|
{:else if error}
|
||||||
|
<p class="text-[11px] text-text-tertiary py-2">{error}</p>
|
||||||
|
{:else if steps.length === 0}
|
||||||
|
<p class="text-[11px] text-text-tertiary py-2">No micro-steps yet</p>
|
||||||
|
{:else}
|
||||||
|
<div class="flex flex-col gap-0.5">
|
||||||
|
{#each steps as step (step.id)}
|
||||||
|
<div class="flex items-center gap-2 px-2 py-1.5 rounded-lg group
|
||||||
|
{step.done ? 'opacity-50' : 'hover:bg-hover'}"
|
||||||
|
style="transition-duration: var(--duration-ui)">
|
||||||
|
|
||||||
|
<!-- Checkbox -->
|
||||||
|
<button
|
||||||
|
class="w-3.5 h-3.5 rounded border flex-shrink-0 flex items-center justify-center
|
||||||
|
{step.done
|
||||||
|
? 'border-accent bg-accent/20'
|
||||||
|
: 'border-border-subtle hover:border-accent'}"
|
||||||
|
onclick={() => toggleStep(step)}
|
||||||
|
aria-label={step.done ? 'Completed' : 'Complete micro-step'}
|
||||||
|
disabled={step.done}
|
||||||
|
>
|
||||||
|
{#if step.done}
|
||||||
|
<Check size={10} class="text-accent" aria-hidden="true" />
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Step text -->
|
||||||
|
<span class="text-[12px] flex-1 min-w-0
|
||||||
|
{step.done ? 'text-text-tertiary line-through' : 'text-text'}">
|
||||||
|
{step.text}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<!-- Just Start timer button -->
|
||||||
|
{#if !step.done}
|
||||||
|
<button
|
||||||
|
class="text-text-tertiary hover:text-accent opacity-0 group-hover:opacity-100"
|
||||||
|
style="transition: opacity var(--duration-ui)"
|
||||||
|
onclick={() => startTimer(step)}
|
||||||
|
aria-label="Just start — 2 minutes"
|
||||||
|
title="Just start — 2 minutes"
|
||||||
|
>
|
||||||
|
<Timer size={12} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Progress -->
|
||||||
|
<p class="text-[10px] text-text-tertiary mt-1 px-2">
|
||||||
|
{doneCount}/{steps.length} steps done
|
||||||
|
{#if allDone}
|
||||||
|
<span class="text-success ml-1">— all done</span>
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -1,23 +1,48 @@
|
|||||||
<script>
|
<script>
|
||||||
import { getTasks, createTask, completeTask, deleteTask } from '$lib/stores/tasks.svelte.js';
|
import { onMount } from 'svelte';
|
||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
import { getTasks, createTask, completeTask, deleteTask, loadTasks } from '$lib/stores/tasks.svelte.js';
|
||||||
const tasks = getTasks();
|
const tasks = getTasks();
|
||||||
import { ChevronDown, Timer } from 'lucide-svelte';
|
import { ChevronDown, ChevronUp, Timer, ListTree, Loader2 } from 'lucide-svelte';
|
||||||
|
import MicroSteps from './MicroSteps.svelte';
|
||||||
function startTimer(task, duration = 120) {
|
|
||||||
window.dispatchEvent(new CustomEvent('kon:start-timer', {
|
|
||||||
detail: { id: task.id, text: task.text, duration }
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
let { wipLimit = 3 } = $props();
|
let { wipLimit = 3 } = $props();
|
||||||
|
|
||||||
let newTaskText = $state('');
|
let newTaskText = $state('');
|
||||||
let showOverflow = $state(false);
|
let showOverflow = $state(false);
|
||||||
|
let expandedTaskIds = $state(new Set());
|
||||||
|
let decomposing = $state(new Set());
|
||||||
|
let hasChildren = $state(new Map());
|
||||||
|
|
||||||
let activeTasks = $derived(tasks.filter(t => !t.done));
|
let activeTasks = $derived(tasks.filter(t => !t.done));
|
||||||
let visibleTasks = $derived(activeTasks.slice(0, wipLimit));
|
let visibleTasks = $derived(activeTasks.slice(0, wipLimit));
|
||||||
let overflowTasks = $derived(activeTasks.slice(wipLimit));
|
let overflowTasks = $derived(activeTasks.slice(wipLimit));
|
||||||
let hasOverflow = $derived(overflowTasks.length > 0);
|
let hasOverflowTasks = $derived(overflowTasks.length > 0);
|
||||||
|
|
||||||
|
// Check which tasks have children on mount and when tasks change
|
||||||
|
onMount(() => {
|
||||||
|
checkChildrenStatus();
|
||||||
|
window.addEventListener('kon:tasks-changed', onTasksChanged);
|
||||||
|
return () => window.removeEventListener('kon:tasks-changed', onTasksChanged);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function onTasksChanged() {
|
||||||
|
await loadTasks();
|
||||||
|
await checkChildrenStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkChildrenStatus() {
|
||||||
|
for (const task of activeTasks) {
|
||||||
|
try {
|
||||||
|
const subtasks = await invoke('list_subtasks', { parentTaskId: task.id });
|
||||||
|
hasChildren.set(task.id, subtasks.length > 0);
|
||||||
|
} catch {
|
||||||
|
// Tauri not available — ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Trigger reactivity
|
||||||
|
hasChildren = new Map(hasChildren);
|
||||||
|
}
|
||||||
|
|
||||||
async function handleAdd() {
|
async function handleAdd() {
|
||||||
const text = newTaskText.trim();
|
const text = newTaskText.trim();
|
||||||
@@ -29,6 +54,44 @@
|
|||||||
function handleKeydown(e) {
|
function handleKeydown(e) {
|
||||||
if (e.key === 'Enter') handleAdd();
|
if (e.key === 'Enter') handleAdd();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleExpand(taskId) {
|
||||||
|
const next = new Set(expandedTaskIds);
|
||||||
|
if (next.has(taskId)) {
|
||||||
|
next.delete(taskId);
|
||||||
|
} else {
|
||||||
|
next.add(taskId);
|
||||||
|
}
|
||||||
|
expandedTaskIds = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function decomposeTask(task) {
|
||||||
|
const next = new Set(decomposing);
|
||||||
|
next.add(task.id);
|
||||||
|
decomposing = next;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await invoke('decompose_and_store', { parentTaskId: task.id });
|
||||||
|
// Mark as having children and expand
|
||||||
|
hasChildren.set(task.id, true);
|
||||||
|
hasChildren = new Map(hasChildren);
|
||||||
|
const expanded = new Set(expandedTaskIds);
|
||||||
|
expanded.add(task.id);
|
||||||
|
expandedTaskIds = expanded;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Decompose failed:', e);
|
||||||
|
} finally {
|
||||||
|
const done = new Set(decomposing);
|
||||||
|
done.delete(task.id);
|
||||||
|
decomposing = done;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startTimer(task, duration = 120) {
|
||||||
|
window.dispatchEvent(new CustomEvent('kon:start-timer', {
|
||||||
|
detail: { id: task.id, text: task.text, duration }
|
||||||
|
}));
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex flex-col gap-3">
|
<div class="flex flex-col gap-3">
|
||||||
@@ -51,39 +114,78 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<div class="flex flex-col gap-1">
|
<div class="flex flex-col gap-1">
|
||||||
{#each visibleTasks as task (task.id)}
|
{#each visibleTasks as task (task.id)}
|
||||||
<div class="flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-hover group"
|
<div>
|
||||||
style="transition-duration: var(--duration-ui)">
|
<div class="flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-hover group"
|
||||||
<button
|
style="transition-duration: var(--duration-ui)">
|
||||||
class="w-4 h-4 rounded border border-border-subtle flex items-center justify-center flex-shrink-0
|
<button
|
||||||
hover:border-accent"
|
class="w-4 h-4 rounded border border-border-subtle flex items-center justify-center flex-shrink-0
|
||||||
onclick={() => completeTask(task.id)}
|
hover:border-accent"
|
||||||
aria-label="Complete task"
|
onclick={() => completeTask(task.id)}
|
||||||
>
|
aria-label="Complete task"
|
||||||
</button>
|
>
|
||||||
<span class="text-[13px] text-text flex-1 min-w-0 truncate">{task.text}</span>
|
</button>
|
||||||
<button
|
<span class="text-[13px] text-text flex-1 min-w-0 truncate">{task.text}</span>
|
||||||
class="text-text-tertiary hover:text-accent opacity-0 group-hover:opacity-100"
|
|
||||||
style="transition: opacity var(--duration-ui)"
|
<!-- Expand/Decompose button -->
|
||||||
onclick={() => startTimer(task)}
|
{#if hasChildren.get(task.id)}
|
||||||
aria-label="Start 2-minute timer"
|
<button
|
||||||
title="Just start — 2 minutes"
|
class="text-text-tertiary hover:text-text-secondary"
|
||||||
>
|
style="transition: color var(--duration-ui)"
|
||||||
<Timer size={14} aria-hidden="true" />
|
onclick={() => toggleExpand(task.id)}
|
||||||
</button>
|
aria-label={expandedTaskIds.has(task.id) ? 'Collapse micro-steps' : 'Expand micro-steps'}
|
||||||
<button
|
>
|
||||||
class="text-text-tertiary hover:text-danger opacity-0 group-hover:opacity-100 text-[11px]"
|
{#if expandedTaskIds.has(task.id)}
|
||||||
onclick={() => deleteTask(task.id)}
|
<ChevronUp size={14} aria-hidden="true" />
|
||||||
aria-label="Delete task"
|
{:else}
|
||||||
>
|
<ChevronDown size={14} aria-hidden="true" />
|
||||||
×
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
|
{:else if decomposing.has(task.id)}
|
||||||
|
<Loader2 size={14} class="text-accent animate-spin" aria-hidden="true" />
|
||||||
|
{:else}
|
||||||
|
<button
|
||||||
|
class="flex items-center gap-1 text-text-tertiary hover:text-accent opacity-0 group-hover:opacity-100 text-[10px]"
|
||||||
|
style="transition: opacity var(--duration-ui)"
|
||||||
|
onclick={() => decomposeTask(task)}
|
||||||
|
aria-label="Break down into micro-steps"
|
||||||
|
title="Break down"
|
||||||
|
>
|
||||||
|
<ListTree size={12} aria-hidden="true" />
|
||||||
|
<span>Break down</span>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Timer button -->
|
||||||
|
<button
|
||||||
|
class="text-text-tertiary hover:text-accent opacity-0 group-hover:opacity-100"
|
||||||
|
style="transition: opacity var(--duration-ui)"
|
||||||
|
onclick={() => startTimer(task)}
|
||||||
|
aria-label="Start 2-minute timer"
|
||||||
|
title="Just start — 2 minutes"
|
||||||
|
>
|
||||||
|
<Timer size={14} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="text-text-tertiary hover:text-danger opacity-0 group-hover:opacity-100 text-[11px]"
|
||||||
|
onclick={() => deleteTask(task.id)}
|
||||||
|
aria-label="Delete task"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Expanded micro-steps -->
|
||||||
|
{#if expandedTaskIds.has(task.id)}
|
||||||
|
<MicroSteps parentTaskId={task.id} />
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Overflow -->
|
<!-- Overflow -->
|
||||||
{#if hasOverflow}
|
{#if hasOverflowTasks}
|
||||||
<button
|
<button
|
||||||
class="flex items-center gap-1.5 text-[11px] text-text-tertiary hover:text-text-secondary px-3"
|
class="flex items-center gap-1.5 text-[11px] text-text-tertiary hover:text-text-secondary px-3"
|
||||||
onclick={() => showOverflow = !showOverflow}
|
onclick={() => showOverflow = !showOverflow}
|
||||||
|
|||||||
Reference in New Issue
Block a user