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:
@@ -12,6 +12,7 @@
|
||||
import Settings from "./lib/components/Settings.svelte";
|
||||
import StatsView from "./lib/components/StatsView.svelte";
|
||||
import BackgroundBlobs from "./lib/components/BackgroundBlobs.svelte";
|
||||
import Celebration from "./lib/components/Celebration.svelte";
|
||||
|
||||
const appWindow = getCurrentWebviewWindow();
|
||||
|
||||
@@ -133,4 +134,5 @@
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<Celebration />
|
||||
</main>
|
||||
|
||||
581
src/lib/components/ActivityManager.svelte
Normal file
581
src/lib/components/ActivityManager.svelte
Normal file
@@ -0,0 +1,581 @@
|
||||
<script lang="ts">
|
||||
import { config, autoSave } from "../stores/config";
|
||||
import { breakActivities, getCategoryLabel } from "../utils/activities";
|
||||
import ToggleSwitch from "./ToggleSwitch.svelte";
|
||||
import { pressable } from "../utils/animate";
|
||||
|
||||
const categories = ["eyes", "stretch", "breathing", "movement"] as const;
|
||||
let expandedCategory = $state<string | null>(null);
|
||||
let newActivityText = $state("");
|
||||
let newActivityCategory = $state<string>("eyes");
|
||||
let dropdownOpen = $state(false);
|
||||
let dropdownRef = $state<HTMLElement>(undefined!);
|
||||
let dropdownTriggerRef = $state<HTMLElement>(undefined!);
|
||||
let focusedOptionIndex = $state(-1);
|
||||
|
||||
// Close dropdown on outside click
|
||||
function handleOutsideClick(e: MouseEvent) {
|
||||
if (dropdownRef && !dropdownRef.contains(e.target as Node)) {
|
||||
dropdownOpen = false;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (dropdownOpen) {
|
||||
document.addEventListener("mousedown", handleOutsideClick);
|
||||
// Set initial focused option to current selection
|
||||
focusedOptionIndex = categories.indexOf(newActivityCategory as typeof categories[number]);
|
||||
return () => document.removeEventListener("mousedown", handleOutsideClick);
|
||||
}
|
||||
});
|
||||
|
||||
// Focus the highlighted option when index changes
|
||||
$effect(() => {
|
||||
if (dropdownOpen && focusedOptionIndex >= 0) {
|
||||
const options = dropdownRef?.querySelectorAll<HTMLElement>('[role="option"]');
|
||||
options?.[focusedOptionIndex]?.focus();
|
||||
}
|
||||
});
|
||||
|
||||
function handleDropdownKeydown(e: KeyboardEvent) {
|
||||
if (!dropdownOpen) {
|
||||
// Open on ArrowDown/Up/Enter/Space when closed
|
||||
if (["ArrowDown", "ArrowUp", "Enter", " "].includes(e.key)) {
|
||||
e.preventDefault();
|
||||
dropdownOpen = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
switch (e.key) {
|
||||
case "Escape":
|
||||
e.preventDefault();
|
||||
dropdownOpen = false;
|
||||
dropdownTriggerRef?.focus();
|
||||
break;
|
||||
case "ArrowDown":
|
||||
e.preventDefault();
|
||||
focusedOptionIndex = Math.min(focusedOptionIndex + 1, categories.length - 1);
|
||||
break;
|
||||
case "ArrowUp":
|
||||
e.preventDefault();
|
||||
focusedOptionIndex = Math.max(focusedOptionIndex - 1, 0);
|
||||
break;
|
||||
case "Home":
|
||||
e.preventDefault();
|
||||
focusedOptionIndex = 0;
|
||||
break;
|
||||
case "End":
|
||||
e.preventDefault();
|
||||
focusedOptionIndex = categories.length - 1;
|
||||
break;
|
||||
case "Enter":
|
||||
case " ":
|
||||
e.preventDefault();
|
||||
if (focusedOptionIndex >= 0) {
|
||||
newActivityCategory = categories[focusedOptionIndex];
|
||||
dropdownOpen = false;
|
||||
dropdownTriggerRef?.focus();
|
||||
}
|
||||
break;
|
||||
case "Tab":
|
||||
dropdownOpen = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function markChanged() {
|
||||
autoSave();
|
||||
}
|
||||
|
||||
function toggleCategory(cat: string) {
|
||||
expandedCategory = expandedCategory === cat ? null : cat;
|
||||
}
|
||||
|
||||
function isBuiltinDisabled(text: string): boolean {
|
||||
return $config.disabled_builtin_activities.includes(text);
|
||||
}
|
||||
|
||||
function isBuiltinFavorite(text: string): boolean {
|
||||
return $config.favorite_builtin_activities.includes(text);
|
||||
}
|
||||
|
||||
function toggleBuiltinEnabled(text: string) {
|
||||
if (isBuiltinDisabled(text)) {
|
||||
$config.disabled_builtin_activities = $config.disabled_builtin_activities.filter((t) => t !== text);
|
||||
} else {
|
||||
$config.disabled_builtin_activities = [...$config.disabled_builtin_activities, text];
|
||||
}
|
||||
markChanged();
|
||||
}
|
||||
|
||||
function toggleBuiltinFavorite(text: string) {
|
||||
if (isBuiltinFavorite(text)) {
|
||||
$config.favorite_builtin_activities = $config.favorite_builtin_activities.filter((t) => t !== text);
|
||||
} else {
|
||||
$config.favorite_builtin_activities = [...$config.favorite_builtin_activities, text];
|
||||
}
|
||||
markChanged();
|
||||
}
|
||||
|
||||
function addCustomActivity() {
|
||||
const trimmed = newActivityText.trim();
|
||||
if (!trimmed || trimmed.length > 500) return;
|
||||
if ($config.custom_activities.length >= 100) return;
|
||||
|
||||
const id = `custom-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
|
||||
$config.custom_activities = [
|
||||
...$config.custom_activities,
|
||||
{ id, category: newActivityCategory, text: trimmed, is_favorite: false, enabled: true },
|
||||
];
|
||||
newActivityText = "";
|
||||
markChanged();
|
||||
}
|
||||
|
||||
function removeCustomActivity(id: string) {
|
||||
$config.custom_activities = $config.custom_activities.filter((a) => a.id !== id);
|
||||
markChanged();
|
||||
}
|
||||
|
||||
function toggleCustomEnabled(id: string) {
|
||||
$config.custom_activities = $config.custom_activities.map((a) =>
|
||||
a.id === id ? { ...a, enabled: !a.enabled } : a,
|
||||
);
|
||||
markChanged();
|
||||
}
|
||||
|
||||
function toggleCustomFavorite(id: string) {
|
||||
$config.custom_activities = $config.custom_activities.map((a) =>
|
||||
a.id === id ? { ...a, is_favorite: !a.is_favorite } : a,
|
||||
);
|
||||
markChanged();
|
||||
}
|
||||
|
||||
// Get counts per category
|
||||
function builtinCount(cat: string): number {
|
||||
return breakActivities.filter((a) => a.category === cat).length;
|
||||
}
|
||||
function customCount(cat: string): number {
|
||||
return $config.custom_activities.filter((a) => a.category === cat).length;
|
||||
}
|
||||
function disabledCount(cat: string): number {
|
||||
return breakActivities
|
||||
.filter((a) => a.category === cat)
|
||||
.filter((a) => isBuiltinDisabled(a.text)).length
|
||||
+ $config.custom_activities
|
||||
.filter((a) => a.category === cat)
|
||||
.filter((a) => !a.enabled).length;
|
||||
}
|
||||
|
||||
// ── Svelte action: blocks mousedown from reaching parent dragScroll ──
|
||||
// Uses native addEventListener (not Svelte delegation) so stopPropagation
|
||||
// fires BEFORE the parent's node-level mousedown handler in bubble phase.
|
||||
function blockParentDrag(node: HTMLElement) {
|
||||
function stop(e: MouseEvent) { e.stopPropagation(); }
|
||||
node.addEventListener("mousedown", stop);
|
||||
return { destroy() { node.removeEventListener("mousedown", stop); } };
|
||||
}
|
||||
|
||||
// ── Svelte action: inner drag-scroll with momentum, overscroll, custom scrollbar ──
|
||||
const prefersReducedMotion = typeof window !== "undefined"
|
||||
? window.matchMedia("(prefers-reduced-motion: reduce)").matches
|
||||
: false;
|
||||
|
||||
function innerDragScroll(node: HTMLElement) {
|
||||
const scrollEl = node.querySelector(".activity-scroll") as HTMLElement;
|
||||
const content = scrollEl?.querySelector(".scroll-content") as HTMLElement;
|
||||
if (!scrollEl || !content) return { destroy() {} };
|
||||
|
||||
// Reduced motion: skip custom scroll physics, allow normal scroll
|
||||
if (prefersReducedMotion) {
|
||||
// Still block wheel propagation for isolation
|
||||
function onWheel(e: WheelEvent) {
|
||||
e.stopPropagation();
|
||||
const { scrollTop, scrollHeight, clientHeight } = scrollEl;
|
||||
const atTop = scrollTop <= 0 && e.deltaY < 0;
|
||||
const atBottom = scrollTop + clientHeight >= scrollHeight && e.deltaY > 0;
|
||||
if (!atTop && !atBottom) {
|
||||
e.preventDefault();
|
||||
scrollEl.scrollTop += e.deltaY;
|
||||
}
|
||||
}
|
||||
node.addEventListener("wheel", onWheel, { passive: false });
|
||||
return { destroy() { node.removeEventListener("wheel", onWheel); } };
|
||||
}
|
||||
|
||||
// Create custom scrollbar thumb
|
||||
const thumb = document.createElement("div");
|
||||
thumb.style.cssText =
|
||||
"position:absolute;right:2px;top:0;width:3px;border-radius:1.5px;" +
|
||||
"background:rgba(255,255,255,0.15);opacity:0;transition:opacity 0.3s ease;" +
|
||||
"z-index:10;pointer-events:none;";
|
||||
node.appendChild(thumb);
|
||||
|
||||
let hideTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let coastFrame = 0;
|
||||
let overscroll = 0;
|
||||
|
||||
function getMaxScroll() {
|
||||
return scrollEl.scrollHeight - scrollEl.clientHeight;
|
||||
}
|
||||
|
||||
function showThumb() {
|
||||
const { scrollTop, scrollHeight, clientHeight } = scrollEl;
|
||||
if (scrollHeight <= clientHeight) { thumb.style.opacity = "0"; return; }
|
||||
const h = Math.max(20, (clientHeight / scrollHeight) * clientHeight);
|
||||
const t = (scrollTop / (scrollHeight - clientHeight)) * (clientHeight - h);
|
||||
thumb.style.height = `${h}px`;
|
||||
thumb.style.transform = `translateY(${t}px)`;
|
||||
thumb.style.opacity = "1";
|
||||
if (hideTimer) clearTimeout(hideTimer);
|
||||
hideTimer = setTimeout(() => { thumb.style.opacity = "0"; }, 1000);
|
||||
}
|
||||
|
||||
function setOverscroll(amount: number) {
|
||||
overscroll = amount;
|
||||
if (Math.abs(amount) < 0.5) {
|
||||
content.style.transform = "";
|
||||
overscroll = 0;
|
||||
} else {
|
||||
content.style.transform = `translateY(${-amount}px)`;
|
||||
}
|
||||
}
|
||||
|
||||
function springBack() {
|
||||
const from = overscroll;
|
||||
if (Math.abs(from) < 0.5) { setOverscroll(0); return; }
|
||||
const start = performance.now();
|
||||
function frame() {
|
||||
const t = Math.min(1, (performance.now() - start) / 500);
|
||||
const ease = 1 - Math.pow(1 - t, 3); // cubic ease-out
|
||||
setOverscroll(from * (1 - ease));
|
||||
showThumb();
|
||||
if (t < 1) coastFrame = requestAnimationFrame(frame);
|
||||
else { content.style.transform = ""; overscroll = 0; }
|
||||
}
|
||||
coastFrame = requestAnimationFrame(frame);
|
||||
}
|
||||
|
||||
function forceReset() {
|
||||
cancelAnimationFrame(coastFrame);
|
||||
content.style.transform = "";
|
||||
overscroll = 0;
|
||||
}
|
||||
|
||||
function onDown(e: MouseEvent) {
|
||||
e.stopPropagation();
|
||||
if (e.button !== 0) return;
|
||||
const tag = (e.target as HTMLElement).tagName;
|
||||
if (["BUTTON", "INPUT", "LABEL", "SELECT"].includes(tag)) return;
|
||||
|
||||
forceReset();
|
||||
const startY = e.clientY;
|
||||
const startScroll = scrollEl.scrollTop;
|
||||
let lastY = e.clientY;
|
||||
let lastTime = Date.now();
|
||||
const vSamples: number[] = [];
|
||||
|
||||
scrollEl.style.cursor = "grabbing";
|
||||
showThumb();
|
||||
|
||||
function onMove(ev: MouseEvent) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
const y = ev.clientY;
|
||||
const now = Date.now();
|
||||
const dt = now - lastTime;
|
||||
if (dt > 0) {
|
||||
vSamples.push((lastY - y) / dt);
|
||||
if (vSamples.length > 5) vSamples.shift();
|
||||
}
|
||||
lastY = y;
|
||||
lastTime = now;
|
||||
|
||||
const desired = startScroll - (y - startY);
|
||||
const max = getMaxScroll();
|
||||
|
||||
if (desired < 0) {
|
||||
scrollEl.scrollTop = 0;
|
||||
setOverscroll(desired * 0.3);
|
||||
} else if (desired > max) {
|
||||
scrollEl.scrollTop = max;
|
||||
setOverscroll((desired - max) * 0.3);
|
||||
} else {
|
||||
scrollEl.scrollTop = desired;
|
||||
if (overscroll !== 0) setOverscroll(0);
|
||||
}
|
||||
showThumb();
|
||||
}
|
||||
|
||||
function onUp(ev: MouseEvent) {
|
||||
ev.stopPropagation();
|
||||
scrollEl.style.cursor = "";
|
||||
window.removeEventListener("mousemove", onMove, true);
|
||||
window.removeEventListener("mouseup", onUp, true);
|
||||
|
||||
if (overscroll !== 0) { springBack(); return; }
|
||||
|
||||
// Momentum coast
|
||||
const avgV = vSamples.length > 0
|
||||
? vSamples.reduce((a, b) => a + b, 0) / vSamples.length : 0;
|
||||
const v0 = Math.max(-4, Math.min(4, avgV));
|
||||
if (Math.abs(v0) < 0.005) return;
|
||||
|
||||
const tau = 300;
|
||||
const coastStart = performance.now();
|
||||
const scrollStart2 = scrollEl.scrollTop;
|
||||
const totalDist = v0 * tau;
|
||||
|
||||
function coast() {
|
||||
const t = performance.now() - coastStart;
|
||||
const decay = Math.exp(-t / tau);
|
||||
const offset = totalDist * (1 - decay);
|
||||
const target = scrollStart2 + offset;
|
||||
const max = getMaxScroll();
|
||||
|
||||
if (target < 0) {
|
||||
scrollEl.scrollTop = 0;
|
||||
const bounce = Math.min(30, Math.abs(v0 * decay) * 40);
|
||||
setOverscroll(-bounce);
|
||||
springBack();
|
||||
return;
|
||||
}
|
||||
if (target > max) {
|
||||
scrollEl.scrollTop = max;
|
||||
const bounce = Math.min(30, Math.abs(v0 * decay) * 40);
|
||||
setOverscroll(bounce);
|
||||
springBack();
|
||||
return;
|
||||
}
|
||||
|
||||
scrollEl.scrollTop = target;
|
||||
showThumb();
|
||||
if (Math.abs(v0 * decay) > 0.0005) {
|
||||
coastFrame = requestAnimationFrame(coast);
|
||||
}
|
||||
}
|
||||
coastFrame = requestAnimationFrame(coast);
|
||||
}
|
||||
|
||||
window.addEventListener("mousemove", onMove, true);
|
||||
window.addEventListener("mouseup", onUp, true);
|
||||
}
|
||||
|
||||
function onWheel(e: WheelEvent) {
|
||||
e.stopPropagation();
|
||||
const { scrollTop, scrollHeight, clientHeight } = scrollEl;
|
||||
const atTop = scrollTop <= 0 && e.deltaY < 0;
|
||||
const atBottom = scrollTop + clientHeight >= scrollHeight && e.deltaY > 0;
|
||||
if (!atTop && !atBottom) {
|
||||
e.preventDefault();
|
||||
scrollEl.scrollTop += e.deltaY;
|
||||
showThumb();
|
||||
}
|
||||
}
|
||||
|
||||
node.addEventListener("mousedown", onDown);
|
||||
node.addEventListener("wheel", onWheel, { passive: false });
|
||||
|
||||
return {
|
||||
destroy() {
|
||||
node.removeEventListener("mousedown", onDown);
|
||||
node.removeEventListener("wheel", onWheel);
|
||||
forceReset();
|
||||
if (hideTimer) clearTimeout(hideTimer);
|
||||
thumb.remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-3">
|
||||
<!-- Add custom activity -->
|
||||
<div class="flex gap-2 items-center">
|
||||
<div class="flex-1">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Add custom activity..."
|
||||
aria-label="New activity text"
|
||||
maxlength={500}
|
||||
class="w-full rounded-xl border border-[#161616] bg-black px-3 py-2.5 text-[13px]
|
||||
text-white outline-none placeholder:text-[#2a2a2a] focus:border-[#333]"
|
||||
bind:value={newActivityText}
|
||||
onkeydown={(e) => { if (e.key === "Enter") addCustomActivity(); }}
|
||||
/>
|
||||
</div>
|
||||
<!-- Custom category dropdown -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="relative" bind:this={dropdownRef} onkeydown={handleDropdownKeydown} role="presentation">
|
||||
<button
|
||||
bind:this={dropdownTriggerRef}
|
||||
use:pressable
|
||||
class="flex items-center gap-1.5 rounded-xl border border-[#161616] bg-black px-3 py-2.5 text-[13px] text-[#8a8a8a]
|
||||
hover:border-[#333] hover:text-white transition-colors"
|
||||
onclick={() => { dropdownOpen = !dropdownOpen; }}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={dropdownOpen}
|
||||
aria-label="Category: {getCategoryLabel(newActivityCategory)}"
|
||||
>
|
||||
<span>{getCategoryLabel(newActivityCategory)}</span>
|
||||
<svg aria-hidden="true" class="w-3 h-3 opacity-50 transition-transform duration-200 {dropdownOpen ? 'rotate-180' : ''}" viewBox="0 0 12 12" fill="none">
|
||||
<path d="M3 4.5L6 7.5L9 4.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
{#if dropdownOpen}
|
||||
<div class="absolute top-full left-0 mt-1 z-50 min-w-[140px] rounded-xl border border-[#222] bg-[#111] shadow-xl shadow-black/50 overflow-hidden"
|
||||
role="listbox" aria-label="Activity category">
|
||||
{#each categories as cat, i}
|
||||
<button
|
||||
class="w-full text-left px-3.5 py-2.5 text-[13px] transition-colors outline-none
|
||||
{newActivityCategory === cat ? 'text-white bg-[#1a1a1a]' : 'text-[#8a8a8a] hover:bg-[#1a1a1a] hover:text-white'}
|
||||
{focusedOptionIndex === i ? 'bg-[#1a1a1a] text-white' : ''}"
|
||||
role="option"
|
||||
aria-selected={newActivityCategory === cat}
|
||||
tabindex={focusedOptionIndex === i ? 0 : -1}
|
||||
onclick={() => { newActivityCategory = cat; dropdownOpen = false; dropdownTriggerRef?.focus(); }}
|
||||
>
|
||||
{getCategoryLabel(cat)}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<button
|
||||
use:pressable
|
||||
class="flex items-center justify-center w-10 h-10 rounded-xl border border-[#161616] text-[18px] text-[#8a8a8a]
|
||||
hover:border-[#333] hover:text-white transition-colors disabled:opacity-30"
|
||||
onclick={addCustomActivity}
|
||||
disabled={!newActivityText.trim()}
|
||||
aria-label="Add custom activity"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Category buttons -->
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
{#each categories as cat}
|
||||
{@const total = builtinCount(cat) + customCount(cat)}
|
||||
{@const disabled = disabledCount(cat)}
|
||||
{@const isExpanded = expandedCategory === cat}
|
||||
<button
|
||||
use:pressable
|
||||
class="rounded-xl px-3 py-2 text-[11px] tracking-wider transition-all duration-200
|
||||
{isExpanded
|
||||
? 'bg-[#1a1a1a] text-white border border-[#333]'
|
||||
: 'bg-[#0a0a0a] text-[#8a8a8a] border border-[#161616] hover:border-[#333] hover:text-white'}"
|
||||
onclick={() => toggleCategory(cat)}
|
||||
aria-expanded={isExpanded}
|
||||
aria-controls="activity-panel-{cat}"
|
||||
>
|
||||
<span class="uppercase">{getCategoryLabel(cat)}</span>
|
||||
<span class="ml-1.5 text-[10px] {isExpanded ? 'text-[#8a8a8a]' : 'text-[#8a8a8a] opacity-60'}">
|
||||
{total - disabled}/{total}
|
||||
</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Animated accordion for activity list -->
|
||||
{#each categories as cat}
|
||||
{@const isExpanded = expandedCategory === cat}
|
||||
{@const catBuiltins = breakActivities.filter((a) => a.category === cat)}
|
||||
{@const catCustoms = $config.custom_activities.filter((a) => a.category === cat)}
|
||||
<div class="accordion-wrapper" class:accordion-open={isExpanded} id="activity-panel-{cat}" role="region" aria-label="{getCategoryLabel(cat)} activities">
|
||||
<div class="accordion-inner">
|
||||
<div class="rounded-xl border border-[#161616] bg-[#0a0a0a] overflow-hidden" use:blockParentDrag>
|
||||
{#if catCustoms.length > 0}
|
||||
<div class="px-3 pt-2.5 pb-1">
|
||||
<div class="text-[9px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase">Custom</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="relative" use:innerDragScroll>
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
||||
<div class="activity-scroll max-h-[200px] overflow-y-auto overflow-x-hidden" tabindex="0" role="group" aria-label="{getCategoryLabel(cat)} activity list">
|
||||
<div class="scroll-content">
|
||||
{#each catCustoms as activity (activity.id)}
|
||||
<div class="flex items-center gap-1 px-1.5 py-0.5 group hover:bg-[#111]">
|
||||
<button
|
||||
class="flex items-center justify-center min-w-[32px] min-h-[32px] text-[13px] transition-opacity {activity.is_favorite ? 'opacity-100' : 'opacity-30 hover:opacity-60'}"
|
||||
onclick={() => toggleCustomFavorite(activity.id)}
|
||||
aria-label="{activity.is_favorite ? 'Remove from favorites' : 'Add to favorites'}"
|
||||
>
|
||||
★
|
||||
</button>
|
||||
<span class="flex-1 text-[12px] {activity.enabled ? 'text-white' : 'text-[#8a8a8a] line-through'}">
|
||||
{activity.text}
|
||||
</span>
|
||||
<button
|
||||
class="flex items-center justify-center min-w-[32px] min-h-[32px] text-[#8a8a8a] hover:text-[#f85149] opacity-0 group-hover:opacity-100 focus-visible:opacity-100 transition-all text-[11px]"
|
||||
onclick={() => removeCustomActivity(activity.id)}
|
||||
aria-label="Remove {activity.text}"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
<ToggleSwitch
|
||||
checked={activity.enabled}
|
||||
label="Enable {activity.text}"
|
||||
onchange={() => toggleCustomEnabled(activity.id)}
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
{#if catCustoms.length > 0 && catBuiltins.length > 0}
|
||||
<div class="px-3 pt-2 pb-1">
|
||||
<div class="text-[9px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase">Built-in</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#each catBuiltins as activity (activity.text)}
|
||||
<div class="flex items-center gap-1 px-1.5 py-0.5 hover:bg-[#111]">
|
||||
<button
|
||||
class="flex items-center justify-center min-w-[32px] min-h-[32px] text-[13px] transition-opacity {isBuiltinFavorite(activity.text) ? 'opacity-100' : 'opacity-30 hover:opacity-60'}"
|
||||
onclick={() => toggleBuiltinFavorite(activity.text)}
|
||||
aria-label="{isBuiltinFavorite(activity.text) ? 'Remove from favorites' : 'Add to favorites'}"
|
||||
>
|
||||
★
|
||||
</button>
|
||||
<span class="flex-1 text-[12px] {!isBuiltinDisabled(activity.text) ? 'text-[#8a8a8a]' : 'text-[#8a8a8a] line-through opacity-60'}">
|
||||
{activity.text}
|
||||
</span>
|
||||
<ToggleSwitch
|
||||
checked={!isBuiltinDisabled(activity.text)}
|
||||
label="Enable {activity.text}"
|
||||
onchange={() => toggleBuiltinEnabled(activity.text)}
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Accordion: CSS grid row transition for height animation */
|
||||
.accordion-wrapper {
|
||||
display: grid;
|
||||
grid-template-rows: 0fr;
|
||||
transition: grid-template-rows 0.3s cubic-bezier(0.22, 0.03, 0.26, 1);
|
||||
}
|
||||
.accordion-wrapper.accordion-open {
|
||||
grid-template-rows: 1fr;
|
||||
}
|
||||
.accordion-inner {
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* Hide native scrollbar completely */
|
||||
.activity-scroll {
|
||||
overscroll-behavior: contain;
|
||||
scrollbar-width: none; /* Firefox */
|
||||
}
|
||||
.activity-scroll::-webkit-scrollbar {
|
||||
display: none; /* Chrome/Edge */
|
||||
}
|
||||
</style>
|
||||
56
src/lib/components/BreakOverlay.svelte
Normal file
56
src/lib/components/BreakOverlay.svelte
Normal file
@@ -0,0 +1,56 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { loadConfig, config } from "../stores/config";
|
||||
import type { TimerSnapshot } from "../stores/timer";
|
||||
|
||||
let breakTimeRemaining = $state(0);
|
||||
let breakTotalDuration = $state(0);
|
||||
|
||||
const progress = $derived(breakTotalDuration > 0 ? breakTimeRemaining / breakTotalDuration : 0);
|
||||
|
||||
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")}`;
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
await loadConfig();
|
||||
|
||||
try {
|
||||
const snap = await invoke<TimerSnapshot>("get_timer_state");
|
||||
breakTimeRemaining = snap.breakTimeRemaining;
|
||||
breakTotalDuration = snap.breakTotalDuration;
|
||||
} catch {}
|
||||
|
||||
await listen<TimerSnapshot>("timer-tick", (event) => {
|
||||
breakTimeRemaining = event.payload.breakTimeRemaining;
|
||||
breakTotalDuration = event.payload.breakTotalDuration;
|
||||
});
|
||||
|
||||
await listen("break-ended", () => {
|
||||
// Window will be closed by backend
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="fixed inset-0 flex flex-col items-center justify-center"
|
||||
style="background: rgba(0, 0, 0, {$config.backdrop_opacity});"
|
||||
>
|
||||
<p class="text-[16px] font-medium text-white mb-4">Break in progress</p>
|
||||
|
||||
<span class="text-[42px] font-semibold tabular-nums text-white leading-none mb-6">
|
||||
{formatTime(breakTimeRemaining)}
|
||||
</span>
|
||||
|
||||
<!-- Progress bar -->
|
||||
<div class="w-48 h-[3px] rounded-full overflow-hidden" style="background: rgba(255,255,255,0.06);">
|
||||
<div
|
||||
class="h-full transition-[width] duration-1000 ease-linear"
|
||||
style="width: {(1 - progress) * 100}%; background: {$config.break_color};"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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;
|
||||
|
||||
183
src/lib/components/BreathingGuide.svelte
Normal file
183
src/lib/components/BreathingGuide.svelte
Normal 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>
|
||||
166
src/lib/components/Celebration.svelte
Normal file
166
src/lib/components/Celebration.svelte
Normal file
@@ -0,0 +1,166 @@
|
||||
<script lang="ts">
|
||||
import { milestoneEvent, dailyGoalEvent } from "../stores/timer";
|
||||
import { config } from "../stores/config";
|
||||
|
||||
const showMilestone = $derived($milestoneEvent !== null && $config.milestone_celebrations);
|
||||
const showGoal = $derived($dailyGoalEvent && $config.milestone_celebrations);
|
||||
const streakDays = $derived($milestoneEvent ?? 0);
|
||||
|
||||
// Generate confetti particles on milestone
|
||||
const confettiColors = ["#ff4d00", "#7c6aef", "#3fb950", "#fca311", "#f72585", "#4361ee"];
|
||||
const confettiParticles = $derived(
|
||||
showMilestone
|
||||
? Array.from({ length: 24 }, (_, i) => ({
|
||||
id: i,
|
||||
color: confettiColors[i % confettiColors.length],
|
||||
angle: (i / 24) * 360 + Math.random() * 15,
|
||||
distance: 60 + Math.random() * 80,
|
||||
delay: Math.random() * 0.3,
|
||||
size: 4 + Math.random() * 4,
|
||||
}))
|
||||
: [],
|
||||
);
|
||||
</script>
|
||||
|
||||
{#if showMilestone}
|
||||
<div class="celebration-overlay" role="alert" aria-live="assertive">
|
||||
<!-- Confetti burst -->
|
||||
<div class="confetti-container">
|
||||
{#each confettiParticles as p (p.id)}
|
||||
<div
|
||||
class="confetti-particle"
|
||||
style="
|
||||
--angle: {p.angle}deg;
|
||||
--distance: {p.distance}px;
|
||||
--delay: {p.delay}s;
|
||||
--size: {p.size}px;
|
||||
background: {p.color};
|
||||
"
|
||||
></div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Milestone text -->
|
||||
<div class="celebration-text">
|
||||
<div class="text-[32px] font-bold text-white mb-1">{streakDays}</div>
|
||||
<div class="text-[13px] font-medium tracking-wider uppercase text-white opacity-80">
|
||||
day streak!
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if showGoal && !showMilestone}
|
||||
<div class="goal-overlay" role="alert" aria-live="assertive">
|
||||
<div class="goal-badge">
|
||||
<svg aria-hidden="true" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#3fb950" stroke-width="2.5">
|
||||
<path d="M20 6L9 17l-5-5"/>
|
||||
</svg>
|
||||
<span class="text-[14px] font-medium text-[#3fb950] ml-2">Daily goal reached!</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.celebration-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 9999;
|
||||
pointer-events: none;
|
||||
animation: celebration-fade 3.5s ease forwards;
|
||||
}
|
||||
|
||||
@keyframes celebration-fade {
|
||||
0%, 70% { opacity: 1; }
|
||||
100% { opacity: 0; }
|
||||
}
|
||||
|
||||
.confetti-container {
|
||||
position: absolute;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.confetti-particle {
|
||||
position: absolute;
|
||||
width: var(--size);
|
||||
height: var(--size);
|
||||
border-radius: 2px;
|
||||
animation: confetti-burst 1.2s ease-out var(--delay) forwards;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@keyframes confetti-burst {
|
||||
0% {
|
||||
transform: translate(0, 0) rotate(0deg) scale(0);
|
||||
opacity: 1;
|
||||
}
|
||||
30% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform:
|
||||
translate(
|
||||
calc(cos(var(--angle)) * var(--distance)),
|
||||
calc(sin(var(--angle)) * var(--distance) + 40px)
|
||||
)
|
||||
rotate(720deg)
|
||||
scale(1);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.celebration-text {
|
||||
text-align: center;
|
||||
animation: celebration-pop 0.5s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
|
||||
text-shadow: 0 2px 20px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
@keyframes celebration-pop {
|
||||
0% { transform: scale(0.5); opacity: 0; }
|
||||
100% { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
|
||||
.goal-overlay {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 9999;
|
||||
pointer-events: none;
|
||||
animation: goal-slide 3.5s ease forwards;
|
||||
}
|
||||
|
||||
@keyframes goal-slide {
|
||||
0% { transform: translateX(-50%) translateY(-20px); opacity: 0; }
|
||||
10% { transform: translateX(-50%) translateY(0); opacity: 1; }
|
||||
75% { opacity: 1; }
|
||||
100% { transform: translateX(-50%) translateY(-10px); opacity: 0; }
|
||||
}
|
||||
|
||||
.goal-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: rgba(63, 185, 80, 0.12);
|
||||
border: 1px solid rgba(63, 185, 80, 0.25);
|
||||
border-radius: 12px;
|
||||
padding: 10px 18px;
|
||||
backdrop-filter: blur(16px);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.celebration-overlay,
|
||||
.confetti-particle,
|
||||
.celebration-text,
|
||||
.goal-overlay {
|
||||
animation: none;
|
||||
opacity: 1;
|
||||
}
|
||||
.confetti-particle {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -10,6 +10,7 @@
|
||||
import { config } from "../stores/config";
|
||||
import TimerRing from "./TimerRing.svelte";
|
||||
import { scaleIn, fadeIn, pressable, glowHover } from "../utils/animate";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
|
||||
async function toggleTimer() {
|
||||
const snap = await invoke<TimerSnapshot>("toggle_timer");
|
||||
@@ -28,15 +29,45 @@
|
||||
}
|
||||
|
||||
const statusText = $derived(
|
||||
$timer.idlePaused
|
||||
? "IDLE"
|
||||
: $timer.prebreakWarning
|
||||
? "BREAK SOON"
|
||||
: $timer.state === "running"
|
||||
? "FOCUS"
|
||||
: "PAUSED",
|
||||
$timer.deferredBreakPending
|
||||
? "DEFERRED"
|
||||
: $timer.idlePaused
|
||||
? "IDLE"
|
||||
: $timer.prebreakWarning
|
||||
? "BREAK SOON"
|
||||
: $timer.state === "running"
|
||||
? "FOCUS"
|
||||
: "PAUSED",
|
||||
);
|
||||
|
||||
// F1: Microbreak countdown
|
||||
const microbreakCountdown = $derived(() => {
|
||||
if (!$timer.microbreakEnabled || $timer.microbreakActive) return "";
|
||||
const secs = $timer.microbreakCountdown;
|
||||
const m = Math.floor(secs / 60);
|
||||
const s = secs % 60;
|
||||
return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
|
||||
});
|
||||
|
||||
// F10: Daily goal from stats
|
||||
let dailyGoalProgress = $state(0);
|
||||
let dailyGoalMet = $state(false);
|
||||
|
||||
// Load stats for daily goal display
|
||||
async function loadGoalProgress() {
|
||||
try {
|
||||
const stats = await invoke<{ dailyGoalProgress: number; dailyGoalMet: boolean }>("get_stats");
|
||||
dailyGoalProgress = stats.dailyGoalProgress;
|
||||
dailyGoalMet = stats.dailyGoalMet;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
// Reload goal progress on each tick (approximately)
|
||||
const _state = $timer.state;
|
||||
loadGoalProgress();
|
||||
});
|
||||
|
||||
// Track status changes for aria-live region (announce only on change, not every tick)
|
||||
let lastAnnouncedStatus = $state("");
|
||||
let statusAnnouncement = $state("");
|
||||
@@ -146,11 +177,74 @@
|
||||
<!-- Status label -->
|
||||
<span
|
||||
class="block text-center text-[11px] font-medium tracking-[0.25em]"
|
||||
class:text-[#8a8a8a]={!$timer.prebreakWarning}
|
||||
class:text-[#8a8a8a]={!$timer.prebreakWarning && !$timer.deferredBreakPending}
|
||||
class:text-warning={$timer.prebreakWarning}
|
||||
class:text-[#fca311]={$timer.deferredBreakPending}
|
||||
>
|
||||
{statusText}
|
||||
</span>
|
||||
|
||||
<!-- Indicators inside ring -->
|
||||
<div class="mt-2 flex flex-col items-center gap-1">
|
||||
<!-- Pomodoro cycle -->
|
||||
{#if $timer.pomodoroEnabled}
|
||||
<div class="flex items-center gap-1.5">
|
||||
<div class="flex items-center gap-1">
|
||||
{#each Array($timer.pomodoroTotalInCycle) as _, i}
|
||||
{@const isLong = i === $timer.pomodoroTotalInCycle - 1}
|
||||
{@const isFilled = i < $timer.pomodoroCyclePosition}
|
||||
{@const isCurrent = i === $timer.pomodoroCyclePosition}
|
||||
<div
|
||||
class="rounded-full transition-colors duration-300"
|
||||
style="
|
||||
width: {isLong ? 8 : 5}px;
|
||||
height: {isLong ? 8 : 5}px;
|
||||
background: {isFilled ? $config.accent_color : isCurrent ? $config.accent_color + '60' : '#222'};
|
||||
{isCurrent ? 'box-shadow: 0 0 4px ' + $config.accent_color + '40;' : ''}
|
||||
"
|
||||
></div>
|
||||
{/each}
|
||||
</div>
|
||||
<span class="text-[9px] text-[#8a8a8a] tabular-nums">
|
||||
{$timer.pomodoroCyclePosition + 1}/{$timer.pomodoroTotalInCycle}
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Microbreak countdown -->
|
||||
{#if $timer.microbreakEnabled && !$timer.microbreakActive && $timer.state === "running"}
|
||||
<div class="flex items-center gap-1 text-[9px] text-[#8a8a8a]">
|
||||
<svg aria-hidden="true" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<path d="M2.036 12.322a1.012 1.012 0 0 1 0-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178Z"/>
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
</svg>
|
||||
<span class="tabular-nums">{microbreakCountdown()}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Daily goal -->
|
||||
{#if $config.daily_goal_enabled}
|
||||
<div class="flex items-center gap-1.5">
|
||||
{#if dailyGoalMet}
|
||||
<svg aria-hidden="true" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="#3fb950" stroke-width="2.5">
|
||||
<path d="M20 6L9 17l-5-5"/>
|
||||
</svg>
|
||||
<span class="text-[9px] text-[#3fb950]">Goal met</span>
|
||||
{:else}
|
||||
<span class="text-[9px] text-[#8a8a8a]">Goal</span>
|
||||
<div class="w-16 h-[2px] rounded-full overflow-hidden" style="background: #161616;">
|
||||
<div
|
||||
class="h-full rounded-full transition-[width] duration-500"
|
||||
style="width: {Math.min(100, (dailyGoalProgress / $config.daily_goal_breaks) * 100)}%; background: {$config.accent_color};"
|
||||
></div>
|
||||
</div>
|
||||
<span class="text-[9px] text-[#8a8a8a] tabular-nums">
|
||||
{dailyGoalProgress}/{$config.daily_goal_breaks}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</TimerRing>
|
||||
</div>
|
||||
|
||||
23
src/lib/components/DimOverlay.svelte
Normal file
23
src/lib/components/DimOverlay.svelte
Normal file
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
|
||||
let opacity = $state(0);
|
||||
|
||||
onMount(async () => {
|
||||
await listen<{ progress: number; maxOpacity: number }>("screen-dim-update", (event) => {
|
||||
opacity = event.payload.progress * event.payload.maxOpacity;
|
||||
});
|
||||
|
||||
// When break starts, this window gets destroyed by backend
|
||||
await listen("break-started", () => {
|
||||
opacity = 0;
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="fixed inset-0 pointer-events-none"
|
||||
style="background: rgba(0, 0, 0, {opacity}); transition: opacity 1s linear;"
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
80
src/lib/components/MicrobreakOverlay.svelte
Normal file
80
src/lib/components/MicrobreakOverlay.svelte
Normal file
@@ -0,0 +1,80 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type { TimerSnapshot } from "../stores/timer";
|
||||
import { loadConfig, config } from "../stores/config";
|
||||
import { pickRandomActivity, getCategoryLabel, type BreakActivity } from "../utils/activities";
|
||||
|
||||
let timeRemaining = $state(20);
|
||||
let totalDuration = $state(20);
|
||||
let activity = $state<BreakActivity | null>(null);
|
||||
|
||||
const progress = $derived(totalDuration > 0 ? 1 - timeRemaining / totalDuration : 0);
|
||||
|
||||
onMount(async () => {
|
||||
await loadConfig();
|
||||
|
||||
if ($config.microbreak_show_activity) {
|
||||
// Pick an eye-focused activity for microbreaks
|
||||
activity = pickRandomActivity(undefined, $config);
|
||||
}
|
||||
|
||||
try {
|
||||
const snap = await invoke<TimerSnapshot>("get_timer_state");
|
||||
timeRemaining = snap.microbreakTimeRemaining;
|
||||
totalDuration = snap.microbreakTotalDuration;
|
||||
} catch {}
|
||||
|
||||
await listen<TimerSnapshot>("timer-tick", (event) => {
|
||||
const snap = event.payload;
|
||||
timeRemaining = snap.microbreakTimeRemaining;
|
||||
totalDuration = snap.microbreakTotalDuration;
|
||||
});
|
||||
|
||||
await listen("microbreak-ended", () => {
|
||||
// Window will be closed by backend
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="microbreak-card">
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<svg aria-hidden="true" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#7c6aef" stroke-width="1.5">
|
||||
<path d="M2.036 12.322a1.012 1.012 0 0 1 0-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178Z"/>
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
</svg>
|
||||
<span class="text-[15px] font-medium text-white">Look away — 20 feet for {timeRemaining}s</span>
|
||||
</div>
|
||||
|
||||
{#if activity && $config.microbreak_show_activity}
|
||||
<p class="text-[12px] text-[#8a8a8a] mb-3 ml-[34px]">
|
||||
{activity.text}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<!-- Progress bar -->
|
||||
<div class="h-[3px] w-full rounded-full overflow-hidden" style="background: rgba(255,255,255,0.05);">
|
||||
<div
|
||||
class="h-full transition-[width] duration-1000 ease-linear rounded-full"
|
||||
style="width: {progress * 100}%; background: linear-gradient(to right, #7c6aef, #4361ee);"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.microbreak-card {
|
||||
position: fixed;
|
||||
top: 16px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 380px;
|
||||
background: rgba(12, 12, 12, 0.95);
|
||||
backdrop-filter: blur(24px);
|
||||
-webkit-backdrop-filter: blur(24px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 16px;
|
||||
padding: 16px 20px;
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
</style>
|
||||
@@ -16,6 +16,9 @@
|
||||
let breakColor = $state("#7c6aef");
|
||||
let countdownFont = $state("");
|
||||
let draggable = $state(false);
|
||||
let pomodoroEnabled = $state(false);
|
||||
let pomodoroCyclePosition = $state(0);
|
||||
let pomodoroTotalInCycle = $state(4);
|
||||
|
||||
// Use config store directly for live updates
|
||||
const uiZoom = $derived($config.ui_zoom);
|
||||
@@ -132,6 +135,9 @@
|
||||
timeText = formatTime(snap.timeRemaining);
|
||||
progress = snap.progress;
|
||||
}
|
||||
pomodoroEnabled = snap.pomodoroEnabled;
|
||||
pomodoroCyclePosition = snap.pomodoroCyclePosition;
|
||||
pomodoroTotalInCycle = snap.pomodoroTotalInCycle;
|
||||
}
|
||||
|
||||
// Click opens main window
|
||||
@@ -333,6 +339,12 @@ const fontStyle = $derived(
|
||||
>
|
||||
{timeText}
|
||||
</span>
|
||||
<!-- F3: Pomodoro cycle indicator -->
|
||||
{#if pomodoroEnabled}
|
||||
<span class="ml-1.5 text-[10px] tabular-nums" style="color: #8a8a8a;">
|
||||
{pomodoroCyclePosition + 1}/{pomodoroTotalInCycle}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,10 +7,36 @@
|
||||
import ColorPicker from "./ColorPicker.svelte";
|
||||
import FontSelector from "./FontSelector.svelte";
|
||||
import TimeSpinner from "./TimeSpinner.svelte";
|
||||
import ActivityManager from "./ActivityManager.svelte";
|
||||
import { fadeIn, inView, pressable, dragScroll } from "../utils/animate";
|
||||
import { playSound } from "../utils/sounds";
|
||||
import type { TimeRange } from "../stores/config";
|
||||
|
||||
const breathingPatternMeta = [
|
||||
{ id: "box", label: "Box", desc: "4s in \u00b7 4s hold \u00b7 4s out \u00b7 4s hold" },
|
||||
{ id: "relaxing", label: "Relaxing", desc: "4s in \u00b7 7s hold \u00b7 8s out" },
|
||||
{ id: "energizing", label: "Energizing", desc: "6s in \u00b7 2s hold \u00b7 6s out \u00b7 2s hold" },
|
||||
{ id: "calm", label: "Calm", desc: "4s in \u00b7 4s hold \u00b7 6s out" },
|
||||
{ id: "deep", label: "Deep", desc: "5s in \u00b7 5s out" },
|
||||
] as const;
|
||||
|
||||
// F8: Auto-start on login
|
||||
let autoStartEnabled = $state(false);
|
||||
async function loadAutoStartStatus() {
|
||||
try {
|
||||
autoStartEnabled = await invoke<boolean>("get_auto_start_status");
|
||||
} catch {}
|
||||
}
|
||||
async function toggleAutoStart() {
|
||||
try {
|
||||
await invoke("set_auto_start", { enabled: !autoStartEnabled });
|
||||
autoStartEnabled = !autoStartEnabled;
|
||||
} catch (e) {
|
||||
console.error("Failed to set auto-start:", e);
|
||||
}
|
||||
}
|
||||
$effect(() => { loadAutoStartStatus(); });
|
||||
|
||||
const soundPresets = ["bell", "chime", "soft", "digital", "harp", "bowl", "rain", "whistle"] as const;
|
||||
const daysOfWeek = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"] as const;
|
||||
|
||||
@@ -131,11 +157,10 @@
|
||||
use:dragScroll
|
||||
>
|
||||
<div class="space-y-3">
|
||||
<!-- Timer -->
|
||||
|
||||
<!-- 1. Timer -->
|
||||
<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">
|
||||
Timer
|
||||
</h3>
|
||||
|
||||
@@ -188,11 +213,137 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Break Screen -->
|
||||
<!-- 2. Pomodoro Mode -->
|
||||
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.03 }}>
|
||||
<h3 class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase">
|
||||
Pomodoro Mode
|
||||
</h3>
|
||||
|
||||
<div class="flex items-center">
|
||||
<div class="flex-1">
|
||||
<div class="text-[13px] text-white">Enable Pomodoro</div>
|
||||
<div class="text-[11px] text-[#8a8a8a]">Short breaks then a long break</div>
|
||||
</div>
|
||||
<ToggleSwitch bind:checked={$config.pomodoro_enabled} label="Pomodoro mode" onchange={markChanged} />
|
||||
</div>
|
||||
|
||||
{#if $config.pomodoro_enabled}
|
||||
<div class="my-4 h-px bg-[#161616]"></div>
|
||||
<div class="flex items-center">
|
||||
<div class="flex-1">
|
||||
<div class="text-[13px] text-white">Short breaks before long</div>
|
||||
<div class="text-[11px] text-[#8a8a8a]">{$config.pomodoro_short_breaks} short + 1 long</div>
|
||||
</div>
|
||||
<Stepper bind:value={$config.pomodoro_short_breaks} label="Short breaks" min={1} max={10} onchange={markChanged} />
|
||||
</div>
|
||||
|
||||
<div class="my-4 h-px bg-[#161616]"></div>
|
||||
<div class="flex items-center">
|
||||
<div class="flex-1">
|
||||
<div class="text-[13px] text-white">Long break duration</div>
|
||||
<div class="text-[11px] text-[#8a8a8a]">{$config.pomodoro_long_break_duration} min</div>
|
||||
</div>
|
||||
<Stepper bind:value={$config.pomodoro_long_break_duration} label="Long break duration" min={5} max={60} onchange={markChanged} />
|
||||
</div>
|
||||
|
||||
<div class="my-4 h-px bg-[#161616]"></div>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<label class="text-[13px] text-white" for="pomo-title">Long break title</label>
|
||||
<input id="pomo-title" type="text" maxlength={100}
|
||||
class="rounded-xl border border-[#161616] bg-black px-3.5 py-2.5 text-[13px] text-white outline-none transition-colors placeholder:text-[#2a2a2a] focus:border-[#333]"
|
||||
bind:value={$config.pomodoro_long_break_title} oninput={markChanged}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="my-4 h-px bg-[#161616]"></div>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<label class="text-[13px] text-white" for="pomo-msg">Long break message</label>
|
||||
<input id="pomo-msg" type="text" maxlength={500}
|
||||
class="rounded-xl border border-[#161616] bg-black px-3.5 py-2.5 text-[13px] text-white outline-none transition-colors placeholder:text-[#2a2a2a] focus:border-[#333]"
|
||||
bind:value={$config.pomodoro_long_break_message} oninput={markChanged}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="my-4 h-px bg-[#161616]"></div>
|
||||
<div class="flex items-center">
|
||||
<div class="flex-1">
|
||||
<div class="text-[13px] text-white">Reset on skip</div>
|
||||
<div class="text-[11px] text-[#8a8a8a]">Reset cycle when skipping a break</div>
|
||||
</div>
|
||||
<ToggleSwitch bind:checked={$config.pomodoro_reset_on_skip} label="Reset on skip" onchange={markChanged} />
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- 3. Microbreaks -->
|
||||
<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">
|
||||
Microbreaks
|
||||
</h3>
|
||||
|
||||
<div class="flex items-center">
|
||||
<div class="flex-1">
|
||||
<div class="text-[13px] text-white">20-20-20 eye breaks</div>
|
||||
<div class="text-[11px] text-[#8a8a8a]">Quick eye rest reminders</div>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
bind:checked={$config.microbreak_enabled}
|
||||
label="Microbreaks"
|
||||
onchange={markChanged}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if $config.microbreak_enabled}
|
||||
<div class="my-4 h-px bg-[#161616]"></div>
|
||||
<div class="flex items-center">
|
||||
<div class="flex-1">
|
||||
<div class="text-[13px] text-white">Frequency</div>
|
||||
<div class="text-[11px] text-[#8a8a8a]">Every {$config.microbreak_frequency} min</div>
|
||||
</div>
|
||||
<Stepper bind:value={$config.microbreak_frequency} label="Microbreak frequency" min={5} max={60} onchange={markChanged} />
|
||||
</div>
|
||||
|
||||
<div class="my-4 h-px bg-[#161616]"></div>
|
||||
<div class="flex items-center">
|
||||
<div class="flex-1">
|
||||
<div class="text-[13px] text-white">Duration</div>
|
||||
<div class="text-[11px] text-[#8a8a8a]">{$config.microbreak_duration} seconds</div>
|
||||
</div>
|
||||
<Stepper bind:value={$config.microbreak_duration} label="Microbreak duration" min={10} max={60} step={5} onchange={markChanged} />
|
||||
</div>
|
||||
|
||||
<div class="my-4 h-px bg-[#161616]"></div>
|
||||
<div class="flex items-center">
|
||||
<div class="flex-1">
|
||||
<div class="text-[13px] text-white">Sound</div>
|
||||
<div class="text-[11px] text-[#8a8a8a]">Play sound on eye break</div>
|
||||
</div>
|
||||
<ToggleSwitch bind:checked={$config.microbreak_sound_enabled} label="Microbreak sound" onchange={markChanged} />
|
||||
</div>
|
||||
|
||||
<div class="my-4 h-px bg-[#161616]"></div>
|
||||
<div class="flex items-center">
|
||||
<div class="flex-1">
|
||||
<div class="text-[13px] text-white">Show activity</div>
|
||||
<div class="text-[11px] text-[#8a8a8a]">Activity suggestion during eye break</div>
|
||||
</div>
|
||||
<ToggleSwitch bind:checked={$config.microbreak_show_activity} label="Microbreak activity" onchange={markChanged} />
|
||||
</div>
|
||||
|
||||
<div class="my-4 h-px bg-[#161616]"></div>
|
||||
<div class="flex items-center">
|
||||
<div class="flex-1">
|
||||
<div class="text-[13px] text-white">Pause during breaks</div>
|
||||
<div class="text-[11px] text-[#8a8a8a]">No eye breaks during main breaks</div>
|
||||
</div>
|
||||
<ToggleSwitch bind:checked={$config.microbreak_pause_during_break} label="Pause during breaks" onchange={markChanged} />
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- 4. Break Screen (stripped down) -->
|
||||
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.09 }}>
|
||||
<h3 class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase">
|
||||
Break Screen
|
||||
</h3>
|
||||
|
||||
@@ -261,13 +412,90 @@
|
||||
onchange={markChanged}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if $config.fullscreen_mode}
|
||||
<div class="my-4 h-px bg-[#161616]"></div>
|
||||
<div class="flex items-center">
|
||||
<div class="flex-1">
|
||||
<div class="text-[13px] text-white">Block all monitors</div>
|
||||
<div class="text-[11px] text-[#8a8a8a]">Show overlay on all screens during breaks</div>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
bind:checked={$config.multi_monitor_break}
|
||||
label="Block all monitors"
|
||||
onchange={markChanged}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- Behavior -->
|
||||
<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"
|
||||
>
|
||||
<!-- 5. Break Activities (own card, conditional) -->
|
||||
{#if $config.show_break_activities}
|
||||
<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">
|
||||
Break Activities
|
||||
</h3>
|
||||
<ActivityManager />
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- 6. Breathing Guide (own card) -->
|
||||
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.15 }}>
|
||||
<h3 class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase">
|
||||
Breathing Guide
|
||||
</h3>
|
||||
|
||||
<div class="flex items-center">
|
||||
<div class="flex-1">
|
||||
<div class="text-[13px] text-white">Guided breathing</div>
|
||||
<div class="text-[11px] text-[#8a8a8a]">Visual breathing guide during breaks</div>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
bind:checked={$config.breathing_guide_enabled}
|
||||
label="Guided breathing"
|
||||
onchange={markChanged}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if $config.breathing_guide_enabled}
|
||||
<div class="my-4 h-px bg-[#161616]"></div>
|
||||
<div>
|
||||
<div class="mb-3 text-[13px] text-white">Breathing pattern</div>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
{#each breathingPatternMeta as bp}
|
||||
<button
|
||||
use:pressable
|
||||
class="flex items-center gap-3 rounded-xl px-3.5 py-2.5 text-left
|
||||
transition-all duration-200
|
||||
{$config.breathing_pattern === bp.id
|
||||
? 'bg-[#1a1a1a] border border-[#333]'
|
||||
: 'bg-[#0a0a0a] border border-[#161616] hover:border-[#333]'}"
|
||||
onclick={() => {
|
||||
$config.breathing_pattern = bp.id;
|
||||
markChanged();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
class="w-3 h-3 rounded-full border-2 flex-shrink-0 transition-colors duration-200"
|
||||
style="border-color: {$config.breathing_pattern === bp.id ? $config.accent_color : '#333'};
|
||||
background: {$config.breathing_pattern === bp.id ? $config.accent_color : 'transparent'};"
|
||||
></div>
|
||||
<span class="text-[12px] font-medium {$config.breathing_pattern === bp.id ? 'text-white' : 'text-[#8a8a8a]'}">
|
||||
{bp.label}
|
||||
</span>
|
||||
<span class="ml-auto text-[11px] text-[#8a8a8a] opacity-60 tabular-nums">
|
||||
{bp.desc}
|
||||
</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- 7. Behavior -->
|
||||
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.18 }}>
|
||||
<h3 class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase">
|
||||
Behavior
|
||||
</h3>
|
||||
|
||||
@@ -357,114 +585,152 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Working Hours -->
|
||||
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.18 }}>
|
||||
<!-- 8. Alerts (MERGED: Notifications + Pre-Break Nudge) -->
|
||||
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.21 }}>
|
||||
<h3 class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase">
|
||||
Alerts
|
||||
</h3>
|
||||
|
||||
<div class="flex items-center">
|
||||
<div class="flex-1">
|
||||
<div class="text-[13px] text-white">Working hours</div>
|
||||
<div class="text-[11px] text-[#8a8a8a]">
|
||||
Only show breaks during your configured work schedule
|
||||
</div>
|
||||
<div class="text-[13px] text-white">Pre-break alert</div>
|
||||
<div class="text-[11px] text-[#8a8a8a]">Warn before breaks</div>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
bind:checked={$config.working_hours_enabled}
|
||||
label="Working hours"
|
||||
bind:checked={$config.notification_enabled}
|
||||
label="Pre-break alert"
|
||||
onchange={markChanged}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if $config.working_hours_enabled}
|
||||
{#if $config.notification_enabled}
|
||||
<div class="my-4 h-px bg-[#161616]"></div>
|
||||
|
||||
{#each $config.working_hours_schedule as daySchedule, dayIndex}
|
||||
{@const dayName = daysOfWeek[dayIndex]}
|
||||
<div class="mb-4">
|
||||
<!-- Day header with toggle -->
|
||||
<div class="flex items-center gap-3 mb-3">
|
||||
<ToggleSwitch
|
||||
bind:checked={$config.working_hours_schedule[dayIndex].enabled}
|
||||
label={dayName}
|
||||
onchange={markChanged}
|
||||
/>
|
||||
<span class="text-[13px] text-white w-20">{dayName}</span>
|
||||
<div class="flex items-center">
|
||||
<div class="flex-1">
|
||||
<div class="text-[13px] text-white">Alert timing</div>
|
||||
<div class="text-[11px] text-[#8a8a8a]">
|
||||
{$config.notification_before_break}s before
|
||||
</div>
|
||||
|
||||
{#if daySchedule.enabled}
|
||||
<!-- Time ranges for this day -->
|
||||
<div class="space-y-2">
|
||||
{#each daySchedule.ranges as range, rangeIndex}
|
||||
<div class="flex items-center gap-2">
|
||||
<TimeSpinner
|
||||
value={range.start}
|
||||
countdownFont={$config.countdown_font}
|
||||
onchange={(v) => updateTimeRange(dayIndex, rangeIndex, "start", v)}
|
||||
/>
|
||||
<span class="text-[#555] text-[13px]">to</span>
|
||||
<TimeSpinner
|
||||
value={range.end}
|
||||
countdownFont={$config.countdown_font}
|
||||
onchange={(v) => updateTimeRange(dayIndex, rangeIndex, "end", v)}
|
||||
/>
|
||||
|
||||
<!-- Add range button -->
|
||||
{#if rangeIndex === daySchedule.ranges.length - 1}
|
||||
<button
|
||||
use:pressable
|
||||
class="ml-2 w-8 h-8 flex items-center justify-center rounded-full text-[#444] hover:text-white hover:bg-[#1a1a1a] transition-colors"
|
||||
onclick={() => addTimeRange(dayIndex)}
|
||||
aria-label="Add time range"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M12 5v14M5 12h14"/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<!-- Clone button -->
|
||||
<button
|
||||
use:pressable
|
||||
class="w-8 h-8 flex items-center justify-center rounded-full text-[#444] hover:text-white hover:bg-[#1a1a1a] transition-colors"
|
||||
onclick={() => cloneTimeRange(dayIndex, rangeIndex)}
|
||||
aria-label="Clone time range"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>
|
||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Delete button (never show for first range) -->
|
||||
{#if rangeIndex > 0}
|
||||
<button
|
||||
use:pressable
|
||||
class="w-8 h-8 flex items-center justify-center rounded-full text-[#444] hover:text-[#f85149] hover:bg-[#f8514915] transition-colors"
|
||||
onclick={() => removeTimeRange(dayIndex, rangeIndex)}
|
||||
aria-label="Remove time range"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M18 6L6 18M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if dayIndex < 6}
|
||||
<div class="my-4 h-px bg-[#161616]"></div>
|
||||
{/if}
|
||||
{/each}
|
||||
<Stepper
|
||||
bind:value={$config.notification_before_break}
|
||||
label="Alert timing"
|
||||
min={0}
|
||||
max={300}
|
||||
step={10}
|
||||
onchange={markChanged}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="my-4 h-px bg-[#161616]"></div>
|
||||
|
||||
<div class="flex items-center">
|
||||
<div class="flex-1">
|
||||
<div class="text-[13px] text-white">Screen dimming</div>
|
||||
<div class="text-[11px] text-[#8a8a8a]">Gradually dim screen before breaks</div>
|
||||
</div>
|
||||
<ToggleSwitch bind:checked={$config.screen_dim_enabled} label="Screen dimming" onchange={markChanged} />
|
||||
</div>
|
||||
|
||||
{#if $config.screen_dim_enabled}
|
||||
<div class="my-4 h-px bg-[#161616]"></div>
|
||||
<div class="flex items-center">
|
||||
<div class="flex-1">
|
||||
<div class="text-[13px] text-white">Start dimming</div>
|
||||
<div class="text-[11px] text-[#8a8a8a]">{$config.screen_dim_seconds}s before break</div>
|
||||
</div>
|
||||
<Stepper bind:value={$config.screen_dim_seconds} label="Dim start" min={3} max={60} onchange={markChanged} />
|
||||
</div>
|
||||
|
||||
<div class="my-4 h-px bg-[#161616]"></div>
|
||||
<div class="flex items-center">
|
||||
<div class="flex-1">
|
||||
<div class="text-[13px] text-white">Max dimming</div>
|
||||
<div class="text-[11px] text-[#8a8a8a]">{Math.round($config.screen_dim_max_opacity * 100)}%</div>
|
||||
</div>
|
||||
<Stepper
|
||||
bind:value={$config.screen_dim_max_opacity}
|
||||
label="Max dim opacity"
|
||||
min={0.1}
|
||||
max={0.7}
|
||||
step={0.05}
|
||||
formatValue={(v) => `${Math.round(v * 100)}%`}
|
||||
onchange={markChanged}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- Idle Detection -->
|
||||
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.21 }}>
|
||||
<h3
|
||||
class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase"
|
||||
>
|
||||
Idle Detection
|
||||
<!-- 9. Sound -->
|
||||
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.24 }}>
|
||||
<h3 class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase">
|
||||
Sound
|
||||
</h3>
|
||||
|
||||
<div class="flex items-center">
|
||||
<div class="flex-1">
|
||||
<div class="text-[13px] text-white">Sound effects</div>
|
||||
<div class="text-[11px] text-[#8a8a8a]">Play sounds on break events</div>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
bind:checked={$config.sound_enabled}
|
||||
label="Sound effects"
|
||||
onchange={markChanged}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if $config.sound_enabled}
|
||||
<div class="my-4 h-px bg-[#161616]"></div>
|
||||
|
||||
<div class="flex items-center">
|
||||
<div class="flex-1">
|
||||
<div class="text-[13px] text-white">Volume</div>
|
||||
<div class="text-[11px] text-[#8a8a8a]">{$config.sound_volume}%</div>
|
||||
</div>
|
||||
<Stepper
|
||||
bind:value={$config.sound_volume}
|
||||
label="Volume"
|
||||
min={0}
|
||||
max={100}
|
||||
step={10}
|
||||
formatValue={(v) => `${v}%`}
|
||||
onchange={markChanged}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="my-4 h-px bg-[#161616]"></div>
|
||||
|
||||
<div>
|
||||
<div class="mb-3 text-[13px] text-white">Sound preset</div>
|
||||
<div class="grid grid-cols-4 gap-2">
|
||||
{#each soundPresets as preset}
|
||||
<button
|
||||
use:pressable
|
||||
class="rounded-xl py-2.5 text-[11px] tracking-wider uppercase
|
||||
transition-all duration-200
|
||||
{$config.sound_preset === preset
|
||||
? 'bg-[#1a1a1a] text-white border border-[#333]'
|
||||
: 'bg-[#0a0a0a] text-[#8a8a8a] border border-[#161616] hover:border-[#333] hover:text-white'}"
|
||||
onclick={() => {
|
||||
$config.sound_preset = preset;
|
||||
markChanged();
|
||||
playSound(preset, $config.sound_volume);
|
||||
}}
|
||||
>
|
||||
{preset}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- 10. Idle & Smart Breaks (MERGED) -->
|
||||
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.27 }}>
|
||||
<h3 class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase">
|
||||
Idle & Smart Breaks
|
||||
</h3>
|
||||
|
||||
<div class="flex items-center">
|
||||
@@ -500,19 +766,12 @@
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- Smart Breaks -->
|
||||
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.24 }}>
|
||||
<h3
|
||||
class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase"
|
||||
>
|
||||
Smart Breaks
|
||||
</h3>
|
||||
<div class="my-4 h-px bg-[#161616]"></div>
|
||||
|
||||
<div class="flex items-center">
|
||||
<div class="flex-1">
|
||||
<div class="text-[13px] text-white">Enable smart breaks</div>
|
||||
<div class="text-[13px] text-white">Smart breaks</div>
|
||||
<div class="text-[11px] text-[#8a8a8a]">Auto-reset timer when you step away</div>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
@@ -561,119 +820,90 @@
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- Notifications -->
|
||||
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.27 }}>
|
||||
<h3
|
||||
class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase"
|
||||
>
|
||||
Notifications
|
||||
<!-- 11. Presentation Mode -->
|
||||
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.30 }}>
|
||||
<h3 class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase">
|
||||
Presentation Mode
|
||||
</h3>
|
||||
|
||||
<div class="flex items-center">
|
||||
<div class="flex-1">
|
||||
<div class="text-[13px] text-white">Pre-break alert</div>
|
||||
<div class="text-[11px] text-[#8a8a8a]">Warn before breaks</div>
|
||||
<div class="text-[13px] text-white">Auto-detect fullscreen</div>
|
||||
<div class="text-[11px] text-[#8a8a8a]">Defer breaks during fullscreen apps</div>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
bind:checked={$config.notification_enabled}
|
||||
label="Pre-break alert"
|
||||
onchange={markChanged}
|
||||
/>
|
||||
<ToggleSwitch bind:checked={$config.presentation_mode_enabled} label="Presentation mode" onchange={markChanged} />
|
||||
</div>
|
||||
|
||||
{#if $config.notification_enabled}
|
||||
<div class="my-4 h-px bg-[#161616]"></div>
|
||||
|
||||
<div class="flex items-center">
|
||||
<div class="flex-1">
|
||||
<div class="text-[13px] text-white">Alert timing</div>
|
||||
<div class="text-[11px] text-[#8a8a8a]">
|
||||
{$config.notification_before_break}s before
|
||||
{#if $config.presentation_mode_enabled}
|
||||
{#if $config.microbreak_enabled}
|
||||
<div class="my-4 h-px bg-[#161616]"></div>
|
||||
<div class="flex items-center">
|
||||
<div class="flex-1">
|
||||
<div class="text-[13px] text-white">Defer microbreaks</div>
|
||||
<div class="text-[11px] text-[#8a8a8a]">Also pause eye breaks</div>
|
||||
</div>
|
||||
<ToggleSwitch bind:checked={$config.presentation_mode_defer_microbreaks} label="Defer microbreaks" onchange={markChanged} />
|
||||
</div>
|
||||
<Stepper
|
||||
bind:value={$config.notification_before_break}
|
||||
label="Alert timing"
|
||||
min={0}
|
||||
max={300}
|
||||
step={10}
|
||||
onchange={markChanged}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<div class="my-4 h-px bg-[#161616]"></div>
|
||||
<div class="flex items-center">
|
||||
<div class="flex-1">
|
||||
<div class="text-[13px] text-white">Notification</div>
|
||||
<div class="text-[11px] text-[#8a8a8a]">Show toast when break is deferred</div>
|
||||
</div>
|
||||
<ToggleSwitch bind:checked={$config.presentation_mode_notification} label="Deferral notification" onchange={markChanged} />
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- Sound -->
|
||||
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.27 }}>
|
||||
<h3
|
||||
class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase"
|
||||
>
|
||||
Sound
|
||||
<!-- 12. Goals & Streaks -->
|
||||
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.33 }}>
|
||||
<h3 class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase">
|
||||
Goals & Streaks
|
||||
</h3>
|
||||
|
||||
<div class="flex items-center">
|
||||
<div class="flex-1">
|
||||
<div class="text-[13px] text-white">Sound effects</div>
|
||||
<div class="text-[11px] text-[#8a8a8a]">Play sounds on break events</div>
|
||||
<div class="text-[13px] text-white">Daily goal</div>
|
||||
<div class="text-[11px] text-[#8a8a8a]">Track daily break target</div>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
bind:checked={$config.sound_enabled}
|
||||
label="Sound effects"
|
||||
onchange={markChanged}
|
||||
/>
|
||||
<ToggleSwitch bind:checked={$config.daily_goal_enabled} label="Daily goal" onchange={markChanged} />
|
||||
</div>
|
||||
|
||||
{#if $config.sound_enabled}
|
||||
{#if $config.daily_goal_enabled}
|
||||
<div class="my-4 h-px bg-[#161616]"></div>
|
||||
|
||||
<div class="flex items-center">
|
||||
<div class="flex-1">
|
||||
<div class="text-[13px] text-white">Volume</div>
|
||||
<div class="text-[11px] text-[#8a8a8a]">{$config.sound_volume}%</div>
|
||||
</div>
|
||||
<Stepper
|
||||
bind:value={$config.sound_volume}
|
||||
label="Volume"
|
||||
min={0}
|
||||
max={100}
|
||||
step={10}
|
||||
formatValue={(v) => `${v}%`}
|
||||
onchange={markChanged}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="my-4 h-px bg-[#161616]"></div>
|
||||
|
||||
<div>
|
||||
<div class="mb-3 text-[13px] text-white">Sound preset</div>
|
||||
<div class="grid grid-cols-4 gap-2">
|
||||
{#each soundPresets as preset}
|
||||
<button
|
||||
use:pressable
|
||||
class="rounded-xl py-2.5 text-[11px] tracking-wider uppercase
|
||||
transition-all duration-200
|
||||
{$config.sound_preset === preset
|
||||
? 'bg-[#1a1a1a] text-white border border-[#333]'
|
||||
: 'bg-[#0a0a0a] text-[#555] border border-[#161616] hover:border-[#333] hover:text-[#999]'}"
|
||||
onclick={() => {
|
||||
$config.sound_preset = preset;
|
||||
markChanged();
|
||||
playSound(preset, $config.sound_volume);
|
||||
}}
|
||||
>
|
||||
{preset}
|
||||
</button>
|
||||
{/each}
|
||||
<div class="text-[13px] text-white">Target breaks</div>
|
||||
<div class="text-[11px] text-[#8a8a8a]">{$config.daily_goal_breaks} per day</div>
|
||||
</div>
|
||||
<Stepper bind:value={$config.daily_goal_breaks} label="Daily goal breaks" min={1} max={30} onchange={markChanged} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="my-4 h-px bg-[#161616]"></div>
|
||||
<div class="flex items-center">
|
||||
<div class="flex-1">
|
||||
<div class="text-[13px] text-white">Celebrations</div>
|
||||
<div class="text-[11px] text-[#8a8a8a]">Confetti on milestones and goals</div>
|
||||
</div>
|
||||
<ToggleSwitch bind:checked={$config.milestone_celebrations} label="Celebrations" onchange={markChanged} />
|
||||
</div>
|
||||
|
||||
<div class="my-4 h-px bg-[#161616]"></div>
|
||||
<div class="flex items-center">
|
||||
<div class="flex-1">
|
||||
<div class="text-[13px] text-white">Streak notifications</div>
|
||||
<div class="text-[11px] text-[#8a8a8a]">Toast on streak milestones</div>
|
||||
</div>
|
||||
<ToggleSwitch bind:checked={$config.streak_notifications} label="Streak notifications" onchange={markChanged} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Appearance -->
|
||||
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.3 }}>
|
||||
<h3
|
||||
class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase"
|
||||
>
|
||||
<!-- 13. Appearance -->
|
||||
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.36 }}>
|
||||
<h3 class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase">
|
||||
Appearance
|
||||
</h3>
|
||||
|
||||
@@ -741,11 +971,111 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Mini Mode -->
|
||||
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.33 }}>
|
||||
<h3
|
||||
class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase"
|
||||
>
|
||||
<!-- 14. Working Hours -->
|
||||
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.39 }}>
|
||||
<div class="flex items-center">
|
||||
<div class="flex-1">
|
||||
<div class="text-[13px] text-white">Working hours</div>
|
||||
<div class="text-[11px] text-[#8a8a8a]">
|
||||
Only show breaks during your configured work schedule
|
||||
</div>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
bind:checked={$config.working_hours_enabled}
|
||||
label="Working hours"
|
||||
onchange={markChanged}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if $config.working_hours_enabled}
|
||||
<div class="my-4 h-px bg-[#161616]"></div>
|
||||
|
||||
{#each $config.working_hours_schedule as daySchedule, dayIndex}
|
||||
{@const dayName = daysOfWeek[dayIndex]}
|
||||
<div class="mb-4">
|
||||
<!-- Day header with toggle -->
|
||||
<div class="flex items-center gap-3 mb-3">
|
||||
<ToggleSwitch
|
||||
bind:checked={$config.working_hours_schedule[dayIndex].enabled}
|
||||
label={dayName}
|
||||
onchange={markChanged}
|
||||
/>
|
||||
<span class="text-[13px] text-white w-20">{dayName}</span>
|
||||
</div>
|
||||
|
||||
{#if daySchedule.enabled}
|
||||
<!-- Time ranges for this day -->
|
||||
<div class="space-y-2">
|
||||
{#each daySchedule.ranges as range, rangeIndex}
|
||||
<div class="flex items-center gap-2">
|
||||
<TimeSpinner
|
||||
value={range.start}
|
||||
countdownFont={$config.countdown_font}
|
||||
onchange={(v) => updateTimeRange(dayIndex, rangeIndex, "start", v)}
|
||||
/>
|
||||
<span class="text-[#8a8a8a] text-[13px]">to</span>
|
||||
<TimeSpinner
|
||||
value={range.end}
|
||||
countdownFont={$config.countdown_font}
|
||||
onchange={(v) => updateTimeRange(dayIndex, rangeIndex, "end", v)}
|
||||
/>
|
||||
|
||||
<!-- Add range button -->
|
||||
{#if rangeIndex === daySchedule.ranges.length - 1}
|
||||
<button
|
||||
use:pressable
|
||||
class="ml-2 w-8 h-8 flex items-center justify-center rounded-full text-[#8a8a8a] hover:text-white hover:bg-[#1a1a1a] transition-colors"
|
||||
onclick={() => addTimeRange(dayIndex)}
|
||||
aria-label="Add time range"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M12 5v14M5 12h14"/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<!-- Clone button -->
|
||||
<button
|
||||
use:pressable
|
||||
class="w-8 h-8 flex items-center justify-center rounded-full text-[#8a8a8a] hover:text-white hover:bg-[#1a1a1a] transition-colors"
|
||||
onclick={() => cloneTimeRange(dayIndex, rangeIndex)}
|
||||
aria-label="Clone time range"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>
|
||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Delete button (never show for first range) -->
|
||||
{#if rangeIndex > 0}
|
||||
<button
|
||||
use:pressable
|
||||
class="w-8 h-8 flex items-center justify-center rounded-full text-[#8a8a8a] hover:text-[#f85149] hover:bg-[#f8514915] transition-colors"
|
||||
onclick={() => removeTimeRange(dayIndex, rangeIndex)}
|
||||
aria-label="Remove time range"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M18 6L6 18M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if dayIndex < 6}
|
||||
<div class="my-4 h-px bg-[#161616]"></div>
|
||||
{/if}
|
||||
{/each}
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- 15. Mini Mode -->
|
||||
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.42 }}>
|
||||
<h3 class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase">
|
||||
Mini Mode
|
||||
</h3>
|
||||
|
||||
@@ -784,11 +1114,28 @@
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- Shortcuts -->
|
||||
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.36 }}>
|
||||
<h3
|
||||
class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase"
|
||||
>
|
||||
<!-- 16. General -->
|
||||
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.45 }}>
|
||||
<h3 class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase">
|
||||
General
|
||||
</h3>
|
||||
|
||||
<div class="flex items-center">
|
||||
<div class="flex-1">
|
||||
<div class="text-[13px] text-white">Start on Windows login</div>
|
||||
<div class="text-[11px] text-[#8a8a8a]">Launch automatically at startup</div>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
checked={autoStartEnabled}
|
||||
label="Start on login"
|
||||
onchange={toggleAutoStart}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 17. Keyboard Shortcuts -->
|
||||
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.48 }}>
|
||||
<h3 class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase">
|
||||
Keyboard Shortcuts
|
||||
</h3>
|
||||
|
||||
@@ -808,8 +1155,8 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Reset -->
|
||||
<div class="pt-2 pb-6" use:inView={{ delay: 0.39 }}>
|
||||
<!-- 18. Reset -->
|
||||
<div class="pt-2 pb-6" use:inView={{ delay: 0.51 }}>
|
||||
<button
|
||||
use:pressable
|
||||
class="w-full rounded-full border py-3 text-[12px]
|
||||
@@ -817,7 +1164,7 @@
|
||||
transition-all duration-200
|
||||
{resetConfirming
|
||||
? 'border-[#f85149] text-[#f85149] hover:bg-[#f85149] hover:text-white'
|
||||
: 'border-[#1a1a1a] text-[#444] hover:border-[#333] hover:text-white'}"
|
||||
: 'border-[#1a1a1a] text-[#8a8a8a] hover:border-[#333] hover:text-white'}"
|
||||
onclick={handleReset}
|
||||
>
|
||||
{resetConfirming ? "Tap again to confirm reset" : "Reset to defaults"}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -62,7 +62,7 @@
|
||||
type="button"
|
||||
aria-label="Decrease"
|
||||
class="flex h-7 w-7 items-center justify-center rounded-lg
|
||||
bg-[#141414] text-[#999] transition-colors
|
||||
bg-[#141414] text-[#8a8a8a] transition-colors
|
||||
hover:bg-[#1c1c1c] hover:text-white
|
||||
disabled:opacity-20"
|
||||
onmousedown={() => startHold(decrement)}
|
||||
@@ -80,7 +80,7 @@
|
||||
type="button"
|
||||
aria-label="Increase"
|
||||
class="flex h-7 w-7 items-center justify-center rounded-lg
|
||||
bg-[#141414] text-[#999] transition-colors
|
||||
bg-[#141414] text-[#8a8a8a] transition-colors
|
||||
hover:bg-[#1c1c1c] hover:text-white
|
||||
disabled:opacity-20"
|
||||
onmousedown={() => startHold(increment)}
|
||||
|
||||
@@ -11,7 +11,13 @@ export interface DaySchedule {
|
||||
ranges: TimeRange[];
|
||||
}
|
||||
|
||||
export type { TimeRange, DaySchedule };
|
||||
export interface CustomActivity {
|
||||
id: string;
|
||||
category: string;
|
||||
text: string;
|
||||
is_favorite: boolean;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface Config {
|
||||
break_duration: number;
|
||||
@@ -24,7 +30,7 @@ export interface Config {
|
||||
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
|
||||
working_hours_schedule: DaySchedule[];
|
||||
dark_mode: boolean;
|
||||
color_scheme: string;
|
||||
backdrop_opacity: number;
|
||||
@@ -49,6 +55,45 @@ export interface Config {
|
||||
background_blobs_enabled: boolean;
|
||||
mini_click_through: boolean;
|
||||
mini_hover_threshold: number;
|
||||
// F8: Auto-start on login
|
||||
auto_start_on_login: boolean;
|
||||
// F6: Custom activities
|
||||
custom_activities: CustomActivity[];
|
||||
disabled_builtin_activities: string[];
|
||||
favorite_builtin_activities: string[];
|
||||
favorite_weight: number;
|
||||
// F4: Breathing guide
|
||||
breathing_guide_enabled: boolean;
|
||||
breathing_pattern: string;
|
||||
// F10: Gamification
|
||||
daily_goal_enabled: boolean;
|
||||
daily_goal_breaks: number;
|
||||
milestone_celebrations: boolean;
|
||||
streak_notifications: boolean;
|
||||
// F1: Microbreaks
|
||||
microbreak_enabled: boolean;
|
||||
microbreak_frequency: number;
|
||||
microbreak_duration: number;
|
||||
microbreak_sound_enabled: boolean;
|
||||
microbreak_show_activity: boolean;
|
||||
microbreak_pause_during_break: boolean;
|
||||
// F3: Pomodoro
|
||||
pomodoro_enabled: boolean;
|
||||
pomodoro_short_breaks: number;
|
||||
pomodoro_long_break_duration: number;
|
||||
pomodoro_long_break_title: string;
|
||||
pomodoro_long_break_message: string;
|
||||
pomodoro_reset_on_skip: boolean;
|
||||
// F5: Screen dimming
|
||||
screen_dim_enabled: boolean;
|
||||
screen_dim_seconds: number;
|
||||
screen_dim_max_opacity: number;
|
||||
// F2: Presentation mode
|
||||
presentation_mode_enabled: boolean;
|
||||
presentation_mode_defer_microbreaks: boolean;
|
||||
presentation_mode_notification: boolean;
|
||||
// F9: Multi-monitor
|
||||
multi_monitor_break: boolean;
|
||||
}
|
||||
|
||||
const defaultConfig: Config = {
|
||||
@@ -63,13 +108,13 @@ const defaultConfig: Config = {
|
||||
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
|
||||
{ enabled: true, ranges: [{ start: "09:00", end: "18:00" }] },
|
||||
{ enabled: true, ranges: [{ start: "09:00", end: "18:00" }] },
|
||||
{ enabled: true, ranges: [{ start: "09:00", end: "18:00" }] },
|
||||
{ enabled: true, ranges: [{ start: "09:00", end: "18:00" }] },
|
||||
{ enabled: true, ranges: [{ start: "09:00", end: "18:00" }] },
|
||||
{ enabled: false, ranges: [{ start: "09:00", end: "18:00" }] },
|
||||
{ enabled: false, ranges: [{ start: "09:00", end: "18:00" }] },
|
||||
],
|
||||
dark_mode: true,
|
||||
color_scheme: "Ocean",
|
||||
@@ -95,6 +140,45 @@ const defaultConfig: Config = {
|
||||
background_blobs_enabled: true,
|
||||
mini_click_through: true,
|
||||
mini_hover_threshold: 3.0,
|
||||
// F8
|
||||
auto_start_on_login: false,
|
||||
// F6
|
||||
custom_activities: [],
|
||||
disabled_builtin_activities: [],
|
||||
favorite_builtin_activities: [],
|
||||
favorite_weight: 3,
|
||||
// F4
|
||||
breathing_guide_enabled: true,
|
||||
breathing_pattern: "box",
|
||||
// F10
|
||||
daily_goal_enabled: true,
|
||||
daily_goal_breaks: 8,
|
||||
milestone_celebrations: true,
|
||||
streak_notifications: true,
|
||||
// F1
|
||||
microbreak_enabled: false,
|
||||
microbreak_frequency: 20,
|
||||
microbreak_duration: 20,
|
||||
microbreak_sound_enabled: true,
|
||||
microbreak_show_activity: true,
|
||||
microbreak_pause_during_break: true,
|
||||
// F3
|
||||
pomodoro_enabled: false,
|
||||
pomodoro_short_breaks: 3,
|
||||
pomodoro_long_break_duration: 15,
|
||||
pomodoro_long_break_title: "Long break",
|
||||
pomodoro_long_break_message: "Great work! Take a longer rest.",
|
||||
pomodoro_reset_on_skip: false,
|
||||
// F5
|
||||
screen_dim_enabled: false,
|
||||
screen_dim_seconds: 10,
|
||||
screen_dim_max_opacity: 0.3,
|
||||
// F2
|
||||
presentation_mode_enabled: true,
|
||||
presentation_mode_defer_microbreaks: true,
|
||||
presentation_mode_notification: true,
|
||||
// F9
|
||||
multi_monitor_break: true,
|
||||
};
|
||||
|
||||
export const config = writable<Config>(defaultConfig);
|
||||
|
||||
@@ -26,6 +26,27 @@ export interface TimerSnapshot {
|
||||
naturalBreakOccurred: boolean;
|
||||
smartBreaksEnabled: boolean;
|
||||
smartBreakThreshold: number;
|
||||
// F1: Microbreaks
|
||||
microbreakEnabled: boolean;
|
||||
microbreakActive: boolean;
|
||||
microbreakTimeRemaining: number;
|
||||
microbreakTotalDuration: number;
|
||||
microbreakCountdown: number;
|
||||
microbreakFrequency: number;
|
||||
// F3: Pomodoro
|
||||
pomodoroEnabled: boolean;
|
||||
pomodoroCyclePosition: number;
|
||||
pomodoroTotalInCycle: number;
|
||||
pomodoroIsLongBreak: boolean;
|
||||
pomodoroNextIsLong: boolean;
|
||||
// F5: Screen dimming
|
||||
screenDimActive: boolean;
|
||||
screenDimProgress: number;
|
||||
// F2: Presentation mode
|
||||
presentationModeActive: boolean;
|
||||
deferredBreakPending: boolean;
|
||||
// F10: Gamification
|
||||
isLongBreak: boolean;
|
||||
}
|
||||
|
||||
const defaultSnapshot: TimerSnapshot = {
|
||||
@@ -50,6 +71,22 @@ const defaultSnapshot: TimerSnapshot = {
|
||||
naturalBreakOccurred: false,
|
||||
smartBreaksEnabled: true,
|
||||
smartBreakThreshold: 300,
|
||||
microbreakEnabled: false,
|
||||
microbreakActive: false,
|
||||
microbreakTimeRemaining: 0,
|
||||
microbreakTotalDuration: 0,
|
||||
microbreakCountdown: 0,
|
||||
microbreakFrequency: 1200,
|
||||
pomodoroEnabled: false,
|
||||
pomodoroCyclePosition: 0,
|
||||
pomodoroTotalInCycle: 4,
|
||||
pomodoroIsLongBreak: false,
|
||||
pomodoroNextIsLong: false,
|
||||
screenDimActive: false,
|
||||
screenDimProgress: 0,
|
||||
presentationModeActive: false,
|
||||
deferredBreakPending: false,
|
||||
isLongBreak: false,
|
||||
};
|
||||
|
||||
export const timer = writable<TimerSnapshot>(defaultSnapshot);
|
||||
@@ -114,8 +151,43 @@ export async function initTimerStore() {
|
||||
playSound(cfg.sound_preset as any, cfg.sound_volume * 0.5);
|
||||
}
|
||||
});
|
||||
|
||||
// F1: Microbreak events
|
||||
await listen("microbreak-started", () => {
|
||||
const cfg = get(config);
|
||||
if (cfg.microbreak_sound_enabled && cfg.sound_enabled) {
|
||||
playSound(cfg.sound_preset as any, cfg.sound_volume * 0.4);
|
||||
}
|
||||
});
|
||||
|
||||
await listen("microbreak-ended", () => {
|
||||
const cfg = get(config);
|
||||
if (cfg.microbreak_sound_enabled && cfg.sound_enabled) {
|
||||
playBreakEndSound(cfg.sound_preset as any, cfg.sound_volume * 0.3);
|
||||
}
|
||||
});
|
||||
|
||||
// F10: Milestone and daily goal events
|
||||
await listen<{ streak: number }>("milestone-reached", (event) => {
|
||||
milestoneEvent.set(event.payload.streak);
|
||||
setTimeout(() => milestoneEvent.set(null), 4000);
|
||||
});
|
||||
|
||||
await listen("daily-goal-met", () => {
|
||||
dailyGoalEvent.set(true);
|
||||
setTimeout(() => dailyGoalEvent.set(false), 4000);
|
||||
});
|
||||
|
||||
// F2: Break deferred
|
||||
await listen("break-deferred", () => {
|
||||
// Dashboard will show deferred status from snapshot
|
||||
});
|
||||
}
|
||||
|
||||
// F10: Gamification event stores
|
||||
export const milestoneEvent = writable<number | null>(null);
|
||||
export const dailyGoalEvent = writable<boolean>(false);
|
||||
|
||||
// Helper: format seconds as MM:SS
|
||||
export function formatTime(secs: number): string {
|
||||
const m = Math.floor(secs / 60);
|
||||
|
||||
@@ -106,10 +106,55 @@ export function getCategoryLabel(cat: BreakActivity["category"]): string {
|
||||
return categoryLabels[cat];
|
||||
}
|
||||
|
||||
/** Pick a random activity, optionally excluding a previous one */
|
||||
export function pickRandomActivity(exclude?: BreakActivity): BreakActivity {
|
||||
const pool = exclude
|
||||
? breakActivities.filter((a) => a.text !== exclude.text)
|
||||
: breakActivities;
|
||||
return pool[Math.floor(Math.random() * pool.length)];
|
||||
/** Pick a random activity, optionally excluding a previous one.
|
||||
* When config is provided, respects disabled/favorite/custom activity settings. */
|
||||
export function pickRandomActivity(
|
||||
exclude?: BreakActivity,
|
||||
config?: {
|
||||
disabled_builtin_activities?: string[];
|
||||
favorite_builtin_activities?: string[];
|
||||
custom_activities?: Array<{ category: string; text: string; is_favorite: boolean; enabled: boolean }>;
|
||||
favorite_weight?: number;
|
||||
},
|
||||
): BreakActivity {
|
||||
const disabled = new Set(config?.disabled_builtin_activities ?? []);
|
||||
const favorites = new Set(config?.favorite_builtin_activities ?? []);
|
||||
const weight = config?.favorite_weight ?? 3;
|
||||
|
||||
// Build pool: enabled builtins + enabled customs
|
||||
let pool: BreakActivity[] = breakActivities.filter((a) => !disabled.has(a.text));
|
||||
|
||||
// Add enabled custom activities
|
||||
if (config?.custom_activities) {
|
||||
for (const ca of config.custom_activities) {
|
||||
if (ca.enabled) {
|
||||
const cat = (["eyes", "stretch", "breathing", "movement"].includes(ca.category)
|
||||
? ca.category
|
||||
: "movement") as BreakActivity["category"];
|
||||
pool.push({ category: cat, text: ca.text });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Exclude previous
|
||||
if (exclude) {
|
||||
pool = pool.filter((a) => a.text !== exclude.text);
|
||||
}
|
||||
|
||||
if (pool.length === 0) {
|
||||
return exclude ?? breakActivities[0];
|
||||
}
|
||||
|
||||
// Build weighted pool: favorites appear `weight` times
|
||||
const weighted: BreakActivity[] = [];
|
||||
for (const a of pool) {
|
||||
const isFav = favorites.has(a.text) ||
|
||||
(config?.custom_activities?.some((c) => c.text === a.text && c.is_favorite) ?? false);
|
||||
const count = isFav ? weight : 1;
|
||||
for (let i = 0; i < count; i++) {
|
||||
weighted.push(a);
|
||||
}
|
||||
}
|
||||
|
||||
return weighted[Math.floor(Math.random() * weighted.length)];
|
||||
}
|
||||
|
||||
20
src/main.ts
20
src/main.ts
@@ -2,19 +2,35 @@ import "./app.css";
|
||||
import App from "./App.svelte";
|
||||
import MiniTimer from "./lib/components/MiniTimer.svelte";
|
||||
import BreakWindow from "./lib/components/BreakWindow.svelte";
|
||||
import MicrobreakOverlay from "./lib/components/MicrobreakOverlay.svelte";
|
||||
import DimOverlay from "./lib/components/DimOverlay.svelte";
|
||||
import BreakOverlay from "./lib/components/BreakOverlay.svelte";
|
||||
import { mount } from "svelte";
|
||||
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const isMicrobreak = params.has("microbreak");
|
||||
const isDim = params.has("dim");
|
||||
const isBreakOverlay = params.has("breakoverlay");
|
||||
const isMini = params.has("mini");
|
||||
const isBreak = params.has("break");
|
||||
|
||||
if (isMini || isBreak) {
|
||||
if (isMini || isBreak || isMicrobreak || isDim || isBreakOverlay) {
|
||||
// Transparent body so rounded shapes show through the transparent window
|
||||
document.body.style.background = "transparent";
|
||||
document.documentElement.style.background = "transparent";
|
||||
}
|
||||
|
||||
const component = isMini ? MiniTimer : isBreak ? BreakWindow : App;
|
||||
const component = isMicrobreak
|
||||
? MicrobreakOverlay
|
||||
: isDim
|
||||
? DimOverlay
|
||||
: isBreakOverlay
|
||||
? BreakOverlay
|
||||
: isMini
|
||||
? MiniTimer
|
||||
: isBreak
|
||||
? BreakWindow
|
||||
: App;
|
||||
|
||||
const app = mount(component, {
|
||||
target: document.getElementById("app")!,
|
||||
|
||||
Reference in New Issue
Block a user