← Back to Files
Timer.cshtml
@page "/timer"
@model TimerPageModel
@{
ViewData["Title"] = "KaiTimer - KaiChat";
}
<div class="timer-page">
<section class="timer-panel timer-hero">
<div class="timer-header">
<div>
<h1>KaiTimer</h1>
<p class="timer-subtitle">Count-up ritual dashboard</p>
</div>
<span id="timerStatus" class="timer-status">Paused</span>
</div>
<div id="timerDisplay" class="timer-display">0m 0s</div>
<div id="timerMeta" class="timer-meta">Elapsed seconds: 0</div>
<div class="timer-actions">
<button class="btn btn-primary" onclick="startTimer()">Start</button>
<button class="btn" onclick="pauseTimer()">Pause</button>
<button class="btn btn-danger" onclick="resetTimer()">Reset</button>
<button class="btn" onclick="loadTimer()">Refresh</button>
<button class="btn" id="timerNotificationBtn" onclick="enableKaiNotifications()">Enable Notifications</button>
</div>
<div id="timerMessage" class="timer-message"></div>
</section>
<div class="timer-dashboard-grid">
<section class="timer-panel">
<div class="timer-section-header">
<h2>Wheel</h2>
<button class="btn btn-primary" onclick="spinWheel()">Spin</button>
</div>
<div class="timer-wheel-wrap">
<div class="timer-wheel-pointer"></div>
<div id="timerWheel" class="timer-wheel"></div>
</div>
<div id="wheelResult" class="timer-result"></div>
<form class="timer-inline-form" onsubmit="addWheelSegment(event)">
<input id="wheelText" class="form-input timer-flex-input" placeholder="Task or action">
<input id="wheelWeight" class="form-input timer-number-input" type="number" min="1" max="100" value="1" title="Weight">
<button class="btn btn-primary" type="submit">Add</button>
</form>
<div id="wheelSegments" class="timer-list"></div>
</section>
<section class="timer-panel">
<div class="timer-section-header">
<h2>Milestones</h2>
</div>
<form class="timer-stack-form" onsubmit="addMilestone(event)">
<input id="milestoneName" class="form-input" placeholder="Name">
<div class="timer-inline-form">
<input id="milestoneSeconds" class="form-input timer-flex-input" type="number" min="1" step="1" placeholder="Threshold seconds">
<button class="btn btn-primary" type="submit">Add</button>
</div>
<textarea id="milestonePrompt" class="form-input timer-textarea" placeholder="Prompt for Kai to output when this threshold is crossed"></textarea>
</form>
<div id="milestones" class="timer-list"></div>
</section>
<section class="timer-panel">
<div class="timer-section-header">
<h2>Counters</h2>
</div>
<form class="timer-stack-form" onsubmit="addCounter(event)">
<div class="timer-inline-form">
<input id="counterName" class="form-input timer-flex-input" placeholder="Counter name">
<input id="counterValue" class="form-input timer-number-input" type="number" value="0">
</div>
<div class="timer-inline-form">
<select id="counterMode" class="form-input timer-flex-input" onchange="syncCounterModeUi()">
<option value="manual">Manual</option>
<option value="automatic">Automatic</option>
</select>
<select id="counterAutomaticKind" class="form-input timer-flex-input" disabled>
<option value="days_since_last_reset">Days since last reset</option>
<option value="elapsed_days">Elapsed days</option>
</select>
<button class="btn btn-primary" type="submit">Add</button>
</div>
</form>
<div id="counters" class="timer-list"></div>
</section>
<section class="timer-panel">
<div class="timer-section-header">
<h2>Recent Actions</h2>
</div>
<div id="timerEvents" class="timer-list timer-events"></div>
</section>
</div>
</div>
<script>
let timerState = null;
let tickHandle = null;
let wheelRotation = 0;
function setTimerMessage(message, isError) {
const el = document.getElementById('timerMessage');
el.textContent = message || '';
el.classList.toggle('error', !!isError);
}
function formatElapsed(totalSeconds) {
totalSeconds = Math.max(0, Math.floor(totalSeconds || 0));
const days = Math.floor(totalSeconds / 86400);
const hours = Math.floor((totalSeconds % 86400) / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
if (days > 0) return days + 'd ' + hours + 'h ' + minutes + 'm ' + seconds + 's';
if (hours > 0) return hours + 'h ' + minutes + 'm ' + seconds + 's';
return minutes + 'm ' + seconds + 's';
}
function currentElapsedSeconds() {
if (!timerState) return 0;
let elapsed = Math.max(0, Math.floor(timerState.accumulatedSeconds || timerState.elapsedSeconds || 0));
if (timerState.isRunning && timerState.startedAt) {
const started = new Date(timerState.startedAt).getTime();
if (!Number.isNaN(started)) {
elapsed += Math.max(0, Math.floor((Date.now() - started) / 1000));
}
}
return elapsed;
}
function renderTimer() {
const elapsed = currentElapsedSeconds();
const running = !!(timerState && timerState.isRunning);
document.getElementById('timerDisplay').textContent = formatElapsed(elapsed);
document.getElementById('timerMeta').textContent = 'Elapsed seconds: ' + elapsed;
const status = document.getElementById('timerStatus');
status.textContent = running ? 'Running' : 'Paused';
status.classList.toggle('running', running);
}
function setTimerState(state) {
timerState = state || {};
renderTimer();
renderDashboard();
if (tickHandle) clearInterval(tickHandle);
if (timerState.isRunning) {
tickHandle = setInterval(renderTimer, 1000);
}
}
function apiPost(path, body, successMessage) {
setTimerMessage('', false);
return fetch(path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body || {})
})
.then(async response => {
const data = await response.json().catch(() => null);
if (!response.ok || (data && data.success === false)) {
throw new Error((data && data.message) || 'KaiTimer request failed.');
}
if (data && data.state) setTimerState(data.state);
if (successMessage) setTimerMessage(successMessage, false);
return data;
})
.catch(error => {
setTimerMessage(error.message || String(error), true);
throw error;
});
}
function startTimer() {
apiPost('/api/kaitimer/start', {}, 'Timer started.');
}
function pauseTimer() {
apiPost('/api/kaitimer/pause', {}, 'Timer paused.');
}
function resetTimer() {
if (!confirm('Reset the timer to zero?')) return;
apiPost('/api/kaitimer/reset', {}, 'Timer reset.');
}
function loadTimer() {
fetch('/api/kaitimer/dashboard')
.then(async response => {
const data = await response.json().catch(() => null);
if (!response.ok) throw new Error('Could not load timer.');
setTimerState(data);
updateTimerNotificationButton();
})
.catch(error => setTimerMessage(error.message || String(error), true));
}
function renderDashboard() {
renderWheel();
renderCounters();
renderMilestones();
renderEvents();
}
function getWheelSegments() {
return (timerState && timerState.wheelSegments) || [];
}
function renderWheel() {
const wheel = document.getElementById('timerWheel');
const list = document.getElementById('wheelSegments');
const segments = getWheelSegments();
const enabled = segments.filter(s => s.enabled !== false);
const colors = ['#58a6ff', '#d29922', '#3fb950', '#f778ba', '#a371f7', '#39c5cf', '#ff7b72', '#79c0ff'];
if (!enabled.length) {
wheel.style.background = 'var(--bg-main)';
} else {
const slice = 100 / enabled.length;
let cursor = 0;
const stops = enabled.map((segment, index) => {
const start = cursor;
cursor += slice;
return colors[index % colors.length] + ' ' + start + '% ' + cursor + '%';
});
wheel.style.background = 'conic-gradient(' + stops.join(', ') + ')';
}
list.innerHTML = segments.length ? segments.map(segment => wheelSegmentHtml(segment)).join('') : '<div class="timer-empty">No wheel segments yet.</div>';
}
function wheelSegmentHtml(segment) {
const id = escapeHtml(segment.id || '');
const checked = segment.enabled === false ? '' : ' checked';
return '<div class="timer-row" data-segment-id="' + id + '">'
+ '<textarea class="form-input timer-row-text" data-field="text">' + escapeHtml(segment.text || '') + '</textarea>'
+ '<input class="form-input timer-row-weight" data-field="weight" type="number" min="1" max="100" value="' + escapeHtml(segment.weight || 1) + '">'
+ '<label class="timer-check"><input type="checkbox" data-field="enabled"' + checked + '> On</label>'
+ '<button class="btn btn-small" onclick="saveWheelSegment(\'' + id + '\')">Save</button>'
+ '<button class="btn btn-small btn-danger" onclick="deleteWheelSegment(\'' + id + '\')">Delete</button>'
+ '</div>';
}
function addWheelSegment(event) {
event.preventDefault();
const text = document.getElementById('wheelText').value.trim();
const weight = parseInt(document.getElementById('wheelWeight').value || '1', 10);
if (!text) return setTimerMessage('Wheel segment text is required.', true);
apiPost('/api/kaitimer/wheel/segments', { text, weight, enabled: true }, 'Wheel segment added.')
.then(() => {
document.getElementById('wheelText').value = '';
document.getElementById('wheelWeight').value = '1';
});
}
function saveWheelSegment(id) {
const row = document.querySelector('[data-segment-id="' + cssEscapeValue(id) + '"]');
if (!row) return;
apiPost('/api/kaitimer/wheel/segments/update', {
id: id,
text: row.querySelector('[data-field="text"]').value,
weight: parseInt(row.querySelector('[data-field="weight"]').value || '1', 10),
enabled: row.querySelector('[data-field="enabled"]').checked
}, 'Wheel segment saved.');
}
function deleteWheelSegment(id) {
if (!confirm('Remove this wheel segment?')) return;
apiPost('/api/kaitimer/wheel/segments/delete', { id: id }, 'Wheel segment removed.');
}
function spinWheel() {
apiPost('/api/kaitimer/wheel/spin', {}, '')
.then(result => {
const selected = result && result.data ? result.data : null;
if (selected) {
animateWheelToSegment(selected.id);
document.getElementById('wheelResult').textContent = selected.text || result.message || '';
} else {
document.getElementById('wheelResult').textContent = result && result.message ? result.message : '';
}
});
}
function animateWheelToSegment(id) {
const wheel = document.getElementById('timerWheel');
const enabled = getWheelSegments().filter(s => s.enabled !== false);
const index = enabled.findIndex(s => s.id === id);
if (index < 0 || !enabled.length) return;
const slice = 360 / enabled.length;
const target = 360 - (index * slice + slice / 2);
wheelRotation += 1440 + target;
wheel.style.transform = 'rotate(' + wheelRotation + 'deg)';
}
function renderMilestones() {
const list = document.getElementById('milestones');
const milestones = (timerState && timerState.milestones) || [];
list.innerHTML = milestones.length ? milestones.map(milestone => milestoneHtml(milestone)).join('') : '<div class="timer-empty">No milestones yet.</div>';
}
function milestoneHtml(milestone) {
const id = escapeHtml(milestone.id || '');
const checked = milestone.enabled === false ? '' : ' checked';
const status = milestone.enabled === false ? 'Disabled' : milestone.wasPrompted ? 'Sent' : milestone.isDue ? 'Due' : 'Upcoming';
return '<div class="timer-row timer-row-stack" data-milestone-id="' + id + '">'
+ '<div class="timer-row-top">'
+ '<input class="form-input timer-flex-input" data-field="name" value="' + escapeHtml(milestone.name || '') + '">'
+ '<input class="form-input timer-number-input" data-field="thresholdSeconds" type="number" min="1" value="' + escapeHtml(milestone.thresholdSeconds || 1) + '">'
+ '<span class="timer-pill">' + escapeHtml(status) + '</span>'
+ '</div>'
+ '<textarea class="form-input timer-textarea" data-field="prompt">' + escapeHtml(milestone.prompt || '') + '</textarea>'
+ '<div class="timer-row-actions">'
+ '<span class="timer-meta-inline">' + escapeHtml(milestone.displayThreshold || formatElapsed(milestone.thresholdSeconds || 0)) + '</span>'
+ '<label class="timer-check"><input type="checkbox" data-field="enabled"' + checked + '> On</label>'
+ '<label class="timer-check"><input type="checkbox" data-field="resetPromptState"> Reset sent state</label>'
+ '<button class="btn btn-small" onclick="saveMilestone(\'' + id + '\')">Save</button>'
+ '<button class="btn btn-small btn-danger" onclick="deleteMilestone(\'' + id + '\')">Delete</button>'
+ '</div>'
+ '</div>';
}
function addMilestone(event) {
event.preventDefault();
const name = document.getElementById('milestoneName').value.trim();
const thresholdSeconds = parseInt(document.getElementById('milestoneSeconds').value || '0', 10);
const prompt = document.getElementById('milestonePrompt').value.trim();
if (!name || !thresholdSeconds || !prompt) return setTimerMessage('Milestone name, threshold, and prompt are required.', true);
apiPost('/api/kaitimer/milestones', { name, thresholdSeconds, prompt, enabled: true }, 'Milestone added.')
.then(() => {
document.getElementById('milestoneName').value = '';
document.getElementById('milestoneSeconds').value = '';
document.getElementById('milestonePrompt').value = '';
});
}
function saveMilestone(id) {
const row = document.querySelector('[data-milestone-id="' + cssEscapeValue(id) + '"]');
if (!row) return;
apiPost('/api/kaitimer/milestones/update', {
id: id,
name: row.querySelector('[data-field="name"]').value,
thresholdSeconds: parseInt(row.querySelector('[data-field="thresholdSeconds"]').value || '1', 10),
prompt: row.querySelector('[data-field="prompt"]').value,
enabled: row.querySelector('[data-field="enabled"]').checked,
resetPromptState: row.querySelector('[data-field="resetPromptState"]').checked
}, 'Milestone saved.');
}
function deleteMilestone(id) {
if (!confirm('Remove this milestone?')) return;
apiPost('/api/kaitimer/milestones/delete', { id: id }, 'Milestone removed.');
}
function syncCounterModeUi() {
const mode = document.getElementById('counterMode').value;
document.getElementById('counterAutomaticKind').disabled = mode !== 'automatic';
}
function renderCounters() {
const list = document.getElementById('counters');
const counters = (timerState && timerState.counters) || [];
list.innerHTML = counters.length ? counters.map(counter => counterHtml(counter)).join('') : '<div class="timer-empty">No counters yet.</div>';
}
function counterHtml(counter) {
const id = escapeHtml(counter.id || '');
const checked = counter.active === false ? '' : ' checked';
const automatic = counter.mode === 'automatic';
return '<div class="timer-row" data-counter-id="' + id + '">'
+ '<input class="form-input timer-flex-input" data-field="name" value="' + escapeHtml(counter.name || '') + '">'
+ '<span class="timer-counter-value">' + escapeHtml(counter.displayValue || counter.value || 0) + '</span>'
+ '<span class="timer-pill">' + escapeHtml(automatic ? (counter.automaticKind || 'automatic') : 'manual') + '</span>'
+ '<label class="timer-check"><input type="checkbox" data-field="active"' + checked + '> Active</label>'
+ (automatic ? '' : '<button class="btn btn-small" onclick="changeCounter(\'' + id + '\', -1)">-</button><button class="btn btn-small" onclick="changeCounter(\'' + id + '\', 1)">+</button>')
+ '<button class="btn btn-small" onclick="saveCounter(\'' + id + '\')">Save</button>'
+ '<button class="btn btn-small btn-danger" onclick="deleteCounter(\'' + id + '\')">Delete</button>'
+ '</div>';
}
function addCounter(event) {
event.preventDefault();
const name = document.getElementById('counterName').value.trim();
const value = parseInt(document.getElementById('counterValue').value || '0', 10);
const mode = document.getElementById('counterMode').value;
const automaticKind = document.getElementById('counterAutomaticKind').value;
if (!name) return setTimerMessage('Counter name is required.', true);
apiPost('/api/kaitimer/counters', { name, value, mode, automaticKind, active: true }, 'Counter added.')
.then(() => {
document.getElementById('counterName').value = '';
document.getElementById('counterValue').value = '0';
});
}
function saveCounter(id) {
const row = document.querySelector('[data-counter-id="' + cssEscapeValue(id) + '"]');
if (!row) return;
apiPost('/api/kaitimer/counters/update', {
id: id,
name: row.querySelector('[data-field="name"]').value,
active: row.querySelector('[data-field="active"]').checked
}, 'Counter saved.');
}
function changeCounter(id, amount) {
apiPost(amount >= 0 ? '/api/kaitimer/counters/increment' : '/api/kaitimer/counters/decrement', {
id: id,
amount: Math.abs(amount)
}, 'Counter updated.');
}
function deleteCounter(id) {
if (!confirm('Remove this counter?')) return;
apiPost('/api/kaitimer/counters/delete', { id: id }, 'Counter removed.');
}
function renderEvents() {
const list = document.getElementById('timerEvents');
const events = ((timerState && timerState.events) || []).slice().reverse().slice(0, 20);
list.innerHTML = events.length ? events.map(evt => {
const date = evt.timestamp ? new Date(evt.timestamp) : null;
const when = date && !Number.isNaN(date.getTime()) ? date.toLocaleString() : '';
return '<div class="timer-event"><span>' + escapeHtml(when) + '</span><strong>' + escapeHtml(evt.message || evt.type || '') + '</strong></div>';
}).join('') : '<div class="timer-empty">No actions yet.</div>';
}
function updateTimerNotificationButton() {
const btn = document.getElementById('timerNotificationBtn');
if (!btn || !window.updateKaiNotificationButton) return;
window.updateKaiNotificationButton(btn);
}
document.addEventListener('DOMContentLoaded', function () {
syncCounterModeUi();
loadTimer();
});
</script>