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:
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user