Size: 19.3 KB Modified: 4/07/2026 11:26 PM
@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>
Offline