[claude] Time-lapse mode — replay a day of Nexus activity in 30 seconds (#245) #350

Merged
claude merged 1 commits from claude/issue-245 into main 2026-03-24 05:14:34 +00:00
3 changed files with 291 additions and 0 deletions

187
app.js
View File

@@ -1444,6 +1444,28 @@ function animate() {
updateLightningArcs();
}
// Time-lapse replay tick
if (timelapseActive) {
const realElapsed = elapsed - timelapseRealStart;
timelapseProgress = Math.min(realElapsed / TIMELAPSE_DURATION_S, 1.0);
const span = timelapseWindow.endMs - timelapseWindow.startMs;
const virtualMs = timelapseWindow.startMs + span * timelapseProgress;
// Fire commit events for commits we've reached in virtual time
while (
timelapseNextCommitIdx < timelapseCommits.length &&
timelapseCommits[timelapseNextCommitIdx].ts <= virtualMs
) {
fireTimelapseCommit(timelapseCommits[timelapseNextCommitIdx]);
timelapseNextCommitIdx++;
}
updateTimelapseHeatmap(virtualMs);
updateTimelapseHUD(timelapseProgress, virtualMs);
if (timelapseProgress >= 1.0) stopTimelapse();
}
composer.render();
}
@@ -3169,6 +3191,171 @@ function showTimmySpeech(text) {
timmySpeechState = { startTime: clock.getElapsedTime(), sprite };
}
// === TIME-LAPSE MODE ===
// Press 'L' (or click ⏩ in the HUD) to replay a day of Nexus commit activity
// compressed into 30 real seconds. A HUD clock scrubs 00:00 → 23:59 while the
// heatmap and shockwave effects fire in sync with each commit.
const TIMELAPSE_DURATION_S = 30; // real seconds = one full virtual day
let timelapseActive = false;
let timelapseRealStart = 0; // clock.getElapsedTime() when replay began
let timelapseProgress = 0; // 0..1
/** @type {Array<{ts: number, author: string, message: string, hash: string}>} */
let timelapseCommits = [];
/** Virtual day window: midnight-to-now of today. */
let timelapseWindow = { startMs: 0, endMs: 0 };
/** Index of the next commit not yet fired. */
let timelapseNextCommitIdx = 0;
const timelapseIndicator = document.getElementById('timelapse-indicator');
const timelapseClock = document.getElementById('timelapse-clock');
const timelapseBarEl = document.getElementById('timelapse-bar');
const timelapseBtnEl = document.getElementById('timelapse-btn');
/**
* Loads today's commits from the Gitea API for replay.
*/
async function loadTimelapseData() {
try {
const res = await fetch(
'http://143.198.27.163:3000/api/v1/repos/Timmy_Foundation/the-nexus/commits?limit=50',
{ headers: { 'Authorization': 'token dc0517a965226b7a0c5ffdd961b1ba26521ac592' } }
);
if (!res.ok) throw new Error('fetch failed');
const data = await res.json();
const midnight = new Date();
midnight.setHours(0, 0, 0, 0);
timelapseCommits = data
.map(c => ({
ts: new Date(c.commit?.author?.date || 0).getTime(),
author: c.commit?.author?.name || c.author?.login || 'unknown',
message: (c.commit?.message || '').split('\n')[0],
hash: (c.sha || '').slice(0, 7),
}))
.filter(c => c.ts >= midnight.getTime())
.sort((a, b) => a.ts - b.ts);
} catch {
timelapseCommits = [];
}
// Always replay midnight-to-now so the clock reads as a natural day
const midnight = new Date();
midnight.setHours(0, 0, 0, 0);
timelapseWindow = { startMs: midnight.getTime(), endMs: Date.now() };
}
/**
* Fires the visual event for a single replayed commit.
* @param {{ ts: number, author: string, message: string, hash: string }} commit
*/
function fireTimelapseCommit(commit) {
// Spike the matching agent zone briefly
const zone = HEATMAP_ZONES.find(z => z.authorMatch.test(commit.author));
if (zone) {
zoneIntensity[zone.name] = Math.min(1.0, (zoneIntensity[zone.name] || 0) + 0.4);
}
// Shockwave from the commit landing
triggerShockwave();
}
/**
* Recalculates heatmap zone intensities from commits within a trailing window
* ending at virtualMs. Uses a 90-virtual-minute half-life so recent commits
* stay lit while older ones fade.
* @param {number} virtualMs
*/
function updateTimelapseHeatmap(virtualMs) {
const WINDOW_MS = 90 * 60 * 1000; // 90 virtual minutes
const rawWeights = Object.fromEntries(HEATMAP_ZONES.map(z => [z.name, 0]));
for (const commit of timelapseCommits) {
if (commit.ts > virtualMs) break; // array is sorted
const age = virtualMs - commit.ts;
if (age > WINDOW_MS) continue;
const weight = 1 - age / WINDOW_MS;
for (const zone of HEATMAP_ZONES) {
if (zone.authorMatch.test(commit.author)) {
rawWeights[zone.name] += weight;
break;
}
}
}
const MAX_WEIGHT = 4;
for (const zone of HEATMAP_ZONES) {
zoneIntensity[zone.name] = Math.min(rawWeights[zone.name] / MAX_WEIGHT, 1.0);
}
drawHeatmap();
}
/**
* Updates the time-lapse HUD clock and progress bar.
* @param {number} progress 0..1
* @param {number} virtualMs
*/
function updateTimelapseHUD(progress, virtualMs) {
if (timelapseClock) {
const d = new Date(virtualMs);
const hh = String(d.getHours()).padStart(2, '0');
const mm = String(d.getMinutes()).padStart(2, '0');
timelapseClock.textContent = `${hh}:${mm}`;
}
if (timelapseBarEl) {
timelapseBarEl.style.width = `${(progress * 100).toFixed(1)}%`;
}
}
/**
* Starts time-lapse mode: fetches data, resets state, shows HUD.
*/
async function startTimelapse() {
if (timelapseActive) return;
await loadTimelapseData();
timelapseActive = true;
timelapseRealStart = clock.getElapsedTime();
timelapseProgress = 0;
timelapseNextCommitIdx = 0;
// Clear heatmap to zero — driven entirely by replay
for (const zone of HEATMAP_ZONES) zoneIntensity[zone.name] = 0;
drawHeatmap();
if (timelapseIndicator) timelapseIndicator.classList.add('visible');
if (timelapseBtnEl) timelapseBtnEl.classList.add('active');
}
/**
* Stops time-lapse mode and restores the live heatmap.
*/
function stopTimelapse() {
if (!timelapseActive) return;
timelapseActive = false;
if (timelapseIndicator) timelapseIndicator.classList.remove('visible');
if (timelapseBtnEl) timelapseBtnEl.classList.remove('active');
// Restore normal heatmap
updateHeatmap();
}
// Key binding: L to toggle, Esc to stop
document.addEventListener('keydown', (e) => {
if (e.key === 'l' || e.key === 'L') {
if (timelapseActive) stopTimelapse(); else startTimelapse();
}
if (e.key === 'Escape' && timelapseActive) stopTimelapse();
});
// HUD button
if (timelapseBtnEl) {
timelapseBtnEl.addEventListener('click', () => {
if (timelapseActive) stopTimelapse(); else startTimelapse();
});
}
// === BITCOIN BLOCK HEIGHT ===
// Polls blockstream.info every 60 s for the current tip block height.
// Shows a flash animation when the block number increments.

