Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions src/lib/components/Timer.svelte
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { browser } from '$app/environment';
import { timer } from '$lib/stores/timer.svelte';
import { formatTime } from '$lib/utils/formatTime';
import { playTimerSound } from '$lib/utils/audio';
import { sendPeriodNotification } from '$lib/utils/notifications';
import { startBackgroundTimer, clearBackgroundNotifications, isServiceWorkerAvailable } from '$lib/utils/sw-messaging';
import TimerHeader from '$lib/components/timer/TimerHeader.svelte';
import CircularProgress from '$lib/components/timer/CircularProgress.svelte';
import TimerControls from '$lib/components/timer/TimerControls.svelte';
Expand Down Expand Up @@ -32,10 +34,20 @@

function handleToggle() {
timer.toggle();

// If we're pausing, clear any background notifications
if (!timer.state.isRunning && isServiceWorkerAvailable()) {
clearBackgroundNotifications();
}
}

function handleReset() {
timer.reset();

// Clear any background notifications
if (isServiceWorkerAvailable()) {
clearBackgroundNotifications();
}
}

onMount(() => {
Expand All @@ -50,6 +62,41 @@
sendPeriodNotification(periodType);
}
});

// Handle page visibility changes for background operation
if (browser) {
const handleVisibilityChange = () => {
if (document.hidden) {
// Tab is hidden/minimized - hand off to service worker
if (timer.state.isRunning && timer.state.startedAt && isServiceWorkerAvailable()) {
startBackgroundTimer({
startedAt: timer.state.startedAt,
periodType: timer.state.periodType,
cycleCount: timer.state.cycleCount,
durations: timer.getDurations()
});
}
} else {
// Tab is visible again - take back control
if (isServiceWorkerAvailable()) {
// Clear any scheduled service worker notifications
clearBackgroundNotifications();
}

// Sync timer state from timeline
if (timer.state.isRunning && timer.state.startedAt) {
timer.syncFromTimeline();
}
}
};

document.addEventListener('visibilitychange', handleVisibilityChange);

// Cleanup
return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
}
});

