mirror of
https://github.com/dralkh/spaceforge.git
synced 2026-07-22 06:45:03 +00:00
490 lines
20 KiB
TypeScript
490 lines
20 KiB
TypeScript
import SpaceforgePlugin from "../main";
|
|
import { PluginStateData } from "../models/plugin-data";
|
|
import { ReviewSchedule } from "../models/review-schedule";
|
|
import { SpaceforgeSettings } from "../models/settings";
|
|
|
|
export type PomodoroMode = 'work' | 'shortBreak' | 'longBreak' | 'idle';
|
|
|
|
export class PomodoroService {
|
|
private plugin: SpaceforgePlugin;
|
|
private timerInterval: number | null = null;
|
|
|
|
constructor(plugin: SpaceforgePlugin) {
|
|
this.plugin = plugin;
|
|
// Initialize state from plugin.pluginState if needed, or rely on defaults
|
|
if (this.plugin.settings.pomodoroEnabled && this.plugin.pluginState.pomodoroIsRunning) {
|
|
// If resuming from a saved state where timer was running, recalculate timeLeft
|
|
this.recalculateTimeLeftFromEndTime();
|
|
this.startTimerInterval(); // Resume timer interval
|
|
}
|
|
}
|
|
|
|
private get settings(): SpaceforgeSettings {
|
|
return this.plugin.settings;
|
|
}
|
|
|
|
private get state(): PluginStateData {
|
|
return this.plugin.pluginState;
|
|
}
|
|
|
|
// --- Public API ---
|
|
|
|
public start(): void {
|
|
if (this.state.pomodoroIsRunning) return;
|
|
|
|
this.state.pomodoroIsRunning = true;
|
|
if (this.state.pomodoroCurrentMode === 'idle') {
|
|
this.switchToMode('work');
|
|
} else if (this.state.pomodoroTimeLeftInSeconds <= 0) {
|
|
// If timer ended and user clicks start, transition to next appropriate mode
|
|
this.handleTimerEnd();
|
|
}
|
|
// Calculate end time based on current timeLeft
|
|
this.state.pomodoroEndTimeMs = Date.now() + this.state.pomodoroTimeLeftInSeconds * 1000;
|
|
this.startTimerInterval();
|
|
this.notifyUpdate();
|
|
void this.plugin.savePluginData();
|
|
}
|
|
|
|
public stop(): void { // Pause functionality
|
|
if (!this.state.pomodoroIsRunning) return;
|
|
|
|
this.stopTimerInterval(); // Stop the interval first
|
|
this.state.pomodoroIsRunning = false;
|
|
|
|
// Calculate and store remaining time based on end time
|
|
if (this.state.pomodoroEndTimeMs) {
|
|
const remainingMs = this.state.pomodoroEndTimeMs - Date.now();
|
|
this.state.pomodoroTimeLeftInSeconds = Math.max(0, Math.round(remainingMs / 1000));
|
|
}
|
|
// Clear the end time as it's no longer actively counting down
|
|
this.state.pomodoroEndTimeMs = null;
|
|
|
|
this.notifyUpdate();
|
|
void this.plugin.savePluginData();
|
|
}
|
|
|
|
public resetCurrentSession(): void {
|
|
this.stopTimerInterval();
|
|
this.state.pomodoroIsRunning = false;
|
|
this.state.pomodoroEndTimeMs = null; // Clear end time on reset
|
|
this.resetTimeForMode(this.state.pomodoroCurrentMode === 'idle' ? 'work' : this.state.pomodoroCurrentMode);
|
|
this.notifyUpdate();
|
|
void this.plugin.savePluginData();
|
|
}
|
|
|
|
public skipSession(): void {
|
|
this.stopTimerInterval();
|
|
this.state.pomodoroIsRunning = false; // Stop current timer before switching
|
|
this.handleTimerEnd(true); // true to indicate a skip
|
|
// handleTimerEnd will call start if it transitions to a new timed session
|
|
// If it transitions to idle, it remains stopped.
|
|
// If it transitions and starts, pomodoroIsRunning will be true.
|
|
// We need to ensure the interval is started if it's running.
|
|
if (this.state.pomodoroIsRunning) {
|
|
this.startTimerInterval();
|
|
}
|
|
this.notifyUpdate(); // handleTimerEnd also notifies, but an extra one here is fine
|
|
void this.plugin.savePluginData();
|
|
}
|
|
|
|
public getFormattedTimeLeft(): string {
|
|
const minutes = Math.floor(this.state.pomodoroTimeLeftInSeconds / 60);
|
|
const seconds = this.state.pomodoroTimeLeftInSeconds % 60;
|
|
return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
|
|
}
|
|
|
|
public updateDurations(work: number, short: number, long: number, sessions: number): void {
|
|
// Check if user is modifying from the current settings (indicating manual override)
|
|
const currentWork = this.settings.pomodoroWorkDuration;
|
|
const currentShort = this.settings.pomodoroShortBreakDuration;
|
|
const currentLong = this.settings.pomodoroLongBreakDuration;
|
|
const currentSessions = this.settings.pomodoroSessionsUntilLongBreak;
|
|
|
|
const userChangedSettings = work !== currentWork || short !== currentShort || long !== currentLong || sessions !== currentSessions;
|
|
|
|
this.settings.pomodoroWorkDuration = work;
|
|
this.settings.pomodoroShortBreakDuration = short;
|
|
this.settings.pomodoroLongBreakDuration = long;
|
|
this.settings.pomodoroSessionsUntilLongBreak = sessions;
|
|
|
|
// If user manually changed settings, mark as modified and deactivate estimation
|
|
if (userChangedSettings) {
|
|
this.state.pomodoroUserHasModifiedSettings = true;
|
|
this.state.pomodoroIsEstimationActive = false;
|
|
this.state.pomodoroEstimatedTotalCycles = null;
|
|
this.state.pomodoroEstimatedWorkSessions = null;
|
|
}
|
|
|
|
// If current session is not running and its mode (which is an active timed mode) had its duration changed, update its timeLeft
|
|
const activeModesForDurationUpdate: PomodoroMode[] = ['work', 'shortBreak', 'longBreak'];
|
|
if (!this.state.pomodoroIsRunning && activeModesForDurationUpdate.includes(this.state.pomodoroCurrentMode)) {
|
|
this.resetTimeForMode(this.state.pomodoroCurrentMode);
|
|
}
|
|
void this.plugin.savePluginData(); // Save settings and potentially updated state
|
|
this.notifyUpdate();
|
|
}
|
|
|
|
/**
|
|
* Calculate and set estimation based on reading time
|
|
*/
|
|
public async calculateEstimationFromNotes(notes: ReviewSchedule[]): Promise<{
|
|
totalReadingTimeInSeconds: number;
|
|
totalReadingTimeInMinutes: number;
|
|
pomodorosNeeded: number;
|
|
totalTimeWithBreaksMinutes: number;
|
|
} | null> {
|
|
// Apply user override if specified
|
|
const userOverrideHours = this.state.pomodoroUserOverrideHours || 0;
|
|
const userOverrideMinutes = this.state.pomodoroUserOverrideMinutes || 0;
|
|
const userOverrideTimeInMinutes = (userOverrideHours * 60) + userOverrideMinutes;
|
|
const addToEstimation = this.state.pomodoroUserAddToEstimation || false;
|
|
|
|
let totalReadingTimeInSeconds = 0;
|
|
let totalReadingTimeInMinutes = 0;
|
|
|
|
// Calculate base reading time from notes
|
|
if (notes.length > 0) {
|
|
for (const note of notes) {
|
|
totalReadingTimeInSeconds += await this.plugin.reviewScheduleService.estimateReviewTime(note.path);
|
|
}
|
|
totalReadingTimeInMinutes = totalReadingTimeInSeconds / 60;
|
|
}
|
|
|
|
// Apply user override
|
|
if (userOverrideTimeInMinutes > 0) {
|
|
if (addToEstimation) {
|
|
totalReadingTimeInMinutes += userOverrideTimeInMinutes;
|
|
} else {
|
|
totalReadingTimeInMinutes = userOverrideTimeInMinutes;
|
|
totalReadingTimeInSeconds = userOverrideTimeInMinutes * 60; // Update seconds too for consistency
|
|
}
|
|
}
|
|
|
|
// If no notes and no override, return null
|
|
if (notes.length === 0 && userOverrideTimeInMinutes === 0) {
|
|
this.state.pomodoroIsEstimationActive = false;
|
|
this.state.pomodoroEstimatedTotalCycles = null;
|
|
this.state.pomodoroEstimatedWorkSessions = null;
|
|
this.notifyUpdate();
|
|
return null;
|
|
}
|
|
|
|
const settings = this.settings;
|
|
const workDuration = settings.pomodoroWorkDuration;
|
|
const shortBreakDuration = settings.pomodoroShortBreakDuration;
|
|
const longBreakDuration = settings.pomodoroLongBreakDuration;
|
|
const sessionsUntilLongBreak = settings.pomodoroSessionsUntilLongBreak;
|
|
|
|
if (totalReadingTimeInMinutes === 0 || workDuration === 0) {
|
|
this.state.pomodoroIsEstimationActive = false;
|
|
this.state.pomodoroEstimatedTotalCycles = null;
|
|
this.state.pomodoroEstimatedWorkSessions = null;
|
|
this.notifyUpdate();
|
|
return null;
|
|
}
|
|
|
|
let pomodorosNeeded = 0;
|
|
let sessionsCompletedInCycle = 0;
|
|
let remainingReadingTimeMinutes = totalReadingTimeInMinutes;
|
|
let totalBreakTimeInMinutes = 0;
|
|
|
|
while (remainingReadingTimeMinutes > 0) {
|
|
pomodorosNeeded++;
|
|
remainingReadingTimeMinutes -= workDuration;
|
|
sessionsCompletedInCycle++;
|
|
|
|
if (remainingReadingTimeMinutes <= 0) break;
|
|
|
|
if (sessionsCompletedInCycle >= sessionsUntilLongBreak) {
|
|
totalBreakTimeInMinutes += longBreakDuration;
|
|
sessionsCompletedInCycle = 0;
|
|
} else {
|
|
totalBreakTimeInMinutes += shortBreakDuration;
|
|
}
|
|
}
|
|
|
|
// Always update estimation when calculate is called (this resets the estimation)
|
|
this.state.pomodoroEstimatedTotalCycles = Math.ceil(pomodorosNeeded / sessionsUntilLongBreak);
|
|
this.state.pomodoroEstimatedWorkSessions = pomodorosNeeded;
|
|
this.state.pomodoroIsEstimationActive = true;
|
|
|
|
void this.plugin.savePluginData();
|
|
this.notifyUpdate();
|
|
|
|
// Return calculation results for UI display
|
|
return {
|
|
totalReadingTimeInSeconds,
|
|
totalReadingTimeInMinutes,
|
|
pomodorosNeeded,
|
|
totalTimeWithBreaksMinutes: (pomodorosNeeded * workDuration) + totalBreakTimeInMinutes
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Get current cycle progress information
|
|
*/
|
|
public getCycleProgress(): {
|
|
current: number;
|
|
total: number;
|
|
workSessionsRemaining: number;
|
|
totalWorkSessions: number;
|
|
totalTimeMinutes: number;
|
|
} | null {
|
|
if (!this.state.pomodoroIsEstimationActive || !this.state.pomodoroEstimatedWorkSessions) {
|
|
return null;
|
|
}
|
|
|
|
const currentCycle = Math.floor(this.state.pomodoroSessionsCompletedInCycle / this.settings.pomodoroSessionsUntilLongBreak) + 1;
|
|
const totalCycles = this.state.pomodoroEstimatedTotalCycles || 1;
|
|
const workSessionsRemaining = Math.max(0, this.state.pomodoroEstimatedWorkSessions - this.state.pomodoroSessionsCompletedInCycle);
|
|
const totalWorkSessions = this.state.pomodoroEstimatedWorkSessions;
|
|
|
|
// Calculate total time including breaks
|
|
const workDuration = this.settings.pomodoroWorkDuration;
|
|
const shortBreakDuration = this.settings.pomodoroShortBreakDuration;
|
|
const longBreakDuration = this.settings.pomodoroLongBreakDuration;
|
|
const sessionsUntilLongBreak = this.settings.pomodoroSessionsUntilLongBreak;
|
|
|
|
let totalBreakTime = 0;
|
|
let sessionsInCycle = 0;
|
|
|
|
for (let i = 0; i < totalWorkSessions; i++) {
|
|
sessionsInCycle++;
|
|
if (i < totalWorkSessions - 1) { // Don't add break after last session
|
|
if (sessionsInCycle >= sessionsUntilLongBreak) {
|
|
totalBreakTime += longBreakDuration;
|
|
sessionsInCycle = 0;
|
|
} else {
|
|
totalBreakTime += shortBreakDuration;
|
|
}
|
|
}
|
|
}
|
|
|
|
const totalTimeMinutes = (totalWorkSessions * workDuration) + totalBreakTime;
|
|
|
|
return {
|
|
current: currentCycle,
|
|
total: totalCycles,
|
|
workSessionsRemaining: workSessionsRemaining,
|
|
totalWorkSessions: totalWorkSessions,
|
|
totalTimeMinutes: totalTimeMinutes
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Reset estimation state (call when user wants to clear estimation)
|
|
*/
|
|
public resetEstimation(): void {
|
|
this.state.pomodoroIsEstimationActive = false;
|
|
this.state.pomodoroEstimatedTotalCycles = null;
|
|
this.state.pomodoroEstimatedWorkSessions = null;
|
|
this.state.pomodoroUserHasModifiedSettings = false;
|
|
void this.plugin.savePluginData();
|
|
this.notifyUpdate();
|
|
}
|
|
|
|
|
|
// --- Internal Logic ---
|
|
|
|
private startTimerInterval(): void {
|
|
if (this.timerInterval !== null) {
|
|
window.clearInterval(this.timerInterval);
|
|
}
|
|
if (!this.settings.pomodoroEnabled || !this.state.pomodoroIsRunning) return;
|
|
|
|
this.timerInterval = window.setInterval(() => {
|
|
this.tick();
|
|
}, 1000);
|
|
}
|
|
|
|
private stopTimerInterval(): void {
|
|
if (this.timerInterval !== null) {
|
|
window.clearInterval(this.timerInterval);
|
|
this.timerInterval = null;
|
|
}
|
|
}
|
|
|
|
private tick(): void {
|
|
if (!this.state.pomodoroIsRunning || !this.state.pomodoroEndTimeMs) {
|
|
// Should not happen if running, but safety check
|
|
this.stop(); // Stop if state is inconsistent
|
|
return;
|
|
}
|
|
|
|
const now = Date.now();
|
|
const remainingMs = this.state.pomodoroEndTimeMs - now;
|
|
const remainingSeconds = Math.max(0, Math.round(remainingMs / 1000));
|
|
|
|
this.state.pomodoroTimeLeftInSeconds = remainingSeconds;
|
|
|
|
if (remainingSeconds <= 0) {
|
|
this.handleTimerEnd();
|
|
// handleTimerEnd will notifyUpdate and savePluginData
|
|
} else {
|
|
this.notifyUpdate();
|
|
// No need to savePluginData on every tick
|
|
}
|
|
}
|
|
|
|
private handleTimerEnd(skipped = false): void {
|
|
this.stopTimerInterval(); // Stop the current interval
|
|
// pomodoroIsRunning might still be true if we auto-transition to a new session
|
|
|
|
if (this.settings.pomodoroSoundEnabled && !skipped) {
|
|
this.playSoundNotification();
|
|
}
|
|
|
|
const currentMode = this.state.pomodoroCurrentMode;
|
|
let nextMode: PomodoroMode = 'idle';
|
|
|
|
if (currentMode === 'work') {
|
|
this.state.pomodoroSessionsCompletedInCycle++;
|
|
if (this.state.pomodoroSessionsCompletedInCycle >= this.settings.pomodoroSessionsUntilLongBreak) {
|
|
nextMode = 'longBreak';
|
|
} else {
|
|
nextMode = 'shortBreak';
|
|
}
|
|
} else if (currentMode === 'shortBreak' || currentMode === 'longBreak') {
|
|
nextMode = 'work';
|
|
if (currentMode === 'longBreak') {
|
|
this.state.pomodoroSessionsCompletedInCycle = 0; // Reset for new cycle
|
|
}
|
|
} // If currentMode was 'idle' or another unhandled state, nextMode remains 'idle' as initialized.
|
|
|
|
const wasRunning = this.state.pomodoroIsRunning; // Capture state before potentially changing it
|
|
this.switchToMode(nextMode); // This resets timeLeft based on the new mode
|
|
|
|
if (nextMode !== 'idle') {
|
|
this.state.pomodoroIsRunning = true; // Auto-start next session
|
|
// Calculate new end time based on the duration of the *new* mode
|
|
this.state.pomodoroEndTimeMs = Date.now() + this.state.pomodoroTimeLeftInSeconds * 1000;
|
|
if (!wasRunning) { // Only start interval if it wasn't already running (e.g., transitioning from idle)
|
|
this.startTimerInterval();
|
|
} else if (!this.timerInterval) { // Or if interval somehow stopped but should be running
|
|
this.startTimerInterval();
|
|
}
|
|
} else {
|
|
this.state.pomodoroIsRunning = false;
|
|
this.state.pomodoroEndTimeMs = null; // No end time when idle
|
|
this.stopTimerInterval(); // Ensure interval is stopped when idle
|
|
}
|
|
|
|
void this.plugin.savePluginData(); // Save state changes
|
|
this.notifyUpdate();
|
|
}
|
|
|
|
private switchToMode(mode: PomodoroMode): void {
|
|
this.state.pomodoroCurrentMode = mode;
|
|
this.resetTimeForMode(mode);
|
|
}
|
|
|
|
private resetTimeForMode(mode: PomodoroMode): void {
|
|
switch (mode) {
|
|
case 'work':
|
|
this.state.pomodoroTimeLeftInSeconds = this.settings.pomodoroWorkDuration * 60;
|
|
break;
|
|
case 'shortBreak':
|
|
this.state.pomodoroTimeLeftInSeconds = this.settings.pomodoroShortBreakDuration * 60;
|
|
break;
|
|
case 'longBreak':
|
|
this.state.pomodoroTimeLeftInSeconds = this.settings.pomodoroLongBreakDuration * 60;
|
|
break;
|
|
case 'idle':
|
|
this.state.pomodoroTimeLeftInSeconds = this.settings.pomodoroWorkDuration * 60; // Default to work duration when idle
|
|
break;
|
|
}
|
|
}
|
|
|
|
private playSoundNotification(): void {
|
|
// Simple beep sound using AudioContext
|
|
try {
|
|
const AudioContextClass = window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext;
|
|
const audioContext = new AudioContextClass();
|
|
if (!audioContext) return;
|
|
const oscillator = audioContext.createOscillator();
|
|
const gainNode = audioContext.createGain();
|
|
|
|
oscillator.connect(gainNode);
|
|
gainNode.connect(audioContext.destination);
|
|
|
|
oscillator.type = 'sine'; // sine, square, sawtooth, triangle
|
|
oscillator.frequency.setValueAtTime(440, audioContext.currentTime); // A4 note
|
|
gainNode.gain.setValueAtTime(0.1, audioContext.currentTime); // Volume
|
|
|
|
oscillator.start();
|
|
oscillator.stop(audioContext.currentTime + 0.5); // Play for 0.5 seconds
|
|
} catch { /* handle error */ }
|
|
}
|
|
|
|
private notifyUpdate(): void {
|
|
this.plugin.events.emit('pomodoro-update');
|
|
}
|
|
|
|
// Call this when global settings change from the settings tab
|
|
public onSettingsChanged(): void {
|
|
if (!this.settings.pomodoroEnabled) {
|
|
this.stop();
|
|
this.state.pomodoroCurrentMode = 'idle';
|
|
this.resetTimeForMode('idle'); // Reset to default work time but keep idle
|
|
} else {
|
|
// If timer is not running, and current mode is an active timed mode
|
|
if (!this.state.pomodoroIsRunning) {
|
|
const currentMode = this.state.pomodoroCurrentMode;
|
|
const activeModes: PomodoroMode[] = ['work', 'shortBreak', 'longBreak'];
|
|
if (activeModes.includes(currentMode)) {
|
|
this.resetTimeForMode(currentMode);
|
|
}
|
|
}
|
|
}
|
|
this.notifyUpdate();
|
|
}
|
|
|
|
public destroy(): void {
|
|
this.stopTimerInterval();
|
|
}
|
|
|
|
// Recalculate time left based on stored end time, useful on load/reinit
|
|
private recalculateTimeLeftFromEndTime(): void {
|
|
if (this.state.pomodoroIsRunning && this.state.pomodoroEndTimeMs) {
|
|
const now = Date.now();
|
|
const remainingMs = this.state.pomodoroEndTimeMs - now;
|
|
this.state.pomodoroTimeLeftInSeconds = Math.max(0, Math.round(remainingMs / 1000));
|
|
|
|
if (remainingMs <= 0) {
|
|
// Timer should have ended while plugin was inactive/closed
|
|
// We could try to simulate the transition, but it gets complex.
|
|
// Simplest: Stop the timer, reset to the expected next state's duration.
|
|
this.handleTimerEnd(true); // Treat as skipped to avoid sound, force transition logic
|
|
}
|
|
} else if (!this.state.pomodoroIsRunning) {
|
|
// If not running, ensure end time is null
|
|
this.state.pomodoroEndTimeMs = null;
|
|
}
|
|
// If running but no end time (shouldn't happen), maybe log error or reset?
|
|
// For now, assume state is consistent or gets corrected by start/stop.
|
|
}
|
|
|
|
// Call this after pluginState has been externally modified (e.g., by data import)
|
|
public reinitializeTimerFromState(): void {
|
|
this.stopTimerInterval(); // Stop any existing timer first
|
|
|
|
if (this.settings.pomodoroEnabled) {
|
|
// Recalculate time left based on potentially loaded end time
|
|
this.recalculateTimeLeftFromEndTime();
|
|
|
|
// Start interval only if it should be running *after* recalculation
|
|
if (this.state.pomodoroIsRunning) {
|
|
this.startTimerInterval();
|
|
}
|
|
} else {
|
|
// If Pomodoro is disabled, ensure it's stopped and idle
|
|
this.stop();
|
|
this.state.pomodoroCurrentMode = 'idle';
|
|
this.resetTimeForMode('idle');
|
|
this.state.pomodoroEndTimeMs = null;
|
|
}
|
|
|
|
this.notifyUpdate(); // Ensure UI reflects the potentially new state
|
|
}
|
|
}
|