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

@@ -9,9 +9,13 @@
todaySkipped: number;
todaySnoozed: number;
todayBreakTimeSecs: number;
todayNaturalBreaks: number;
todayNaturalBreakTimeSecs: number;
complianceRate: number;
currentStreak: number;
bestStreak: number;
dailyGoalProgress: number;
dailyGoalMet: boolean;
}
interface DayRecord {
@@ -22,13 +26,27 @@
totalBreakTimeSecs: number;
}
interface WeekSummary {
weekStart: string;
totalCompleted: number;
totalSkipped: number;
totalBreakTimeSecs: number;
complianceRate: number;
avgDailyCompleted: number;
}
let stats = $state<StatsSnapshot | null>(null);
let history = $state<DayRecord[]>([]);
let monthHistory = $state<DayRecord[]>([]);
let weeklySummaries = $state<WeekSummary[]>([]);
let activeTab = $state<"today" | "weekly" | "monthly">("today");
async function loadStats() {
try {
stats = await invoke<StatsSnapshot>("get_stats");
history = await invoke<DayRecord[]>("get_daily_history", { days: 7 });
monthHistory = await invoke<DayRecord[]>("get_daily_history", { days: 30 });
weeklySummaries = await invoke<WeekSummary[]>("get_weekly_summary", { weeks: 4 });
} catch (e) {
console.error("Failed to load stats:", e);
}
@@ -56,7 +74,21 @@
return `${hrs}h ${rem}m`;
});
// Chart rendering
// F10: Daily goal progress
const goalPercent = $derived(
$config.daily_goal_breaks > 0
? Math.min(100, Math.round(((stats?.dailyGoalProgress ?? 0) / $config.daily_goal_breaks) * 100))
: 0,
);
// F10: Next milestone
const milestones = [3, 5, 7, 14, 21, 30, 50, 100, 365];
const nextMilestone = $derived(() => {
const current = stats?.currentStreak ?? 0;
return milestones.find((m) => m > current) ?? null;
});
// Chart rendering — 7-day
let chartCanvas: HTMLCanvasElement | undefined = $state();
$effect(() => {
@@ -64,6 +96,22 @@
drawChart(chartCanvas, history);
});
// Chart rendering — 30-day
let monthChartCanvas: HTMLCanvasElement | undefined = $state();
$effect(() => {
if (!monthChartCanvas || monthHistory.length === 0) return;
drawChart(monthChartCanvas, monthHistory);
});
// Heatmap canvas
let heatmapCanvas: HTMLCanvasElement | undefined = $state();
$effect(() => {
if (!heatmapCanvas || monthHistory.length === 0) return;
drawHeatmap(heatmapCanvas, monthHistory);
});
function drawChart(canvas: HTMLCanvasElement, data: DayRecord[]) {
const ctx = canvas.getContext("2d");
if (!ctx) return;
@@ -78,8 +126,8 @@
ctx.clearRect(0, 0, w, h);
const maxBreaks = Math.max(1, ...data.map((d) => d.breaksCompleted + d.breaksSkipped));
const barWidth = Math.floor((w - 40) / data.length) - 8;
const barGap = 8;
const barWidth = Math.max(2, Math.floor((w - 40) / data.length) - (data.length > 10 ? 2 : 8));
const barGap = data.length > 10 ? 2 : 8;
const chartHeight = h - 30;
const accentColor = $config.accent_color || "#ff4d00";
@@ -90,34 +138,87 @@
const completedH = total > 0 ? (day.breaksCompleted / maxBreaks) * chartHeight : 0;
const skippedH = total > 0 ? (day.breaksSkipped / maxBreaks) * chartHeight : 0;
// Completed bar
if (completedH > 0) {
ctx.fillStyle = accentColor;
ctx.beginPath();
const barY = chartHeight - completedH;
roundedRect(ctx, x, barY, barWidth, completedH, 4);
roundedRect(ctx, x, barY, barWidth, completedH, Math.min(4, barWidth / 2));
ctx.fill();
}
// Skipped bar (stacked on top)
if (skippedH > 0) {
ctx.fillStyle = "#333";
ctx.beginPath();
const barY = chartHeight - completedH - skippedH;
roundedRect(ctx, x, barY, barWidth, skippedH, 4);
roundedRect(ctx, x, barY, barWidth, skippedH, Math.min(4, barWidth / 2));
ctx.fill();
}
// Day label
ctx.fillStyle = "#8a8a8a";
ctx.font = "10px -apple-system, sans-serif";
ctx.textAlign = "center";
const label = day.date.slice(5); // "MM-DD"
ctx.fillText(label, x + barWidth / 2, h - 5);
// Day label — show every Nth for 30-day
if (data.length <= 7 || i % 5 === 0) {
ctx.fillStyle = "#8a8a8a";
ctx.font = "10px -apple-system, sans-serif";
ctx.textAlign = "center";
const label = day.date.slice(5);
ctx.fillText(label, x + barWidth / 2, h - 5);
}
});
}
function drawHeatmap(canvas: HTMLCanvasElement, data: DayRecord[]) {
const ctx = canvas.getContext("2d");
if (!ctx) return;
const dpr = window.devicePixelRatio || 1;
const cellSize = 16;
const gap = 2;
const cols = 7; // days of week
const rows = Math.ceil(data.length / cols);
const w = cols * (cellSize + gap) - gap;
const h = rows * (cellSize + gap) - gap;
canvas.width = w * dpr;
canvas.height = h * dpr;
canvas.style.width = `${w}px`;
canvas.style.height = `${h}px`;
ctx.scale(dpr, dpr);
ctx.clearRect(0, 0, w, h);
const maxBreaks = Math.max(1, ...data.map((d) => d.breaksCompleted));
const accentColor = $config.accent_color || "#ff4d00";
// Parse accent color for intensity scaling
const r = parseInt(accentColor.slice(1, 3), 16);
const g = parseInt(accentColor.slice(3, 5), 16);
const b = parseInt(accentColor.slice(5, 7), 16);
data.forEach((day, i) => {
const col = i % cols;
const row = Math.floor(i / cols);
const x = col * (cellSize + gap);
const y = row * (cellSize + gap);
const intensity = day.breaksCompleted > 0
? Math.min(1, day.breaksCompleted / maxBreaks)
: 0;
if (intensity === 0) {
ctx.fillStyle = "#161616";
} else {
// Blend from dark (#161616 = 22,22,22) to accent color
const level = 0.2 + intensity * 0.8;
const cr = Math.round(22 + (r - 22) * level);
const cg = Math.round(22 + (g - 22) * level);
const cb = Math.round(22 + (b - 22) * level);
ctx.fillStyle = `rgb(${cr}, ${cg}, ${cb})`;
}
ctx.beginPath();
roundedRect(ctx, x, y, cellSize, cellSize, 3);
ctx.fill();
});
}
// Accessible chart summary
const chartAriaLabel = $derived(() => {
if (history.length === 0) return "No break history data available";
const total = history.reduce((sum, d) => sum + d.breaksCompleted, 0);
@@ -125,6 +226,15 @@
return `Weekly break chart: ${total} completed and ${skipped} skipped over ${history.length} days`;
});
// Monthly aggregations
const monthTotalCompleted = $derived(monthHistory.reduce((s, d) => s + d.breaksCompleted, 0));
const monthTotalSkipped = $derived(monthHistory.reduce((s, d) => s + d.breaksSkipped, 0));
const monthTotalTime = $derived(monthHistory.reduce((s, d) => s + d.totalBreakTimeSecs, 0));
const monthAvgCompliance = $derived(() => {
const total = monthTotalCompleted + monthTotalSkipped;
return total > 0 ? Math.round((monthTotalCompleted / total) * 100) : 100;
});
function roundedRect(
ctx: CanvasRenderingContext2D,
x: number,
@@ -183,14 +293,30 @@
</h1>
</div>
<!-- Tab navigation -->
<div class="flex gap-1 px-5 mb-3" use:fadeIn={{ duration: 0.3, y: 6 }}>
{#each [["today", "Today"], ["weekly", "Weekly"], ["monthly", "Monthly"]] as [tab, label]}
<button
use:pressable
class="rounded-lg px-4 py-1.5 text-[11px] tracking-wider uppercase transition-all duration-200
{activeTab === tab
? 'bg-[#1a1a1a] text-white'
: 'text-[#8a8a8a] hover:text-white'}"
onclick={() => activeTab = tab as any}
>
{label}
</button>
{/each}
</div>
<!-- Scrollable content -->
<div class="flex-1 overflow-y-auto px-5 pb-6" use:dragScroll>
<div class="space-y-3">
{#if activeTab === "today"}
<!-- Today's summary -->
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0 }}>
<h3
class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase"
>
<h3 class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase">
Today
</h3>
@@ -224,11 +350,42 @@
</div>
</section>
<!-- F10: Daily goal -->
{#if $config.daily_goal_enabled}
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.04 }}>
<h3 class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase">
Daily Goal
</h3>
<div class="flex items-center gap-4">
<div class="relative w-16 h-16">
<svg width="64" height="64" viewBox="0 0 64 64" style="transform: rotate(-90deg);">
<circle cx="32" cy="32" r="28" fill="none" stroke="#161616" stroke-width="4" />
<circle cx="32" cy="32" r="28" fill="none" stroke={$config.accent_color} stroke-width="4"
stroke-dasharray={2 * Math.PI * 28}
stroke-dashoffset={2 * Math.PI * 28 * (1 - goalPercent / 100)}
stroke-linecap="round"
class="transition-[stroke-dashoffset] duration-500"
/>
</svg>
<div class="absolute inset-0 flex items-center justify-center">
<span class="text-[14px] font-semibold text-white tabular-nums">{goalPercent}%</span>
</div>
</div>
<div>
<div class="text-[14px] text-white font-medium">
{stats?.dailyGoalProgress ?? 0} / {$config.daily_goal_breaks} breaks
</div>
<div class="text-[11px] text-[#8a8a8a]">
{stats?.dailyGoalMet ? "Goal reached!" : `${$config.daily_goal_breaks - (stats?.dailyGoalProgress ?? 0)} more to go`}
</div>
</div>
</div>
</section>
{/if}
<!-- Streak -->
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.06 }}>
<h3
class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase"
>
<h3 class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase">
Streak
</h3>
@@ -253,13 +410,25 @@
{stats?.bestStreak ?? 0}
</div>
</div>
<!-- F10: Next milestone -->
{#if nextMilestone()}
<div class="my-4 h-px bg-[#161616]"></div>
<div class="flex items-center justify-between">
<div>
<div class="text-[13px] text-white">Next milestone</div>
<div class="text-[11px] text-[#8a8a8a]">{nextMilestone()} day streak</div>
</div>
<div class="text-[13px] text-[#8a8a8a] tabular-nums">
{nextMilestone()! - (stats?.currentStreak ?? 0)} days away
</div>
</div>
{/if}
</section>
<!-- Weekly chart -->
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.12 }}>
<h3
class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase"
>
<h3 class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase">
Last 7 Days
</h3>
@@ -271,7 +440,6 @@
aria-label={chartAriaLabel()}
></canvas>
<!-- Screen-reader accessible data table for the chart -->
{#if history.length > 0}
<table class="sr-only">
<caption>Break history for the last {history.length} days</caption>
@@ -301,6 +469,142 @@
</div>
</div>
</section>
{:else if activeTab === "weekly"}
<!-- Weekly summaries -->
{#each weeklySummaries as week, i}
{@const prevWeek = weeklySummaries[i + 1]}
{@const trend = prevWeek ? week.complianceRate - prevWeek.complianceRate : 0}
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: i * 0.06 }}>
<h3 class="mb-3 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase">
Week of {week.weekStart}
</h3>
<div class="grid grid-cols-3 gap-3 mb-3">
<div class="text-center">
<div class="text-[22px] font-semibold text-white tabular-nums">{week.totalCompleted}</div>
<div class="text-[10px] text-[#8a8a8a]">Completed</div>
</div>
<div class="text-center">
<div class="text-[22px] font-semibold text-white tabular-nums">{week.totalSkipped}</div>
<div class="text-[10px] text-[#8a8a8a]">Skipped</div>
</div>
<div class="text-center">
<div class="text-[22px] font-semibold tabular-nums" style="color: {$config.accent_color}">
{Math.round(week.complianceRate * 100)}%
</div>
<div class="text-[10px] text-[#8a8a8a]">Compliance</div>
</div>
</div>
<div class="flex items-center justify-between text-[11px]">
<span class="text-[#8a8a8a]">Avg {week.avgDailyCompleted.toFixed(1)} breaks/day</span>
{#if prevWeek}
<span class="flex items-center gap-1"
style="color: {trend > 0 ? '#3fb950' : trend < 0 ? '#f85149' : '#8a8a8a'};"
>
{#if trend > 0}
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><path d="M18 15l-6-6-6 6"/></svg>
{:else if trend < 0}
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><path d="M6 9l6 6 6-6"/></svg>
{:else}
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><path d="M5 12h14"/></svg>
{/if}
{Math.abs(Math.round(trend * 100))}%
</span>
{/if}
</div>
</section>
{/each}
{:else}
<!-- Monthly: 30-day chart -->
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0 }}>
<h3 class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase">
Last 30 Days
</h3>
<!-- svelte-ignore a11y_no_interactive_element_to_noninteractive_role -->
<canvas
bind:this={monthChartCanvas}
class="h-[140px] w-full"
role="img"
aria-label="30-day break history chart"
></canvas>
<div class="mt-3 flex items-center justify-center gap-4 text-[10px] text-[#8a8a8a]">
<div class="flex items-center gap-1.5">
<div class="h-2 w-2 rounded-sm" style="background: {$config.accent_color}"></div>
Completed
</div>
<div class="flex items-center gap-1.5">
<div class="h-2 w-2 rounded-sm bg-[#333]"></div>
Skipped
</div>
</div>
</section>
<!-- Heatmap -->
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.06 }}>
<h3 class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase">
Activity Heatmap
</h3>
<div class="flex justify-center">
<!-- svelte-ignore a11y_no_interactive_element_to_noninteractive_role -->
<canvas
bind:this={heatmapCanvas}
role="img"
aria-label="30-day activity heatmap"
></canvas>
</div>
<div class="mt-3 flex items-center justify-center gap-2 text-[10px] text-[#8a8a8a]">
<span>Less</span>
<div class="flex gap-1">
<div class="w-3 h-3 rounded-sm" style="background: #161616;"></div>
<div class="w-3 h-3 rounded-sm" style="background: {$config.accent_color}40;"></div>
<div class="w-3 h-3 rounded-sm" style="background: {$config.accent_color}80;"></div>
<div class="w-3 h-3 rounded-sm" style="background: {$config.accent_color}c0;"></div>
<div class="w-3 h-3 rounded-sm" style="background: {$config.accent_color};"></div>
</div>
<span>More</span>
</div>
</section>
<!-- Monthly totals -->
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.12 }}>
<h3 class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase">
Monthly Summary
</h3>
<div class="grid grid-cols-2 gap-4">
<div class="text-center">
<div class="text-[22px] font-semibold text-white tabular-nums">{monthTotalCompleted}</div>
<div class="text-[10px] text-[#8a8a8a]">Total breaks</div>
</div>
<div class="text-center">
<div class="text-[22px] font-semibold tabular-nums" style="color: {$config.accent_color}">
{monthAvgCompliance()}%
</div>
<div class="text-[10px] text-[#8a8a8a]">Avg compliance</div>
</div>
<div class="text-center">
<div class="text-[22px] font-semibold text-white tabular-nums">
{Math.floor(monthTotalTime / 60)} min
</div>
<div class="text-[10px] text-[#8a8a8a]">Total break time</div>
</div>
<div class="text-center">
<div class="text-[22px] font-semibold text-white tabular-nums">
{(monthTotalCompleted / 30).toFixed(1)}
</div>
<div class="text-[10px] text-[#8a8a8a]">Avg daily breaks</div>
</div>
</div>
</section>
{/if}
</div>
</div>
</div>