View File

@@ -36,6 +36,9 @@
<button id="export-session" class="chat-toggle-btn" aria-label="Export session as markdown" title="Export session log as Markdown">
📥
</button>
<button id="timelapse-btn" class="chat-toggle-btn" aria-label="Start time-lapse replay" title="Time-lapse: replay today&#39;s activity in 30s [L]">
</button>
<audio id="ambient-sound" src="ambient.mp3" loop></audio>
</div>
@@ -67,6 +70,14 @@
<span id="weather-desc">Lempster NH</span>
</div>
<!-- TIME-LAPSE MODE indicator -->
<div id="timelapse-indicator" aria-live="polite" aria-label="Time-lapse mode active">
<span class="timelapse-label">⏩ TIME-LAPSE</span>
<span id="timelapse-clock">00:00</span>
<div class="timelapse-track"><div id="timelapse-bar"></div></div>
<span class="timelapse-hint">[L] or [Esc] to stop</span>
</div>
<script>
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js').catch(() => {});

View File

@@ -435,3 +435,96 @@ body.photo-mode #overview-indicator {
margin-top: 28px;
text-transform: uppercase;
}
/* === TIME-LAPSE MODE === */
#timelapse-indicator {
display: none;
position: fixed;
bottom: 44px;
left: 50%;
transform: translateX(-50%);
color: #00ffcc;
font-family: var(--font-body);
font-size: 11px;
letter-spacing: 0.15em;
text-transform: uppercase;
pointer-events: none;
z-index: 20;
border: 1px solid #00ffcc;
padding: 6px 14px 8px;
background: rgba(0, 8, 24, 0.85);
white-space: nowrap;
text-align: center;
}
#timelapse-indicator.visible {
display: flex;
align-items: center;
gap: 6px;
animation: timelapse-glow 1.5s ease-in-out infinite alternate;
}
@keyframes timelapse-glow {
from { box-shadow: 0 0 6px rgba(0, 255, 204, 0.3); }
to { box-shadow: 0 0 16px rgba(0, 255, 204, 0.75); }
}
.timelapse-label {
color: #00ffcc;
font-size: 11px;
}
#timelapse-clock {
color: #ffffff;
font-size: 15px;
font-weight: bold;
min-width: 38px;
text-align: center;
font-variant-numeric: tabular-nums;
}
.timelapse-track {
width: 110px;
height: 4px;
background: rgba(0, 255, 204, 0.18);
border-radius: 2px;
overflow: hidden;
}
#timelapse-bar {
height: 100%;
background: #00ffcc;
border-radius: 2px;
width: 0%;
transition: width 0.12s linear;
}
.timelapse-hint {
color: var(--color-text-muted);
font-size: 10px;
letter-spacing: 0.08em;
}
#timelapse-btn {
margin-left: 8px;
background-color: var(--color-secondary);
color: var(--color-text);
padding: 4px 8px;
border: none;
border-radius: 4px;
font-size: 12px;
cursor: pointer;
font-family: var(--font-body);
transition: background-color 0.2s ease;
}
#timelapse-btn:hover {
background-color: #00664433;
color: #00ffcc;
}
#timelapse-btn.active {
background-color: rgba(0, 255, 204, 0.15);
color: #00ffcc;
border: 1px solid #00ffcc;
}