feat(timer): wire VisualTimer to tasks with notifications and persistence

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
jake
2026-03-21 13:59:12 +00:00
parent f1ef171acb
commit 2fc4ee7087
11 changed files with 371 additions and 5 deletions

10
package-lock.json generated
View File

@@ -12,6 +12,7 @@
"@tauri-apps/api": "^2", "@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "^2.6.0", "@tauri-apps/plugin-dialog": "^2.6.0",
"@tauri-apps/plugin-global-shortcut": "^2.3.1", "@tauri-apps/plugin-global-shortcut": "^2.3.1",
"@tauri-apps/plugin-notification": "^2.3.3",
"@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-opener": "^2",
"lucide-svelte": "^0.577.0" "lucide-svelte": "^0.577.0"
}, },
@@ -1563,6 +1564,15 @@
"@tauri-apps/api": "^2.8.0" "@tauri-apps/api": "^2.8.0"
} }
}, },
"node_modules/@tauri-apps/plugin-notification": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-notification/-/plugin-notification-2.3.3.tgz",
"integrity": "sha512-Zw+ZH18RJb41G4NrfHgIuofJiymusqN+q8fGUIIV7vyCH+5sSn5coqRv/MWB9qETsUs97vmU045q7OyseCV3Qg==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.8.0"
}
},
"node_modules/@tauri-apps/plugin-opener": { "node_modules/@tauri-apps/plugin-opener": {
"version": "2.5.3", "version": "2.5.3",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.5.3.tgz", "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.5.3.tgz",

View File

@@ -16,6 +16,7 @@
"@tauri-apps/api": "^2", "@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "^2.6.0", "@tauri-apps/plugin-dialog": "^2.6.0",
"@tauri-apps/plugin-global-shortcut": "^2.3.1", "@tauri-apps/plugin-global-shortcut": "^2.3.1",
"@tauri-apps/plugin-notification": "^2.3.3",
"@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-opener": "^2",
"lucide-svelte": "^0.577.0" "lucide-svelte": "^0.577.0"
}, },

View File

@@ -26,6 +26,7 @@ tauri = { version = "2", features = ["tray-icon"] }
tauri-plugin-opener = "2" tauri-plugin-opener = "2"
tauri-plugin-dialog = "2" tauri-plugin-dialog = "2"
tauri-plugin-global-shortcut = "2" tauri-plugin-global-shortcut = "2"
tauri-plugin-notification = "2"
# Serialisation # Serialisation
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }

View File

@@ -17,6 +17,7 @@
"opener:default", "opener:default",
"dialog:default", "dialog:default",
"global-shortcut:allow-register", "global-shortcut:allow-register",
"global-shortcut:allow-unregister" "global-shortcut:allow-unregister",
"notification:default"
] ]
} }

View File

@@ -4,5 +4,6 @@ pub mod hardware;
pub mod history; pub mod history;
pub mod models; pub mod models;
pub mod tasks; pub mod tasks;
pub mod timer;
pub mod transcription; pub mod transcription;
pub mod windows; pub mod windows;

View File

@@ -0,0 +1,56 @@
use serde::Serialize;
use crate::AppState;
use kon_storage::{clear_timer_state, get_timer_state, save_timer_state};
/// Serialisable timer state response.
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct TimerStateResponse {
pub task_id: String,
pub total_seconds: i64,
pub remaining_seconds: i64,
pub started_at: String,
pub paused: bool,
}
/// Save or update the active timer state.
#[tauri::command]
pub async fn save_timer(
state: tauri::State<'_, AppState>,
task_id: String,
total_seconds: i64,
remaining_seconds: i64,
paused: bool,
) -> Result<(), String> {
save_timer_state(&state.db, &task_id, total_seconds, remaining_seconds, paused)
.await
.map_err(|e| e.to_string())
}
/// Retrieve the active timer state (for context restoration on app restart).
#[tauri::command]
pub async fn get_timer(
state: tauri::State<'_, AppState>,
) -> Result<Option<TimerStateResponse>, String> {
let row = get_timer_state(&state.db)
.await
.map_err(|e| e.to_string())?;
Ok(row.map(|r| TimerStateResponse {
task_id: r.task_id,
total_seconds: r.total_seconds,
remaining_seconds: r.remaining_seconds,
started_at: r.started_at,
paused: r.paused,
}))
}
/// Clear the active timer (completed or cancelled).
#[tauri::command]
pub async fn clear_timer(
state: tauri::State<'_, AppState>,
) -> Result<(), String> {
clear_timer_state(&state.db)
.await
.map_err(|e| e.to_string())
}

