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:
@@ -1,10 +1,14 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { invoke } from '@tauri-apps/api/core';
|
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 { 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 type { TaskDto } from '$lib/types/app.ts';
|
||||||
import SpeakerButton from '$lib/components/SpeakerButton.svelte';
|
import SpeakerButton from '$lib/components/SpeakerButton.svelte';
|
||||||
|
import {
|
||||||
|
recordActivity,
|
||||||
|
shouldShowReorientBanner,
|
||||||
|
} from '$lib/utils/microstepActivity.js';
|
||||||
|
|
||||||
let { parentTaskId, parentTaskText = '', reduceMotion = false } = $props();
|
let { parentTaskId, parentTaskText = '', reduceMotion = false } = $props();
|
||||||
|
|
||||||
@@ -35,6 +39,52 @@
|
|||||||
// a faster second save that already committed.
|
// a faster second save that already committed.
|
||||||
let saveToken = $state<Record<string, number>>({});
|
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() {
|
async function loadSubtasks() {
|
||||||
loading = true;
|
loading = true;
|
||||||
error = '';
|
error = '';
|
||||||
@@ -45,6 +95,11 @@
|
|||||||
} finally {
|
} finally {
|
||||||
loading = false;
|
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() {
|
async function decompose() {
|
||||||
@@ -55,6 +110,10 @@
|
|||||||
parentTaskId,
|
parentTaskId,
|
||||||
profileId: profilesStore.activeProfileId,
|
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
|
// 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
|
// check on this parent task id — if no step or the task itself
|
||||||
// is completed in that window, a gentle "still with that one?"
|
// is completed in that window, a gentle "still with that one?"
|
||||||
@@ -72,6 +131,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function checkStep(subtaskId: string) {
|
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 {
|
try {
|
||||||
const result = await invoke<{
|
const result = await invoke<{
|
||||||
updatedSubtask: TaskDto;
|
updatedSubtask: TaskDto;
|
||||||
@@ -160,6 +223,10 @@
|
|||||||
const next = (draft[step.id] ?? '').trim();
|
const next = (draft[step.id] ?? '').trim();
|
||||||
cancelEdit(step.id);
|
cancelEdit(step.id);
|
||||||
if (!next || next === step.text) return;
|
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;
|
const original = step.text;
|
||||||
// Update in-memory first so the UI is snappy; roll back if the
|
// Update in-memory first so the UI is snappy; roll back if the
|
||||||
// persistence call fails so we never show stale-but-different text.
|
// persistence call fails so we never show stale-but-different text.
|
||||||
@@ -232,6 +299,36 @@
|
|||||||
{:else if error}
|
{:else if error}
|
||||||
<p class="text-[11px] text-text-tertiary py-1">{error}</p>
|
<p class="text-[11px] text-text-tertiary py-1">{error}</p>
|
||||||
{:else}
|
{: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)}
|
{#each subtasks as step (step.id)}
|
||||||
<div class="flex items-center gap-2 group py-0.5">
|
<div class="flex items-center gap-2 group py-0.5">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
import { hasTauriRuntime } from '$lib/utils/runtime.js';
|
import { hasTauriRuntime } from '$lib/utils/runtime.js';
|
||||||
import { settings } from '$lib/stores/page.svelte.js';
|
import { settings } from '$lib/stores/page.svelte.js';
|
||||||
import { toasts } from '$lib/stores/toasts.svelte.js';
|
import { toasts } from '$lib/stores/toasts.svelte.js';
|
||||||
|
import { inFreshStartWindow } from '$lib/utils/freshStart.js';
|
||||||
|
|
||||||
interface TriageTask {
|
interface TriageTask {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -81,6 +82,11 @@
|
|||||||
if (open || applying) return;
|
if (open || applying) return;
|
||||||
if (!settings.ritualsMorning) return;
|
if (!settings.ritualsMorning) return;
|
||||||
if (!hasTauriRuntime()) 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;
|
if (currentMinutes() < parseTriggerMinutes(settings.ritualsMorningTime)) return;
|
||||||
|
|
||||||
let lastShown: string | null = null;
|
let lastShown: string | null = null;
|
||||||
|
|||||||
@@ -19,17 +19,18 @@
|
|||||||
import { Moon, ArrowLeft } from 'lucide-svelte';
|
import { Moon, ArrowLeft } from 'lucide-svelte';
|
||||||
import { page, settings } 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';
|
||||||
|
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
|
// settings.reentryFreshStartUntil with a future timestamp (the user
|
||||||
// has been away long enough to warrant a "yesterday is sealed"
|
// has been away long enough to warrant a "yesterday is sealed"
|
||||||
// window), the "Still here" reflection is suppressed so the shutdown
|
// window), the "Still here" reflection is suppressed so the shutdown
|
||||||
// surface doesn't drag forward what the fresh-start window is
|
// surface doesn't drag forward what the fresh-start window is
|
||||||
// explicitly inviting the user to set down. The `!!` guards against
|
// explicitly inviting the user to set down. Shared util — same
|
||||||
// the legacy null and any malformed string.
|
// gate consulted by the shell banner, morning triage, sparkline,
|
||||||
const inFreshStartWindow = $derived(
|
// and nudge bus.
|
||||||
!!settings.reentryFreshStartUntil &&
|
const inFreshStartWindowNow = $derived(
|
||||||
new Date(settings.reentryFreshStartUntil).getTime() > Date.now(),
|
inFreshStartWindow(settings.reentryFreshStartUntil),
|
||||||
);
|
);
|
||||||
|
|
||||||
interface TaskRow {
|
interface TaskRow {
|
||||||
@@ -128,7 +129,7 @@
|
|||||||
presented with a "and 27 more" tally on the way out the door.
|
presented with a "and 27 more" tally on the way out the door.
|
||||||
Whole section is suppressed inside an active fresh-start
|
Whole section is suppressed inside an active fresh-start
|
||||||
window — see inFreshStartWindow above. -->
|
window — see inFreshStartWindow above. -->
|
||||||
{#if !inFreshStartWindow}
|
{#if !inFreshStartWindowNow}
|
||||||
<section class="mb-8">
|
<section class="mb-8">
|
||||||
<h2 class="text-[10px] font-medium text-text-tertiary uppercase tracking-wider mb-2">Still here</h2>
|
<h2 class="text-[10px] font-medium text-text-tertiary uppercase tracking-wider mb-2">Still here</h2>
|
||||||
{#if openLoops.length === 0}
|
{#if openLoops.length === 0}
|
||||||
|
|||||||
@@ -20,8 +20,18 @@
|
|||||||
import Card from "$lib/components/Card.svelte";
|
import Card from "$lib/components/Card.svelte";
|
||||||
import { formatTimestamp, todayLocalDate } from "$lib/utils/time.js";
|
import { formatTimestamp, todayLocalDate } from "$lib/utils/time.js";
|
||||||
import { resolveEnergyLabels } from "$lib/utils/energyLabels";
|
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";
|
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
|
// 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
|
// surface area for "what should I do now?" is now scoped to today's
|
||||||
// commitments, with All still reachable as a one-click filter.
|
// commitments, with All still reachable as a one-click filter.
|
||||||
@@ -479,7 +489,7 @@
|
|||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if settings.showMomentumSparkline}
|
{#if settings.showMomentumSparkline && !inFreshStartNow}
|
||||||
<CompletionSparkline data={recentCompletions} />
|
<CompletionSparkline data={recentCompletions} />
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
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 { settings } from "$lib/stores/page.svelte.js";
|
||||||
|
import { inFreshStartWindow } from "$lib/utils/freshStart.js";
|
||||||
|
|
||||||
const HOUR_MS = 60 * 60 * 1000;
|
const HOUR_MS = 60 * 60 * 1000;
|
||||||
const INACTIVITY_TIMER_THRESHOLD_MS = 90_000;
|
const INACTIVITY_TIMER_THRESHOLD_MS = 90_000;
|
||||||
@@ -94,6 +95,10 @@ function pruneRecentNudges(now: number): void {
|
|||||||
function canNudgeNow(now: number): boolean {
|
function canNudgeNow(now: number): boolean {
|
||||||
if (!settings.nudgesEnabled) return false;
|
if (!settings.nudgesEnabled) return false;
|
||||||
if (settings.nudgesMuted) 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;
|
if (typeof document !== "undefined" && document.hasFocus()) return false;
|
||||||
pruneRecentNudges(now);
|
pruneRecentNudges(now);
|
||||||
if (recentNudges.length >= 3) return false;
|
if (recentNudges.length >= 3) return false;
|
||||||
|
|||||||
119
src/lib/utils/freshStart.ts
Normal file
119
src/lib/utils/freshStart.ts
Normal 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;
|
||||||
|
}
|
||||||
92
src/lib/utils/microstepActivity.ts
Normal file
92
src/lib/utils/microstepActivity.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -30,6 +30,11 @@
|
|||||||
startImplementationIntentions,
|
startImplementationIntentions,
|
||||||
stopImplementationIntentions,
|
stopImplementationIntentions,
|
||||||
} from "$lib/stores/implementationIntentions.svelte.ts";
|
} from "$lib/stores/implementationIntentions.svelte.ts";
|
||||||
|
import {
|
||||||
|
claimReentryLatch,
|
||||||
|
decideReentry,
|
||||||
|
inFreshStartWindow,
|
||||||
|
} from "$lib/utils/freshStart.js";
|
||||||
|
|
||||||
import { page as sveltePage } from "$app/stores";
|
import { page as sveltePage } from "$app/stores";
|
||||||
|
|
||||||
@@ -243,6 +248,72 @@
|
|||||||
let onWindowError = null;
|
let onWindowError = null;
|
||||||
let onUnhandledRejection = 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() {
|
function installGlobalErrorCapture() {
|
||||||
if (!hasTauriRuntime()) return;
|
if (!hasTauriRuntime()) return;
|
||||||
|
|
||||||
@@ -272,6 +343,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMount(async () => {
|
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
|
// Auto-collapse if window is already narrow on first load
|
||||||
handleResize();
|
handleResize();
|
||||||
window.addEventListener("resize", handleResize);
|
window.addEventListener("resize", handleResize);
|
||||||
@@ -425,8 +501,43 @@
|
|||||||
{#if page.current !== "first-run"}
|
{#if page.current !== "first-run"}
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
{/if}
|
{/if}
|
||||||
<div class="flex-1 overflow-hidden bg-bg">
|
<div class="flex-1 overflow-hidden bg-bg flex flex-col min-w-0">
|
||||||
{@render children()}
|
{#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>
|
</div>
|
||||||
{#if page.taskSidebarOpen && page.current !== "first-run"}
|
{#if page.taskSidebarOpen && page.current !== "first-run"}
|
||||||
<div class="w-[280px] min-w-[280px] shadow-xl">
|
<div class="w-[280px] min-w-[280px] shadow-xl">
|
||||||
|
|||||||
Reference in New Issue
Block a user