feat: time-lapse replay mode — replay today's commits in 30s (#484)
Some checks failed
CI / validate (pull_request) Failing after 5s

- Fetches today's commits from Gitea API and replays them over 30 seconds
- HUD indicator with real-time virtual clock and progress bar
- Each commit fires a nexus-core flash and logs to agent stream
- Press [L] to toggle, [Esc] to stop; also wired to inline button
- Indicator glows with animation while active

Fixes #484
This commit is contained in:
Alexander Whitestone
2026-03-24 23:08:56 -04:00
parent a377da05de
commit 2622e42b89
3 changed files with 225 additions and 1 deletions

124
app.js
View File

@@ -45,6 +45,15 @@ let chatOpen = true;
let loadProgress = 0;
let performanceTier = 'high';
// ═══ TIMELAPSE STATE ═══
const TIMELAPSE_DURATION_S = 30;
let timelapseActive = false;
let timelapseRealStart = 0;
let timelapseProgress = 0;
let timelapseNextCommitIdx = 0;
let timelapseCommits = [];
let timelapseWindow = { startMs: 0, endMs: 0 };
// ═══ NAVIGATION SYSTEM ═══
const NAV_MODES = ['walk', 'orbit', 'fly'];
let navModeIdx = 0;
@@ -1069,10 +1078,14 @@ function setupControls() {
document.getElementById('chat-input').blur();
if (portalOverlayActive) closePortalOverlay();
if (visionOverlayActive) closeVisionOverlay();
if (timelapseActive) stopTimelapse();
}
if (e.key.toLowerCase() === 'v' && document.activeElement !== document.getElementById('chat-input')) {
cycleNavMode();
}
if (e.key.toLowerCase() === 'l' && document.activeElement !== document.getElementById('chat-input')) {
if (timelapseActive) stopTimelapse(); else startTimelapse();
}
if (e.key.toLowerCase() === 'f' && activePortal && !portalOverlayActive) {
activatePortal(activePortal);
}
@@ -1141,6 +1154,13 @@ function setupControls() {
document.getElementById('portal-close-btn').addEventListener('click', closePortalOverlay);
document.getElementById('vision-close-btn').addEventListener('click', closeVisionOverlay);
const timelapseBtnEl = document.getElementById('timelapse-btn');
if (timelapseBtnEl) {
timelapseBtnEl.addEventListener('click', () => {
if (timelapseActive) stopTimelapse(); else startTimelapse();
});
}
}
function sendChatMessage() {
@@ -1463,6 +1483,25 @@ function gameLoop() {
dustParticles.rotation.y = elapsed * 0.01;
}
// ─── TIMELAPSE 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;
while (
timelapseNextCommitIdx < timelapseCommits.length &&
timelapseCommits[timelapseNextCommitIdx].ts <= virtualMs
) {
fireTimelapseCommit(timelapseCommits[timelapseNextCommitIdx]);
timelapseNextCommitIdx++;
}
updateTimelapseHUD(timelapseProgress, virtualMs);
if (timelapseProgress >= 1.0) stopTimelapse();
}
for (let i = 0; i < 5; i++) {
const stone = scene.getObjectByName('runestone_' + i);
if (stone) {
@@ -1665,6 +1704,91 @@ function simulateAgentThought() {
addAgentLog(agentId, thought);
}
// ═══ TIME-LAPSE MODE ═══
async function loadTimelapseData() {
try {
const midnight = new Date();
midnight.setHours(0, 0, 0, 0);
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();
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);
timelapseWindow = { startMs: midnight.getTime(), endMs: Date.now() };
} catch {
timelapseCommits = [];
const midnight = new Date();
midnight.setHours(0, 0, 0, 0);
timelapseWindow = { startMs: midnight.getTime(), endMs: Date.now() };
}
}
function fireTimelapseCommit(commit) {
// Flash the nexus core
const core = scene.getObjectByName('nexus-core');
if (core) {
core.material.emissiveIntensity = 8;
setTimeout(() => { if (core) core.material.emissiveIntensity = 2; }, 300);
}
// Log the commit in agent stream
const shortMsg = commit.message.length > 40
? commit.message.slice(0, 37) + '...'
: commit.message;
addAgentLog('timmy', `[${commit.hash}] ${shortMsg}`);
}
function updateTimelapseHUD(progress, virtualMs) {
const clockEl = document.getElementById('timelapse-clock');
if (clockEl) {
const d = new Date(virtualMs);
const hh = String(d.getHours()).padStart(2, '0');
const mm = String(d.getMinutes()).padStart(2, '0');
clockEl.textContent = `${hh}:${mm}`;
}
const barEl = document.getElementById('timelapse-bar');
if (barEl) {
barEl.style.width = `${(progress * 100).toFixed(1)}%`;
}
}
async function startTimelapse() {
if (timelapseActive) return;
addChatMessage('system', 'Loading time-lapse data...');
await loadTimelapseData();
timelapseActive = true;
timelapseRealStart = clock.elapsedTime;
timelapseProgress = 0;
timelapseNextCommitIdx = 0;
const indicator = document.getElementById('timelapse-indicator');
if (indicator) indicator.classList.add('visible');
const btn = document.getElementById('timelapse-btn');
if (btn) btn.classList.add('active');
const commitCount = timelapseCommits.length;
addChatMessage('system', `Time-lapse started. Replaying ${commitCount} commit${commitCount !== 1 ? 's' : ''} from today.`);
}
function stopTimelapse() {
if (!timelapseActive) return;
timelapseActive = false;
const indicator = document.getElementById('timelapse-indicator');
if (indicator) indicator.classList.remove('visible');
const btn = document.getElementById('timelapse-btn');
if (btn) btn.classList.remove('active');
const barEl = document.getElementById('timelapse-bar');
if (barEl) barEl.style.width = '0%';
addChatMessage('system', 'Time-lapse complete.');
}
function addAgentLog(agentId, text) {
const container = document.getElementById('agent-log-content');
if (!container) return;

View File

@@ -104,10 +104,19 @@
<!-- Controls hint + nav mode -->
<div class="hud-controls">
<span>WASD</span> move &nbsp; <span>Mouse</span> look &nbsp; <span>Enter</span> chat &nbsp;
<span>V</span> mode: <span id="nav-mode-label">WALK</span>
<span>V</span> mode: <span id="nav-mode-label">WALK</span> &nbsp;
<span>L</span> <button id="timelapse-btn" class="timelapse-inline-btn" aria-label="Start time-lapse replay" title="Time-lapse: replay today&#39;s activity in 30s [L]">⏩ TIME-LAPSE</button>
<span id="nav-mode-hint" class="nav-mode-hint"></span>
</div>
<!-- TIME-LAPSE 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>
<!-- Portal Hint -->
<div id="portal-hint" class="portal-hint" style="display:none;">
<div class="portal-hint-key">F</div>

View File

@@ -625,6 +625,97 @@ canvas#nexus-canvas {
color: var(--color-primary);
}
/* === TIME-LAPSE MODE === */
#timelapse-indicator {
display: none;
position: fixed;
bottom: 44px;
left: 50%;
transform: translateX(-50%);
color: var(--color-primary);
font-family: var(--font-body);
font-size: 11px;
letter-spacing: 0.15em;
text-transform: uppercase;
pointer-events: none;
z-index: 20;
border: 1px solid var(--color-primary);
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(74, 240, 192, 0.3); }
to { box-shadow: 0 0 16px rgba(74, 240, 192, 0.75); }
}
.timelapse-label {
color: var(--color-primary);
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(74, 240, 192, 0.18);
border-radius: 2px;
overflow: hidden;
}
#timelapse-bar {
height: 100%;
background: var(--color-primary);
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-inline-btn {
background: none;
border: none;
color: var(--color-primary);
font-family: var(--font-body);
font-size: 11px;
cursor: pointer;
padding: 0;
letter-spacing: 0.05em;
opacity: 0.8;
transition: opacity 0.2s;
}
.timelapse-inline-btn:hover {
opacity: 1;
}
.timelapse-inline-btn.active {
color: #fff;
text-shadow: 0 0 8px rgba(74, 240, 192, 0.8);
}
/* Mobile adjustments */
@media (max-width: 480px) {
.chat-panel {