feat(ux): B3.1 + B3.4 — fresh-start re-entry + MicroStep where-was-I

B3.1: app shell tracks lastLaunchAt; >7-day gap opens a 24h
fresh-start window. While the window is active: a "Welcome back. This
week starts fresh." banner offers Archive old Inbox (Inbox-only via
archive_old_inbox_cmd, system-attributable copy on success), the
morning-triage modal is suppressed, the momentum sparkline is hidden,
and the nudge bus stays quiet. Banner is per-session dismissable.

B3.4: MicroSteps shows a "Last completed: X. Next: Y." re-orientation
banner when re-opening a parent task whose decomposition has been idle
>=30 min OR sat across a session boundary. Speaker button reads the
banner aloud via existing TTS. Per-parent last-activity timestamps are
persisted to a single localStorage key. Banner self-dismisses on first
interaction.

Shared inFreshStartWindow gate extracted to src/lib/utils/freshStart.ts
so ShutdownRitualPage (B3.14), TasksPage, MorningTriageModal, and
nudgeBus all read the same source of truth.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-26 19:38:31 +01:00
parent fc3a56c0dc
commit 2668401104
8 changed files with 453 additions and 12 deletions

View File

@@ -1,10 +1,14 @@
<script lang="ts">
import { invoke } from '@tauri-apps/api/core';
import { ListTree, Check, Timer, Loader2, ThumbsUp, ThumbsDown, Pencil } from 'lucide-svelte';
import { ListTree, Check, Timer, Loader2, ThumbsUp, ThumbsDown, Pencil, X } from 'lucide-svelte';
import { profilesStore } from '$lib/stores/profiles.svelte.ts';
import { replaceTaskFromDto } from '$lib/stores/page.svelte.js';
import { replaceTaskFromDto, settings } from '$lib/stores/page.svelte.js';
import type { TaskDto } from '$lib/types/app.ts';
import SpeakerButton from '$lib/components/SpeakerButton.svelte';
import {
recordActivity,
shouldShowReorientBanner,
} from '$lib/utils/microstepActivity.js';
let { parentTaskId, parentTaskText = '', reduceMotion = false } = $props();
@@ -35,6 +39,52 @@
// a faster second save that already committed.
let saveToken = $state<Record<string, number>>({});
// B3.4 — "Where was I?" re-orientation banner. Shown when the user
// re-opens a parent task whose decomposition has been idle ≥30 min
// OR sat across a session boundary (most recent app launch is after
// the parent's last activity stamp). Banner self-dismisses on first
// interaction (any check/edit/click) or via the explicit X. Never
// shown when the list is all-done or all-pending.
let reorientShown = $state(false);
let reorientDismissed = $state(false);
const lastDoneStep = $derived(
[...subtasks].reverse().find((s) => s.done) ?? null,
);
const nextPendingStep = $derived(subtasks.find((s) => !s.done) ?? null);
const reorientBannerText = $derived(
lastDoneStep && nextPendingStep
? `Last completed: ${lastDoneStep.text}. Next: ${nextPendingStep.text}.`
: '',
);
function dismissReorient() {
if (reorientShown) {
reorientShown = false;
reorientDismissed = true;
}
}
// Evaluate whether to surface the banner. Called once after each
// subtask load — the gate reads the persisted last-activity map and
// the most recent app-launch stamp; both are stable for the duration
// of this mount, so we don't need to re-evaluate on every check.
function maybeShowReorientBanner() {
if (reorientDismissed) return;
if (!parentTaskId) return;
if (subtasks.length === 0) return;
if (!lastDoneStep || !nextPendingStep) return;
if (
shouldShowReorientBanner({
parentTaskId,
lastLaunchAt: settings.lastLaunchAt,
})
) {
reorientShown = true;
}
}
async function loadSubtasks() {
loading = true;
error = '';
@@ -45,6 +95,11 @@
} finally {
loading = false;
}
// B3.4 — evaluate the re-orientation gate once after the steps
// land. Reads the persisted last-activity map plus the stable
// settings.lastLaunchAt stamp; both will not change during this
// mount, so a single evaluation is sufficient.
maybeShowReorientBanner();
}
async function decompose() {
@@ -55,6 +110,10 @@
parentTaskId,
profileId: profilesStore.activeProfileId,
});
// B3.4 — fresh decomposition counts as activity for the
// re-orientation banner (so opening this same task in 30+ min
// can surface the banner).
recordActivity(parentTaskId);
// Phase 6 nudge-bus signal. The bus schedules a 15-min idle
// check on this parent task id — if no step or the task itself
// is completed in that window, a gentle "still with that one?"
@@ -72,6 +131,10 @@
}
async function checkStep(subtaskId: string) {
// B3.4 — checking a step counts as activity AND dismisses the
// re-orientation banner (the user is clearly back in flow).
recordActivity(parentTaskId);
dismissReorient();
try {
const result = await invoke<{
updatedSubtask: TaskDto;
@@ -160,6 +223,10 @@
const next = (draft[step.id] ?? '').trim();
cancelEdit(step.id);
if (!next || next === step.text) return;
// B3.4 — renaming a subtask counts as activity AND dismisses the
// re-orientation banner.
recordActivity(parentTaskId);
dismissReorient();
const original = step.text;
// Update in-memory first so the UI is snappy; roll back if the
// persistence call fails so we never show stale-but-different text.
@@ -232,6 +299,36 @@
{:else if error}
<p class="text-[11px] text-text-tertiary py-1">{error}</p>
{:else}
{#if reorientShown && reorientBannerText}
<!-- B3.4 — re-orientation banner. Shown when the parent task's
decomposition has been idle ≥30 min OR sat across a session
boundary. Self-dismisses on first interaction (handled in
checkStep / saveEdit) or via the explicit X. Calm style
matches the PR 1.4 / B3.6 / B3.1 inline banners. -->
<div
class="mb-2 px-3 py-2 rounded-lg bg-accent-subtle border border-accent/20 flex items-start gap-2"
role="status"
aria-live="polite"
>
<span class="text-[12px] text-text leading-snug flex-1 min-w-0">
{reorientBannerText}
</span>
<SpeakerButton
text={reorientBannerText}
label="Read this re-orientation aloud"
size={12}
/>
<button
type="button"
class="p-0.5 text-text-tertiary hover:text-text"
onclick={dismissReorient}
aria-label="Dismiss re-orientation banner"
title="Dismiss"
>
<X size={12} aria-hidden="true" />
</button>
</div>
{/if}
{#each subtasks as step (step.id)}
<div class="flex items-center gap-2 group py-0.5">
<button

View File

@@ -18,6 +18,7 @@
import { hasTauriRuntime } from '$lib/utils/runtime.js';
import { settings } from '$lib/stores/page.svelte.js';
import { toasts } from '$lib/stores/toasts.svelte.js';
import { inFreshStartWindow } from '$lib/utils/freshStart.js';
interface TriageTask {
id: string;
@@ -81,6 +82,11 @@
if (open || applying) return;
if (!settings.ritualsMorning) return;
if (!hasTauriRuntime()) return;
// B3.1 — fresh-start window suppresses the morning triage modal so
// the calm "this week starts fresh" framing isn't undercut by an
// immediate triage demand. The shell banner takes the foreground;
// triage resumes once the 24h window closes.
if (inFreshStartWindow(settings.reentryFreshStartUntil)) return;
if (currentMinutes() < parseTriggerMinutes(settings.ritualsMorningTime)) return;
let lastShown: string | null = null;

View File

@@ -19,17 +19,18 @@
import { Moon, ArrowLeft } from 'lucide-svelte';
import { page, settings } from '$lib/stores/page.svelte.js';
import { hasTauriRuntime } from '$lib/utils/runtime.js';
import { inFreshStartWindow } from '$lib/utils/freshStart.js';
// B3.14 — fresh-start gate. When B3.1 lands and stamps
// B3.14 — fresh-start gate. When B3.1 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(),
// explicitly inviting the user to set down. Shared util — same
// gate consulted by the shell banner, morning triage, sparkline,
// and nudge bus.
const inFreshStartWindowNow = $derived(
inFreshStartWindow(settings.reentryFreshStartUntil),
);
interface TaskRow {
@@ -128,7 +129,7 @@
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}
{#if !inFreshStartWindowNow}
<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}

View File

@@ -20,8 +20,18 @@
import Card from "$lib/components/Card.svelte";
import { formatTimestamp, todayLocalDate } from "$lib/utils/time.js";
import { resolveEnergyLabels } from "$lib/utils/energyLabels";
import { inFreshStartWindow } from "$lib/utils/freshStart.js";
import { BUCKET_COLORS, EFFORT_LABELS, EFFORT_ORDER } from "$lib/utils/constants.js";
// B3.1 — fresh-start gate. While the 24h re-entry window is open, the
// momentum sparkline is hidden so the header doesn't push a "look at
// your last 7 days" frame onto a user the app has just told "this
// week starts fresh". Same gate the shell banner / morning triage /
// nudge bus consult.
const inFreshStartNow = $derived(
inFreshStartWindow(settings.reentryFreshStartUntil),
);
// PR 1.2: default landing bucket is Today rather than All. The cold-open
// surface area for "what should I do now?" is now scoped to today's
// commitments, with All still reachable as a one-click filter.
@@ -479,7 +489,7 @@
</span>
{/if}
{#if settings.showMomentumSparkline}
{#if settings.showMomentumSparkline && !inFreshStartNow}
<CompletionSparkline data={recentCompletions} />
{/if}
</div>

View File

@@ -23,6 +23,7 @@
import { invoke } from "@tauri-apps/api/core";
import { hasTauriRuntime } from "$lib/utils/runtime.js";
import { settings } from "$lib/stores/page.svelte.js";
import { inFreshStartWindow } from "$lib/utils/freshStart.js";
const HOUR_MS = 60 * 60 * 1000;
const INACTIVITY_TIMER_THRESHOLD_MS = 90_000;
@@ -94,6 +95,10 @@ function pruneRecentNudges(now: number): void {
function canNudgeNow(now: number): boolean {
if (!settings.nudgesEnabled) return false;
if (settings.nudgesMuted) return false;
// B3.1 — fresh-start window stays quiet on every trigger. The window
// is the user explicitly being told "this week starts fresh"; an OS
// notification an hour later would directly contradict that frame.
if (inFreshStartWindow(settings.reentryFreshStartUntil, now)) return false;
if (typeof document !== "undefined" && document.hasFocus()) return false;
pruneRecentNudges(now);
if (recentNudges.length >= 3) return false;

119
src/lib/utils/freshStart.ts Normal file
View File

@@ -0,0 +1,119 @@
/**
* B3.1 — fresh-start re-entry window.
*
* Single source of truth for "is the user currently inside the 24-hour
* fresh-start window opened by a >7-day re-entry gap?". The window is
* stamped onto `settings.reentryFreshStartUntil` by the app-shell launch
* logic in `+layout.svelte` (see `runReentryDetection`).
*
* While the window is active, four UI surfaces stay quiet:
* - the morning-triage modal (won't auto-open)
* - the momentum sparkline on the Tasks page (hidden)
* - the nudge bus (no nudges fire)
* - the ShutdownRitualPage "Still here" reflection (B3.14)
*
* The B3.1 banner in the layout shell uses the same gate to know when to
* render itself. Callers that depend on Svelte 5 reactivity should wrap
* the `inFreshStartWindow(settings.reentryFreshStartUntil)` call inside
* a `$derived(...)` expression so changes to the setting flow through.
*/
/**
* Pure check: is the supplied ISO timestamp parseable AND in the future
* relative to `nowMs`? Both branches that could fail (null/undefined,
* malformed string) collapse to `false` so callers can treat any value
* as safe input.
*/
export function inFreshStartWindow(
reentryFreshStartUntil: string | null | undefined,
nowMs: number = Date.now(),
): boolean {
if (!reentryFreshStartUntil) return false;
const parsed = new Date(reentryFreshStartUntil).getTime();
if (Number.isNaN(parsed)) return false;
return parsed > nowMs;
}
/**
* Re-entry classifier used at app-shell mount.
*
* Branches:
* - `firstLaunch` — `lastLaunchAt` is null (or unparseable). Caller
* just stamps `lastLaunchAt = now` and saves; no fresh-start window.
* - `freshStart` — gap from `lastLaunchAt` to now exceeds the
* threshold (default 7 days). Caller stamps both
* `reentryFreshStartUntil = now + windowMs` AND `lastLaunchAt = now`.
* - `normal` — gap is within the threshold. Caller just bumps
* `lastLaunchAt = now`.
*
* Returns `freshStartUntilIso` only on the freshStart branch so callers
* have the exact ISO string to write back without recomputing.
*/
export type ReentryDecision =
| { kind: "firstLaunch"; lastLaunchAtIso: string }
| { kind: "freshStart"; lastLaunchAtIso: string; freshStartUntilIso: string }
| { kind: "normal"; lastLaunchAtIso: string };
const DAY_MS = 24 * 60 * 60 * 1000;
const DEFAULT_REENTRY_THRESHOLD_MS = 7 * DAY_MS;
const DEFAULT_FRESH_START_WINDOW_MS = 24 * 60 * 60 * 1000;
export interface ReentryDecisionOptions {
/** Override "now" for testing. Defaults to `Date.now()`. */
nowMs?: number;
/** Gap above which we open a fresh-start window. Default 7 days. */
thresholdMs?: number;
/** Length of the fresh-start window. Default 24 h. */
windowMs?: number;
}
export function decideReentry(
lastLaunchAt: string | null | undefined,
opts: ReentryDecisionOptions = {},
): ReentryDecision {
const nowMs = opts.nowMs ?? Date.now();
const thresholdMs = opts.thresholdMs ?? DEFAULT_REENTRY_THRESHOLD_MS;
const windowMs = opts.windowMs ?? DEFAULT_FRESH_START_WINDOW_MS;
const nowIso = new Date(nowMs).toISOString();
if (!lastLaunchAt) {
return { kind: "firstLaunch", lastLaunchAtIso: nowIso };
}
const last = new Date(lastLaunchAt).getTime();
if (Number.isNaN(last)) {
// Treat unparseable as first launch — safer than silently skipping
// the gap calculation and never stamping a fresh-start window.
return { kind: "firstLaunch", lastLaunchAtIso: nowIso };
}
if (nowMs - last > thresholdMs) {
return {
kind: "freshStart",
lastLaunchAtIso: nowIso,
freshStartUntilIso: new Date(nowMs + windowMs).toISOString(),
};
}
return { kind: "normal", lastLaunchAtIso: nowIso };
}
/**
* Module-scoped once-per-launch latch. The app shell calls
* `claimReentryLatch()` on mount; the first caller gets `true`, every
* subsequent caller gets `false`. Living here (not in the layout
* component) means HMR re-mounts of `+layout.svelte` don't double-stamp
* `lastLaunchAt`, and secondary windows that share the JS module graph
* see the same already-claimed latch.
*
* `resetReentryLatchForTests()` is provided for unit tests only — never
* called from production code.
*/
let reentryLatchClaimed = false;
export function claimReentryLatch(): boolean {
if (reentryLatchClaimed) return false;
reentryLatchClaimed = true;
return true;
}
export function resetReentryLatchForTests(): void {
reentryLatchClaimed = false;
}

View File

@@ -0,0 +1,92 @@
/**
* B3.4 — per-parent-task last-activity tracking for the MicroSteps
* "Where was I?" re-orientation banner.
*
* Persisted to a single localStorage key as a JSON object keyed by
* parent task id; the value is the ms-since-epoch timestamp of the
* most recent meaningful activity (subtask check, decomposition
* generation, subtask rename).
*
* Lives in localStorage rather than SQLite (per the spec) because the
* banner is a UX hint, not durable user data — the cost of losing the
* map on a localStorage clear is one missed banner, not a regression.
*
* Map is read lazily, mutated, and re-written on each update. The
* cardinality is bounded by the number of parent tasks the user has
* decomposed in the lifetime of the install; well within localStorage
* comfort.
*/
const STORAGE_KEY = "kon.microstep.lastActivity.v1";
type ActivityMap = Record<string, number>;
function canUseStorage(): boolean {
return typeof localStorage !== "undefined";
}
function readMap(): ActivityMap {
if (!canUseStorage()) return {};
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return {};
try {
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === "object") return parsed as ActivityMap;
} catch {
// Corrupt blob — drop it so we don't keep tripping the parse.
try { localStorage.removeItem(STORAGE_KEY); } catch {}
}
return {};
}
function writeMap(map: ActivityMap): void {
if (!canUseStorage()) return;
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(map));
} catch {
// Quota / disabled storage — silently drop the write. The banner
// gate falls back to "no last-activity recorded", which means the
// idle/session checks fail closed (no banner). That's fine.
}
}
export function getLastActivity(parentTaskId: string): number | null {
const map = readMap();
const v = map[parentTaskId];
return typeof v === "number" && Number.isFinite(v) ? v : null;
}
export function recordActivity(parentTaskId: string, nowMs: number = Date.now()): void {
if (!parentTaskId) return;
const map = readMap();
map[parentTaskId] = nowMs;
writeMap(map);
}
const IDLE_THRESHOLD_MS = 30 * 60 * 1000;
/**
* The B3.4 gate: returns true when the parent's decomposition has been
* either (a) idle ≥30 min, OR (b) sat across a session boundary
* (the most recent app launch happened *after* the parent's last
* recorded activity — i.e. the user closed and reopened Kon since
* they last touched these steps).
*
* Returns false if there's no recorded activity — a fresh decomposition
* the user is actively working on shouldn't trigger the banner.
*/
export function shouldShowReorientBanner(args: {
parentTaskId: string;
lastLaunchAt: string | null | undefined;
nowMs?: number;
}): boolean {
const last = getLastActivity(args.parentTaskId);
if (last === null) return false;
const nowMs = args.nowMs ?? Date.now();
if (nowMs - last > IDLE_THRESHOLD_MS) return true;
if (args.lastLaunchAt) {
const launch = new Date(args.lastLaunchAt).getTime();
if (!Number.isNaN(launch) && launch > last) return true;
}
return false;
}

View File

@@ -30,6 +30,11 @@
startImplementationIntentions,
stopImplementationIntentions,
} from "$lib/stores/implementationIntentions.svelte.ts";
import {
claimReentryLatch,
decideReentry,
inFreshStartWindow,
} from "$lib/utils/freshStart.js";
import { page as sveltePage } from "$app/stores";
@@ -243,6 +248,72 @@
let onWindowError = null;
let onUnhandledRejection = null;
// ---------------------------------------------------------------
// B3.1 — re-entry / fresh-start detection.
//
// Runs exactly once per app launch (the module-scoped flag below
// survives Svelte's HMR re-mounts and protects the secondary-window
// case where +layout.svelte may be parsed twice). A >7-day gap from
// the last launch opens a 24h fresh-start window; the banner below
// renders while that window is in the future, and the four
// suppression sites (morning-triage modal, momentum sparkline,
// nudge bus, shutdown ritual) all read the same gate via
// inFreshStartWindow() in $lib/utils/freshStart.
let archivingOldInbox = $state(false);
// Per-session dismiss for the welcome-back banner. Module-scoped so
// dismissing on one navigation doesn't re-show on the next; reset on
// app restart by virtue of being a fresh module load.
let bannerDismissedThisSession = $state(false);
// Reactive banner gate. The fresh-start util takes the raw setting so
// any change to settings.reentryFreshStartUntil flows through.
const showFreshStartBanner = $derived(
inFreshStartWindow(settings.reentryFreshStartUntil) &&
!bannerDismissedThisSession,
);
function runReentryDetection() {
// claimReentryLatch() returns true exactly once per JS module load
// — secondary windows that share the module graph and HMR remounts
// both see "already claimed" and skip the stamp.
if (!claimReentryLatch()) return;
const decision = decideReentry(settings.lastLaunchAt);
settings.lastLaunchAt = decision.lastLaunchAtIso;
if (decision.kind === "freshStart") {
settings.reentryFreshStartUntil = decision.freshStartUntilIso;
}
saveSettings();
}
async function archiveOldInbox() {
if (archivingOldInbox) return;
if (!hasTauriRuntime()) {
bannerDismissedThisSession = true;
return;
}
archivingOldInbox = true;
try {
// Inbox-only by design — see archive_old_inbox_cmd in tasks.rs.
// We swallow the rows_affected count: the toast copy is
// system-attributable per the B3.1 memo §A and must not say
// "we archived N items" (RSD-sensitive copy rule).
await invoke("archive_old_inbox_cmd", { days: 7 });
toasts.success(
"The inbox overflowed. Anything important will come back.",
);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
toasts.error("Couldn't archive old Inbox", msg);
} finally {
archivingOldInbox = false;
bannerDismissedThisSession = true;
}
}
function dismissFreshStartBanner() {
bannerDismissedThisSession = true;
}
function installGlobalErrorCapture() {
if (!hasTauriRuntime()) return;
@@ -272,6 +343,11 @@
}
onMount(async () => {
// B3.1 — re-entry / fresh-start detection. Runs first so the
// suppression sites (morning triage, momentum sparkline, nudge
// bus) see the right gate state when they initialise below.
runReentryDetection();
// Auto-collapse if window is already narrow on first load
handleResize();
window.addEventListener("resize", handleResize);
@@ -425,9 +501,44 @@
{#if page.current !== "first-run"}
<Sidebar />
{/if}
<div class="flex-1 overflow-hidden bg-bg">
<div class="flex-1 overflow-hidden bg-bg flex flex-col min-w-0">
{#if showFreshStartBanner && page.current !== "first-run"}
<!-- B3.1 fresh-start banner. Shown when the user has been
away >7 days; offers a one-tap Inbox archive (Inbox-only
via archive_old_inbox_cmd) and a per-session dismiss.
Calm style matches the PR 1.4 / B3.6 inline banners. -->
<div class="px-7 pt-3 pb-1 animate-fade-in">
<div
class="px-4 py-2 rounded-lg bg-accent-subtle border border-accent/20 text-[12px] flex items-center gap-3"
role="status"
aria-live="polite"
>
<span class="text-text">Welcome back. This week starts fresh.</span>
<div class="flex-1"></div>
<button
type="button"
class="px-3 py-1 rounded-md text-[11px] font-medium bg-accent text-white hover:bg-accent-hover disabled:opacity-50"
onclick={archiveOldInbox}
disabled={archivingOldInbox}
>
{archivingOldInbox ? "Archiving…" : "Archive old Inbox"}
</button>
<button
type="button"
class="px-2 py-1 text-text-tertiary hover:text-text"
onclick={dismissFreshStartBanner}
aria-label="Dismiss this banner for this session"
title="Dismiss"
>
×
</button>
</div>
</div>
{/if}
<div class="flex-1 min-h-0 overflow-hidden">
{@render children()}
</div>
</div>
{#if page.taskSidebarOpen && page.current !== "first-run"}
<div class="w-[280px] min-w-[280px] shadow-xl">
<TaskSidebar />