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:
274
src/lib/components/StatsView.svelte
Normal file
274
src/lib/components/StatsView.svelte
Normal file
@@ -0,0 +1,274 @@
|
||||
<script lang="ts">
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { currentView } from "../stores/timer";
|
||||
import { config } from "../stores/config";
|
||||
import { fadeIn, inView, pressable, dragScroll } from "../utils/animate";
|
||||
|
||||
interface StatsSnapshot {
|
||||
todayCompleted: number;
|
||||
todaySkipped: number;
|
||||
todaySnoozed: number;
|
||||
todayBreakTimeSecs: number;
|
||||
complianceRate: number;
|
||||
currentStreak: number;
|
||||
bestStreak: number;
|
||||
}
|
||||
|
||||
interface DayRecord {
|
||||
date: string;
|
||||
breaksCompleted: number;
|
||||
breaksSkipped: number;
|
||||
breaksSnoozed: number;
|
||||
totalBreakTimeSecs: number;
|
||||
}
|
||||
|
||||
let stats = $state<StatsSnapshot | null>(null);
|
||||
let history = $state<DayRecord[]>([]);
|
||||
|
||||
async function loadStats() {
|
||||
try {
|
||||
stats = await invoke<StatsSnapshot>("get_stats");
|
||||
history = await invoke<DayRecord[]>("get_daily_history", { days: 7 });
|
||||
} catch (e) {
|
||||
console.error("Failed to load stats:", e);
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
loadStats();
|
||||
});
|
||||
|
||||
function goBack() {
|
||||
invoke("set_view", { view: "dashboard" });
|
||||
currentView.set("dashboard");
|
||||
}
|
||||
|
||||
const compliancePercent = $derived(
|
||||
stats ? Math.round(stats.complianceRate * 100) : 100,
|
||||
);
|
||||
|
||||
const breakTimeFormatted = $derived(() => {
|
||||
if (!stats) return "0 min";
|
||||
const mins = Math.floor(stats.todayBreakTimeSecs / 60);
|
||||
if (mins < 60) return `${mins} min`;
|
||||
const hrs = Math.floor(mins / 60);
|
||||
const rem = mins % 60;
|
||||
return `${hrs}h ${rem}m`;
|
||||
});
|
||||
|
||||
// Chart rendering
|
||||
let chartCanvas: HTMLCanvasElement | undefined = $state();
|
||||
|
||||
$effect(() => {
|
||||
if (!chartCanvas || history.length === 0) return;
|
||||
drawChart(chartCanvas, history);
|
||||
});
|
||||
|
||||
function drawChart(canvas: HTMLCanvasElement, data: DayRecord[]) {
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const w = canvas.clientWidth;
|
||||
const h = canvas.clientHeight;
|
||||
canvas.width = w * dpr;
|
||||
canvas.height = h * dpr;
|
||||
ctx.scale(dpr, dpr);
|
||||
|
||||
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 chartHeight = h - 30;
|
||||
|
||||
const accentColor = $config.accent_color || "#ff4d00";
|
||||
|
||||
data.forEach((day, i) => {
|
||||
const x = 20 + i * (barWidth + barGap);
|
||||
const total = day.breaksCompleted + day.breaksSkipped;
|
||||
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);
|
||||
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);
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
// Day label
|
||||
ctx.fillStyle = "#444";
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
function roundedRect(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
x: number,
|
||||
y: number,
|
||||
w: number,
|
||||
h: number,
|
||||
r: number,
|
||||
) {
|
||||
r = Math.min(r, h / 2, w / 2);
|
||||
ctx.moveTo(x + r, y);
|
||||
ctx.lineTo(x + w - r, y);
|
||||
ctx.quadraticCurveTo(x + w, y, x + w, y + r);
|
||||
ctx.lineTo(x + w, y + h - r);
|
||||
ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
|
||||
ctx.lineTo(x + r, y + h);
|
||||
ctx.quadraticCurveTo(x, y + h, x, y + h - r);
|
||||
ctx.lineTo(x, y + r);
|
||||
ctx.quadraticCurveTo(x, y, x + r, y);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex h-full flex-col">
|
||||
<!-- Header -->
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
class="flex items-center px-5 pt-5 pb-4"
|
||||
use:fadeIn={{ duration: 0.4, y: 8 }}
|
||||
>
|
||||
<button
|
||||
aria-label="Back to dashboard"
|
||||
use:pressable
|
||||
class="mr-3 flex h-8 w-8 items-center justify-center rounded-full
|
||||
text-[#444] transition-colors hover:text-white"
|
||||
onclick={goBack}
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<polyline points="15 18 9 12 15 6" />
|
||||
</svg>
|
||||
</button>
|
||||
<h1
|
||||
data-tauri-drag-region
|
||||
class="flex-1 text-lg font-medium text-white"
|
||||
>
|
||||
Statistics
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<!-- Scrollable content -->
|
||||
<div class="flex-1 overflow-y-auto px-5 pb-6" use:dragScroll>
|
||||
<div class="space-y-3">
|
||||
<!-- 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-[#666] uppercase"
|
||||
>
|
||||
Today
|
||||
</h3>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="text-center">
|
||||
<div class="text-[28px] font-semibold text-white tabular-nums">
|
||||
{stats?.todayCompleted ?? 0}
|
||||
</div>
|
||||
<div class="text-[11px] text-[#777]">Breaks taken</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-[28px] font-semibold tabular-nums"
|
||||
style="color: {$config.accent_color}"
|
||||
>
|
||||
{compliancePercent}%
|
||||
</div>
|
||||
<div class="text-[11px] text-[#777]">Compliance</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-[28px] font-semibold text-white tabular-nums">
|
||||
{breakTimeFormatted()}
|
||||
</div>
|
||||
<div class="text-[11px] text-[#777]">Break time</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-[28px] font-semibold text-white tabular-nums">
|
||||
{stats?.todaySkipped ?? 0}
|
||||
</div>
|
||||
<div class="text-[11px] text-[#777]">Skipped</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 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-[#666] uppercase"
|
||||
>
|
||||
Streak
|
||||
</h3>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="text-[13px] text-white">Current streak</div>
|
||||
<div class="text-[11px] text-[#777]">Consecutive days with breaks</div>
|
||||
</div>
|
||||
<div class="text-[24px] font-semibold tabular-nums" style="color: {$config.accent_color}">
|
||||
{stats?.currentStreak ?? 0}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="my-4 h-px bg-[#161616]"></div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="text-[13px] text-white">Best streak</div>
|
||||
<div class="text-[11px] text-[#777]">All-time record</div>
|
||||
</div>
|
||||
<div class="text-[24px] font-semibold text-white tabular-nums">
|
||||
{stats?.bestStreak ?? 0}
|
||||
</div>
|
||||
</div>
|
||||
</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-[#666] uppercase"
|
||||
>
|
||||
Last 7 Days
|
||||
</h3>
|
||||
|
||||
<canvas
|
||||
bind:this={chartCanvas}
|
||||
class="h-[140px] w-full"
|
||||
></canvas>
|
||||
|
||||
<div class="mt-3 flex items-center justify-center gap-4 text-[10px] text-[#777]">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
Reference in New Issue
Block a user