feat(energy): Phase 3 — match-my-energy task sort + tri-state tag column
Some checks failed
check / cargo check (macos-latest) (push) Has been cancelled
check / cargo check (ubuntu-22.04) (push) Has been cancelled
check / cargo check (windows-latest) (push) Has been cancelled
check / svelte build + lint (push) Has been cancelled

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:
2026-04-24 14:53:19 +01:00
parent a327f4d882
commit 1d4f1070a2
10 changed files with 445 additions and 27 deletions

View 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>