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">
|
||||
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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
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,
|
||||
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 />
|
||||
|
||||
Reference in New Issue
Block a user