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

This commit is contained in:
2026-02-07 15:11:44 +02:00
parent 8367cff9e1
commit 96cf3b2a86
27 changed files with 3714 additions and 448 deletions
+183
View File
@@ -0,0 +1,183 @@
<script lang="ts">
import { onMount } from "svelte";
interface Props {
pattern?: string;
size?: number;
color?: string;
showLabel?: boolean;
phaseLabel?: string;
countdown?: number;
breathScale?: number;
}
let {
pattern = "box",
size = 200,
color = "#7c6aef",
showLabel = true,
phaseLabel = $bindable("Inhale"),
countdown = $bindable(4),
breathScale = $bindable(0.6),
}: Props = $props();
// Breathing patterns: arrays of [phase, durationSeconds]
const patterns: Record<string, [string, number][]> = {
box: [["Inhale", 4], ["Hold", 4], ["Exhale", 4], ["Hold", 4]],
relaxing: [["Inhale", 4], ["Hold", 7], ["Exhale", 8]],
energizing: [["Inhale", 6], ["Hold", 2], ["Exhale", 6], ["Hold", 2]],
calm: [["Inhale", 4], ["Hold", 4], ["Exhale", 6]],
deep: [["Inhale", 5], ["Exhale", 5]],
};
const phases = $derived(patterns[pattern] ?? patterns.box);
const totalCycleDuration = $derived(phases.reduce((sum, [, d]) => sum + d, 0));
let scale = $state(0.6);
let animationId: number | null = null;
let startTime = 0;
function animate(timestamp: number) {
if (!startTime) startTime = timestamp;
const elapsed = ((timestamp - startTime) / 1000) % totalCycleDuration;
let accumulated = 0;
for (const [label, duration] of phases) {
if (elapsed < accumulated + duration) {
phaseLabel = label;
const phaseElapsed = elapsed - accumulated;
countdown = Math.ceil(duration - phaseElapsed);
// Calculate scale based on phase
const t = phaseElapsed / duration;
if (label === "Inhale") {
scale = 0.6 + 0.4 * t; // 60% -> 100%
} else if (label === "Exhale") {
scale = 1.0 - 0.4 * t; // 100% -> 60%
}
// Hold phases keep current scale
breathScale = scale;
break;
}
accumulated += duration;
}
animationId = requestAnimationFrame(animate);
}
onMount(() => {
// Check reduced motion preference
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
phaseLabel = "Breathe";
countdown = 0;
scale = 0.8;
return;
}
animationId = requestAnimationFrame(animate);
return () => {
if (animationId !== null) cancelAnimationFrame(animationId);
};
});
const circleR = $derived(size / 2 - 8);
const cx = $derived(size / 2);
const cy = $derived(size / 2);
</script>
<div class="breathing-guide" style="width: {size}px; height: {size}px;">
<svg
width={size}
height={size}
viewBox="0 0 {size} {size}"
class="breathing-svg"
aria-label="Breathing guide: {phaseLabel}"
role="img"
>
<defs>
<filter id="breathing-glow" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="12" result="blur" />
<feMerge>
<feMergeNode in="blur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
</defs>
<!-- Outer glow circle -->
<circle
cx={cx}
cy={cy}
r={circleR * scale}
fill="none"
stroke={color}
stroke-width="2"
opacity="0.15"
filter="url(#breathing-glow)"
class="transition-r"
/>
<!-- Main circle -->
<circle
cx={cx}
cy={cy}
r={circleR * scale}
fill="none"
stroke={color}
stroke-width="2"
opacity="0.4"
class="transition-r"
/>
<!-- Inner fill -->
<circle
cx={cx}
cy={cy}
r={circleR * scale * 0.85}
fill={color}
opacity="0.06"
class="transition-r"
/>
</svg>
<!-- Phase label + countdown -->
{#if showLabel}
<div class="breathing-label">
<span class="text-[14px] font-medium text-white tracking-wider uppercase opacity-80">
{phaseLabel}
</span>
{#if countdown > 0}
<span class="text-[24px] font-semibold text-white tabular-nums mt-0.5">
{countdown}
</span>
{/if}
</div>
{/if}
</div>
<style>
.breathing-guide {
position: relative;
display: flex;
align-items: center;
justify-content: center;
}
.breathing-svg {
position: absolute;
inset: 0;
}
.breathing-label {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
z-index: 1;
pointer-events: none;
}
/* Smooth radius transitions via CSS */
.transition-r {
transition: r 0.3s ease-out, opacity 0.3s ease-out;
}
</style>