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:
10
package-lock.json
generated
10
package-lock.json
generated
@@ -12,6 +12,7 @@
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-dialog": "^2.6.0",
|
||||
"@tauri-apps/plugin-global-shortcut": "^2.3.1",
|
||||
"@tauri-apps/plugin-notification": "^2.3.3",
|
||||
"@tauri-apps/plugin-opener": "^2",
|
||||
"lucide-svelte": "^0.577.0"
|
||||
},
|
||||
@@ -1563,6 +1564,15 @@
|
||||
"@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": {
|
||||
"version": "2.5.3",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.5.3.tgz",
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-dialog": "^2.6.0",
|
||||
"@tauri-apps/plugin-global-shortcut": "^2.3.1",
|
||||
"@tauri-apps/plugin-notification": "^2.3.3",
|
||||
"@tauri-apps/plugin-opener": "^2",
|
||||
"lucide-svelte": "^0.577.0"
|
||||
},
|
||||
|
||||
@@ -26,6 +26,7 @@ tauri = { version = "2", features = ["tray-icon"] }
|
||||
tauri-plugin-opener = "2"
|
||||
tauri-plugin-dialog = "2"
|
||||
tauri-plugin-global-shortcut = "2"
|
||||
tauri-plugin-notification = "2"
|
||||
|
||||
# Serialisation
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
"opener:default",
|
||||
"dialog:default",
|
||||
"global-shortcut:allow-register",
|
||||
"global-shortcut:allow-unregister"
|
||||
"global-shortcut:allow-unregister",
|
||||
"notification:default"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -4,5 +4,6 @@ pub mod hardware;
|
||||
pub mod history;
|
||||
pub mod models;
|
||||
pub mod tasks;
|
||||
pub mod timer;
|
||||
pub mod transcription;
|
||||
pub mod windows;
|
||||
|
||||
56
src-tauri/src/commands/timer.rs
Normal file
56
src-tauri/src/commands/timer.rs
Normal 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())
|
||||
}
|
||||
@@ -72,6 +72,7 @@ pub fn run() {
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_global_shortcut::Builder::new().build())
|
||||
.plugin(tauri_plugin_notification::init())
|
||||
.setup(|app| {
|
||||
// Initialise database (blocking in setup — runs once at startup)
|
||||
let db_path = database_path();
|
||||
@@ -154,6 +155,10 @@ pub fn run() {
|
||||
commands::tasks::reorder_tasks_cmd,
|
||||
commands::tasks::complete_task_cmd,
|
||||
commands::tasks::uncomplete_task_cmd,
|
||||
// Timer
|
||||
commands::timer::save_timer,
|
||||
commands::timer::get_timer,
|
||||
commands::timer::clear_timer,
|
||||
// Transcription
|
||||
commands::transcription::transcribe_pcm,
|
||||
commands::transcription::transcribe_file,
|
||||
|
||||
242
src/lib/components/TaskTimer.svelte
Normal file
242
src/lib/components/TaskTimer.svelte
Normal 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}
|
||||
@@ -1,7 +1,13 @@
|
||||
<script>
|
||||
import { getTasks, createTask, completeTask, deleteTask } from '$lib/stores/tasks.svelte.js';
|
||||
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();
|
||||
|
||||
@@ -55,6 +61,15 @@
|
||||
>
|
||||
</button>
|
||||
<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
|
||||
class="text-text-tertiary hover:text-danger opacity-0 group-hover:opacity-100 text-[11px]"
|
||||
onclick={() => deleteTask(task.id)}
|
||||
|
||||
@@ -13,7 +13,13 @@
|
||||
onMount(() => { loadTasks(); });
|
||||
import WipTaskList from '$lib/components/WipTaskList.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 { formatTimestamp } from "$lib/utils/time.js";
|
||||
import { BUCKET_COLORS, EFFORT_LABELS, EFFORT_ORDER } from "$lib/utils/constants.js";
|
||||
@@ -440,6 +446,17 @@
|
||||
{/if}
|
||||
</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 -->
|
||||
<button
|
||||
aria-label="Delete task"
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import Sidebar from "$lib/Sidebar.svelte";
|
||||
import TaskSidebar from "$lib/components/TaskSidebar.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 { getPreferences, updatePreferences } from "$lib/stores/preferences.svelte.js";
|
||||
|
||||
@@ -14,6 +15,16 @@
|
||||
|
||||
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
|
||||
// but as a fallback, hide chrome if the URL matches
|
||||
let isSecondaryWindow = $derived(
|
||||
@@ -99,6 +110,7 @@
|
||||
// Auto-collapse if window is already narrow on first load
|
||||
handleResize();
|
||||
window.addEventListener("resize", handleResize);
|
||||
window.addEventListener("kon:start-timer", handleStartTimer);
|
||||
|
||||
try {
|
||||
const whisper = await invoke("list_models");
|
||||
@@ -113,6 +125,7 @@
|
||||
|
||||
onDestroy(() => {
|
||||
window.removeEventListener("resize", handleResize);
|
||||
window.removeEventListener("kon:start-timer", handleStartTimer);
|
||||
if (registeredHotkey) {
|
||||
import("@tauri-apps/plugin-global-shortcut")
|
||||
.then((mod) => mod.unregister(registeredHotkey))
|
||||
@@ -140,8 +153,12 @@
|
||||
{#if page.current !== "first-run"}
|
||||
<Sidebar />
|
||||
{/if}
|
||||
<div class="flex-1 overflow-hidden bg-bg">
|
||||
{@render children()}
|
||||
<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()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user