Add pomodoro, microbreaks, breathing guide, screen dimming, presentation mode, goals, multi-monitor, and activity manager

Major feature release (v0.1.3) adding 15 new features to the break timer:

Backend (Rust):
- Pomodoro cycle tracking with configurable short/long break pattern
- Microbreak scheduling (20-20-20 rule) with independent timer
- Screen dimming events with gradual opacity progression
- Presentation mode detection (fullscreen app deferral)
- Smart break detection (natural idle breaks counting toward goals)
- Daily goal tracking and streak milestone events
- Multi-monitor break overlay support
- Working hours enforcement with per-day schedules
- Weekly summary and natural break stats queries
- Config expanded to 71 validated fields

Frontend (Svelte):
- 6 new components: BreathingGuide, ActivityManager, BreakOverlay,
  MicrobreakOverlay, DimOverlay, Celebration
- Breathing guide with 5 patterns and animated pulsing halo
- Activity manager with favorites, custom activities, momentum scroll
- Confetti celebrations on milestones and goal completion
- Dashboard indicators (pomodoro/microbreak/goal) moved inside ring
- Settings reorganized into 18 logical cards
- Breathing pattern selector redesigned with timing descriptions
- Break activities expanded from 40 to 71 curated exercises
- Sound presets expanded from 4 to 8
- Stats view with weekly summary and natural break tracking

Also: version bump to 0.1.3, CHANGELOG, README and CLAUDE.md updates
This commit is contained in:
Your Name
2026-02-07 15:11:44 +02:00
parent 460bf2c613
commit a339dd1bb3
28 changed files with 3792 additions and 448 deletions

View File

