- FontSelector: text-[#8a8a8a]→text-text-sec, border-[#222]→border-border - StatsView: Canvas fillStyle and inline trend color #8a8a8a→#a8a8a8 - ActivityManager: border-[#222]→border-border, #f85149→#ff6b6b danger color - Settings: #f85149→#ff6b6b danger color on reset/delete buttons
649 lines
23 KiB
Svelte
649 lines
23 KiB
Svelte
<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;
|
|
todayNaturalBreaks: number;
|
|
todayNaturalBreakTimeSecs: number;
|
|
complianceRate: number;
|
|
currentStreak: number;
|
|
bestStreak: number;
|
|
dailyGoalProgress: number;
|
|
dailyGoalMet: boolean;
|
|
}
|
|
|
|
interface DayRecord {
|
|
date: string;
|
|
breaksCompleted: number;
|
|
breaksSkipped: number;
|
|
breaksSnoozed: number;
|
|
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);
|
|
}
|
|
}
|
|
|
|
$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`;
|
|
});
|
|
|
|
// 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(() => {
|
|
if (!chartCanvas || history.length === 0) return;
|
|
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;
|
|
|
|
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.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";
|
|
|
|
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;
|
|
|
|
if (completedH > 0) {
|
|
ctx.fillStyle = accentColor;
|
|
ctx.beginPath();
|
|
const barY = chartHeight - completedH;
|
|
roundedRect(ctx, x, barY, barWidth, completedH, Math.min(4, barWidth / 2));
|
|
ctx.fill();
|
|
}
|
|
|
|
if (skippedH > 0) {
|
|
ctx.fillStyle = "#333";
|
|
ctx.beginPath();
|
|
const barY = chartHeight - completedH - skippedH;
|
|
roundedRect(ctx, x, barY, barWidth, skippedH, Math.min(4, barWidth / 2));
|
|
ctx.fill();
|
|
}
|
|
|
|
// Day label — show every Nth for 30-day
|
|
if (data.length <= 7 || i % 5 === 0) {
|
|
ctx.fillStyle = "#a8a8a8";
|
|
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();
|
|
});
|
|
}
|
|
|
|
const chartAriaLabel = $derived(() => {
|
|
if (history.length === 0) return "No break history data available";
|
|
const total = history.reduce((sum, d) => sum + d.breaksCompleted, 0);
|
|
const skipped = history.reduce((sum, d) => sum + d.breaksSkipped, 0);
|
|
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,
|
|
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-10 w-10 min-h-[44px] min-w-[44px] items-center justify-center rounded-full
|
|
text-text-sec transition-colors hover:text-white"
|
|
onclick={goBack}
|
|
>
|
|
<svg
|
|
aria-hidden="true"
|
|
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
|
|
tabindex="-1"
|
|
class="flex-1 text-lg font-medium text-white"
|
|
>
|
|
Statistics
|
|
</h1>
|
|
</div>
|
|
|
|
<!-- Tab navigation -->
|
|
<div class="flex gap-1 px-5 mb-3" role="tablist" aria-label="Statistics time range" use:fadeIn={{ duration: 0.3, y: 6 }}>
|
|
{#each [["today", "Today"], ["weekly", "Weekly"], ["monthly", "Monthly"]] as [tab, label]}
|
|
<button
|
|
use:pressable
|
|
role="tab"
|
|
id="tab-{tab}"
|
|
aria-selected={activeTab === tab}
|
|
aria-controls="tabpanel-{tab}"
|
|
class="min-h-[44px] rounded-lg px-4 py-1.5 text-[11px] tracking-wider uppercase transition-all duration-200
|
|
{activeTab === tab
|
|
? 'bg-[#1a1a1a] text-white'
|
|
: 'text-text-sec 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"}
|
|
<div role="tabpanel" id="tabpanel-today" aria-labelledby="tab-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 }}>
|
|
<h2 class="mb-4 text-[11px] font-medium tracking-[0.15em] text-text-sec uppercase">
|
|
Today
|
|
</h2>
|
|
|
|
<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-text-sec">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-text-sec">Compliance</div>
|
|
</div>
|
|
<div class="text-center">
|
|
<div class="text-[28px] font-semibold text-white tabular-nums">
|
|
{breakTimeFormatted()}
|
|
</div>
|
|
<div class="text-[11px] text-text-sec">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-text-sec">Skipped</div>
|
|
</div>
|
|
</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 }}>
|
|
<h2 class="mb-4 text-[11px] font-medium tracking-[0.15em] text-text-sec uppercase">
|
|
Daily Goal
|
|
</h2>
|
|
<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-text-sec">
|
|
{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 }}>
|
|
<h2 class="mb-4 text-[11px] font-medium tracking-[0.15em] text-text-sec uppercase">
|
|
Streak
|
|
</h2>
|
|
|
|
<div class="flex items-center justify-between">
|
|
<div>
|
|
<div class="text-[13px] text-white">Current streak</div>
|
|
<div class="text-[11px] text-text-sec">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-border"></div>
|
|
|
|
<div class="flex items-center justify-between">
|
|
<div>
|
|
<div class="text-[13px] text-white">Best streak</div>
|
|
<div class="text-[11px] text-text-sec">All-time record</div>
|
|
</div>
|
|
<div class="text-[24px] font-semibold text-white tabular-nums">
|
|
{stats?.bestStreak ?? 0}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- F10: Next milestone -->
|
|
{#if nextMilestone()}
|
|
<div class="my-4 h-px bg-border"></div>
|
|
<div class="flex items-center justify-between">
|
|
<div>
|
|
<div class="text-[13px] text-white">Next milestone</div>
|
|
<div class="text-[11px] text-text-sec">{nextMilestone()} day streak</div>
|
|
</div>
|
|
<div class="text-[13px] text-text-sec 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 }}>
|
|
<h2 class="mb-4 text-[11px] font-medium tracking-[0.15em] text-text-sec uppercase">
|
|
Last 7 Days
|
|
</h2>
|
|
|
|
<!-- svelte-ignore a11y_no_interactive_element_to_noninteractive_role -->
|
|
<canvas
|
|
bind:this={chartCanvas}
|
|
class="h-[140px] w-full"
|
|
role="img"
|
|
aria-label={chartAriaLabel()}
|
|
></canvas>
|
|
|
|
{#if history.length > 0}
|
|
<table class="sr-only">
|
|
<caption>Break history for the last {history.length} days</caption>
|
|
<thead>
|
|
<tr><th>Date</th><th>Completed</th><th>Skipped</th></tr>
|
|
</thead>
|
|
<tbody>
|
|
{#each history as day}
|
|
<tr>
|
|
<td>{day.date}</td>
|
|
<td>{day.breaksCompleted}</td>
|
|
<td>{day.breaksSkipped}</td>
|
|
</tr>
|
|
{/each}
|
|
</tbody>
|
|
</table>
|
|
{/if}
|
|
|
|
<div class="mt-3 flex items-center justify-center gap-4 text-[10px] text-text-sec">
|
|
<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>
|
|
{:else if activeTab === "weekly"}
|
|
<div role="tabpanel" id="tabpanel-weekly" aria-labelledby="tab-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 }}>
|
|
<h2 class="mb-3 text-[11px] font-medium tracking-[0.15em] text-text-sec uppercase">
|
|
Week of {week.weekStart}
|
|
</h2>
|
|
|
|
<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-text-sec">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-text-sec">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-text-sec">Compliance</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex items-center justify-between text-[11px]">
|
|
<span class="text-text-sec">Avg {week.avgDailyCompleted.toFixed(1)} breaks/day</span>
|
|
{#if prevWeek}
|
|
<span class="flex items-center gap-1"
|
|
style="color: {trend > 0 ? '#3fb950' : trend < 0 ? '#ff6b6b' : '#a8a8a8'};"
|
|
>
|
|
{#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}
|
|
|
|
</div>
|
|
{:else}
|
|
<div role="tabpanel" id="tabpanel-monthly" aria-labelledby="tab-monthly">
|
|
<!-- 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 }}>
|
|
<h2 class="mb-4 text-[11px] font-medium tracking-[0.15em] text-text-sec uppercase">
|
|
Last 30 Days
|
|
</h2>
|
|
|
|
<!-- 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>
|
|
|
|
{#if monthHistory.length > 0}
|
|
<table class="sr-only">
|
|
<caption>Break history for the last {monthHistory.length} days</caption>
|
|
<thead>
|
|
<tr><th>Date</th><th>Completed</th><th>Skipped</th></tr>
|
|
</thead>
|
|
<tbody>
|
|
{#each monthHistory as day}
|
|
<tr><td>{day.date}</td><td>{day.breaksCompleted}</td><td>{day.breaksSkipped}</td></tr>
|
|
{/each}
|
|
</tbody>
|
|
</table>
|
|
{/if}
|
|
|
|
<div class="mt-3 flex items-center justify-center gap-4 text-[10px] text-text-sec">
|
|
<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 }}>
|
|
<h2 class="mb-4 text-[11px] font-medium tracking-[0.15em] text-text-sec uppercase">
|
|
Activity Heatmap
|
|
</h2>
|
|
|
|
<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>
|
|
|
|
{#if monthHistory.length > 0}
|
|
<table class="sr-only">
|
|
<caption>Activity heatmap for the last {monthHistory.length} days</caption>
|
|
<thead>
|
|
<tr><th>Date</th><th>Breaks completed</th></tr>
|
|
</thead>
|
|
<tbody>
|
|
{#each monthHistory as day}
|
|
<tr><td>{day.date}</td><td>{day.breaksCompleted}</td></tr>
|
|
{/each}
|
|
</tbody>
|
|
</table>
|
|
{/if}
|
|
|
|
<div class="mt-3 flex items-center justify-center gap-2 text-[10px] text-text-sec">
|
|
<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 }}>
|
|
<h2 class="mb-4 text-[11px] font-medium tracking-[0.15em] text-text-sec uppercase">
|
|
Monthly Summary
|
|
</h2>
|
|
|
|
<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-text-sec">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-text-sec">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-text-sec">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-text-sec">Avg daily breaks</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
{/if}
|
|
|
|
</div>
|
|
</div>
|
|
</div>
|