onDestroy(() => {
Expand Down
194 changes: 156 additions & 38 deletions src/lib/stores/timer.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,80 @@ export interface TimerState {
remainingSeconds: number;
cycleCount: number; // 1-4
isRunning: boolean;
startedAt: number | null; // timestamp when current period started
pausedAt: number | null; // timestamp when paused
}

const FOCUS_DURATION = 25 * 60; // 25 minutes
const SHORT_REST_DURATION = 5 * 60; // 5 minutes
const LONG_REST_DURATION = 30 * 60; // 30 minutes
const STORAGE_KEY = 'pomodoro-timer-state';

// Get the duration for a given period type and cycle count
function getPeriodDuration(periodType: PeriodType, cycleCount: number): number {
if (periodType === 'focus') {
return FOCUS_DURATION;
} else {
// Rest period: long rest after 4th cycle, short rest otherwise
return cycleCount === 4 ? LONG_REST_DURATION : SHORT_REST_DURATION;
}
}

// Calculate what the current state should be based on elapsed time from start
// This is the "timeline calculation" that replays through periods
function calculateStateFromTimeline(
startedAt: number,
initialPeriodType: PeriodType,
initialCycleCount: number,
now: number = Date.now()
): { periodType: PeriodType; cycleCount: number; remainingSeconds: number; periodsCompleted: number } {
let elapsedSeconds = Math.floor((now - startedAt) / 1000);
let currentPeriodType = initialPeriodType;
let currentCycleCount = initialCycleCount;
let periodsCompleted = 0;

// Keep advancing through periods until we find where we are now
while (true) {
const periodDuration = getPeriodDuration(currentPeriodType, currentCycleCount);

if (elapsedSeconds < periodDuration) {
// We're in the middle of this period
return {
periodType: currentPeriodType,
cycleCount: currentCycleCount,
remainingSeconds: periodDuration - elapsedSeconds,
periodsCompleted
};
}

// This period is complete, move to next
elapsedSeconds -= periodDuration;
periodsCompleted++;

// Advance to next period
if (currentPeriodType === 'focus') {
currentPeriodType = 'rest';
} else {
// Rest period ended
if (currentCycleCount === 4) {
// After 4th rest, reset to cycle 1
currentCycleCount = 1;
currentPeriodType = 'focus';
// Stop advancing - full cycle complete
return {
periodType: currentPeriodType,
cycleCount: currentCycleCount,
remainingSeconds: getPeriodDuration(currentPeriodType, currentCycleCount),
periodsCompleted
};
} else {
currentCycleCount++;
currentPeriodType = 'focus';
}
}
}
}

// Load initial state from localStorage or use defaults
function getInitialState(): TimerState {
if (browser) {
Expand All @@ -32,7 +99,9 @@ function getInitialState(): TimerState {
periodType: 'focus',
remainingSeconds: FOCUS_DURATION,
cycleCount: 1,
isRunning: false
isRunning: false,
startedAt: null,
pausedAt: null
};
}

Expand All @@ -48,56 +117,75 @@ let state = $state<TimerState>(getInitialState());
let intervalId: number | null = null;
let periodEndCallbacks: Array<(periodType: PeriodType) => void> = [];

// Timer tick function
// Timer tick function - recalculates from timestamp
function tick() {
if (state.remainingSeconds > 0) {
state.remainingSeconds--;
saveState();
} else {
// Period ended, transition to next period
progressToNextPeriod();
}
}
if (!state.startedAt) return;

// Progress to the next period
function progressToNextPeriod() {
const oldPeriodType = state.periodType;
// Calculate current state from timeline
const calculated = calculateStateFromTimeline(
state.startedAt,
state.periodType,
state.cycleCount
);

if (state.periodType === 'focus') {
// Focus period ended, start rest period
if (state.cycleCount === 4) {
// After 4th cycle, long rest
state.periodType = 'rest';
state.remainingSeconds = LONG_REST_DURATION;
} else {
// Short rest after cycles 1-3
state.periodType = 'rest';
state.remainingSeconds = SHORT_REST_DURATION;
}
} else {
// Rest period ended
if (state.cycleCount === 4) {
// After long rest, reset to cycle 1 and stop
state.cycleCount = 1;
state.periodType = 'focus';
state.remainingSeconds = FOCUS_DURATION;
// Check if we've completed any periods
if (calculated.periodsCompleted > 0) {
// Period(s) have ended - update state and notify
const oldPeriodType = state.periodType;
state.periodType = calculated.periodType;
state.cycleCount = calculated.cycleCount;
state.remainingSeconds = calculated.remainingSeconds;

// Adjust startedAt to reflect the new period start
const now = Date.now();
const periodDuration = getPeriodDuration(calculated.periodType, calculated.cycleCount);
state.startedAt = now - (periodDuration - calculated.remainingSeconds) * 1000;

// Check if we've completed a full 4-cycle sequence
if (calculated.periodType === 'focus' && calculated.cycleCount === 1 && calculated.periodsCompleted >= 8) {
// Stop after completing 4 focus + 4 rest periods
state.isRunning = false;
if (intervalId !== null) {
clearInterval(intervalId);
intervalId = null;
}
} else {
// Increment cycle count and continue to next focus period
state.cycleCount++;
state.periodType = 'focus';
state.remainingSeconds = FOCUS_DURATION;
}

saveState();

// Notify callbacks for each completed period
// For simplicity, just notify for the most recent transition
periodEndCallbacks.forEach((callback) => callback(calculated.periodType));
} else {
// Still in same period, just update remaining time
state.remainingSeconds = calculated.remainingSeconds;
}
}

// Sync state from timeline (used when resuming from background)
function syncFromTimeline() {
if (!state.startedAt) return;

const calculated = calculateStateFromTimeline(
state.startedAt,
state.periodType,
state.cycleCount
);

// Update state
state.periodType = calculated.periodType;
state.cycleCount = calculated.cycleCount;
state.remainingSeconds = calculated.remainingSeconds;

// Adjust startedAt to reflect current period start
const now = Date.now();
const periodDuration = getPeriodDuration(calculated.periodType, calculated.cycleCount);
state.startedAt = now - (periodDuration - calculated.remainingSeconds) * 1000;

saveState();

// Notify all callbacks that period ended
periodEndCallbacks.forEach((callback) => callback(state.periodType));
// Return number of periods completed (for notification purposes)
return calculated.periodsCompleted;
}

export const timer = {
Expand All @@ -109,6 +197,19 @@ export const timer = {
// Start the timer
start() {
if (!state.isRunning) {
const now = Date.now();

if (state.pausedAt) {
// Resuming from pause - adjust startedAt to account for pause duration
const pauseDuration = now - state.pausedAt;
state.startedAt = state.startedAt ? state.startedAt + pauseDuration : now;
state.pausedAt = null;
} else if (!state.startedAt) {
// Starting fresh - set initial timestamp
const periodDuration = getPeriodDuration(state.periodType, state.cycleCount);
state.startedAt = now - (periodDuration - state.remainingSeconds) * 1000;
}

state.isRunning = true;
saveState();
intervalId = window.setInterval(tick, 1000);
Expand All @@ -119,6 +220,7 @@ export const timer = {
pause() {
if (state.isRunning && intervalId !== null) {
state.isRunning = false;
state.pausedAt = Date.now();
saveState();
clearInterval(intervalId);
intervalId = null;
Expand All @@ -141,9 +243,25 @@ export const timer = {
state.remainingSeconds = FOCUS_DURATION;
state.cycleCount = 1;
state.isRunning = false;
state.startedAt = null;
state.pausedAt = null;
saveState();
},

// Sync from timeline (for resuming from background)
syncFromTimeline() {
return syncFromTimeline();
},

// Get durations (exposed for service worker communication)
getDurations() {
return {
focus: FOCUS_DURATION,
shortRest: SHORT_REST_DURATION,
longRest: LONG_REST_DURATION
};
},

// Subscribe to period end events (returns unsubscribe function)
onPeriodEnd(callback: (periodType: PeriodType) => void) {
periodEndCallbacks.push(callback);
Expand Down
57 changes: 57 additions & 0 deletions src/lib/utils/sw-messaging.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { browser } from '$app/environment'
import type { PeriodType } from '$lib/stores/timer.svelte'

export interface TimerPayload {
startedAt: number
periodType: PeriodType
cycleCount: number
durations: {
focus: number
shortRest: number
longRest: number
}
}

/**
* Send a message to the service worker
*/
async function sendToServiceWorker (type: string, payload?: TimerPayload): Promise<void> {
if (!browser) return

try {
const registration = await navigator.serviceWorker.ready
if (registration.active) {
registration.active.postMessage({ type, payload })
}
} catch (error) {
console.error('Failed to send message to service worker:', error)
}
}

/**
* Start background timer in service worker
*/
export async function startBackgroundTimer (payload: TimerPayload): Promise<void> {
await sendToServiceWorker('START_TIMER', payload)
}

/**
* Stop background timer in service worker
*/
export async function stopBackgroundTimer (): Promise<void> {
await sendToServiceWorker('STOP_TIMER')
}

/**
* Clear all scheduled notifications in service worker
*/
export async function clearBackgroundNotifications (): Promise<void> {
await sendToServiceWorker('CLEAR_NOTIFICATIONS')
}

/**
* Check if service worker is available and ready
*/
export function isServiceWorkerAvailable (): boolean {
return browser && 'serviceWorker' in navigator
}
Loading