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:
@@ -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.6–1.0 scale to 0.9–1.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;
|
||||
|
||||
Reference in New Issue
Block a user