@@ -7,6 +7,7 @@
import { onMount } from "svelte";
import { scaleIn, fadeIn, pressable } from "../utils/animate";
import { pickRandomActivity, getCategoryIcon, getCategoryLabel, type BreakActivity } from "../utils/activities";
import BreathingGuide from "./BreathingGuide.svelte";
interface Props {
standalone?: boolean;
@@ -16,14 +17,14 @@
const appWindow = $derived(standalone ? getCurrentWebviewWindow() : null);
let currentActivity = $state<BreakActivity>(pickRandomActivity());
let currentActivity = $state<BreakActivity>(pickRandomActivity(undefined, $config));
let activityCycleTimer: ReturnType<typeof setInterval> | null = null;
// Cycle activity every 30 seconds during break
$effect(() => {
if ($config.show_break_activities && $timer.state === "breakActive") {
activityCycleTimer = setInterval(() => {
currentActivity = pickRandomActivity(currentActivity);
currentActivity = pickRandomActivity(currentActivity, $config);
}, 30_000);
}
return () => {
@@ -34,6 +35,9 @@
};
});
// F3: Long break indicator
const isLongBreak = $derived($timer.isLongBreak);
async function cancelBreak() {
const snap = await invoke<TimerSnapshot>("cancel_break");
timer.set(snap);
@@ -65,6 +69,32 @@
const showButtons = $derived(!$config.strict_mode);
// Breathing guide bindable state
let breathPhase = $state("Inhale");
let breathCountdown = $state(4);
let breathScale = $state(0.6);
// Map raw 0.61.0 scale to 0.91.6 range for visible breathing text
const textScale = $derived(0.9 + (breathScale - 0.6) * (0.7 / 0.4));
// Interpolate color between break_color (inhale) and accent_color (exhale)
// breathScale: 0.6 = exhale (accent), 1.0 = inhale (break)
function hexToRgb(hex: string): [number, number, number] {
const h = hex.replace("#", "");
return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)];
}
function lerpColor(c1: string, c2: string, t: number): string {
const [r1, g1, b1] = hexToRgb(c1);
const [r2, g2, b2] = hexToRgb(c2);
const r = Math.round(r1 + (r2 - r1) * t);
const g = Math.round(g1 + (g2 - g1) * t);
const b = Math.round(b1 + (b2 - b1) * t);
return `rgb(${r},${g},${b})`;
}
// t=0 at exhale (scale=0.6), t=1 at inhale (scale=1.0)
const breathT = $derived((breathScale - 0.6) / 0.4);
const breathColor = $derived(lerpColor($config.accent_color, $config.break_color, breathT));
// Bottom progress bar uses a gradient from break color to accent
const barGradient = $derived(
`linear-gradient(to right, ${$config.break_color}, ${$config.accent_color})`,
@@ -105,7 +135,21 @@
<div class="break-ripple ripple-2" style="--ripple-color: {$config.break_color}"></div>
<div class="break-ripple ripple-3" style="--ripple-color: {$config.break_color}"></div>
</div>
<div class="break-breathe">
<!-- Hidden: runs breathing animation logic, we only use its phase data -->
{#if $config.breathing_guide_enabled}
<div class="hidden" aria-hidden="true">
<BreathingGuide
pattern={$config.breathing_pattern}
size={0}
color={$config.break_color}
showLabel={false}
bind:phaseLabel={breathPhase}
bind:countdown={breathCountdown}
bind:breathScale={breathScale}
/>
</div>
{/if}
<div>
<TimerRing
progress={breakRingProgress}
size={140}
@@ -114,13 +158,23 @@
label="Break timer"
valueText="{formatTime($timer.breakTimeRemaining)} remaining"
>
<div class="break-breathe-counter">
<div class="flex flex-col items-center">
<span
class="font-semibold leading-none tabular-nums text-white text-[26px]"
style={$config.countdown_font ? `font-family: '${$config.countdown_font}', monospace;` : ""}
>
{formatTime($timer.breakTimeRemaining)}
</span>
{#if $config.breathing_guide_enabled}
<span
class="block mt-1.5 text-[9px] tracking-wider uppercase text-center font-medium"
style="transform: scale({textScale}); color: {breathColor}; opacity: {0.5 + breathT * 0.5}; transition: transform 0.15s ease-out, opacity 0.15s ease-out, color 0.15s ease-out;"
aria-live="polite" aria-atomic="true"
aria-label="Breathing guide: {breathPhase} {breathCountdown > 0 ? breathCountdown + ' seconds' : ''}"
>
{breathPhase}{breathCountdown > 0 ? ` ${breathCountdown}s` : ""}
</span>
{/if}
</div>
</TimerRing>
</div>
@@ -140,7 +194,7 @@
<div class="text-[9px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase mb-1">
{getCategoryLabel(currentActivity.category)}
</div>
<p class="text-[12px] leading-relaxed text-[#999]" aria-live="polite">
<p class="text-[12px] leading-relaxed text-[#8a8a8a]" aria-live="polite">
{currentActivity.text}
</p>
</div>
@@ -209,7 +263,22 @@
<div class="break-ripple ripple-3" style="--ripple-color: {$config.break_color}; --ripple-size: {isModal ? 160 : 200}px"></div>
</div>
<div class="break-breathe relative">
<!-- Hidden: runs breathing animation logic, we only use its phase data -->
{#if $config.breathing_guide_enabled}
<div class="hidden" aria-hidden="true">
<BreathingGuide
pattern={$config.breathing_pattern}
size={0}
color={$config.break_color}
showLabel={false}
bind:phaseLabel={breathPhase}
bind:countdown={breathCountdown}
bind:breathScale={breathScale}
/>
</div>
{/if}
<div class="relative">
<TimerRing
progress={breakRingProgress}
size={isModal ? 160 : 200}
@@ -218,7 +287,7 @@
label="Break timer"
valueText="{formatTime($timer.breakTimeRemaining)} remaining"
>
<div class="break-breathe-counter">
<div class="flex flex-col items-center">
<span
class="font-semibold leading-none tabular-nums text-white"
class:text-[30px]={isModal}
@@ -227,11 +296,33 @@
>
{formatTime($timer.breakTimeRemaining)}
</span>
{#if $config.breathing_guide_enabled}
<span
class="block mt-2 tracking-wider uppercase text-center font-medium"
class:text-[10px]={!isModal}
class:text-[9px]={isModal}
style="transform: scale({textScale}); color: {breathColor}; opacity: {0.5 + breathT * 0.5}; transition: transform 0.15s ease-out, opacity 0.15s ease-out, color 0.15s ease-out;"
aria-live="polite" aria-atomic="true"
aria-label="Breathing guide: {breathPhase} {breathCountdown > 0 ? breathCountdown + ' seconds' : ''}"
>
{breathPhase}{breathCountdown > 0 ? ` ${breathCountdown}s` : ""}
</span>
{/if}
</div>
</TimerRing>
</div>
</div>
<!-- F3: Long break badge -->
{#if isLongBreak}
<div class="mb-2 rounded-full px-3 py-1 text-[10px] font-medium tracking-wider uppercase"
style="background: {$config.break_color}20; color: {$config.break_color}; border: 1px solid {$config.break_color}30;"
use:fadeIn={{ delay: 0.2, y: 8 }}
>
Long break
</div>
{/if}
<h2 class="mb-2 text-lg font-medium text-white" tabindex="-1" use:fadeIn={{ delay: 0.25, y: 10 }}>
{$timer.breakTitle}
</h2>
@@ -253,7 +344,7 @@
<div class="mb-1.5 text-[10px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase">
{getCategoryLabel(currentActivity.category)}
</div>
<p class="text-[13px] leading-relaxed text-[#999]" aria-live="polite">
<p class="text-[13px] leading-relaxed text-[#8a8a8a]" aria-live="polite">
{currentActivity.text}
</p>
</div>
@@ -411,23 +502,6 @@
background: rgba(255, 255, 255, 0.05);
}
/* ── Breathing pulse on the ring ── */
.break-breathe {
animation: breathe 4s ease-in-out infinite;
}
@keyframes breathe {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.04); }
}
.break-breathe-counter {
animation: breathe-counter 4s ease-in-out infinite;
}
@keyframes breathe-counter {
0%, 100% { transform: scale(1); }
50% { transform: scale(0.962); }
}
/* ── Ripple circles ── */
.break-ripple {
position: absolute;