feat(ux): B3.6 — editable energy labels (global + per-profile) + first-tap prompt

Settings → Energy: edit global energy labels (label + description per
level) with reset-to-defaults. Per-profile override toggle in the
Profiles section ("Customise for this profile" / "Use global").
EnergyChip and Tasks-page energy controls now resolve labels via
resolveEnergyLabels(profile, settings) — global by default, profile
override when present.

First-time prompt: on first energy-chip tap of the day (per profile)
or first-ever tap to Zero, an inline non-modal prompt asks "What does
Zero mean for you today?" with example chips. Per-day gate via
energyPromptLastShownDate; one-shot first-Zero gate via
energyPromptZeroSeen; Settings escape resets both.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-26 19:30:06 +01:00
parent 7daf5677c9
commit fc3a56c0dc
6 changed files with 375 additions and 19 deletions

View File

@@ -7,8 +7,9 @@
tasks, addTask, completeTask, uncompleteTask, deleteTask, updateTask,
setTaskEnergy,
taskLists, addTaskList, renameTaskList, deleteTaskList,
settings, saveSettings,
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';
@@ -17,7 +18,8 @@
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 } from "$lib/utils/time.js";
import { formatTimestamp, todayLocalDate } from "$lib/utils/time.js";
import { resolveEnergyLabels } from "$lib/utils/energyLabels";
import { BUCKET_COLORS, EFFORT_LABELS, EFFORT_ORDER } from "$lib/utils/constants.js";
// PR 1.2: default landing bucket is Today rather than All. The cold-open
@@ -185,9 +187,83 @@
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() {
@@ -197,9 +273,9 @@
function energyLabel(level: EnergyLevel | null): string {
switch (level) {
case "high": return "High";
case "medium": return "Medium";
case "brain_dead": return "Zero";
case "high": return energyLabels.high.label;
case "medium": return energyLabels.medium.label;
case "brain_dead": return energyLabels.brain_dead.label;
default: return "Not set";
}
}
@@ -210,13 +286,19 @@
// 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 };
const energyOptions: EnergyOption[] = [
let energyOptions = $derived<EnergyOption[]>([
{ value: null, label: "—" },
{ value: "high", label: "High" },
{ value: "medium", label: "Med" },
{ value: "brain_dead", label: "Zero" },
];
{ 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) {
@@ -467,6 +549,42 @@
{/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"