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

@@ -19,9 +19,16 @@
// //
// Callers pass the task's current energy and a setter. This component // Callers pass the task's current energy and a setter. This component
// owns no state — the task store is the source of truth. // 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 type { EnergyLevel } from "$lib/types/app";
import { Zap } from "lucide-svelte"; import { Zap } from "lucide-svelte";
import { settings, profiles, page } from "$lib/stores/page.svelte.js";
import { resolveEnergyLabels } from "$lib/utils/energyLabels";
let { let {
energy = null as EnergyLevel | null, energy = null as EnergyLevel | null,
@@ -45,11 +52,21 @@
return CYCLE[(idx + 1) % CYCLE.length]; 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 { function labelFor(level: EnergyLevel | null): string {
switch (level) { switch (level) {
case "high": return "High"; case "high": return labels.high.label;
case "medium": return "Medium"; case "medium": return labels.medium.label;
case "brain_dead": return "Zero"; case "brain_dead": return labels.brain_dead.label;
default: return "No energy set"; default: return "No energy set";
} }
} }

View File

@@ -3,7 +3,7 @@
import { onMount, onDestroy } from "svelte"; import { onMount, onDestroy } from "svelte";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event"; 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 Card from "$lib/components/Card.svelte";
import Toggle from "$lib/components/Toggle.svelte"; import Toggle from "$lib/components/Toggle.svelte";
import SegmentedButton from "$lib/components/SegmentedButton.svelte"; import SegmentedButton from "$lib/components/SegmentedButton.svelte";
@@ -1625,6 +1625,94 @@
</p> </p>
</div> </div>
{/if} {/if}
<!-- B3.6 — Energy labels (global). Three rows for the three
energy levels: edit the display label and a one-line
description that surfaces in the first-tap prompt. The
internal enum values (high/medium/brain_dead) never
move; only the user-facing strings.
Per-profile overrides live in the Profiles section
below — that's the right home because the override is
a property of the profile, not of the Tasks page. -->
<div class="mt-6 pt-5 border-t border-border-subtle">
<div class="flex items-center justify-between mb-2">
<p class="text-[10px] font-medium text-text-tertiary uppercase tracking-wider">Energy labels</p>
<button
type="button"
class="text-[11px] text-text-tertiary hover:text-text"
onclick={() => {
settings.energyLabels = defaultEnergyLabels();
saveSettings();
}}
title="Restore the default High / Medium / Zero labels and descriptions"
>Reset to defaults</button>
</div>
<p class="text-[11px] text-text-tertiary mb-3">
Rename the three energy levels and the short description shown when you set one. Profiles can override these — see the Profiles section below.
</p>
{#each ["high", "medium", "brain_dead"] as level}
<div class="mb-3">
<p class="text-[10px] font-medium text-text-tertiary uppercase tracking-wider mb-1">
{level === "high" ? "High" : level === "medium" ? "Medium" : "Zero / Low"} <span class="text-text-tertiary normal-case">({level})</span>
</p>
<div class="flex items-center gap-2">
<input
type="text"
class="w-[140px] bg-bg-input border border-border-subtle rounded-lg px-2 py-1 text-[13px] text-text placeholder:text-text-tertiary focus:outline-none focus:border-accent"
placeholder="Label"
bind:value={settings.energyLabels[level].label}
oninput={() => saveSettings()}
data-no-transition
/>
<input
type="text"
class="flex-1 bg-bg-input border border-border-subtle rounded-lg px-2 py-1 text-[13px] text-text placeholder:text-text-tertiary focus:outline-none focus:border-accent"
placeholder="Description (e.g. Sharp focus, big lifts)"
bind:value={settings.energyLabels[level].description}
oninput={() => saveSettings()}
data-no-transition
/>
</div>
</div>
{/each}
</div>
<!-- B3.6 — first-tap prompt controls. The energy-prompt
fires at most once per local calendar day, plus once
the very first time the user taps Zero. This block
holds the kill-switch and the "re-introduce" escape
so the user is never stuck with a state they can't
reset. -->
<div class="mt-6 pt-5 border-t border-border-subtle">
<Toggle
bind:checked={settings.energyPromptsEnabled}
label="Show energy prompts"
description="On the first energy tap of the day (or the first ever Zero), Kon asks what the level means for you today, with example chips you can save into the description. Never modal."
/>
<div class="mt-3 flex items-center gap-3">
<button
type="button"
class="px-3 py-1.5 rounded-lg bg-bg-input border border-border-subtle text-[12px] text-text-secondary hover:text-text hover:border-accent"
style="transition-duration: var(--duration-ui)"
onclick={() => {
settings.energyPromptLastShownDate = null;
settings.energyPromptZeroSeen = false;
saveSettings();
toasts.info(
"Energy prompts re-introduced",
"The next time you set an energy level (and the next time you pick Zero), Kon will ask what it means for you today.",
);
}}
>Re-introduce energy prompts</button>
<span class="text-[11px] text-text-tertiary">
{settings.energyPromptLastShownDate
? `Last shown ${settings.energyPromptLastShownDate}`
: "Not shown yet"}
</span>
</div>
</div>
</div> </div>
{/if} {/if}
</div> </div>
@@ -2022,6 +2110,98 @@
oninput={() => saveProfiles()} oninput={() => saveProfiles()}
data-no-transition data-no-transition
></textarea> ></textarea>
<!-- B3.6 — per-profile energy label override.
OFF = profile.energyLabelsOverride is null
and the profile uses settings.energyLabels.
ON = override is populated with a copy of
the current global (or the existing
override) and three rows render below.
Inlined toggle (rather than the <Toggle>
component) so we can run the deep-clone
on the same click that flips the switch
— the shared Toggle has no onchange hook. -->
<div class="mt-3 pt-3 border-t border-border-subtle">
<div class="flex items-start justify-between gap-2">
<div class="flex items-start gap-3 py-2.5 flex-1">
<button
type="button"
class="relative mt-0.5 w-[38px] min-w-[38px] h-[22px] rounded-full flex-shrink-0
{profile.energyLabelsOverride ? 'bg-accent shadow-[0_0_8px_rgba(232,168,124,0.25)]' : 'bg-bg-elevated'}
active:scale-95"
style="transition-duration: var(--duration-ui)"
role="switch"
aria-checked={!!profile.energyLabelsOverride}
aria-label="Customise energy labels for this profile"
onclick={() => {
if (profile.energyLabelsOverride) {
profile.energyLabelsOverride = null;
} else {
const base = profile.energyLabelsOverride ?? settings.energyLabels;
profile.energyLabelsOverride = {
high: { ...base.high },
medium: { ...base.medium },
brain_dead: { ...base.brain_dead },
};
}
saveProfiles();
}}
>
<span
class="absolute top-[3px] left-[3px] w-4 h-4 rounded-full bg-white shadow-sm ease-[cubic-bezier(0.34,1.56,0.64,1)]
{profile.energyLabelsOverride ? 'translate-x-[16px]' : 'translate-x-0'}"
style="transition: transform var(--duration-ui) cubic-bezier(0.34, 1.56, 0.64, 1)"
></span>
</button>
<div class="flex-1 min-w-0">
<p class="text-[13px] text-text leading-tight">Customise energy labels for this profile</p>
<p class="text-[11px] text-text-tertiary mt-0.5 leading-snug">When on, this profile uses its own High / Medium / Zero names instead of the global ones.</p>
</div>
</div>
{#if profile.energyLabelsOverride}
<button
type="button"
class="text-[11px] text-text-tertiary hover:text-text whitespace-nowrap mt-3"
onclick={() => {
profile.energyLabelsOverride = null;
saveProfiles();
}}
title="Drop the override and fall back to the global labels"
>Use global</button>
{/if}
</div>
{#if profile.energyLabelsOverride}
<div class="mt-3 pl-1 animate-fade-in">
{#each ["high", "medium", "brain_dead"] as level}
<div class="mb-2">
<p class="text-[10px] font-medium text-text-tertiary uppercase tracking-wider mb-1">
{level === "high" ? "High" : level === "medium" ? "Medium" : "Zero / Low"} <span class="text-text-tertiary normal-case">({level})</span>
</p>
<div class="flex items-center gap-2">
<input
type="text"
class="w-[140px] bg-bg-input border border-border-subtle rounded-lg px-2 py-1 text-[13px] text-text placeholder:text-text-tertiary focus:outline-none focus:border-accent"
placeholder="Label"
bind:value={profile.energyLabelsOverride[level].label}
oninput={() => saveProfiles()}
data-no-transition
/>
<input
type="text"
class="flex-1 bg-bg-input border border-border-subtle rounded-lg px-2 py-1 text-[13px] text-text placeholder:text-text-tertiary focus:outline-none focus:border-accent"
placeholder="Description"
bind:value={profile.energyLabelsOverride[level].description}
oninput={() => saveProfiles()}
data-no-transition
/>
</div>
</div>
{/each}
</div>
{/if}
</div>
</div> </div>
{/if} {/if}
</div> </div>

View File

@@ -7,8 +7,9 @@
tasks, addTask, completeTask, uncompleteTask, deleteTask, updateTask, tasks, addTask, completeTask, uncompleteTask, deleteTask, updateTask,
setTaskEnergy, setTaskEnergy,
taskLists, addTaskList, renameTaskList, deleteTaskList, taskLists, addTaskList, renameTaskList, deleteTaskList,
settings, saveSettings, settings, saveSettings, profiles, saveProfiles,
} from "$lib/stores/page.svelte.js"; } from "$lib/stores/page.svelte.js";
import { page } from "$lib/stores/page.svelte.js";
import type { TaskDto } from "$lib/types/app"; import type { TaskDto } from "$lib/types/app";
import { recentCompletions, todayCount } from "$lib/stores/completionStats.svelte"; import { recentCompletions, todayCount } from "$lib/stores/completionStats.svelte";
import WipTaskList from '$lib/components/WipTaskList.svelte'; import WipTaskList from '$lib/components/WipTaskList.svelte';
@@ -17,7 +18,8 @@
import EnergyChip from '$lib/components/EnergyChip.svelte'; import EnergyChip from '$lib/components/EnergyChip.svelte';
import { SquareCheck, Search, ExternalLink, ChevronLeft, ArrowUpDown, Plus, X, ChevronRight, Zap, Archive, ArchiveRestore } from 'lucide-svelte'; import { SquareCheck, Search, ExternalLink, ChevronLeft, ArrowUpDown, Plus, X, ChevronRight, Zap, Archive, ArchiveRestore } from 'lucide-svelte';
import Card from "$lib/components/Card.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"; 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 // 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 nowLaneIds = $derived(new Set(nowLaneTasks.map((t) => t.id)));
let bucketListTasks = $derived(filteredTasks.filter((t) => !nowLaneIds.has(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) { function cycleCurrentEnergy(next: EnergyLevel | null) {
settings.currentEnergy = next; settings.currentEnergy = next;
saveSettings(); saveSettings();
maybeShowEnergyPrompt(next);
} }
function toggleMatchMyEnergy() { function toggleMatchMyEnergy() {
@@ -197,9 +273,9 @@
function energyLabel(level: EnergyLevel | null): string { function energyLabel(level: EnergyLevel | null): string {
switch (level) { switch (level) {
case "high": return "High"; case "high": return energyLabels.high.label;
case "medium": return "Medium"; case "medium": return energyLabels.medium.label;
case "brain_dead": return "Zero"; case "brain_dead": return energyLabels.brain_dead.label;
default: return "Not set"; default: return "Not set";
} }
} }
@@ -210,13 +286,19 @@
// here so the keyboard handler and the render loop share one source // here so the keyboard handler and the render loop share one source
// of truth — desynchronising them is the usual way these patterns // of truth — desynchronising them is the usual way these patterns
// rot over time. // 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 }; type EnergyOption = { value: EnergyLevel | null; label: string };
const energyOptions: EnergyOption[] = [ let energyOptions = $derived<EnergyOption[]>([
{ value: null, label: "—" }, { value: null, label: "—" },
{ value: "high", label: "High" }, { value: "high", label: energyLabels.high.label },
{ value: "medium", label: "Med" }, { value: "medium", label: energyLabels.medium.label.length > 4
{ value: "brain_dead", label: "Zero" }, ? energyLabels.medium.label.slice(0, 3)
]; : energyLabels.medium.label },
{ value: "brain_dead", label: energyLabels.brain_dead.label },
]);
let energyRadioGroupEl = $state<HTMLDivElement | null>(null); let energyRadioGroupEl = $state<HTMLDivElement | null>(null);
function selectEnergyByIndex(idx: number) { function selectEnergyByIndex(idx: number) {
@@ -467,6 +549,42 @@
{/if} {/if}
</div> </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 --> <!-- Search -->
<div class="px-7 pb-2"> <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" <div class="flex items-center gap-2 bg-bg-input border border-border rounded-lg px-3 py-1.5 focus-within:border-accent"

View File

@@ -41,6 +41,20 @@ const PROFILES_KEY = "kon_profiles";
const TASK_LISTS_KEY = "kon_task_lists"; const TASK_LISTS_KEY = "kon_task_lists";
const TEMPLATES_KEY = "kon_templates"; const TEMPLATES_KEY = "kon_templates";
/**
* B3.6 — exported energy-label defaults so the Settings "Reset to
* defaults" button can pull them without reaching into the private
* `defaults` block. Returns a fresh object on each call so callers
* cannot mutate the source-of-truth.
*/
export function defaultEnergyLabels(): SettingsState["energyLabels"] {
return {
high: { label: "High", description: "Sharp focus, big lifts" },
medium: { label: "Medium", description: "Steady work, moderate lifts" },
brain_dead: { label: "Zero", description: "Rest only, low-touch admin" },
};
}
const defaults: SettingsState = { const defaults: SettingsState = {
engine: "whisper", engine: "whisper",
modelSize: "Base", modelSize: "Base",
@@ -97,11 +111,13 @@ const defaults: SettingsState = {
sparklineRangeDays: 7, sparklineRangeDays: 7,
// Defaults mirror current EnergyChip copy so existing behaviour is // Defaults mirror current EnergyChip copy so existing behaviour is
// unchanged until the user edits via the (B3.6) UI. // unchanged until the user edits via the (B3.6) UI.
energyLabels: { energyLabels: defaultEnergyLabels(),
high: { label: "High", description: "Sharp focus, big lifts" }, // B3.6 — first-tap energy-prompt gates. Default ON for prompts;
medium: { label: "Medium", description: "Steady work, moderate lifts" }, // never-shown date stamp; first-Zero one-shot unfired. The merge in
brain_dead: { label: "Zero", description: "Rest only, low-touch admin" }, // loadSettings will fill these on existing installs as well.
}, energyPromptsEnabled: true,
energyPromptLastShownDate: null,
energyPromptZeroSeen: false,
}; };
function canUseStorage(): boolean { function canUseStorage(): boolean {

View File

@@ -210,6 +210,19 @@ export interface SettingsState {
* EnergyChip copy so behaviour doesn't change until the user edits. * EnergyChip copy so behaviour doesn't change until the user edits.
*/ */
energyLabels: EnergyLabels; energyLabels: EnergyLabels;
/**
* B3.6 — energy-prompt gates. The inline "What does Zero mean for you
* today?" prompt fires at most once per local calendar day and once
* the very first time the user taps the Zero level. The Settings
* "Re-introduce energy prompts" escape resets both fields.
*
* - `energyPromptsEnabled`: hard kill-switch. False = never show.
* - `energyPromptLastShownDate`: "YYYY-MM-DD" local; null = not yet.
* - `energyPromptZeroSeen`: one-shot for the first-ever Zero tap.
*/
energyPromptsEnabled: boolean;
energyPromptLastShownDate: string | null;
energyPromptZeroSeen: boolean;
} }
export interface Profile { export interface Profile {

View File

@@ -44,3 +44,15 @@ export function formatTimeVTT(seconds: number): string {
const ms = Math.floor((seconds % 1) * 1000); const ms = Math.floor((seconds % 1) * 1000);
return `${pad(h)}:${pad(m)}:${pad(s)}.${ms.toString().padStart(3, "0")}`; return `${pad(h)}:${pad(m)}:${pad(s)}.${ms.toString().padStart(3, "0")}`;
} }
/**
* Local-calendar-day key, "YYYY-MM-DD", computed in the user's local
* timezone. Used by the B3.6 energy-prompt gate (and any other once-per-
* day surface) so reopening Kon on the same day doesn't re-prompt.
*
* Local — not UTC — because the user's idea of "today" is wall-clock,
* not Greenwich. A 23:59 → 00:01 transition should reset the gate.
*/
export function todayLocalDate(d: Date = new Date()): string {
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
}