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/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",
|
||||||
|
|||||||
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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"] }
|
||||||
|
|||||||
@@ -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"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
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_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,
|
||||||
|
|||||||
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>
|
<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)}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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,8 +153,12 @@
|
|||||||
{#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">
|
||||||
{@render children()}
|
<!-- Active timer (persists across page navigation) -->
|
||||||
|
<TaskTimer bind:this={taskTimer} />
|
||||||
|
<div class="flex-1 overflow-hidden">
|
||||||
|
{@render children()}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user