Skip to content

Commit 73d92ba

Browse files
authored
Merge pull request #136 from LennartvdM/codex/add-run-history-panel-to-calendar-tab
Add calendar run history panel on visuals tab
2 parents 1ee6460 + 257d7a4 commit 73d92ba

2 files changed

Lines changed: 426 additions & 2 deletions

File tree

web/app.js

Lines changed: 314 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,8 @@ let currentJsonMetadata = { variant: '', rig: '', week: '', events: null };
8585
const VISUALS_LEGACY_FLAG = 'wyrd.visuals.legacy';
8686
const visualsState = {
8787
container: null,
88+
layout: null,
89+
mainPanel: null,
8890
mount: null,
8991
fallback: null,
9092
fallbackImg: null,
@@ -94,6 +96,14 @@ const visualsState = {
9496
};
9597
let lastVisualPayload = null;
9698

99+
const CALENDAR_HISTORY_LIMIT = 20;
100+
const calendarHistoryState = {
101+
runHistory: [],
102+
panel: null,
103+
list: null,
104+
activeId: null,
105+
};
106+
97107
function readVisualsLegacyFlag() {
98108
try {
99109
return localStorage.getItem(VISUALS_LEGACY_FLAG) === '1';
@@ -210,9 +220,19 @@ function initVisualsMount() {
210220
visualsState.container = container;
211221
visualsState.useLegacy = readVisualsLegacyFlag();
212222

223+
const layout = document.createElement('div');
224+
layout.className = 'visuals-layout';
225+
container.append(layout);
226+
visualsState.layout = layout;
227+
228+
const mainPanel = document.createElement('div');
229+
mainPanel.className = 'visuals-main';
230+
layout.append(mainPanel);
231+
visualsState.mainPanel = mainPanel;
232+
213233
const mount = document.createElement('div');
214234
mount.className = 'visuals-mount';
215-
container.append(mount);
235+
mainPanel.append(mount);
216236
visualsState.mount = mount;
217237

218238
const fallback = document.createElement('div');
@@ -226,11 +246,16 @@ function initVisualsMount() {
226246
fallbackMessage.textContent = 'Legacy visuals preview unavailable.';
227247
fallbackMessage.hidden = true;
228248
fallback.append(fallbackImg, fallbackMessage);
229-
container.append(fallback);
249+
mainPanel.append(fallback);
230250
visualsState.fallback = fallback;
231251
visualsState.fallbackImg = fallbackImg;
232252
visualsState.fallbackMessage = fallbackMessage;
233253

254+
const historyPanel = createCalendarHistoryPanel();
255+
if (historyPanel) {
256+
layout.append(historyPanel);
257+
}
258+
234259
if (typeof window !== 'undefined') {
235260
window.WYRD_SET_VISUALS_LEGACY = (enabled) => {
236261
const flag = Boolean(enabled);
@@ -260,6 +285,279 @@ function safeInitVisuals(initialData) {
260285
}
261286
}
262287

288+
function generateCalendarHistoryId() {
289+
try {
290+
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
291+
return crypto.randomUUID();
292+
}
293+
} catch (error) {
294+
// ignore
295+
}
296+
const random = Math.random().toString(16).slice(2);
297+
return `calendar-${Date.now()}-${random}`;
298+
}
299+
300+
function parseHistoryTime(value) {
301+
if (typeof value !== 'string') {
302+
return null;
303+
}
304+
const [hoursPart, minutesPart] = value.split(':');
305+
const hours = Number.parseInt(hoursPart, 10);
306+
const minutes = Number.parseInt(minutesPart, 10);
307+
if (!Number.isFinite(hours) || !Number.isFinite(minutes)) {
308+
return null;
309+
}
310+
const total = hours * 60 + minutes;
311+
return Number.isFinite(total) ? ((total % (24 * 60)) + 24 * 60) % (24 * 60) : null;
312+
}
313+
314+
function computeEventDurationMinutes(event) {
315+
if (!event || typeof event !== 'object') {
316+
return 0;
317+
}
318+
const start = parseHistoryTime(event.start);
319+
const end = parseHistoryTime(event.end);
320+
if (start === null || end === null) {
321+
return 0;
322+
}
323+
if (end >= start) {
324+
return end - start;
325+
}
326+
return 24 * 60 - start + end;
327+
}
328+
329+
function computeCalendarHistorySummary(events) {
330+
if (!Array.isArray(events) || events.length === 0) {
331+
return { totalEvents: Array.isArray(events) ? events.length : 0 };
332+
}
333+
334+
let sleepMinutes = 0;
335+
let workMinutes = 0;
336+
337+
events.forEach((event) => {
338+
const duration = computeEventDurationMinutes(event);
339+
if (duration <= 0) {
340+
return;
341+
}
342+
const activity = (event?.activity || event?.label || '').toString().toLowerCase();
343+
if (activity.includes('sleep')) {
344+
sleepMinutes += duration;
345+
}
346+
if (activity.includes('work')) {
347+
workMinutes += duration;
348+
}
349+
});
350+
351+
const summary = { totalEvents: events.length };
352+
if (sleepMinutes > 0) {
353+
summary.totalSleepHours = Math.round((sleepMinutes / 60) * 10) / 10;
354+
}
355+
if (workMinutes > 0) {
356+
summary.totalWorkHours = Math.round((workMinutes / 60) * 10) / 10;
357+
}
358+
return summary;
359+
}
360+
361+
function cloneCalendarHistoryPayload(payload) {
362+
try {
363+
return JSON.parse(JSON.stringify(payload ?? {}));
364+
} catch (error) {
365+
console.warn('Unable to clone calendar history payload:', error);
366+
return null;
367+
}
368+
}
369+
370+
function formatHistoryTimestamp(value) {
371+
try {
372+
const date = new Date(value);
373+
if (Number.isNaN(date.getTime())) {
374+
throw new Error('Invalid date');
375+
}
376+
const pad = (num) => String(num).padStart(2, '0');
377+
const year = date.getFullYear();
378+
const month = pad(date.getMonth() + 1);
379+
const day = pad(date.getDate());
380+
const hours = pad(date.getHours());
381+
const minutes = pad(date.getMinutes());
382+
return `${year}-${month}-${day} ${hours}:${minutes}`;
383+
} catch (error) {
384+
return 'Unknown time';
385+
}
386+
}
387+
388+
function formatHistoryHours(value) {
389+
if (!Number.isFinite(value)) {
390+
return null;
391+
}
392+
const rounded = Math.round(value * 10) / 10;
393+
return rounded.toFixed(1);
394+
}
395+
396+
function renderCalendarRunHistory() {
397+
const list = calendarHistoryState.list;
398+
if (!list) {
399+
return;
400+
}
401+
402+
list.innerHTML = '';
403+
if (calendarHistoryState.runHistory.length === 0) {
404+
const emptyItem = document.createElement('li');
405+
emptyItem.className = 'visuals-history-empty';
406+
emptyItem.textContent = 'No runs yet. Generate a schedule to build history.';
407+
list.append(emptyItem);
408+
return;
409+
}
410+
411+
const fragment = document.createDocumentFragment();
412+
calendarHistoryState.runHistory.forEach((entry) => {
413+
const item = document.createElement('li');
414+
item.className = 'visuals-history-item';
415+
416+
const button = document.createElement('button');
417+
button.type = 'button';
418+
button.className = 'visuals-history-entry';
419+
if (entry.id === calendarHistoryState.activeId) {
420+
button.classList.add('is-active');
421+
}
422+
423+
const headline = document.createElement('span');
424+
headline.className = 'visuals-history-entry__headline';
425+
const parts = [`[${formatHistoryTimestamp(entry.timestamp)}]`];
426+
if (entry.archetype) {
427+
parts.push(`archetype=${entry.archetype}`);
428+
}
429+
if (entry.seed !== undefined && entry.seed !== null && entry.seed !== '') {
430+
parts.push(`seed=${entry.seed}`);
431+
}
432+
const variantLabel = [entry.variant, entry.rig].filter(Boolean).join('/');
433+
if (variantLabel) {
434+
parts.push(variantLabel);
435+
}
436+
headline.textContent = parts.join(' ');
437+
438+
const meta = document.createElement('span');
439+
meta.className = 'visuals-history-entry__meta';
440+
const metaParts = [];
441+
if (entry.weekStart) {
442+
metaParts.push(`week=${entry.weekStart}`);
443+
}
444+
if (entry.summary && Number.isFinite(entry.summary.totalEvents)) {
445+
metaParts.push(`events=${entry.summary.totalEvents}`);
446+
}
447+
if (entry.summary && Number.isFinite(entry.summary.totalSleepHours)) {
448+
const value = formatHistoryHours(entry.summary.totalSleepHours);
449+
if (value) {
450+
metaParts.push(`sleep≈${value}h`);
451+
}
452+
}
453+
if (entry.summary && Number.isFinite(entry.summary.totalWorkHours)) {
454+
const value = formatHistoryHours(entry.summary.totalWorkHours);
455+
if (value) {
456+
metaParts.push(`work≈${value}h`);
457+
}
458+
}
459+
meta.textContent = metaParts.join(' • ') || 'No summary available';
460+
461+
button.append(headline, meta);
462+
button.addEventListener('click', () => {
463+
restoreCalendarHistoryEntry(entry.id);
464+
});
465+
466+
item.append(button);
467+
fragment.append(item);
468+
});
469+
470+
list.append(fragment);
471+
}
472+
473+
function createCalendarHistoryPanel() {
474+
if (calendarHistoryState.panel) {
475+
return calendarHistoryState.panel;
476+
}
477+
const panel = document.createElement('aside');
478+
panel.className = 'visuals-history-panel';
479+
480+
const header = document.createElement('div');
481+
header.className = 'visuals-history-header';
482+
483+
const title = document.createElement('h3');
484+
title.className = 'visuals-history-title';
485+
title.textContent = 'History';
486+
487+
header.append(title);
488+
panel.append(header);
489+
490+
const list = document.createElement('ul');
491+
list.className = 'visuals-history-list';
492+
panel.append(list);
493+
494+
calendarHistoryState.panel = panel;
495+
calendarHistoryState.list = list;
496+
497+
renderCalendarRunHistory();
498+
499+
return panel;
500+
}
501+
502+
function recordCalendarHistoryEntry(entry) {
503+
if (!entry || !entry.rawResult) {
504+
return;
505+
}
506+
const normalized = {
507+
id: entry.id || generateCalendarHistoryId(),
508+
timestamp: entry.timestamp || new Date().toISOString(),
509+
archetype: entry.archetype || '',
510+
seed:
511+
Number.isFinite(entry.seed)
512+
? entry.seed
513+
: Number.isFinite(Number.parseInt(entry.seed, 10))
514+
? Number.parseInt(entry.seed, 10)
515+
: undefined,
516+
variant: entry.variant || '',
517+
rig: entry.rig || '',
518+
weekStart: entry.weekStart || '',
519+
summary: entry.summary ? { ...entry.summary } : null,
520+
rawResult: cloneCalendarHistoryPayload(entry.rawResult) || entry.rawResult,
521+
};
522+
523+
calendarHistoryState.runHistory = [normalized, ...calendarHistoryState.runHistory].slice(
524+
0,
525+
CALENDAR_HISTORY_LIMIT,
526+
);
527+
calendarHistoryState.activeId = normalized.id;
528+
renderCalendarRunHistory();
529+
}
530+
531+
function restoreCalendarHistoryEntry(entryId) {
532+
if (!entryId) {
533+
return;
534+
}
535+
const entry = calendarHistoryState.runHistory.find((item) => item.id === entryId);
536+
if (!entry) {
537+
return;
538+
}
539+
const payload = cloneCalendarHistoryPayload(entry.rawResult) || entry.rawResult;
540+
if (!payload || typeof payload !== 'object') {
541+
return;
542+
}
543+
544+
calendarHistoryState.activeId = entry.id;
545+
546+
setJsonPayload(payload, {
547+
variant: entry.variant,
548+
rig: entry.rig,
549+
weekStart: entry.weekStart,
550+
});
551+
updateJsonActionsState();
552+
const validation = validateWebV1Calendar(payload);
553+
setJsonValidationBadge(validation.ok ? 'ok' : 'err');
554+
renderCalendarRunHistory();
555+
dispatchIntent({
556+
type: INTENT_TYPES.NAVIGATE_TAB,
557+
payload: { tab: 'visuals' },
558+
});
559+
}
560+
263561
let getConfigSnapshot = () => ({
264562
classId: 'calendar',
265563
variant: '',
@@ -3343,6 +3641,20 @@ function hydrateConfigPanel() {
33433641
inputsSnapshot.budget = true;
33443642
}
33453643

3644+
recordCalendarHistoryEntry({
3645+
archetype,
3646+
seed: normalizedSeed,
3647+
variant: variantId,
3648+
rig: rigId,
3649+
weekStart:
3650+
typeof result.week_start === 'string' && result.week_start
3651+
? result.week_start
3652+
: weekStartValue,
3653+
rawResult: result,
3654+
summary: computeCalendarHistorySummary(result.events),
3655+
timestamp: new Date().toISOString(),
3656+
});
3657+
33463658
addRunHistoryEntry({
33473659
kind: 'generate',
33483660
ts: Date.now(),

0 commit comments

Comments
 (0)