Initial commit - Core Cooldown v0.1.0
Portable Windows break timer to prevent RSI and eye strain. Tauri v2 + Svelte 5 + Tailwind CSS v4. No installer, no telemetry, no data leaves the machine. CC0 public domain.
This commit is contained in:
149
src/lib/stores/config.ts
Normal file
149
src/lib/stores/config.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import { writable, get } from "svelte/store";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
export interface TimeRange {
|
||||
start: string; // Format: "HH:MM"
|
||||
end: string; // Format: "HH:MM"
|
||||
}
|
||||
|
||||
export interface DaySchedule {
|
||||
enabled: boolean;
|
||||
ranges: TimeRange[];
|
||||
}
|
||||
|
||||
export type { TimeRange, DaySchedule };
|
||||
|
||||
export interface Config {
|
||||
break_duration: number;
|
||||
break_frequency: number;
|
||||
auto_start: boolean;
|
||||
break_title: string;
|
||||
break_message: string;
|
||||
fullscreen_mode: boolean;
|
||||
strict_mode: boolean;
|
||||
allow_end_early: boolean;
|
||||
immediately_start_breaks: boolean;
|
||||
working_hours_enabled: boolean;
|
||||
working_hours_schedule: DaySchedule[]; // 7 days: Mon, Tue, Wed, Thu, Fri, Sat, Sun
|
||||
dark_mode: boolean;
|
||||
color_scheme: string;
|
||||
backdrop_opacity: number;
|
||||
notification_enabled: boolean;
|
||||
notification_before_break: number;
|
||||
snooze_duration: number;
|
||||
snooze_limit: number;
|
||||
skip_cooldown: number;
|
||||
sound_enabled: boolean;
|
||||
sound_volume: number;
|
||||
sound_preset: string;
|
||||
idle_detection_enabled: boolean;
|
||||
idle_timeout: number;
|
||||
smart_breaks_enabled: boolean;
|
||||
smart_break_threshold: number;
|
||||
smart_break_count_stats: boolean;
|
||||
show_break_activities: boolean;
|
||||
ui_zoom: number;
|
||||
accent_color: string;
|
||||
break_color: string;
|
||||
countdown_font: string;
|
||||
background_blobs_enabled: boolean;
|
||||
mini_click_through: boolean;
|
||||
mini_hover_threshold: number;
|
||||
}
|
||||
|
||||
const defaultConfig: Config = {
|
||||
break_duration: 5,
|
||||
break_frequency: 25,
|
||||
auto_start: true,
|
||||
break_title: "Rest your eyes",
|
||||
break_message: "Look away from the screen. Stretch and relax.",
|
||||
fullscreen_mode: true,
|
||||
strict_mode: false,
|
||||
allow_end_early: true,
|
||||
immediately_start_breaks: false,
|
||||
working_hours_enabled: false,
|
||||
working_hours_schedule: [
|
||||
{ enabled: true, ranges: [{ start: "09:00", end: "18:00" }] }, // Monday
|
||||
{ enabled: true, ranges: [{ start: "09:00", end: "18:00" }] }, // Tuesday
|
||||
{ enabled: true, ranges: [{ start: "09:00", end: "18:00" }] }, // Wednesday
|
||||
{ enabled: true, ranges: [{ start: "09:00", end: "18:00" }] }, // Thursday
|
||||
{ enabled: true, ranges: [{ start: "09:00", end: "18:00" }] }, // Friday
|
||||
{ enabled: false, ranges: [{ start: "09:00", end: "18:00" }] }, // Saturday
|
||||
{ enabled: false, ranges: [{ start: "09:00", end: "18:00" }] }, // Sunday
|
||||
],
|
||||
dark_mode: true,
|
||||
color_scheme: "Ocean",
|
||||
backdrop_opacity: 0.92,
|
||||
notification_enabled: true,
|
||||
notification_before_break: 30,
|
||||
snooze_duration: 5,
|
||||
snooze_limit: 3,
|
||||
skip_cooldown: 60,
|
||||
sound_enabled: true,
|
||||
sound_volume: 70,
|
||||
sound_preset: "bell",
|
||||
idle_detection_enabled: true,
|
||||
idle_timeout: 120,
|
||||
smart_breaks_enabled: true,
|
||||
smart_break_threshold: 300,
|
||||
smart_break_count_stats: false,
|
||||
show_break_activities: true,
|
||||
ui_zoom: 100,
|
||||
accent_color: "#ff4d00",
|
||||
break_color: "#7c6aef",
|
||||
countdown_font: "",
|
||||
background_blobs_enabled: true,
|
||||
mini_click_through: true,
|
||||
mini_hover_threshold: 3.0,
|
||||
};
|
||||
|
||||
export const config = writable<Config>(defaultConfig);
|
||||
|
||||
export async function loadConfig() {
|
||||
try {
|
||||
const cfg = await invoke<Config>("get_config");
|
||||
config.set(cfg);
|
||||
} catch (e) {
|
||||
console.error("Failed to load config:", e);
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveConfig(): Promise<boolean> {
|
||||
try {
|
||||
const cfg = get(config);
|
||||
await invoke("save_config", { config: cfg });
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error("Failed to save config:", e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function notifyConfigChanged() {
|
||||
try {
|
||||
const cfg = get(config);
|
||||
await invoke("update_pending_config", { config: cfg });
|
||||
} catch (e) {
|
||||
console.error("Failed to update pending config:", e);
|
||||
}
|
||||
}
|
||||
|
||||
export async function resetConfig() {
|
||||
try {
|
||||
const cfg = await invoke<Config>("reset_config");
|
||||
config.set(cfg);
|
||||
} catch (e) {
|
||||
console.error("Failed to reset config:", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Debounced auto-save: updates backend immediately, persists to disk after 400ms idle
|
||||
let saveTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
export function autoSave() {
|
||||
notifyConfigChanged();
|
||||
if (saveTimer) clearTimeout(saveTimer);
|
||||
saveTimer = setTimeout(() => {
|
||||
saveConfig();
|
||||
}, 400);
|
||||
}
|
||||
133
src/lib/stores/timer.ts
Normal file
133
src/lib/stores/timer.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { writable, get } from "svelte/store";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { playSound, playBreakEndSound } from "../utils/sounds";
|
||||
import { config } from "./config";
|
||||
|
||||
export interface TimerSnapshot {
|
||||
state: "running" | "paused" | "breakActive";
|
||||
currentView: "dashboard" | "breakScreen" | "settings" | "stats";
|
||||
timeRemaining: number;
|
||||
totalDuration: number;
|
||||
progress: number;
|
||||
hasHadBreak: boolean;
|
||||
secondsSinceLastBreak: number;
|
||||
prebreakWarning: boolean;
|
||||
snoozesUsed: number;
|
||||
canSnooze: boolean;
|
||||
breakTitle: string;
|
||||
breakMessage: string;
|
||||
breakProgress: number;
|
||||
breakTimeRemaining: number;
|
||||
breakTotalDuration: number;
|
||||
breakPastHalf: boolean;
|
||||
settingsModified: boolean;
|
||||
idlePaused: boolean;
|
||||
naturalBreakOccurred: boolean;
|
||||
smartBreaksEnabled: boolean;
|
||||
smartBreakThreshold: number;
|
||||
}
|
||||
|
||||
const defaultSnapshot: TimerSnapshot = {
|
||||
state: "paused",
|
||||
currentView: "dashboard",
|
||||
timeRemaining: 1500,
|
||||
totalDuration: 1500,
|
||||
progress: 1.0,
|
||||
hasHadBreak: false,
|
||||
secondsSinceLastBreak: 0,
|
||||
prebreakWarning: false,
|
||||
snoozesUsed: 0,
|
||||
canSnooze: true,
|
||||
breakTitle: "Rest your eyes",
|
||||
breakMessage: "Look away from the screen. Stretch and relax.",
|
||||
breakProgress: 0,
|
||||
breakTimeRemaining: 0,
|
||||
breakTotalDuration: 0,
|
||||
breakPastHalf: false,
|
||||
settingsModified: false,
|
||||
idlePaused: false,
|
||||
naturalBreakOccurred: false,
|
||||
smartBreaksEnabled: true,
|
||||
smartBreakThreshold: 300,
|
||||
};
|
||||
|
||||
export const timer = writable<TimerSnapshot>(defaultSnapshot);
|
||||
|
||||
// Track the current view separately so UI can switch views optimistically
|
||||
export const currentView = writable<
|
||||
"dashboard" | "breakScreen" | "settings" | "stats"
|
||||
>("dashboard");
|
||||
|
||||
let initialized = false;
|
||||
|
||||
export async function initTimerStore() {
|
||||
if (initialized) return;
|
||||
initialized = true;
|
||||
|
||||
// Get initial state
|
||||
try {
|
||||
const snapshot = await invoke<TimerSnapshot>("get_timer_state");
|
||||
timer.set(snapshot);
|
||||
currentView.set(snapshot.currentView);
|
||||
} catch (e) {
|
||||
console.error("Failed to get initial timer state:", e);
|
||||
}
|
||||
|
||||
// Listen for tick events
|
||||
await listen<TimerSnapshot>("timer-tick", (event) => {
|
||||
timer.set(event.payload);
|
||||
// Sync view from backend (backend is authoritative for break transitions)
|
||||
currentView.set(event.payload.currentView);
|
||||
});
|
||||
|
||||
// Listen for pre-break warning
|
||||
await listen("prebreak-warning", () => {
|
||||
const cfg = get(config);
|
||||
if (cfg.sound_enabled) {
|
||||
playSound(cfg.sound_preset as any, cfg.sound_volume * 0.6);
|
||||
}
|
||||
});
|
||||
|
||||
// Listen for break started
|
||||
await listen("break-started", () => {
|
||||
currentView.set("breakScreen");
|
||||
const cfg = get(config);
|
||||
if (cfg.sound_enabled) {
|
||||
playSound(cfg.sound_preset as any, cfg.sound_volume);
|
||||
}
|
||||
});
|
||||
|
||||
// Listen for break ended
|
||||
await listen("break-ended", () => {
|
||||
currentView.set("dashboard");
|
||||
const cfg = get(config);
|
||||
if (cfg.sound_enabled) {
|
||||
playBreakEndSound(cfg.sound_preset as any, cfg.sound_volume);
|
||||
}
|
||||
});
|
||||
|
||||
// Listen for natural break detected
|
||||
await listen<number>("natural-break-detected", (event) => {
|
||||
const cfg = get(config);
|
||||
if (cfg.sound_enabled) {
|
||||
playSound(cfg.sound_preset as any, cfg.sound_volume * 0.5);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Helper: format seconds as MM:SS
|
||||
export function formatTime(secs: number): string {
|
||||
const m = Math.floor(secs / 60);
|
||||
const s = secs % 60;
|
||||
return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
// Helper: format "X ago" string
|
||||
export function formatDurationAgo(secs: number): string {
|
||||
if (secs < 60) return `${secs} sec ago`;
|
||||
const m = Math.floor(secs / 60);
|
||||
if (m < 60) return m === 1 ? "1 min ago" : `${m} min ago`;
|
||||
const h = Math.floor(secs / 3600);
|
||||
return h === 1 ? "1 hr ago" : `${h} hrs ago`;
|
||||
}
|
||||
Reference in New Issue
Block a user