feat(energy): Phase 3 — match-my-energy task sort + tri-state tag column
Closes Phase 3 of the 2026-04-23 feature-complete roadmap. Incorporates the Codex plan-review fixes from this session: profile-free index, tri- state update command, and de-prioritise-not-hide semantics. Storage (kon-storage): - Migration v11 adds `energy TEXT` to `tasks` with a CHECK constraint on `high | medium | brain_dead | NULL`. Index `(energy, created_at DESC)` — deliberately not per-profile because the tasks table carries no profile_id column yet (tracked as a separate gap in HANDOVER). - `TaskRow.energy: Option<String>` plus `task_row_from` read. - `insert_task` signature grows by one optional arg (`energy`). Allowed `too_many_arguments` with a rationale comment — the positional shape matches the column order and flipping to a params struct would have rippled through every caller for cosmetic benefit only. - New `set_task_energy(pool, id, Option<&str>) -> TaskRow`. Lives as its own function because `update_task` uses COALESCE to let `None` mean "preserve" — which would make clearing the tag impossible. - Two new tests: round-trip including explicit NULL clear, and CHECK constraint rejection of unknown values. - Tests updated for the v10 → v11 version bump. Tauri (src-tauri): - `TaskDto.energy`. `CreateTaskRequest.energy` (optional). Inline validation against the allowed set before hitting the DB, so frontend bugs surface as friendly errors instead of CHECK-constraint failures. - New `set_task_energy_cmd` command mirroring the storage tri-state API. Frontend (svelte): - `EnergyLevel` type added to `types/app.ts`. `TaskDto`, `TaskEntry`, and `TaskDraft` grow an `energy` field. - `SettingsState.currentEnergy` (persisted) + `matchMyEnergy` (persisted toggle). Defaults: null + false — no surface change until user opts in. - `setTaskEnergy(id, EnergyLevel | null)` action on the task store. Calls the dedicated Tauri command, updates local state, broadcasts to sibling windows. - `EnergyChip.svelte` — new component. Cycles unset → High → Medium → Brain-Dead → unset on click. Colour tokens: accent / warning / text-tertiary (deliberately not danger-red for Brain-Dead — the brief is explicit that this state must not feel pathologised). - Chip rendered on every task row in TasksPage and every row in WipTaskList. Hidden-until-hover when energy is unset so untagged rows stay calm; always visible once tagged because the colour is the signal. - Tasks page header gains a "I feel" segmented control and a "Match my energy" toggle. When both are active, matching tasks sort to the top — unset tasks are treated as Medium-equivalent. Nothing is ever hidden; this is a de-prioritisation, not a filter. Deferred / out of scope: - LLM-driven surfacing (brief says "The AI surfaces...") — deterministic client-side sort is v1; LLM layer is a later phase. - tasks.profile_id + per-profile energy sort — separate migration. All green: cargo build + 251 tests + clippy -D warnings (0 warnings) + fmt + svelte-check (0/0) + npm run build.
This commit is contained in:
106
src/lib/components/EnergyChip.svelte
Normal file
106
src/lib/components/EnergyChip.svelte
Normal file
@@ -0,0 +1,106 @@
|
||||
<script lang="ts">
|
||||
// Phase 3 — Energy tag chip. Cycles a task's energy level through
|
||||
// the spec's four states: unset → High → Medium → Brain-Dead → unset.
|
||||
//
|
||||
// Visual discipline: when energy is unset, the chip renders at
|
||||
// `group-hover` opacity only so untagged rows stay calm. Once set,
|
||||
// the chip is always visible because the colour IS the signal for
|
||||
// the match-my-energy sort.
|
||||
//
|
||||
// Colour choices borrow the existing design tokens:
|
||||
// High → accent (warm, on-brand, attention-ready)
|
||||
// Medium → warning (amber, unforced)
|
||||
// Brain-Dead → text-tertiary (low-energy grey, not danger red —
|
||||
// the brief is explicit that this state must not feel
|
||||
// pathologised)
|
||||
//
|
||||
// Callers pass the task's current energy and a setter. This component
|
||||
// owns no state — the task store is the source of truth.
|
||||
|
||||
import type { EnergyLevel } from "$lib/types/app";
|
||||
import { Zap } from "lucide-svelte";
|
||||
|
||||
let {
|
||||
energy = null as EnergyLevel | null,
|
||||
onSelect,
|
||||
size = "sm",
|
||||
reduceMotion = false,
|
||||
}: {
|
||||
energy: EnergyLevel | null;
|
||||
onSelect: (next: EnergyLevel | null) => void;
|
||||
size?: "sm" | "md";
|
||||
reduceMotion?: boolean;
|
||||
} = $props();
|
||||
|
||||
// Cycle order lives here so the chip is the single authority on what
|
||||
// "next" means. Tap once to tag, tap again to move up, tap past
|
||||
// Brain-Dead to clear. Keyboard-equivalent via the <button> element.
|
||||
const CYCLE: (EnergyLevel | null)[] = [null, "high", "medium", "brain_dead"];
|
||||
|
||||
function next(): EnergyLevel | null {
|
||||
const idx = CYCLE.indexOf(energy);
|
||||
return CYCLE[(idx + 1) % CYCLE.length];
|
||||
}
|
||||
|
||||
function labelFor(level: EnergyLevel | null): string {
|
||||
switch (level) {
|
||||
case "high": return "High";
|
||||
case "medium": return "Medium";
|
||||
case "brain_dead": return "Brain-Dead";
|
||||
default: return "No energy set";
|
||||
}
|
||||
}
|
||||
|
||||
let tooltip = $derived(
|
||||
energy === null
|
||||
? "Tag energy (click to set)"
|
||||
: `Energy: ${labelFor(energy)} — click to change`
|
||||
);
|
||||
|
||||
// Icon dimensions. `md` is the one used on the Tasks-page main rows;
|
||||
// `sm` is for the compact WIP list rows and micro-step children.
|
||||
let iconSize = $derived(size === "md" ? 13 : 10);
|
||||
let chipSize = $derived(size === "md" ? "h-5" : "h-4");
|
||||
</script>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="energy-chip inline-flex items-center justify-center rounded-md border px-1 {chipSize} text-[10px] font-medium
|
||||
{energy === null
|
||||
? 'opacity-0 group-hover:opacity-100 text-text-tertiary border-border-subtle hover:border-accent hover:text-text-secondary'
|
||||
: ''}
|
||||
{energy === 'high'
|
||||
? 'text-accent border-accent bg-accent/10'
|
||||
: ''}
|
||||
{energy === 'medium'
|
||||
? 'text-warning border-warning bg-warning/10'
|
||||
: ''}
|
||||
{energy === 'brain_dead'
|
||||
? 'text-text-tertiary border-border bg-hover'
|
||||
: ''}"
|
||||
onclick={() => onSelect(next())}
|
||||
aria-label={tooltip}
|
||||
title={tooltip}
|
||||
data-energy={energy ?? 'unset'}
|
||||
style={reduceMotion
|
||||
? ''
|
||||
: 'transition: opacity var(--duration-ui), color var(--duration-ui), border-color var(--duration-ui), background-color var(--duration-ui)'}
|
||||
>
|
||||
<Zap size={iconSize} aria-hidden="true" />
|
||||
{#if energy !== null && size === "md"}
|
||||
<span class="ml-1">{labelFor(energy)}</span>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<style>
|
||||
.energy-chip {
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
font-family: var(--font-family-body);
|
||||
}
|
||||
|
||||
.energy-chip:focus-visible {
|
||||
outline: 2px solid var(--color-accent);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user