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:
@@ -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) {
|
async function downloadModel(size) {
|
||||||
downloadingModel = size;
|
downloadingModel = size;
|
||||||
downloadProgress = 0;
|
downloadProgress = 0;
|
||||||
@@ -1575,8 +1602,29 @@
|
|||||||
<Toggle
|
<Toggle
|
||||||
bind:checked={settings.showMomentumSparkline}
|
bind:checked={settings.showMomentumSparkline}
|
||||||
label="Show momentum sparkline"
|
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>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -17,9 +17,21 @@
|
|||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
import { Moon, ArrowLeft } from 'lucide-svelte';
|
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';
|
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 {
|
interface TaskRow {
|
||||||
id: string;
|
id: string;
|
||||||
text: string;
|
text: string;
|
||||||
@@ -110,29 +122,36 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Read-only reflection. The Tasks page is where things get done. -->
|
<!-- Read-only reflection. The Tasks page is where things get done.
|
||||||
<section class="mb-8">
|
B3.14 — softened header + body copy (no pressure framing); the
|
||||||
<h2 class="text-[10px] font-medium text-text-tertiary uppercase tracking-wider mb-2">Open loops</h2>
|
truncation tail no longer emits a count, so the user isn't
|
||||||
{#if openLoops.length === 0}
|
presented with a "and 27 more" tally on the way out the door.
|
||||||
<p class="text-[12px] text-text-secondary">Nothing in the list.</p>
|
Whole section is suppressed inside an active fresh-start
|
||||||
{:else}
|
window — see inFreshStartWindow above. -->
|
||||||
<p class="text-[11px] text-text-tertiary mb-3">
|
{#if !inFreshStartWindow}
|
||||||
These are still here. Naming them silences the loop — you don't have to act now.
|
<section class="mb-8">
|
||||||
</p>
|
<h2 class="text-[10px] font-medium text-text-tertiary uppercase tracking-wider mb-2">Still here</h2>
|
||||||
<ul class="flex flex-col gap-1">
|
{#if openLoops.length === 0}
|
||||||
{#each openLoops.slice(0, 12) as task (task.id)}
|
<p class="text-[12px] text-text-secondary">Nothing in the list.</p>
|
||||||
<li class="text-[12px] text-text-secondary truncate">
|
{:else}
|
||||||
{task.text}
|
<p class="text-[11px] text-text-tertiary mb-3">
|
||||||
</li>
|
Still on the list. Just naming them helps — nothing to do here.
|
||||||
{/each}
|
</p>
|
||||||
{#if openLoops.length > 12}
|
<ul class="flex flex-col gap-1">
|
||||||
<li class="text-[11px] text-text-tertiary pt-1">
|
{#each openLoops.slice(0, 12) as task (task.id)}
|
||||||
…and {openLoops.length - 12} more.
|
<li class="text-[12px] text-text-secondary truncate">
|
||||||
</li>
|
{task.text}
|
||||||
{/if}
|
</li>
|
||||||
</ul>
|
{/each}
|
||||||
{/if}
|
{#if openLoops.length > 12}
|
||||||
</section>
|
<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
|
<!-- Physical reset + intentional cue. Evidence: Newport shutdown
|
||||||
template, ~40% rumination reduction in psychological-detachment
|
template, ~40% rumination reduction in psychological-detachment
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// Phase 8 store for forgiving gamification. Owns the last-7-days series
|
// Phase 8 store for forgiving gamification. Owns the recent-days series
|
||||||
// of completion counts rendered by the Tasks-page badge + sparkline.
|
// of completion counts rendered by the Tasks-page badge + sparkline.
|
||||||
//
|
//
|
||||||
// Refresh triggers:
|
// Refresh triggers:
|
||||||
@@ -9,15 +9,24 @@
|
|||||||
// - kon:task-deleted (new event, emitted by deleteTask in Task 10)
|
// - kon:task-deleted (new event, emitted by deleteTask in Task 10)
|
||||||
// - window focus (for day rollover while the app stayed open
|
// - window focus (for day rollover while the app stayed open
|
||||||
// past midnight)
|
// past midnight)
|
||||||
|
// - settings.sparklineRangeDays change (B3.5 picker — re-fetches with
|
||||||
|
// the new window so the sparkline reflects the chosen range)
|
||||||
//
|
//
|
||||||
// We deliberately avoid polling. Every path that changes a row's
|
// We deliberately avoid polling. Every path that changes a row's
|
||||||
// done state or deletes a row emits one of the events above.
|
// done state or deletes a row emits one of the events above.
|
||||||
|
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { hasTauriRuntime } from "$lib/utils/runtime.js";
|
import { hasTauriRuntime } from "$lib/utils/runtime.js";
|
||||||
|
import { settings } from "$lib/stores/page.svelte.js";
|
||||||
import type { DailyCompletionCount } from "$lib/types/app";
|
import type { DailyCompletionCount } from "$lib/types/app";
|
||||||
|
|
||||||
const DAYS = 7;
|
// B3.5 — fall back to 7 if the field is missing (e.g. the settings blob
|
||||||
|
// hasn't been migrated yet on first launch). The supported set is
|
||||||
|
// 7 / 28 / 90 per SparklineRangeDays.
|
||||||
|
function currentRangeDays(): number {
|
||||||
|
const raw = settings?.sparklineRangeDays;
|
||||||
|
return raw === 28 || raw === 90 ? raw : 7;
|
||||||
|
}
|
||||||
|
|
||||||
export const recentCompletions = $state<DailyCompletionCount[]>([]);
|
export const recentCompletions = $state<DailyCompletionCount[]>([]);
|
||||||
|
|
||||||
@@ -34,7 +43,7 @@ export async function refresh(): Promise<void> {
|
|||||||
try {
|
try {
|
||||||
const series = await invoke<DailyCompletionCount[]>(
|
const series = await invoke<DailyCompletionCount[]>(
|
||||||
"list_recent_completions_cmd",
|
"list_recent_completions_cmd",
|
||||||
{ days: DAYS },
|
{ days: currentRangeDays() },
|
||||||
);
|
);
|
||||||
recentCompletions.splice(0, recentCompletions.length, ...series);
|
recentCompletions.splice(0, recentCompletions.length, ...series);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -53,6 +62,20 @@ if (typeof window !== "undefined") {
|
|||||||
window.addEventListener("kon:task-deleted", handler);
|
window.addEventListener("kon:task-deleted", handler);
|
||||||
window.addEventListener("focus", handler);
|
window.addEventListener("focus", handler);
|
||||||
|
|
||||||
|
// B3.5 — re-fetch when the user picks a different sparkline window.
|
||||||
|
// $effect only runs inside components, so we use a lightweight $state
|
||||||
|
// tracker via $effect.root. This is the standard Svelte 5 pattern for
|
||||||
|
// module-scope reactive subscriptions.
|
||||||
|
$effect.root(() => {
|
||||||
|
$effect(() => {
|
||||||
|
// Subscribe to the field. Reading it inside the effect creates the
|
||||||
|
// dependency; the body re-runs whenever the user changes the picker.
|
||||||
|
const _days = settings?.sparklineRangeDays;
|
||||||
|
void _days;
|
||||||
|
refresh().catch(() => {});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
if (hasTauriRuntime()) {
|
if (hasTauriRuntime()) {
|
||||||
refresh().catch(() => {});
|
refresh().catch(() => {});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user