View File

@@ -72,6 +72,7 @@ pub fn run() {
.plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_global_shortcut::Builder::new().build()) .plugin(tauri_plugin_global_shortcut::Builder::new().build())
.plugin(tauri_plugin_notification::init())
.setup(|app| { .setup(|app| {
// Initialise database (blocking in setup — runs once at startup) // Initialise database (blocking in setup — runs once at startup)
let db_path = database_path(); let db_path = database_path();
@@ -154,6 +155,10 @@ pub fn run() {
commands::tasks::reorder_tasks_cmd, commands::tasks::reorder_tasks_cmd,
commands::tasks::complete_task_cmd, commands::tasks::complete_task_cmd,
commands::tasks::uncomplete_task_cmd, commands::tasks::uncomplete_task_cmd,
// Timer
commands::timer::save_timer,
commands::timer::get_timer,
commands::timer::clear_timer,
// Transcription // Transcription
commands::transcription::transcribe_pcm, commands::transcription::transcribe_pcm,
commands::transcription::transcribe_file, commands::transcription::transcribe_file,

View File

@@ -0,0 +1,242 @@
<script>
import { onMount, onDestroy } from 'svelte';
import { invoke } from '@tauri-apps/api/core';
import VisualTimer from './VisualTimer.svelte';
import { getPreferences } from '$lib/stores/preferences.svelte.js';
import { Play, Pause, X, Timer } from 'lucide-svelte';
const prefs = getPreferences();
// Timer state
let taskId = $state(null);
let taskText = $state('');
let totalSeconds = $state(0);
let remainingSeconds = $state(0);
let running = $state(false);
let paused = $state(false);
let intervalId = null;
let persistDebounce = null;
// Reduce motion check
let reduceMotion = $derived(
prefs.accessibility?.reduceMotion === 'on'
|| (prefs.accessibility?.reduceMotion === 'system'
&& typeof window !== 'undefined'
&& window.matchMedia('(prefers-reduced-motion: reduce)').matches)
);
// Formatted time display
let minutes = $derived(Math.floor(remainingSeconds / 60));
let seconds = $derived(remainingSeconds % 60);
let timeDisplay = $derived(
`${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`
);
// Context restoration — check for active timer on mount
onMount(async () => {
try {
const saved = await invoke('get_timer');
if (saved) {
taskId = saved.taskId;
totalSeconds = saved.totalSeconds;
remainingSeconds = saved.remainingSeconds;
paused = saved.paused;
running = true;
if (!paused) {
startInterval();
}
}
} catch (e) {
console.error('Failed to restore timer state:', e);
}
});
onDestroy(() => {
clearInterval(intervalId);
clearTimeout(persistDebounce);
});
/**
* Start a timer for a task.
* @param {string} id - Task ID
* @param {string} text - Task text (for display and notification)
* @param {number} durationSeconds - Timer duration (default 120 = 2 minutes)
*/
export function startTimer(id, text, durationSeconds = 120) {
// Only one timer at a time
if (running) stopTimer();
taskId = id;
taskText = text;
totalSeconds = durationSeconds;
remainingSeconds = durationSeconds;
running = true;
paused = false;
persistState();
startInterval();
}
function startInterval() {
clearInterval(intervalId);
intervalId = setInterval(tick, 1000);
}
function tick() {
if (remainingSeconds <= 0) {
onComplete();
return;
}
remainingSeconds--;
// Persist every 5 seconds (debounced)
clearTimeout(persistDebounce);
if (remainingSeconds % 5 === 0) {
persistState();
}
}
function togglePause() {
if (paused) {
// Resume
paused = false;
startInterval();
} else {
// Pause
paused = true;
clearInterval(intervalId);
}
persistState();
}
function stopTimer() {
clearInterval(intervalId);
clearTimeout(persistDebounce);
running = false;
paused = false;
taskId = null;
taskText = '';
totalSeconds = 0;
remainingSeconds = 0;
clearState();
}
async function onComplete() {
clearInterval(intervalId);
// Play gentle chime via Web Audio API
playChime();
// Send OS notification
try {
const { isPermissionGranted, requestPermission, sendNotification } =
await import('@tauri-apps/plugin-notification');
let granted = await isPermissionGranted();
if (!granted) {
const result = await requestPermission();
granted = result === 'granted';
}
if (granted) {
sendNotification({
title: 'Timer complete',
body: taskText || 'Your focus session is done — well done.',
});
}
} catch (e) {
console.error('Notification failed:', e);
}
// Clean up state
running = false;
paused = false;
clearState();
}
function playChime() {
try {
const ctx = new AudioContext();
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'sine';
osc.frequency.setValueAtTime(523.25, ctx.currentTime); // C5
osc.frequency.setValueAtTime(659.25, ctx.currentTime + 0.15); // E5
osc.frequency.setValueAtTime(783.99, ctx.currentTime + 0.3); // G5
gain.gain.setValueAtTime(0.15, ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.8);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start();
osc.stop(ctx.currentTime + 0.8);
} catch {
// Web Audio not available — silent fallback
}
}
async function persistState() {
try {
await invoke('save_timer', {
taskId: taskId,
totalSeconds: totalSeconds,
remainingSeconds: remainingSeconds,
paused: paused,
});
} catch (e) {
console.error('Failed to persist timer state:', e);
}
}
async function clearState() {
try {
await invoke('clear_timer');
} catch (e) {
console.error('Failed to clear timer state:', e);
}
}
</script>
{#if running}
<div class="flex items-center gap-3 px-4 py-3 rounded-xl bg-bg-elevated border border-border-subtle animate-fade-in">
<!-- Timer ring or static display -->
{#if reduceMotion}
<!-- Static fill state for reduce-motion -->
<div class="flex items-center justify-center w-12 h-12 rounded-full border-3 border-accent"
style="background: conic-gradient(var(--color-accent) {(1 - remainingSeconds / totalSeconds) * 360}deg, transparent 0deg);">
<div class="w-10 h-10 rounded-full bg-bg-elevated"></div>
</div>
{:else}
<VisualTimer {totalSeconds} {remainingSeconds} size={48} />
{/if}
<!-- Time + task info -->
<div class="flex-1 min-w-0">
<p class="text-[16px] font-medium text-text tabular-nums tracking-tight">{timeDisplay}</p>
{#if taskText}
<p class="text-[11px] text-text-tertiary truncate">{taskText}</p>
{/if}
</div>
<!-- Controls -->
<div class="flex items-center gap-1">
<button
class="w-8 h-8 rounded-lg flex items-center justify-center text-text-secondary hover:bg-hover hover:text-text"
style="transition-duration: var(--duration-ui)"
onclick={togglePause}
aria-label={paused ? 'Resume timer' : 'Pause timer'}
>
{#if paused}
<Play size={16} aria-hidden="true" />
{:else}
<Pause size={16} aria-hidden="true" />
{/if}
</button>
<button
class="w-8 h-8 rounded-lg flex items-center justify-center text-text-secondary hover:bg-hover hover:text-danger"
style="transition-duration: var(--duration-ui)"
onclick={stopTimer}
aria-label="Stop timer"
>
<X size={16} aria-hidden="true" />
</button>
</div>
</div>
{/if}

View File

@@ -1,7 +1,13 @@
<script> <script>
import { getTasks, createTask, completeTask, deleteTask } from '$lib/stores/tasks.svelte.js'; import { getTasks, createTask, completeTask, deleteTask } from '$lib/stores/tasks.svelte.js';
const tasks = getTasks(); const tasks = getTasks();
import { ChevronDown } from 'lucide-svelte'; import { ChevronDown, Timer } from 'lucide-svelte';
function startTimer(task, duration = 120) {
window.dispatchEvent(new CustomEvent('kon:start-timer', {
detail: { id: task.id, text: task.text, duration }
}));
}
let { wipLimit = 3 } = $props(); let { wipLimit = 3 } = $props();
@@ -55,6 +61,15 @@
> >
</button> </button>
<span class="text-[13px] text-text flex-1 min-w-0 truncate">{task.text}</span> <span class="text-[13px] text-text flex-1 min-w-0 truncate">{task.text}</span>
<button
class="text-text-tertiary hover:text-accent opacity-0 group-hover:opacity-100"
style="transition: opacity var(--duration-ui)"
onclick={() => startTimer(task)}
aria-label="Start 2-minute timer"
title="Just start — 2 minutes"
>
<Timer size={14} aria-hidden="true" />
</button>
<button <button
class="text-text-tertiary hover:text-danger opacity-0 group-hover:opacity-100 text-[11px]" class="text-text-tertiary hover:text-danger opacity-0 group-hover:opacity-100 text-[11px]"
onclick={() => deleteTask(task.id)} onclick={() => deleteTask(task.id)}

View File

@@ -13,7 +13,13 @@
onMount(() => { loadTasks(); }); onMount(() => { loadTasks(); });
import WipTaskList from '$lib/components/WipTaskList.svelte'; import WipTaskList from '$lib/components/WipTaskList.svelte';
import EmptyState from '$lib/components/EmptyState.svelte'; import EmptyState from '$lib/components/EmptyState.svelte';
import { SquareCheck, Search, ExternalLink, ChevronLeft, ArrowUpDown, Plus, X, ChevronRight } from 'lucide-svelte'; import { SquareCheck, Search, ExternalLink, ChevronLeft, ArrowUpDown, Plus, X, ChevronRight, Timer } from 'lucide-svelte';
function startTimer(task, duration = 120) {
window.dispatchEvent(new CustomEvent('kon:start-timer', {
detail: { id: task.id, text: task.text, duration }
}));
}
import Card from "$lib/components/Card.svelte"; import Card from "$lib/components/Card.svelte";
import { formatTimestamp } from "$lib/utils/time.js"; import { formatTimestamp } from "$lib/utils/time.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";
@@ -440,6 +446,17 @@
{/if} {/if}
</div> </div>
<!-- Timer -->
<button
aria-label="Start 2-minute timer"
title="Just start — 2 minutes"
class="mt-0.5 text-text-tertiary hover:text-accent opacity-0 group-hover:opacity-100 focus:opacity-100"
style="transition: opacity var(--duration-ui)"
onclick={() => startTimer(task)}
>
<Timer size={16} aria-hidden="true" />
</button>
<!-- Delete --> <!-- Delete -->
<button <button
aria-label="Delete task" aria-label="Delete task"

View File

@@ -5,6 +5,7 @@
import Sidebar from "$lib/Sidebar.svelte"; import Sidebar from "$lib/Sidebar.svelte";
import TaskSidebar from "$lib/components/TaskSidebar.svelte"; import TaskSidebar from "$lib/components/TaskSidebar.svelte";
import Titlebar from "$lib/components/Titlebar.svelte"; import Titlebar from "$lib/components/Titlebar.svelte";
import TaskTimer from "$lib/components/TaskTimer.svelte";
import { page, settings, saveSettings } from "$lib/stores/page.svelte.js"; import { page, settings, saveSettings } from "$lib/stores/page.svelte.js";
import { getPreferences, updatePreferences } from "$lib/stores/preferences.svelte.js"; import { getPreferences, updatePreferences } from "$lib/stores/preferences.svelte.js";
@@ -14,6 +15,16 @@
const prefs = getPreferences(); const prefs = getPreferences();
// Task timer — bound component for cross-page timer control
let taskTimer = $state(null);
function handleStartTimer(e) {
if (taskTimer) {
const { id, text, duration } = e.detail;
taskTimer.startTimer(id, text, duration || 120);
}
}
// Detect secondary windows (float, viewer) — they use +layout@.svelte // Detect secondary windows (float, viewer) — they use +layout@.svelte
// but as a fallback, hide chrome if the URL matches // but as a fallback, hide chrome if the URL matches
let isSecondaryWindow = $derived( let isSecondaryWindow = $derived(
@@ -99,6 +110,7 @@
// 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);
window.addEventListener("kon:start-timer", handleStartTimer);
try { try {
const whisper = await invoke("list_models"); const whisper = await invoke("list_models");
@@ -113,6 +125,7 @@
onDestroy(() => { onDestroy(() => {
window.removeEventListener("resize", handleResize); window.removeEventListener("resize", handleResize);
window.removeEventListener("kon:start-timer", handleStartTimer);
if (registeredHotkey) { if (registeredHotkey) {
import("@tauri-apps/plugin-global-shortcut") import("@tauri-apps/plugin-global-shortcut")
.then((mod) => mod.unregister(registeredHotkey)) .then((mod) => mod.unregister(registeredHotkey))
@@ -140,11 +153,15 @@
{#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">
<!-- Active timer (persists across page navigation) -->
<TaskTimer bind:this={taskTimer} />
<div class="flex-1 overflow-hidden">
{@render children()} {@render children()}
</div> </div>
</div> </div>
</div> </div>
</div>
<!-- Task sidebar as overlay — doesn't shrink main content --> <!-- Task sidebar as overlay — doesn't shrink main content -->
{#if page.taskSidebarOpen && page.current !== "first-run" && !isSecondaryWindow} {#if page.taskSidebarOpen && page.current !== "first-run" && !isSecondaryWindow}