diff --git a/docs/index.html b/docs/index.html new file mode 100644 index 0000000..9abe3cc --- /dev/null +++ b/docs/index.html @@ -0,0 +1,180 @@ + + + + + + Chronoception Web Demo + + + + +
+
+
+ + +
+
Chronoception
+ +
+ + + + + + + + + + + + + + + + +
+
+
+
+ +
+

Chronoception Web Demo

+

Interact with the watch screen to simulate the app.

+
+
+ + + diff --git a/docs/script.js b/docs/script.js new file mode 100644 index 0000000..4001d25 --- /dev/null +++ b/docs/script.js @@ -0,0 +1,521 @@ + +// State +const state = { + currentMode: null, // 'challenge', 'fear', 'passive' + targetInterval: 300, // seconds (5m) + attemptCount: 1, // for challenge/fear + repetitionCount: 1, // for passive + + // Session State + currentAttemptIndex: 0, // 0-based + attempts: [], // Array of { start, tap, error, etc. } + passiveRunning: false, + passiveTimerId: null, + passiveStartTime: null, + + // Settings + healthKitEnabled: true, + soundEnabled: true +}; + +// DOM Elements +const screens = { + home: document.getElementById('screen-home'), + picker: document.getElementById('screen-picker'), + config: document.getElementById('screen-config'), + session: document.getElementById('screen-session'), + progress: document.getElementById('screen-progress'), + settings: document.getElementById('screen-settings') +}; + +// Picker Elements +const pickerMin = document.getElementById('picker-min'); +const pickerSec = document.getElementById('picker-sec'); + +// Session Elements +const sessionChallengeContainer = document.getElementById('session-challenge-container'); +const sessionPassiveContainer = document.getElementById('session-passive-container'); +const challengeTargetDisplay = document.getElementById('challenge-target-display'); +const challengeReady = document.getElementById('challenge-ready'); +const challengeRunning = document.getElementById('challenge-running'); +const challengeFeedback = document.getElementById('challenge-feedback'); +const challengeSummary = document.getElementById('challenge-summary'); +const challengeAttemptLabel = document.getElementById('challenge-attempt-label'); +const feedbackMain = document.getElementById('feedback-main'); +const feedbackSub = document.getElementById('feedback-sub'); +const summaryMeanError = document.getElementById('summary-mean-error'); +const summaryScore = document.getElementById('summary-score'); +const summaryDoneBtn = document.getElementById('summary-done'); + +const passiveStatus = document.querySelector('.passive-status'); +const passiveTimer = document.getElementById('passive-timer'); +const passiveStopBtn = document.getElementById('passive-stop'); + +// Init +function init() { + setupMenu(); + setupPicker(); + setupConfig(); + setupSessionInteractions(); +} + +// Navigation +function showScreen(screenName) { + Object.values(screens).forEach(s => { + s.classList.add('hidden'); + s.classList.remove('active'); + }); + screens[screenName].classList.remove('hidden'); + screens[screenName].classList.add('active'); +} + +// Helper: Format Time +function formatTime(seconds) { + if (seconds < 60) { + return `${seconds}s`; + } + const m = Math.floor(seconds / 60); + const s = seconds % 60; + return `${m}m ${s}s`; +} + +function formatTimeLong(seconds) { + const m = Math.floor(seconds / 60); + const s = seconds % 60; + return `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`; +} + +// Menu +function setupMenu() { + const buttons = document.querySelectorAll('.menu-item'); + buttons.forEach(btn => { + btn.addEventListener('click', () => { + const mode = btn.dataset.mode; + if (['challenge', 'fear', 'passive'].includes(mode)) { + state.currentMode = mode; + showScreen('picker'); + + // Restore last used or default to 5m 0s + const currentM = Math.floor(state.targetInterval / 60); + const currentS = state.targetInterval % 60; + + // Need to wait for display to unhide before scrolling works reliably in some browsers, + // but in this simple environment it might be fine. + setTimeout(() => { + scrollToValue(pickerMin, currentM); + scrollToValue(pickerSec, currentS); + }, 10); + + } else if (mode === 'progress') { + showScreen('progress'); + } else if (mode === 'settings') { + showScreen('settings'); + setupSettings(); + } + }); + }); +} + +// Picker Logic +function setupPicker() { + // Clear existing + pickerMin.innerHTML = ''; + pickerSec.innerHTML = ''; + + pickerMin.innerHTML = '
'; // Spacer + for (let i = 0; i <= 120; i++) { + const div = document.createElement('div'); + div.className = 'picker-item'; + div.textContent = i; + div.dataset.val = i; + pickerMin.appendChild(div); + } + pickerMin.innerHTML += '
'; // Spacer + + pickerSec.innerHTML = '
'; // Spacer + for (let i = 0; i < 60; i++) { + const div = document.createElement('div'); + div.className = 'picker-item'; + div.textContent = i; + div.dataset.val = i; + pickerSec.appendChild(div); + } + pickerSec.innerHTML += '
'; // Spacer + + // Scroll listener + pickerMin.addEventListener('scroll', () => highlightSelection(pickerMin)); + pickerSec.addEventListener('scroll', () => highlightSelection(pickerSec)); + + // Initial scroll + setTimeout(() => { + pickerMin.scrollTop = 5 * 50; + pickerSec.scrollTop = 0; + }, 10); + + // Confirm button + document.getElementById('picker-confirm').addEventListener('click', () => { + const m = getSelectedValue(pickerMin); + const s = getSelectedValue(pickerSec); + state.targetInterval = (m * 60) + s; + + // Validate minimum interval (10s) + if (state.targetInterval < 10) { + alert("Minimum interval is 10 seconds"); + return; + } + + // Go to config screen + updateConfigUI(); + showScreen('config'); + }); +} + +function scrollToValue(container, value) { + container.scrollTop = value * 50; +} + +function getSelectedValue(container) { + const itemHeight = 50; + const index = Math.round(container.scrollTop / itemHeight); + const items = container.querySelectorAll('.picker-item:not([style*="height:50px"])'); // exclude spacers + if (items[index]) { + return parseInt(items[index].dataset.val); + } + return 0; +} + +function highlightSelection(container) { + const itemHeight = 50; + const index = Math.round(container.scrollTop / itemHeight); + const items = container.querySelectorAll('.picker-item'); + + items.forEach(item => item.classList.remove('selected')); + const selectedIndex = index + 1; + if (items[selectedIndex]) { + items[selectedIndex].classList.add('selected'); + } +} + + +// Config Screen +function setupConfig() { + const minus = document.getElementById('config-minus'); + const plus = document.getElementById('config-plus'); + const start = document.getElementById('config-start'); + + minus.addEventListener('click', () => { + if (state.currentMode === 'passive') { + // Repetitions + if (state.repetitionCount > 1) state.repetitionCount--; + } else { + // Attempts + if (state.attemptCount > 1) state.attemptCount--; + } + updateConfigUI(); + }); + + plus.addEventListener('click', () => { + if (state.currentMode === 'passive') { + state.repetitionCount++; + } else { + state.attemptCount++; + } + updateConfigUI(); + }); + + start.addEventListener('click', () => { + startSession(); + }); +} + +function updateConfigUI() { + const title = document.getElementById('config-title'); + const value = document.getElementById('config-value'); + const label = document.getElementById('config-label'); + + if (state.currentMode === 'passive') { + title.textContent = 'Repetitions'; + value.textContent = state.repetitionCount; + label.textContent = state.repetitionCount === 1 ? 'Repetition' : 'Repetitions'; + } else { + // Challenge or Fear + title.textContent = 'Attempts'; + value.textContent = state.attemptCount; + label.textContent = state.attemptCount === 1 ? 'Attempt' : 'Attempts'; + } +} + +// Settings Logic +function setupSettings() { + const toggleHK = document.getElementById('toggle-healthkit'); + const toggleSound = document.getElementById('toggle-sound'); + + // Init state + if (state.healthKitEnabled) toggleHK.classList.add('active'); else toggleHK.classList.remove('active'); + if (state.soundEnabled) toggleSound.classList.add('active'); else toggleSound.classList.remove('active'); + + toggleHK.onclick = () => { + state.healthKitEnabled = !state.healthKitEnabled; + toggleHK.classList.toggle('active'); + console.log(`HealthKit Enabled: ${state.healthKitEnabled}`); + }; + + toggleSound.onclick = () => { + state.soundEnabled = !state.soundEnabled; + toggleSound.classList.toggle('active'); + console.log(`Sound Enabled: ${state.soundEnabled}`); + }; +} + +// --- Session Logic --- + +function startSession() { + showScreen('session'); + + if (state.currentMode === 'passive') { + startPassiveSession(); + } else { + startChallengeSession(); // Handles both Challenge and Fear + } +} + +// Challenge / Fear Mode +function startChallengeSession() { + sessionChallengeContainer.classList.remove('hidden'); + sessionPassiveContainer.classList.add('hidden'); + + // Init State + state.currentAttemptIndex = 0; + state.attempts = []; + + // Show Ready Screen + challengeTargetDisplay.textContent = formatTime(state.targetInterval); + + showChallengeState('ready'); +} + +function showChallengeState(s) { + // Hide all + challengeReady.classList.add('hidden'); + challengeRunning.classList.add('hidden'); + challengeFeedback.classList.add('hidden'); + challengeSummary.classList.add('hidden'); + + if (s === 'ready') challengeReady.classList.remove('hidden'); + if (s === 'running') challengeRunning.classList.remove('hidden'); + if (s === 'feedback') challengeFeedback.classList.remove('hidden'); + if (s === 'summary') challengeSummary.classList.remove('hidden'); +} + +function setupSessionInteractions() { + // Ready -> Start First Attempt + challengeReady.addEventListener('click', () => { + startAttempt(); + }); + + // Running -> Tap (End Attempt) + challengeRunning.addEventListener('click', () => { + handleTap(); + }); + + // Summary -> Done (Back to Home) + summaryDoneBtn.addEventListener('click', () => { + showScreen('home'); + }); + + // Passive Stop + passiveStopBtn.addEventListener('click', () => { + endPassiveSession(); + showScreen('home'); + }); +} + +function startAttempt() { + const attemptNum = state.currentAttemptIndex + 1; + challengeAttemptLabel.textContent = `Attempt ${attemptNum} / ${state.attemptCount}`; + + showChallengeState('running'); + + // Record Start Time + state.currentAttemptStart = Date.now(); +} + +function handleTap() { + const tapTime = Date.now(); + const elapsedMs = tapTime - state.currentAttemptStart; + const elapsedSec = elapsedMs / 1000; + + const target = state.targetInterval; + const signedError = elapsedSec - target; + const absError = Math.abs(signedError); + const percentError = signedError / target; + + // Store Attempt Data + state.attempts.push({ + start: state.currentAttemptStart, + tap: tapTime, + elapsed: elapsedSec, + signedError: signedError, + absError: absError, + percentError: percentError + }); + + showFeedback(signedError, absError, percentError); +} + +function showFeedback(signedError, absError, percentError) { + const isLate = signedError > 0; + feedbackMain.textContent = isLate ? "Late" : "Early"; + feedbackSub.textContent = `by ${formatTime(Math.round(absError))}`; + + // Color Logic based on error zones + // Green < 5%, Yellow < 15%, Red >= 15% + const absPercent = Math.abs(percentError); + let color = '#34c759'; // Green + if (absPercent >= 0.15) color = '#ff3b30'; // Red + else if (absPercent >= 0.05) color = '#ffcc00'; // Yellow + + feedbackMain.style.color = color; + + showChallengeState('feedback'); + + // Fear Mode Check + if (state.currentMode === 'fear' && absPercent >= 0.10) { + playFearSound(); + } + + // Auto advance after 2 seconds + setTimeout(() => { + state.currentAttemptIndex++; + if (state.currentAttemptIndex < state.attemptCount) { + startAttempt(); + } else { + calculateAndShowSummary(); + } + }, 2000); +} + +function playFearSound() { + if (!state.soundEnabled) return; + // Simple beep or buzzer simulation + // In a real browser, we might use AudioContext. + console.log("BZZZZT! Fear Mode Penalty!"); + // Visual feedback for demo? Flash screen red + const originalBg = document.body.style.backgroundColor; + document.getElementById('watch-screen').style.backgroundColor = 'red'; + setTimeout(() => { + document.getElementById('watch-screen').style.backgroundColor = 'black'; + }, 500); +} + +function calculateAndShowSummary() { + // Calculate Metrics + const totalAbsError = state.attempts.reduce((sum, a) => sum + a.absError, 0); + const meanAbsError = totalAbsError / state.attempts.length; + + const totalAbsPercent = state.attempts.reduce((sum, a) => sum + Math.abs(a.percentError), 0); + const meanAbsPercent = totalAbsPercent / state.attempts.length; + + // Acuity Score + // E_full_scale = 0.50 + // acuity_raw = 1 - (E / 0.50) + // acuity_clamped = max(0, min(1, acuity_raw)) + // Score = round(100 * acuity_clamped) + + const acuityRaw = 1 - (meanAbsPercent / 0.50); + const acuityClamped = Math.max(0, Math.min(1, acuityRaw)); + const score = Math.round(100 * acuityClamped); + + // Update UI + summaryMeanError.textContent = formatTime(Math.round(meanAbsError)); + summaryScore.textContent = score; + + // Log to "HealthKit" + if (state.healthKitEnabled) { + console.log("Logging to HealthKit (Mindful Session):", { + mode: state.currentMode, + interval: state.targetInterval, + attempts: state.attempts.length, + meanAbsError: meanAbsError, + score: score + }); + } + + showChallengeState('summary'); +} + +// Passive Mode +function startPassiveSession() { + sessionChallengeContainer.classList.add('hidden'); + sessionPassiveContainer.classList.remove('hidden'); + + state.passiveRunning = true; + state.passiveStartTime = Date.now(); + updatePassiveTimer(); + + state.passiveRepetitionsDone = 0; + + // Start Interval Loop + schedulePassiveHaptic(); +} + +function updatePassiveTimer() { + if (!state.passiveRunning) return; + + const now = Date.now(); + const elapsed = Math.floor((now - state.passiveStartTime) / 1000); + passiveTimer.textContent = formatTimeLong(elapsed); + + requestAnimationFrame(updatePassiveTimer); +} + +function schedulePassiveHaptic() { + if (!state.passiveRunning) return; + + // In a real app, this would schedule local notifications or run in background. + // Here we just use setTimeout for the next boundary. + // We need to track how many intervals have passed. + + // Simple simulation: just set timeout for targetInterval + + state.passiveTimerId = setTimeout(() => { + triggerHaptic(); + state.passiveRepetitionsDone++; + if (state.passiveRepetitionsDone >= state.repetitionCount) { + endPassiveSession(); + alert("Passive Session Complete"); + showScreen('home'); + } else { + schedulePassiveHaptic(); // recurse + } + }, state.targetInterval * 1000); +} + +function triggerHaptic() { + console.log("HAPTIC FEEDBACK - Interval Reached"); + // Visual Pulse + const screen = document.getElementById('watch-screen'); + screen.style.backgroundColor = '#333'; + setTimeout(() => { + screen.style.backgroundColor = 'black'; + }, 200); +} + +function endPassiveSession() { + state.passiveRunning = false; + if (state.passiveTimerId) clearTimeout(state.passiveTimerId); + + // Log to HealthKit + if (state.healthKitEnabled) { + const duration = Math.floor((Date.now() - state.passiveStartTime) / 1000); + console.log("Logging to HealthKit (Passive):", { + mode: 'passive', + interval: state.targetInterval, + repetitionCount: state.repetitionCount, // Note: this was the plan, but actual duration might differ if stopped early + duration: duration + }); + } +} + +// Run +init(); diff --git a/docs/style.css b/docs/style.css new file mode 100644 index 0000000..68bd24e --- /dev/null +++ b/docs/style.css @@ -0,0 +1,482 @@ +:root { + --watch-bg: #000000; + --watch-text: #ffffff; + --accent-color: #ff9500; /* Orange-ish like generic watch apps or maybe blue */ + --item-bg: #1c1c1e; /* Dark gray for list items */ + --screen-width: 368px; /* 44mm watch */ + --screen-height: 448px; + --bezel: 20px; +} + +body { + background-color: #f0f0f0; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + display: flex; + justify-content: center; + align-items: center; + height: 100vh; + margin: 0; +} + +.presentation-container { + display: flex; + gap: 50px; + align-items: center; +} + +#watch-case { + position: relative; + width: calc(var(--screen-width) + var(--bezel) * 2); + height: calc(var(--screen-height) + var(--bezel) * 2); + background-color: #1a1a1a; + border-radius: 50px; + box-shadow: 0 20px 50px rgba(0,0,0,0.3); + display: flex; + justify-content: center; + align-items: center; +} + +#watch-screen { + width: var(--screen-width); + height: var(--screen-height); + background-color: var(--watch-bg); + color: var(--watch-text); + border-radius: 40px; /* Screen corner radius */ + overflow: hidden; + position: relative; + font-size: 18px; +} + +#digital-crown { + position: absolute; + right: -18px; + top: 100px; + width: 20px; + height: 50px; + background: linear-gradient(to right, #333, #666, #333); + border-radius: 5px; +} + +#side-button { + position: absolute; + right: -8px; + top: 170px; + width: 8px; + height: 80px; + background-color: #333; + border-radius: 4px; +} + +/* Screens */ +.screen { + width: 100%; + height: 100%; + position: absolute; + top: 0; + left: 0; + padding: 10px; + box-sizing: border-box; + display: flex; + flex-direction: column; + align-items: center; + transition: opacity 0.3s ease; +} + +.screen.hidden { + opacity: 0; + pointer-events: none; + z-index: -1; +} + +.screen.active { + opacity: 1; + z-index: 1; +} + +/* Generic Hidden */ +.hidden { + display: none !important; +} + +/* Common Elements */ +.header { + font-size: 22px; + font-weight: 600; + margin-top: 10px; + margin-bottom: 15px; + color: var(--accent-color); +} + +.header-small { + font-size: 18px; + color: #aaa; + margin-top: 5px; + margin-bottom: 10px; +} + +/* Menu */ +.menu-list { + width: 100%; + overflow-y: auto; + flex: 1; + padding-bottom: 20px; +} + +.menu-item { + width: 100%; + background-color: var(--item-bg); + border: none; + border-radius: 10px; + padding: 15px; + margin-bottom: 8px; + display: flex; + align-items: center; + color: white; + text-align: left; + cursor: pointer; + transition: background-color 0.2s; +} + +.menu-item:active { + background-color: #3a3a3c; +} + +.icon { + width: 30px; + height: 30px; + border-radius: 50%; + margin-right: 15px; + background-color: gray; +} + +.challenge-icon { background-color: #34c759; } /* Green */ +.fear-icon { background-color: #ff3b30; } /* Red */ +.passive-icon { background-color: #5856d6; } /* Purple */ +.progress-icon { background-color: #ff9500; } /* Orange */ +.settings-icon { background-color: #8e8e93; } /* Gray */ + +.label { + font-weight: 500; + font-size: 18px; +} + +/* Picker */ +.picker-container { + display: flex; + justify-content: center; + align-items: center; + height: 200px; + width: 100%; + background-color: #000; + position: relative; + /* 3D Effect Mask */ + mask-image: linear-gradient(to bottom, transparent 0%, black 20%, black 80%, transparent 100%); + -webkit-mask-image: linear-gradient(to bottom, transparent 0%, black 20%, black 80%, transparent 100%); +} + +.picker-column { + height: 150px; + width: 70px; + overflow-y: scroll; + scroll-snap-type: y mandatory; + text-align: center; + margin: 0 5px; + scroll-behavior: smooth; +} + +/* Hide scrollbar */ +.picker-column::-webkit-scrollbar { + display: none; +} + +.picker-item { + height: 50px; + line-height: 50px; + font-size: 28px; + scroll-snap-align: center; + color: #888; +} + +.picker-item.selected { + color: white; + font-weight: bold; +} + +.picker-label { + font-size: 18px; + margin-top: 10px; + color: var(--accent-color); +} + +.action-button { + background-color: var(--accent-color); + color: white; + border: none; + border-radius: 25px; + padding: 12px 40px; + font-size: 18px; + font-weight: 600; + margin-top: 20px; + width: 90%; + cursor: pointer; +} + +.action-button:active { + opacity: 0.8; +} + +.text-button { + background: none; + border: none; + color: #aaa; + font-size: 16px; + margin-top: 10px; + cursor: pointer; +} + +.text-button:active { + color: white; +} + +.stop-btn { + background-color: #ff3b30; +} + +/* Config Stepper */ +.config-value-display { + flex: 1; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; +} + +#config-value { + font-size: 48px; + font-weight: 700; + color: white; +} + +#config-label { + font-size: 16px; + color: #aaa; +} + +.stepper-controls { + display: flex; + justify-content: space-between; + width: 80%; + margin-bottom: 20px; +} + +.stepper-btn { + width: 50px; + height: 50px; + border-radius: 25px; + background-color: #333; + color: white; + font-size: 24px; + border: none; + cursor: pointer; +} + +.stepper-btn:active { + background-color: #555; +} + +/* Session Screen styles */ +#session-content { + width: 100%; + height: 100%; +} + +#session-challenge-container, #session-passive-container { + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + align-items: center; +} + +.session-state { + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; +} + +.large-time { + font-size: 64px; + font-weight: 200; + color: white; + margin-bottom: 20px; +} + +.large-text { + font-size: 36px; + font-weight: 500; + color: white; +} + +.instruction-text { + font-size: 18px; + color: var(--accent-color); + margin-top: 10px; +} + +/* Feedback */ +#challenge-feedback { + background-color: #000; /* will change based on error */ +} + +.feedback-main { + font-size: 48px; + font-weight: 800; + margin-bottom: 10px; +} + +.feedback-sub { + font-size: 32px; + font-weight: 400; + color: #ddd; +} + +/* Summary */ +.summary-stat { + width: 100%; + display: flex; + justify-content: space-between; + padding: 10px 20px; + border-bottom: 1px solid #333; +} + +.stat-label { + color: #aaa; +} + +.stat-value { + color: white; + font-weight: 600; +} + +.passive-status { + font-size: 24px; + color: var(--accent-color); + margin-bottom: 30px; + animation: pulse 2s infinite; +} + +@keyframes pulse { + 0% { opacity: 0.5; } + 50% { opacity: 1; } + 100% { opacity: 0.5; } +} + +/* Progress */ +.progress-list, .settings-list { + width: 100%; + flex: 1; + overflow-y: auto; + padding: 10px 0; +} + +.progress-item { + display: flex; + justify-content: space-between; + align-items: center; + background-color: var(--item-bg); + padding: 15px; + border-radius: 10px; + margin-bottom: 8px; +} + +.progress-label { + color: white; + font-size: 18px; +} + +.progress-value { + color: var(--accent-color); + font-size: 24px; + font-weight: 600; +} + +.progress-chart-placeholder { + width: 100%; + height: 150px; + background-color: var(--item-bg); + border-radius: 10px; + margin-bottom: 15px; + display: flex; + justify-content: space-around; + align-items: flex-end; + padding: 10px; + box-sizing: border-box; +} + +.chart-bar { + width: 15px; + background-color: var(--accent-color); + border-radius: 3px; +} + +.history-item { + display: flex; + justify-content: space-between; + padding: 10px 15px; + border-bottom: 1px solid #333; + color: #aaa; +} + +.history-score { + color: white; + font-weight: bold; +} + +/* Settings */ +.setting-item { + display: flex; + justify-content: space-between; + align-items: center; + background-color: var(--item-bg); + padding: 15px; + border-radius: 10px; + margin-bottom: 8px; +} + +.setting-label { + color: white; + font-size: 18px; +} + +.toggle { + width: 50px; + height: 30px; + background-color: #333; + border-radius: 15px; + position: relative; + cursor: pointer; + transition: background-color 0.3s; +} + +.toggle.active { + background-color: #34c759; +} + +.toggle::after { + content: ''; + position: absolute; + top: 2px; + left: 2px; + width: 26px; + height: 26px; + background-color: white; + border-radius: 50%; + transition: transform 0.3s; +} + +.toggle.active::after { + transform: translateX(20px); +}