mirror of
https://github.com/kunal1522/obsidian-focus-tracker.git
synced 2026-07-22 07:44:51 +00:00
1161 lines
29 KiB
HTML
1161 lines
29 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>calm focus log</title>
|
||
<style>
|
||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||
|
||
:root {
|
||
--bg: #e8e4dc;
|
||
--surface: #f2efe8;
|
||
--surface-inset: #ddd8cf;
|
||
--text: #3d3429;
|
||
--text-dim: #7a7064;
|
||
--border: #c8c0b4;
|
||
--accent: #c4695a;
|
||
--accent-hover: #b05548;
|
||
--heatmap-empty: #e0dcd4;
|
||
--heatmap-light: #d4dfc9;
|
||
--heatmap-mid: #a3b896;
|
||
--heatmap-full: #6b8f5e;
|
||
--session-color: #8fa67a;
|
||
--shadow: 0 2px 8px rgba(61, 52, 41, 0.08), 0 1px 2px rgba(61, 52, 41, 0.06);
|
||
--shadow-lg: 0 8px 24px rgba(61, 52, 41, 0.12), 0 2px 6px rgba(61, 52, 41, 0.06);
|
||
--radius: 16px;
|
||
--radius-sm: 10px;
|
||
--radius-pill: 999px;
|
||
}
|
||
|
||
html, body {
|
||
min-height: 100%;
|
||
background: var(--bg);
|
||
color: var(--text);
|
||
font-family: system-ui, -apple-system, 'Segoe UI', sans-serif;
|
||
font-size: 14px;
|
||
line-height: 1.5;
|
||
}
|
||
|
||
body {
|
||
padding: 2rem 1rem 3rem;
|
||
display: flex;
|
||
justify-content: center;
|
||
}
|
||
|
||
.app-window {
|
||
width: 100%;
|
||
max-width: 520px;
|
||
background: var(--surface);
|
||
border: 1px solid var(--border);
|
||
border-radius: 20px;
|
||
box-shadow: var(--shadow-lg);
|
||
padding: 1.5rem;
|
||
}
|
||
|
||
header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 1.5rem;
|
||
}
|
||
|
||
h1 {
|
||
font-size: 1.15rem;
|
||
font-weight: 700;
|
||
color: var(--accent);
|
||
letter-spacing: -0.01em;
|
||
}
|
||
|
||
.btn {
|
||
background: var(--surface);
|
||
border: 1px solid var(--border);
|
||
color: var(--text);
|
||
font-family: inherit;
|
||
font-size: 0.82rem;
|
||
font-weight: 500;
|
||
padding: 0.45rem 1rem;
|
||
border-radius: var(--radius-pill);
|
||
cursor: pointer;
|
||
box-shadow: var(--shadow);
|
||
transition: background 0.15s, transform 0.1s;
|
||
}
|
||
|
||
.btn:hover {
|
||
background: var(--surface-inset);
|
||
}
|
||
|
||
.btn:active {
|
||
transform: translateY(1px);
|
||
}
|
||
|
||
.btn-primary {
|
||
background: var(--accent);
|
||
border-color: var(--accent-hover);
|
||
color: #fff;
|
||
}
|
||
|
||
.btn-primary:hover {
|
||
background: var(--accent-hover);
|
||
}
|
||
|
||
.btn-dim {
|
||
background: var(--surface-inset);
|
||
color: var(--text-dim);
|
||
box-shadow: none;
|
||
}
|
||
|
||
.btn-dim:hover {
|
||
color: var(--text);
|
||
background: var(--surface-inset);
|
||
}
|
||
|
||
/* heatmap card */
|
||
.heatmap-section {
|
||
margin-bottom: 1.25rem;
|
||
}
|
||
|
||
.heatmap-card {
|
||
background: var(--surface-inset);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius);
|
||
padding: 1rem;
|
||
box-shadow: inset 0 1px 3px rgba(61, 52, 41, 0.06);
|
||
}
|
||
|
||
.heatmap-wrap {
|
||
width: 100%;
|
||
overflow: visible;
|
||
padding: 3px;
|
||
}
|
||
|
||
.heatmap {
|
||
display: grid;
|
||
grid-template-rows: repeat(7, 1fr);
|
||
grid-auto-flow: column;
|
||
grid-auto-columns: 1fr;
|
||
gap: 4px;
|
||
width: 100%;
|
||
aspect-ratio: var(--heatmap-cols, 13) / 7.1;
|
||
}
|
||
|
||
.cell {
|
||
position: relative;
|
||
width: 100%;
|
||
min-width: 0;
|
||
min-height: 0;
|
||
border-radius: 4px;
|
||
border: 1px solid rgba(61, 52, 41, 0.08);
|
||
cursor: pointer;
|
||
transition: box-shadow 0.15s, border-color 0.15s;
|
||
background: var(--heatmap-empty);
|
||
container-type: inline-size;
|
||
}
|
||
|
||
.cell:hover {
|
||
border-color: var(--text-dim);
|
||
box-shadow: inset 0 0 0 1px var(--text-dim);
|
||
}
|
||
|
||
.cell.selected {
|
||
border-color: var(--accent);
|
||
box-shadow: inset 0 0 0 2px var(--accent);
|
||
}
|
||
|
||
.cell.today {
|
||
outline: 2px solid var(--text-dim);
|
||
outline-offset: 1px;
|
||
}
|
||
|
||
.cell.pad {
|
||
visibility: hidden;
|
||
pointer-events: none;
|
||
}
|
||
|
||
.cell-zen {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
flex-shrink: 0;
|
||
width: 50%;
|
||
pointer-events: none;
|
||
}
|
||
|
||
.cell-zen svg {
|
||
width: 100%;
|
||
height: auto;
|
||
filter: drop-shadow(0 0 1px rgba(61, 52, 41, 0.35));
|
||
}
|
||
|
||
.cell-inner {
|
||
position: absolute;
|
||
inset: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 1px;
|
||
padding: 2px;
|
||
pointer-events: none;
|
||
}
|
||
|
||
.cell-mins {
|
||
font-size: clamp(10px, 36cqi, 16px);
|
||
font-weight: 800;
|
||
line-height: 1;
|
||
color: #1f1a14;
|
||
letter-spacing: -0.04em;
|
||
text-shadow:
|
||
0 0 4px rgba(242, 239, 232, 0.95),
|
||
0 1px 0 rgba(242, 239, 232, 0.6);
|
||
}
|
||
|
||
.cell-inner.both .cell-zen {
|
||
width: 34%;
|
||
}
|
||
|
||
.cell-inner.both .cell-mins {
|
||
font-size: clamp(9px, 32cqi, 14px);
|
||
}
|
||
|
||
.meditation-row {
|
||
display: flex;
|
||
align-items: center;
|
||
margin-bottom: 0.75rem;
|
||
padding: 0.5rem 0.65rem;
|
||
background: var(--surface);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius-sm);
|
||
box-shadow: var(--shadow);
|
||
}
|
||
|
||
.meditation-row.vanish {
|
||
animation: meditation-vanish 0.55s ease forwards;
|
||
}
|
||
|
||
@keyframes meditation-vanish {
|
||
0% { opacity: 1; transform: scale(1); max-height: 48px; margin-bottom: 0.75rem; }
|
||
100% { opacity: 0; transform: scale(0.92); max-height: 0; margin-bottom: 0; padding: 0; border-width: 0; }
|
||
}
|
||
|
||
.meditation-label {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.5rem;
|
||
font-size: 0.82rem;
|
||
color: var(--text-dim);
|
||
cursor: pointer;
|
||
user-select: none;
|
||
}
|
||
|
||
.meditation-label input {
|
||
width: 15px;
|
||
height: 15px;
|
||
accent-color: var(--accent);
|
||
cursor: pointer;
|
||
}
|
||
|
||
.heatmap-meta {
|
||
margin-top: 0.75rem;
|
||
font-size: 0.75rem;
|
||
color: var(--text-dim);
|
||
text-align: center;
|
||
}
|
||
|
||
/* day detail */
|
||
.day-detail {
|
||
background: var(--surface-inset);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius);
|
||
padding: 1rem 1.1rem;
|
||
min-height: 100px;
|
||
}
|
||
|
||
.day-detail-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: baseline;
|
||
margin-bottom: 0.85rem;
|
||
gap: 0.5rem;
|
||
}
|
||
|
||
.day-detail h2 {
|
||
font-size: 0.9rem;
|
||
font-weight: 600;
|
||
color: var(--accent);
|
||
}
|
||
|
||
.day-total {
|
||
font-size: 0.78rem;
|
||
color: var(--text-dim);
|
||
font-weight: 500;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.sessions {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.5rem;
|
||
}
|
||
|
||
.session-card {
|
||
position: relative;
|
||
background: var(--surface);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius-sm);
|
||
overflow: hidden;
|
||
box-shadow: var(--shadow);
|
||
}
|
||
|
||
.session-track {
|
||
position: absolute;
|
||
left: 0;
|
||
top: 0;
|
||
bottom: 0;
|
||
min-width: 4px;
|
||
opacity: 0.1;
|
||
border-radius: var(--radius-sm) 0 0 var(--radius-sm);
|
||
pointer-events: none;
|
||
}
|
||
|
||
.session-fill {
|
||
position: absolute;
|
||
left: 0;
|
||
top: 0;
|
||
bottom: 0;
|
||
min-width: 4px;
|
||
opacity: 0.5;
|
||
border-radius: var(--radius-sm) 0 0 var(--radius-sm);
|
||
pointer-events: none;
|
||
transition: width 0.2s ease;
|
||
}
|
||
|
||
.session-content {
|
||
position: relative;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.5rem;
|
||
padding: 0.55rem 0.65rem;
|
||
}
|
||
|
||
.session-dot {
|
||
width: 8px;
|
||
height: 8px;
|
||
border-radius: 50%;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.session-info {
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
|
||
.session-name {
|
||
font-size: 0.82rem;
|
||
font-weight: 500;
|
||
color: var(--text);
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
|
||
.session-dur {
|
||
font-size: 0.72rem;
|
||
font-weight: 600;
|
||
color: var(--text);
|
||
background: var(--surface-inset);
|
||
padding: 0.15rem 0.5rem;
|
||
border-radius: var(--radius-pill);
|
||
border: 1px solid var(--border);
|
||
white-space: nowrap;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.session-delete {
|
||
background: none;
|
||
border: none;
|
||
color: var(--text-dim);
|
||
font-family: inherit;
|
||
font-size: 1rem;
|
||
line-height: 1;
|
||
cursor: pointer;
|
||
padding: 0.15rem 0.25rem;
|
||
border-radius: 6px;
|
||
opacity: 0;
|
||
transition: opacity 0.15s, color 0.15s, background 0.15s;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.session-card:hover .session-delete {
|
||
opacity: 1;
|
||
}
|
||
|
||
.session-delete:hover {
|
||
color: var(--accent);
|
||
background: rgba(196, 105, 90, 0.1);
|
||
}
|
||
|
||
@media (hover: none) {
|
||
.session-delete { opacity: 0.6; }
|
||
}
|
||
|
||
.empty-day {
|
||
color: var(--text-dim);
|
||
font-size: 0.82rem;
|
||
text-align: center;
|
||
padding: 0.5rem 0;
|
||
}
|
||
|
||
/* modals */
|
||
.overlay {
|
||
display: none;
|
||
position: fixed;
|
||
inset: 0;
|
||
background: rgba(61, 52, 41, 0.35);
|
||
z-index: 100;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 1rem;
|
||
}
|
||
|
||
.overlay.active { display: flex; }
|
||
|
||
.modal {
|
||
background: var(--surface);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius);
|
||
padding: 1.35rem;
|
||
width: 100%;
|
||
max-width: 360px;
|
||
box-shadow: var(--shadow-lg);
|
||
}
|
||
|
||
.modal h3 {
|
||
font-size: 0.95rem;
|
||
font-weight: 600;
|
||
color: var(--accent);
|
||
margin-bottom: 1.1rem;
|
||
}
|
||
|
||
.form-group {
|
||
margin-bottom: 0.85rem;
|
||
}
|
||
|
||
.form-group label {
|
||
display: block;
|
||
font-size: 0.72rem;
|
||
font-weight: 500;
|
||
color: var(--text-dim);
|
||
margin-bottom: 0.3rem;
|
||
text-transform: lowercase;
|
||
}
|
||
|
||
.form-group input,
|
||
.form-group select {
|
||
width: 100%;
|
||
background: var(--surface-inset);
|
||
border: 1px solid var(--border);
|
||
color: var(--text);
|
||
font-family: inherit;
|
||
font-size: 0.85rem;
|
||
padding: 0.5rem 0.65rem;
|
||
border-radius: var(--radius-sm);
|
||
color-scheme: light;
|
||
}
|
||
|
||
.form-group input:focus,
|
||
.form-group select:focus {
|
||
outline: none;
|
||
border-color: var(--accent);
|
||
box-shadow: 0 0 0 3px rgba(196, 105, 90, 0.15);
|
||
}
|
||
|
||
.mode-toggle {
|
||
display: flex;
|
||
gap: 0.35rem;
|
||
}
|
||
|
||
.mode-btn {
|
||
flex: 1;
|
||
background: var(--surface-inset);
|
||
border: 1px solid var(--border);
|
||
color: var(--text-dim);
|
||
font-family: inherit;
|
||
font-size: 0.78rem;
|
||
font-weight: 500;
|
||
padding: 0.42rem 0.65rem;
|
||
border-radius: var(--radius-sm);
|
||
cursor: pointer;
|
||
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||
}
|
||
|
||
.mode-btn:hover {
|
||
color: var(--text);
|
||
background: var(--surface);
|
||
}
|
||
|
||
.mode-btn.active {
|
||
background: var(--surface);
|
||
border-color: var(--accent);
|
||
color: var(--accent);
|
||
box-shadow: 0 0 0 2px rgba(196, 105, 90, 0.12);
|
||
}
|
||
|
||
.session-card.timing {
|
||
border-color: rgba(107, 143, 94, 0.45);
|
||
}
|
||
|
||
.session-card.timing .session-dur {
|
||
color: var(--accent);
|
||
border-color: rgba(196, 105, 90, 0.35);
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
|
||
.session-card.timing .session-fill {
|
||
opacity: 0.65;
|
||
}
|
||
|
||
.form-actions {
|
||
display: flex;
|
||
gap: 0.45rem;
|
||
margin-top: 1.1rem;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.form-actions .btn { flex: 1; min-width: 100px; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<div class="app-window">
|
||
|
||
<header>
|
||
<h1>calm focus log</h1>
|
||
<button class="btn btn-primary" id="add-btn">+ session</button>
|
||
</header>
|
||
|
||
<section class="heatmap-section">
|
||
<div class="heatmap-card">
|
||
<div class="heatmap-wrap">
|
||
<div class="heatmap" id="heatmap"></div>
|
||
</div>
|
||
<div class="heatmap-meta" id="heatmap-meta"></div>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="day-detail" id="day-detail">
|
||
<div class="day-detail-header">
|
||
<h2 id="selected-date-label">—</h2>
|
||
<div class="day-total" id="day-total"></div>
|
||
</div>
|
||
<div id="meditation-slot"></div>
|
||
<div class="sessions" id="sessions"></div>
|
||
</section>
|
||
|
||
</div>
|
||
|
||
<!-- add session modal -->
|
||
<div class="overlay" id="add-modal">
|
||
<div class="modal">
|
||
<h3>log session</h3>
|
||
<div class="form-group">
|
||
<label for="session-date">date</label>
|
||
<input type="date" id="session-date">
|
||
</div>
|
||
<div class="form-group">
|
||
<label for="session-label">label</label>
|
||
<input type="text" id="session-label" placeholder="..." autocomplete="off">
|
||
</div>
|
||
<div class="form-group">
|
||
<label for="session-dur">duration (min)</label>
|
||
<input type="number" id="session-dur" min="1" max="480" value="25">
|
||
</div>
|
||
<div class="form-group">
|
||
<label>when</label>
|
||
<div class="mode-toggle">
|
||
<button type="button" class="mode-btn active" data-mode="post" id="mode-post">post</button>
|
||
<button type="button" class="mode-btn" data-mode="pre" id="mode-pre">pre</button>
|
||
</div>
|
||
</div>
|
||
<div class="form-actions">
|
||
<button class="btn btn-primary" id="log-it-btn">log</button>
|
||
<button class="btn btn-dim" id="cancel-add-btn">cancel</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
(function () {
|
||
'use strict';
|
||
|
||
const STORAGE_KEY = 'focus-tracker-data';
|
||
const DAYS = 90;
|
||
const HEATMAP_ROWS = 7;
|
||
const SESSION_COLOR = '#6b8f5e';
|
||
const META_MEDITATION = '_meditation';
|
||
|
||
const YIN_YANG_SVG = '<svg viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">'
|
||
+ '<circle cx="16" cy="16" r="15" fill="#f2efe8" stroke="#3d3429" stroke-width="1"/>'
|
||
+ '<path d="M16 1a15 15 0 0 1 0 30c-4 0-7.5-3.3-7.5-7.5S12 16 16 16s7.5-3.5 7.5-7.5S20 1 16 1z" fill="#3d3429"/>'
|
||
+ '<circle cx="16" cy="8.5" r="2.2" fill="#f2efe8"/>'
|
||
+ '<circle cx="16" cy="23.5" r="2.2" fill="#3d3429"/>'
|
||
+ '</svg>';
|
||
|
||
let data = {};
|
||
let selectedDate = todayStr();
|
||
let lastKnownToday = todayStr();
|
||
let logMode = 'post';
|
||
let timerInterval = null;
|
||
const inPlugin = window.parent !== window;
|
||
|
||
// ── storage ──
|
||
|
||
function loadLocal() {
|
||
try {
|
||
const raw = localStorage.getItem(STORAGE_KEY);
|
||
return raw ? JSON.parse(raw) : {};
|
||
} catch {
|
||
return {};
|
||
}
|
||
}
|
||
|
||
function saveData() {
|
||
if (inPlugin) {
|
||
window.parent.postMessage({ source: 'focus-tracker', type: 'save', payload: data }, '*');
|
||
} else {
|
||
localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
|
||
}
|
||
}
|
||
|
||
function boot(initial) {
|
||
data = initial || loadLocal();
|
||
recoverPreSessions();
|
||
renderAll();
|
||
if (anyActivePreSessions()) ensureTimerLoop();
|
||
}
|
||
|
||
function uid() {
|
||
return Date.now().toString(36) + Math.random().toString(36).slice(2, 7);
|
||
}
|
||
|
||
// ── dates ──
|
||
|
||
function todayStr() {
|
||
return fmtDate(new Date());
|
||
}
|
||
|
||
function tickCalendar() {
|
||
const today = todayStr();
|
||
if (today === lastKnownToday) return false;
|
||
lastKnownToday = today;
|
||
selectedDate = today;
|
||
return true;
|
||
}
|
||
|
||
function refreshCalendar() {
|
||
if (tickCalendar()) renderAll();
|
||
}
|
||
|
||
function fmtDate(d) {
|
||
const y = d.getFullYear();
|
||
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||
const day = String(d.getDate()).padStart(2, '0');
|
||
return `${y}-${m}-${day}`;
|
||
}
|
||
|
||
function parseDate(s) {
|
||
const [y, m, d] = s.split('-').map(Number);
|
||
return new Date(y, m - 1, d);
|
||
}
|
||
|
||
function addDays(d, n) {
|
||
const r = new Date(d);
|
||
r.setDate(r.getDate() + n);
|
||
return r;
|
||
}
|
||
|
||
function daySessions(dateStr) {
|
||
const v = data[dateStr];
|
||
return Array.isArray(v) ? v : [];
|
||
}
|
||
|
||
function isMeditated(dateStr) {
|
||
return !!(data[META_MEDITATION] && data[META_MEDITATION][dateStr]);
|
||
}
|
||
|
||
function setMeditated(dateStr, on) {
|
||
if (!data[META_MEDITATION]) data[META_MEDITATION] = {};
|
||
if (on) data[META_MEDITATION][dateStr] = true;
|
||
else delete data[META_MEDITATION][dateStr];
|
||
if (Object.keys(data[META_MEDITATION]).length === 0) delete data[META_MEDITATION];
|
||
saveData();
|
||
}
|
||
|
||
function sessionPct(s) {
|
||
if (isPreActive(s)) {
|
||
const total = s.dur * 60;
|
||
const elapsed = total - getPreRemaining(s);
|
||
return total > 0 ? Math.min(100, (elapsed / total) * 100) : 0;
|
||
}
|
||
if (s.pct != null) return Number(s.pct) || 100;
|
||
if (s.status === 'incomplete') return 80;
|
||
return 100;
|
||
}
|
||
|
||
function effectiveDur(s) {
|
||
return s.dur * sessionPct(s) / 100;
|
||
}
|
||
|
||
function getPreRemaining(s) {
|
||
if (s.mode !== 'pre' || !s.startedAt) return null;
|
||
const elapsed = (Date.now() - s.startedAt) / 1000;
|
||
return Math.max(0, s.dur * 60 - elapsed);
|
||
}
|
||
|
||
function isPreActive(s) {
|
||
return s.mode === 'pre' && s.startedAt && getPreRemaining(s) > 0;
|
||
}
|
||
|
||
function anyActivePreSessions() {
|
||
for (const dateStr in data) {
|
||
if (dateStr === META_MEDITATION) continue;
|
||
for (const s of daySessions(dateStr)) {
|
||
if (isPreActive(s)) return true;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
function recoverPreSessions() {
|
||
let changed = false;
|
||
for (const dateStr in data) {
|
||
if (dateStr === META_MEDITATION) continue;
|
||
for (const s of daySessions(dateStr)) {
|
||
if (s.mode === 'pre' && s.startedAt && getPreRemaining(s) <= 0) {
|
||
delete s.mode;
|
||
delete s.startedAt;
|
||
s.pct = 100;
|
||
changed = true;
|
||
}
|
||
}
|
||
}
|
||
if (changed) saveData();
|
||
}
|
||
|
||
function fmtCountdown(totalSec) {
|
||
const m = Math.floor(totalSec / 60);
|
||
const s = Math.floor(totalSec % 60);
|
||
return `${m}:${String(s).padStart(2, '0')}`;
|
||
}
|
||
|
||
function updateTimerDisplays() {
|
||
document.querySelectorAll('.session-card[data-session-id]').forEach(card => {
|
||
const session = daySessions(selectedDate).find(s => s.id === card.dataset.sessionId);
|
||
if (!session || !isPreActive(session)) return;
|
||
|
||
const rem = getPreRemaining(session);
|
||
const durEl = card.querySelector('.session-dur');
|
||
const fill = card.querySelector('.session-fill');
|
||
if (durEl) durEl.textContent = fmtCountdown(rem);
|
||
if (fill) fill.style.width = sessionPct(session) + '%';
|
||
});
|
||
}
|
||
|
||
function tickTimers() {
|
||
let completed = false;
|
||
|
||
for (const dateStr in data) {
|
||
if (dateStr === META_MEDITATION) continue;
|
||
for (const s of daySessions(dateStr)) {
|
||
if (s.mode === 'pre' && s.startedAt && getPreRemaining(s) <= 0) {
|
||
delete s.mode;
|
||
delete s.startedAt;
|
||
s.pct = 100;
|
||
completed = true;
|
||
}
|
||
}
|
||
}
|
||
|
||
if (completed) {
|
||
saveData();
|
||
renderAll();
|
||
return;
|
||
}
|
||
|
||
if (anyActivePreSessions()) updateTimerDisplays();
|
||
}
|
||
|
||
function ensureTimerLoop() {
|
||
if (timerInterval) return;
|
||
timerInterval = setInterval(tickTimers, 1000);
|
||
}
|
||
|
||
function heatmapDur(s) {
|
||
if (s.mode === 'pre') return s.dur;
|
||
return effectiveDur(s);
|
||
}
|
||
|
||
function dayMinutes(dateStr) {
|
||
return daySessions(dateStr).reduce((sum, s) => sum + heatmapDur(s), 0);
|
||
}
|
||
|
||
function maxMinutesInRange(dates) {
|
||
let max = 0;
|
||
for (const d of dates) {
|
||
const m = dayMinutes(d);
|
||
if (m > max) max = m;
|
||
}
|
||
return max || 1;
|
||
}
|
||
|
||
function cellColor(mins, maxMins) {
|
||
if (mins === 0) return '#e0dcd4';
|
||
const t = Math.min(mins / maxMins, 1);
|
||
if (t <= 0.33) return lerpColor('#e0dcd4', '#d4dfc9', t / 0.33);
|
||
if (t <= 0.66) return lerpColor('#d4dfc9', '#a3b896', (t - 0.33) / 0.33);
|
||
return lerpColor('#a3b896', '#6b8f5e', (t - 0.66) / 0.34);
|
||
}
|
||
|
||
function lerpColor(a, b, t) {
|
||
const pa = hexRgb(a), pb = hexRgb(b);
|
||
const r = Math.round(pa.r + (pb.r - pa.r) * t);
|
||
const g = Math.round(pa.g + (pb.g - pa.g) * t);
|
||
const bl = Math.round(pa.b + (pb.b - pa.b) * t);
|
||
return `rgb(${r},${g},${bl})`;
|
||
}
|
||
|
||
function hexRgb(h) {
|
||
const n = parseInt(h.slice(1), 16);
|
||
return { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255 };
|
||
}
|
||
|
||
function fmtDisplayDate(s) {
|
||
const d = parseDate(s);
|
||
const days = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'];
|
||
const months = ['jan','feb','mar','apr','may','jun','jul','aug','sep','oct','nov','dec'];
|
||
return `${days[d.getDay()]} ${d.getDate()} ${months[d.getMonth()]} ${d.getFullYear()}`;
|
||
}
|
||
|
||
// ── render heatmap ──
|
||
|
||
function getDateRange() {
|
||
const end = new Date();
|
||
end.setHours(0, 0, 0, 0);
|
||
const start = addDays(end, -(DAYS - 1));
|
||
const dates = [];
|
||
for (let i = 0; i < DAYS; i++) {
|
||
dates.push(fmtDate(addDays(start, i)));
|
||
}
|
||
return dates;
|
||
}
|
||
|
||
function renderHeatmap() {
|
||
const dates = getDateRange();
|
||
const maxMins = maxMinutesInRange(dates);
|
||
const container = document.getElementById('heatmap');
|
||
container.innerHTML = '';
|
||
|
||
const cols = Math.ceil(DAYS / HEATMAP_ROWS);
|
||
const padCount = HEATMAP_ROWS * cols - DAYS;
|
||
|
||
// fixed grid: oldest near top-left, today always bottom-right
|
||
for (let i = 0; i < padCount; i++) {
|
||
const pad = document.createElement('div');
|
||
pad.className = 'cell pad';
|
||
container.appendChild(pad);
|
||
}
|
||
|
||
const today = todayStr();
|
||
container.style.setProperty('--heatmap-cols', cols);
|
||
|
||
dates.forEach(dateStr => {
|
||
const mins = Math.round(dayMinutes(dateStr));
|
||
const meditated = isMeditated(dateStr);
|
||
const cell = document.createElement('div');
|
||
cell.className = 'cell';
|
||
cell.style.background = cellColor(mins, maxMins);
|
||
cell.title = `${dateStr}: ${mins} min${meditated ? ' · meditated' : ''}`;
|
||
cell.dataset.date = dateStr;
|
||
if (dateStr === selectedDate) cell.classList.add('selected');
|
||
if (dateStr === today) cell.classList.add('today');
|
||
if (meditated) cell.classList.add('meditated');
|
||
|
||
if (mins > 0 || meditated) {
|
||
const inner = document.createElement('div');
|
||
inner.className = 'cell-inner' + (mins > 0 && meditated ? ' both' : '');
|
||
|
||
if (mins > 0) {
|
||
const num = document.createElement('span');
|
||
num.className = 'cell-mins';
|
||
num.textContent = mins;
|
||
inner.appendChild(num);
|
||
}
|
||
|
||
if (meditated) {
|
||
const zen = document.createElement('div');
|
||
zen.className = 'cell-zen';
|
||
zen.innerHTML = YIN_YANG_SVG;
|
||
inner.appendChild(zen);
|
||
}
|
||
|
||
cell.appendChild(inner);
|
||
}
|
||
|
||
cell.addEventListener('click', () => selectDate(dateStr));
|
||
container.appendChild(cell);
|
||
});
|
||
|
||
const totalMins = dates.reduce((s, d) => s + dayMinutes(d), 0);
|
||
document.getElementById('heatmap-meta').textContent =
|
||
`${DAYS} days · ${totalMins} min total`;
|
||
}
|
||
|
||
// ── render day detail ──
|
||
|
||
function selectDate(dateStr) {
|
||
selectedDate = dateStr;
|
||
document.querySelectorAll('.cell.selected').forEach(c => c.classList.remove('selected'));
|
||
const cell = document.querySelector(`.cell[data-date="${dateStr}"]`);
|
||
if (cell) cell.classList.add('selected');
|
||
syncDateInput(dateStr);
|
||
renderDayDetail();
|
||
}
|
||
|
||
function syncDateInput(dateStr) {
|
||
const el = document.getElementById('session-date');
|
||
if (el) el.value = dateStr;
|
||
}
|
||
|
||
function getLogDate() {
|
||
const el = document.getElementById('session-date');
|
||
return (el && el.value) ? el.value : selectedDate;
|
||
}
|
||
|
||
function setDateInputBounds() {
|
||
const el = document.getElementById('session-date');
|
||
const today = new Date();
|
||
today.setHours(0, 0, 0, 0);
|
||
el.max = fmtDate(today);
|
||
el.min = fmtDate(addDays(today, -(DAYS - 1)));
|
||
}
|
||
|
||
function deleteSession(dateStr, sessionId) {
|
||
const sessions = daySessions(dateStr);
|
||
if (!sessions.length) return;
|
||
data[dateStr] = sessions.filter(s => s.id !== sessionId);
|
||
if (data[dateStr].length === 0) delete data[dateStr];
|
||
saveData();
|
||
renderAll();
|
||
}
|
||
|
||
function renderMeditationRow() {
|
||
const slot = document.getElementById('meditation-slot');
|
||
slot.innerHTML = '';
|
||
if (isMeditated(selectedDate)) return;
|
||
|
||
const row = document.createElement('div');
|
||
row.className = 'meditation-row';
|
||
|
||
const label = document.createElement('label');
|
||
label.className = 'meditation-label';
|
||
|
||
const check = document.createElement('input');
|
||
check.type = 'checkbox';
|
||
check.addEventListener('change', () => {
|
||
if (!check.checked) return;
|
||
row.classList.add('vanish');
|
||
row.addEventListener('animationend', () => {
|
||
setMeditated(selectedDate, true);
|
||
renderAll();
|
||
}, { once: true });
|
||
});
|
||
|
||
const text = document.createElement('span');
|
||
text.textContent = 'meditate?';
|
||
|
||
label.appendChild(check);
|
||
label.appendChild(text);
|
||
row.appendChild(label);
|
||
slot.appendChild(row);
|
||
}
|
||
|
||
function renderDayDetail() {
|
||
document.getElementById('selected-date-label').textContent = fmtDisplayDate(selectedDate);
|
||
const sessions = daySessions(selectedDate);
|
||
const total = Math.round(sessions.reduce((s, x) => s + heatmapDur(x), 0));
|
||
document.getElementById('day-total').textContent =
|
||
total > 0 ? `${total} min` : '';
|
||
|
||
renderMeditationRow();
|
||
|
||
const container = document.getElementById('sessions');
|
||
container.innerHTML = '';
|
||
|
||
if (sessions.length === 0) {
|
||
container.innerHTML = '<div class="empty-day">—</div>';
|
||
return;
|
||
}
|
||
|
||
sessions.forEach(session => {
|
||
const name = session.label || 'session';
|
||
const active = isPreActive(session);
|
||
const pct = Math.min(100, Math.max(0, sessionPct(session)));
|
||
|
||
const card = document.createElement('div');
|
||
card.className = 'session-card' + (active ? ' timing' : '');
|
||
card.dataset.sessionId = session.id;
|
||
|
||
const track = document.createElement('div');
|
||
track.className = 'session-track';
|
||
track.style.width = '100%';
|
||
track.style.background = SESSION_COLOR;
|
||
|
||
const fill = document.createElement('div');
|
||
fill.className = 'session-fill';
|
||
fill.style.width = pct + '%';
|
||
fill.style.background = SESSION_COLOR;
|
||
|
||
const content = document.createElement('div');
|
||
content.className = 'session-content';
|
||
|
||
const dot = document.createElement('div');
|
||
dot.className = 'session-dot';
|
||
dot.style.background = SESSION_COLOR;
|
||
|
||
const info = document.createElement('div');
|
||
info.className = 'session-info';
|
||
|
||
const nameEl = document.createElement('div');
|
||
nameEl.className = 'session-name';
|
||
nameEl.textContent = name;
|
||
|
||
info.appendChild(nameEl);
|
||
|
||
const dur = document.createElement('div');
|
||
dur.className = 'session-dur';
|
||
dur.textContent = active
|
||
? fmtCountdown(getPreRemaining(session))
|
||
: session.dur + ' min';
|
||
|
||
const del = document.createElement('button');
|
||
del.className = 'session-delete';
|
||
del.textContent = '×';
|
||
del.title = 'delete';
|
||
del.addEventListener('click', () => deleteSession(selectedDate, session.id));
|
||
|
||
content.appendChild(dot);
|
||
content.appendChild(info);
|
||
content.appendChild(dur);
|
||
content.appendChild(del);
|
||
card.appendChild(track);
|
||
card.appendChild(fill);
|
||
card.appendChild(content);
|
||
container.appendChild(card);
|
||
});
|
||
}
|
||
|
||
function renderAll() {
|
||
lastKnownToday = todayStr();
|
||
renderHeatmap();
|
||
renderDayDetail();
|
||
}
|
||
|
||
// ── add session ──
|
||
|
||
function setLogMode(mode) {
|
||
logMode = mode === 'pre' ? 'pre' : 'post';
|
||
document.querySelectorAll('.mode-btn').forEach(btn => {
|
||
btn.classList.toggle('active', btn.dataset.mode === logMode);
|
||
});
|
||
}
|
||
|
||
function addSession(label, dur, mode, dateStr) {
|
||
const target = dateStr || getLogDate();
|
||
if (!Array.isArray(data[target])) data[target] = [];
|
||
|
||
const session = {
|
||
id: uid(),
|
||
label,
|
||
dur: Number(dur)
|
||
};
|
||
|
||
if (mode === 'pre') {
|
||
session.mode = 'pre';
|
||
session.startedAt = Date.now();
|
||
} else {
|
||
session.pct = 100;
|
||
}
|
||
|
||
data[target].push(session);
|
||
selectedDate = target;
|
||
saveData();
|
||
renderAll();
|
||
if (mode === 'pre') ensureTimerLoop();
|
||
}
|
||
|
||
// ── modals ──
|
||
|
||
const addModal = document.getElementById('add-modal');
|
||
|
||
document.getElementById('add-btn').addEventListener('click', () => {
|
||
refreshCalendar();
|
||
document.getElementById('session-label').value = '';
|
||
document.getElementById('session-dur').value = '25';
|
||
setLogMode('post');
|
||
setDateInputBounds();
|
||
syncDateInput(todayStr());
|
||
addModal.classList.add('active');
|
||
document.getElementById('session-label').focus();
|
||
});
|
||
|
||
document.getElementById('mode-post').addEventListener('click', () => setLogMode('post'));
|
||
document.getElementById('mode-pre').addEventListener('click', () => setLogMode('pre'));
|
||
|
||
document.getElementById('session-date').addEventListener('change', () => {
|
||
const d = getLogDate();
|
||
if (d) selectDate(d);
|
||
});
|
||
|
||
document.getElementById('cancel-add-btn').addEventListener('click', () => {
|
||
addModal.classList.remove('active');
|
||
});
|
||
|
||
document.getElementById('log-it-btn').addEventListener('click', () => {
|
||
const label = document.getElementById('session-label').value.trim();
|
||
const dur = document.getElementById('session-dur').value;
|
||
if (!dur || dur < 1) return;
|
||
addSession(label, dur, logMode, getLogDate());
|
||
addModal.classList.remove('active');
|
||
});
|
||
|
||
addModal.addEventListener('click', e => {
|
||
if (e.target === addModal) addModal.classList.remove('active');
|
||
});
|
||
|
||
// keyboard: escape closes add modal
|
||
document.addEventListener('keydown', e => {
|
||
if (e.key === 'Escape') {
|
||
if (addModal.classList.contains('active')) addModal.classList.remove('active');
|
||
}
|
||
});
|
||
|
||
document.addEventListener('visibilitychange', () => {
|
||
if (!document.hidden) refreshCalendar();
|
||
});
|
||
|
||
window.addEventListener('focus', refreshCalendar);
|
||
setInterval(refreshCalendar, 60000);
|
||
|
||
if (inPlugin) {
|
||
window.addEventListener('message', (e) => {
|
||
if (e.data?.source !== 'focus-tracker-plugin') return;
|
||
if (e.data.type === 'init') boot(e.data.result);
|
||
});
|
||
window.parent.postMessage({ source: 'focus-tracker', type: 'ready' }, '*');
|
||
} else {
|
||
boot();
|
||
}
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html>
|