Initial commit - Core Cooldown v0.1.0
Portable Windows break timer to prevent RSI and eye strain. Tauri v2 + Svelte 5 + Tailwind CSS v4. No installer, no telemetry, no data leaves the machine. CC0 public domain.
This commit is contained in:
289
src/lib/components/Dashboard.svelte
Normal file
289
src/lib/components/Dashboard.svelte
Normal file
@@ -0,0 +1,289 @@
|
||||
<script lang="ts">
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import {
|
||||
timer,
|
||||
currentView,
|
||||
formatTime,
|
||||
formatDurationAgo,
|
||||
type TimerSnapshot,
|
||||
} from "../stores/timer";
|
||||
import { config } from "../stores/config";
|
||||
import TimerRing from "./TimerRing.svelte";
|
||||
import { scaleIn, fadeIn, pressable, glowHover } from "../utils/animate";
|
||||
|
||||
async function toggleTimer() {
|
||||
const snap = await invoke<TimerSnapshot>("toggle_timer");
|
||||
timer.set(snap);
|
||||
}
|
||||
|
||||
async function startBreakNow() {
|
||||
const snap = await invoke<TimerSnapshot>("start_break_now");
|
||||
timer.set(snap);
|
||||
currentView.set(snap.currentView);
|
||||
}
|
||||
|
||||
function openSettings() {
|
||||
invoke("set_view", { view: "settings" });
|
||||
currentView.set("settings");
|
||||
}
|
||||
|
||||
const statusText = $derived(
|
||||
$timer.idlePaused
|
||||
? "IDLE"
|
||||
: $timer.prebreakWarning
|
||||
? "BREAK SOON"
|
||||
: $timer.state === "running"
|
||||
? "FOCUS"
|
||||
: "PAUSED",
|
||||
);
|
||||
|
||||
const toggleBtnText = $derived(
|
||||
$timer.state === "running" ? "PAUSE" : "START",
|
||||
);
|
||||
|
||||
// Responsive ring scaling computed from window dimensions
|
||||
let windowW = $state(window.innerWidth);
|
||||
let windowH = $state(window.innerHeight);
|
||||
|
||||
// Ring scales 1.0 → 0.6 based on both dimensions
|
||||
const ringScale = $derived(
|
||||
Math.min(1, Math.max(0.6, Math.min(
|
||||
(windowH - 300) / 400,
|
||||
(windowW - 200) / 300,
|
||||
))),
|
||||
);
|
||||
|
||||
// Text scales less aggressively: 1.0 → 0.7 (counter-scale inside ring)
|
||||
// counterScale = textScale / ringScale, where textScale = lerp(0.7, 1.0, (ringScale-0.6)/0.4)
|
||||
const textCounterScale = $derived(
|
||||
ringScale < 1
|
||||
? Math.min(1.2, (0.7 + (ringScale - 0.6) * 0.75) / ringScale)
|
||||
: 1,
|
||||
);
|
||||
|
||||
// Gap between ring and button, compensating for CSS transform phantom space.
|
||||
// transform: scale() doesn't affect layout, so the 280px box stays full-size
|
||||
// even when visually shrunk — creating phantom space below the visual ring.
|
||||
const ringSize = 280;
|
||||
const phantomBelow = $derived((ringSize * (1 - ringScale)) / 2);
|
||||
const targetGap = $derived(Math.max(16, Math.round((windowH - 400) * 0.05 + 16)));
|
||||
const ringMargin = $derived(targetGap - phantomBelow);
|
||||
|
||||
// Natural break notification
|
||||
let showNaturalBreakToast = $state(false);
|
||||
let naturalBreakToastTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
// Watch for natural break detection
|
||||
$effect(() => {
|
||||
if ($timer.naturalBreakOccurred) {
|
||||
showNaturalBreakToast = true;
|
||||
if (naturalBreakToastTimeout) clearTimeout(naturalBreakToastTimeout);
|
||||
naturalBreakToastTimeout = setTimeout(() => {
|
||||
showNaturalBreakToast = false;
|
||||
}, 5000);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:window bind:innerWidth={windowW} bind:innerHeight={windowH} />
|
||||
|
||||
<div class="relative flex h-full flex-col items-center justify-center">
|
||||
<!-- Outer: responsive scaling (JS-driven), Inner: mount animation -->
|
||||
<div style="transform: scale({ringScale}); transform-origin: center center; margin-bottom: {ringMargin}px;">
|
||||
<div use:scaleIn={{ duration: 0.7, delay: 0.1 }}>
|
||||
<TimerRing
|
||||
progress={$timer.progress}
|
||||
size={280}
|
||||
strokeWidth={8}
|
||||
accentColor={$config.accent_color}
|
||||
>
|
||||
<!-- Counter-scale wrapper: text shrinks less than ring -->
|
||||
<div style="transform: scale({textCounterScale}); transform-origin: center center;">
|
||||
<!-- Eye icon -->
|
||||
<svg
|
||||
class="mx-auto mb-3 eye-blink"
|
||||
width="26"
|
||||
height="26"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="#444"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<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>
|
||||
|
||||
<!-- Time display -->
|
||||
<span
|
||||
class="block text-center text-[54px] font-semibold leading-none tracking-tight tabular-nums text-white"
|
||||
style={$config.countdown_font ? `font-family: '${$config.countdown_font}', monospace;` : ""}
|
||||
>
|
||||
{formatTime($timer.timeRemaining)}
|
||||
</span>
|
||||
|
||||
<div class="h-3"></div>
|
||||
|
||||
<!-- Status label -->
|
||||
<span
|
||||
class="block text-center text-[11px] font-medium tracking-[0.25em]"
|
||||
class:text-[#444]={!$timer.prebreakWarning &&
|
||||
$timer.state === "running"}
|
||||
class:text-[#333]={!$timer.prebreakWarning &&
|
||||
$timer.state === "paused"}
|
||||
class:text-warning={$timer.prebreakWarning}
|
||||
>
|
||||
{statusText}
|
||||
</span>
|
||||
</div>
|
||||
</TimerRing>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Last break info -->
|
||||
<div use:fadeIn={{ delay: 0.4, y: 10 }}>
|
||||
{#if $timer.hasHadBreak}
|
||||
<p style="margin-bottom: {ringMargin}px;" class="text-[12px] text-[#2a2a2a]">
|
||||
Last break {formatDurationAgo($timer.secondsSinceLastBreak)}
|
||||
</p>
|
||||
{:else}
|
||||
<div style="margin-bottom: {ringMargin}px; height: 18px;"></div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Natural break notification toast -->
|
||||
{#if showNaturalBreakToast}
|
||||
<div
|
||||
class="absolute top-6 left-1/2 -translate-x-1/2 rounded-2xl border px-5 py-3 text-center backdrop-blur-xl transition-all duration-500"
|
||||
style="border-color: rgba(63, 185, 80, 0.3); background: rgba(63, 185, 80, 0.1);"
|
||||
use:scaleIn={{ duration: 0.3, delay: 0 }}
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#3fb950" stroke-width="2">
|
||||
<path d="M20 6L9 17l-5-5"/>
|
||||
</svg>
|
||||
<span class="text-[13px] text-[#3fb950]">Natural break detected - timer reset</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Pause / Start button -->
|
||||
<button
|
||||
use:fadeIn={{ delay: 0.3, y: 12 }}
|
||||
use:pressable
|
||||
use:glowHover={{ color: $config.accent_color }}
|
||||
class="w-[200px] rounded-full py-3.5 text-[13px] font-medium
|
||||
tracking-[0.2em] text-white uppercase backdrop-blur-xl
|
||||
transition-colors duration-200"
|
||||
style="background: rgba(20,20,20,0.7);"
|
||||
onclick={toggleTimer}
|
||||
>
|
||||
{toggleBtnText}
|
||||
</button>
|
||||
|
||||
<!-- Bottom left: start break now -->
|
||||
<div class="absolute bottom-5 left-5" use:fadeIn={{ delay: 0.5, y: 8 }}>
|
||||
<button
|
||||
aria-label="Start break now"
|
||||
use:pressable
|
||||
class="flex h-11 w-11 items-center justify-center rounded-full
|
||||
border border-[#222] text-[#383838]
|
||||
transition-colors duration-200
|
||||
hover:border-[#333] hover:text-[#666]"
|
||||
onclick={startBreakNow}
|
||||
>
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<polyline points="12 6 12 12 16 14" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Bottom center: stats -->
|
||||
<div class="absolute bottom-5 left-1/2 -translate-x-1/2" use:fadeIn={{ delay: 0.52, y: 8 }}>
|
||||
<button
|
||||
aria-label="Statistics"
|
||||
use:pressable
|
||||
class="flex h-11 w-11 items-center justify-center rounded-full
|
||||
border border-[#222] text-[#383838]
|
||||
transition-colors duration-200
|
||||
hover:border-[#333] hover:text-[#666]"
|
||||
onclick={() => {
|
||||
invoke("set_view", { view: "stats" });
|
||||
currentView.set("stats");
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width="17"
|
||||
height="17"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<rect x="3" y="12" width="4" height="9" rx="1" />
|
||||
<rect x="10" y="7" width="4" height="14" rx="1" />
|
||||
<rect x="17" y="3" width="4" height="18" rx="1" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Bottom right: settings -->
|
||||
<div class="absolute bottom-5 right-5" use:fadeIn={{ delay: 0.55, y: 8 }}>
|
||||
<button
|
||||
aria-label="Settings"
|
||||
use:pressable
|
||||
class="flex h-11 w-11 items-center justify-center rounded-full
|
||||
border border-[#222] text-[#383838]
|
||||
transition-colors duration-200
|
||||
hover:border-[#333] hover:text-[#666]"
|
||||
onclick={openSettings}
|
||||
>
|
||||
<svg
|
||||
width="17"
|
||||
height="17"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path
|
||||
d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"
|
||||
/>
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Eye blink animation - natural human blink (~4s interval, 0.5s duration) */
|
||||
.eye-blink {
|
||||
transform-origin: center center;
|
||||
animation: blink 4s infinite ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
0%, 45%, 55%, 100% {
|
||||
transform: scaleY(1);
|
||||
}
|
||||
50% {
|
||||
transform: scaleY(0.1);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user