feat(ux): B3.5 + B3.14 — sparkline range picker + shutdown copy

B3.5: settings.sparklineRangeDays (added schema-only in B2b) now drives
the recentCompletions fetch via a reactive effect; new segmented control
in Settings → Tasks lets the user pick 7 / 28 / 90 days. Default 7.

B3.14: /shutdown "Open loops" header softened to "Still here", body
copy updated to remove pressure ("Just naming them helps — nothing to
do here"), truncation tail drops the count. The whole "Still here"
section is now gated by a fresh-start derived flag — when B3.1 lands
and writes reentryFreshStartUntil, this surface auto-suppresses.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-26 19:22:02 +01:00
parent e4cbf62a20
commit 7daf5677c9
3 changed files with 118 additions and 28 deletions

View File

@@ -814,6 +814,33 @@
}
});
// B3.5 — sparkline range picker. SegmentedButton works on string
// options; settings.sparklineRangeDays is the integer 7 / 28 / 90.
// Mirror in both directions so changes from either side propagate.
let sparklineRangeLabel = $state(
settings.sparklineRangeDays === 90
? "90 days"
: settings.sparklineRangeDays === 28
? "28 days"
: "7 days",
);
$effect(() => {
const next = sparklineRangeLabel === "90 days" ? 90 : sparklineRangeLabel === "28 days" ? 28 : 7;
if (settings.sparklineRangeDays !== next) {
settings.sparklineRangeDays = next;
}
});
$effect(() => {
const label = settings.sparklineRangeDays === 90
? "90 days"
: settings.sparklineRangeDays === 28
? "28 days"
: "7 days";
if (sparklineRangeLabel !== label) {
sparklineRangeLabel = label;
}
});
async function downloadModel(size) {
downloadingModel = size;
downloadProgress = 0;
@@ -1575,8 +1602,29 @@
<Toggle
bind:checked={settings.showMomentumSparkline}
label="Show momentum sparkline"
description="A tiny chart of the last 7 days' completion counts, shown on the Tasks header. Never counts against you."
description="A tiny chart of recent completion counts, shown on the Tasks header. Never counts against you."
/>
<!-- B3.5 — sparkline range picker. The integer field
settings.sparklineRangeDays drives the
list_recent_completions_cmd days argument via a
reactive effect in completionStats.svelte. The
SegmentedButton component works on string options, so
we marshal between display labels and the integer
the rest of the system expects via a local mirror. -->
{#if settings.showMomentumSparkline}
<div class="mt-5 pl-1 animate-fade-in">
<p class="text-[10px] font-medium text-text-tertiary uppercase tracking-wider mb-2">Sparkline range</p>
<SegmentedButton
options={["7 days", "28 days", "90 days"]}
bind:value={sparklineRangeLabel}
size="small"
/>
<p class="text-[11px] text-text-tertiary mt-2">
How far back the sparkline looks. Wider windows show longer-arc trends; the narrower window stays close to right-now.
</p>
</div>
{/if}
</div>
{/if}
</div>

View File

@@ -17,9 +17,21 @@
import { onMount } from 'svelte';
import { invoke } from '@tauri-apps/api/core';
import { Moon, ArrowLeft } from 'lucide-svelte';
import { page } from '$lib/stores/page.svelte.js';
import { page, settings } from '$lib/stores/page.svelte.js';
import { hasTauriRuntime } from '$lib/utils/runtime.js';
// B3.14 — fresh-start gate. When B3.1 lands and stamps
// settings.reentryFreshStartUntil with a future timestamp (the user
// has been away long enough to warrant a "yesterday is sealed"
// window), the "Still here" reflection is suppressed so the shutdown
// surface doesn't drag forward what the fresh-start window is
// explicitly inviting the user to set down. The `!!` guards against
// the legacy null and any malformed string.
const inFreshStartWindow = $derived(
!!settings.reentryFreshStartUntil &&
new Date(settings.reentryFreshStartUntil).getTime() > Date.now(),
);
interface TaskRow {
id: string;
text: string;
@@ -110,29 +122,36 @@
{/if}
</section>
<!-- Read-only reflection. The Tasks page is where things get done. -->
<section class="mb-8">
<h2 class="text-[10px] font-medium text-text-tertiary uppercase tracking-wider mb-2">Open loops</h2>
{#if openLoops.length === 0}
<p class="text-[12px] text-text-secondary">Nothing in the list.</p>
{:else}
<p class="text-[11px] text-text-tertiary mb-3">
These are still here. Naming them silences the loop — you don't have to act now.
</p>
<ul class="flex flex-col gap-1">
{#each openLoops.slice(0, 12) as task (task.id)}
<li class="text-[12px] text-text-secondary truncate">
{task.text}
</li>
{/each}
{#if openLoops.length > 12}
<li class="text-[11px] text-text-tertiary pt-1">
…and {openLoops.length - 12} more.
</li>
{/if}
</ul>
{/if}
</section>
<!-- Read-only reflection. The Tasks page is where things get done.
B3.14 — softened header + body copy (no pressure framing); the
truncation tail no longer emits a count, so the user isn't
presented with a "and 27 more" tally on the way out the door.
Whole section is suppressed inside an active fresh-start
window — see inFreshStartWindow above. -->
{#if !inFreshStartWindow}
<section class="mb-8">
<h2 class="text-[10px] font-medium text-text-tertiary uppercase tracking-wider mb-2">Still here</h2>
{#if openLoops.length === 0}
<p class="text-[12px] text-text-secondary">Nothing in the list.</p>
{:else}
<p class="text-[11px] text-text-tertiary mb-3">
Still on the list. Just naming them helps — nothing to do here.
</p>
<ul class="flex flex-col gap-1">
{#each openLoops.slice(0, 12) as task (task.id)}
<li class="text-[12px] text-text-secondary truncate">
{task.text}
</li>
{/each}
{#if openLoops.length > 12}
<li class="text-[11px] text-text-tertiary pt-1">
…the rest is on the Tasks page when you want it.
</li>
{/if}
</ul>
{/if}
</section>
{/if}
<!-- Physical reset + intentional cue. Evidence: Newport shutdown
template, ~40% rumination reduction in psychological-detachment