From fc3a56c0dcc49f389326c7da9f6f17a5c42fa772 Mon Sep 17 00:00:00 2001 From: Jake Date: Sun, 26 Apr 2026 19:30:06 +0100 Subject: [PATCH] =?UTF-8?q?feat(ux):=20B3.6=20=E2=80=94=20editable=20energ?= =?UTF-8?q?y=20labels=20(global=20+=20per-profile)=20+=20first-tap=20promp?= =?UTF-8?q?t?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/lib/components/EnergyChip.svelte | 23 +++- src/lib/pages/SettingsPage.svelte | 182 ++++++++++++++++++++++++++- src/lib/pages/TasksPage.svelte | 138 ++++++++++++++++++-- src/lib/stores/page.svelte.ts | 26 +++- src/lib/types/app.ts | 13 ++ src/lib/utils/time.ts | 12 ++ 6 files changed, 375 insertions(+), 19 deletions(-) diff --git a/src/lib/components/EnergyChip.svelte b/src/lib/components/EnergyChip.svelte index 8666823..85aaa43 100644 --- a/src/lib/components/EnergyChip.svelte +++ b/src/lib/components/EnergyChip.svelte @@ -19,9 +19,16 @@ // // Callers pass the task's current energy and a setter. This component // owns no state — the task store is the source of truth. + // + // B3.6: the displayed string per level now comes from + // resolveEnergyLabels(activeProfile, settings) rather than being + // hardcoded, so users can rename "Zero" to "Battery dead" without + // touching code. import type { EnergyLevel } from "$lib/types/app"; import { Zap } from "lucide-svelte"; + import { settings, profiles, page } from "$lib/stores/page.svelte.js"; + import { resolveEnergyLabels } from "$lib/utils/energyLabels"; let { energy = null as EnergyLevel | null, @@ -45,11 +52,21 @@ return CYCLE[(idx + 1) % CYCLE.length]; } + // Active profile lookup against the localStorage `profiles` array. + // page.activeProfile is "None" or a profile name; we resolve to the + // Profile entry (or null) so the resolver can read its override. + let activeProfile = $derived( + page.activeProfile === "None" + ? null + : profiles.find((p) => p.name === page.activeProfile) ?? null, + ); + let labels = $derived(resolveEnergyLabels(activeProfile, settings)); + function labelFor(level: EnergyLevel | null): string { switch (level) { - case "high": return "High"; - case "medium": return "Medium"; - case "brain_dead": return "Zero"; + case "high": return labels.high.label; + case "medium": return labels.medium.label; + case "brain_dead": return labels.brain_dead.label; default: return "No energy set"; } } diff --git a/src/lib/pages/SettingsPage.svelte b/src/lib/pages/SettingsPage.svelte index 43ed3d1..97aeb5f 100644 --- a/src/lib/pages/SettingsPage.svelte +++ b/src/lib/pages/SettingsPage.svelte @@ -3,7 +3,7 @@ import { onMount, onDestroy } from "svelte"; import { invoke } from "@tauri-apps/api/core"; import { listen } from "@tauri-apps/api/event"; - import { settings, saveSettings, profiles, saveProfiles, templates, createTemplate as createTemplateRow, updateTemplate as updateTemplateRow, deleteTemplate as deleteTemplateRow, page, addProfileTaskList, removeProfileTaskList } from "$lib/stores/page.svelte.js"; + import { settings, saveSettings, profiles, saveProfiles, templates, createTemplate as createTemplateRow, updateTemplate as updateTemplateRow, deleteTemplate as deleteTemplateRow, page, addProfileTaskList, removeProfileTaskList, defaultEnergyLabels } from "$lib/stores/page.svelte.js"; import Card from "$lib/components/Card.svelte"; import Toggle from "$lib/components/Toggle.svelte"; import SegmentedButton from "$lib/components/SegmentedButton.svelte"; @@ -1625,6 +1625,94 @@

{/if} + + +
+
+

Energy labels

+ +
+

+ Rename the three energy levels and the short description shown when you set one. Profiles can override these — see the Profiles section below. +

+ + {#each ["high", "medium", "brain_dead"] as level} +
+

+ {level === "high" ? "High" : level === "medium" ? "Medium" : "Zero / Low"} ({level}) +

+
+ saveSettings()} + data-no-transition + /> + saveSettings()} + data-no-transition + /> +
+
+ {/each} +
+ + +
+ +
+ + + {settings.energyPromptLastShownDate + ? `Last shown ${settings.energyPromptLastShownDate}` + : "Not shown yet"} + +
+
{/if} @@ -2022,6 +2110,98 @@ oninput={() => saveProfiles()} data-no-transition > + + +
+
+
+ +
+

Customise energy labels for this profile

+

When on, this profile uses its own High / Medium / Zero names instead of the global ones.

+
+
+ {#if profile.energyLabelsOverride} + + {/if} +
+ + {#if profile.energyLabelsOverride} +
+ {#each ["high", "medium", "brain_dead"] as level} +
+

+ {level === "high" ? "High" : level === "medium" ? "Medium" : "Zero / Low"} ({level}) +

+
+ saveProfiles()} + data-no-transition + /> + saveProfiles()} + data-no-transition + /> +
+
+ {/each} +
+ {/if} +
{/if} diff --git a/src/lib/pages/TasksPage.svelte b/src/lib/pages/TasksPage.svelte index 71894b1..2b041fb 100644 --- a/src/lib/pages/TasksPage.svelte +++ b/src/lib/pages/TasksPage.svelte @@ -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 | 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([ { 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(null); function selectEnergyByIndex(idx: number) { @@ -467,6 +549,42 @@ {/if} + + {#if energyPromptOpen} +
+
+
+ + What does {energyLabels.brain_dead.label} mean for you today? + +
+ {#each ZERO_EXAMPLE_CHIPS as chip} + + {/each} +
+
+ +
+
+
+ {/if} +