Skip to main content

max / goingson

19.2 KB · 499 lines History Blame Raw
1 /**
2 * GoingsOn - Weekly Review Render Module
3 * Card rendering for the weekly review grid sections
4 */
5
6 (function() {
7 'use strict';
8 const esc = GoingsOn.utils.escapeHtml;
9 const escAttr = GoingsOn.utils.escapeAttr;
10
11 // ============ Helpers ============
12
13 /**
14 * Truncate a string with an ellipsis character.
15 * @param {string} str - String to truncate
16 * @param {number} len - Maximum length
17 * @returns {string} Truncated string
18 */
19 function truncate(str, len) {
20 if (str.length <= len) return str;
21 return str.substring(0, len - 1) + '\u2026';
22 }
23
24 // ============ Section Renderers ============
25
26 /**
27 * Render the week-at-a-glance timeline with day dots and event summaries.
28 * @param {Object} r - Weekly review data object
29 * @returns {string} HTML string
30 */
31 function renderWeekTimeline(r) {
32
33 const days = r.timelineDays || [];
34
35 return `
36 <div class="card card--static review-card week-timeline">
37 <div class="card-header">
38 <span class="card-title">
39 Week at a Glance
40 </span>
41 <span class="card-badge card-badge--neutral">
42 ${getTodayLabel(days)}
43 </span>
44 </div>
45 <div class="timeline-visual">
46 ${days.map(day => `
47 <div class="timeline-day ${day.isToday ? 'today' : ''} ${day.isPast ? 'past' : 'future'} ${day.isVacation ? 'vacation' : ''}">
48 <div class="day-name">${esc(day.dayName)}</div>
49 <div class="day-number">${day.dayNumber}</div>
50 <div class="day-dots">
51 ${renderDayDots(day)}
52 </div>
53 ${(day.events && day.events.length > 0) ? `
54 <div class="day-events">
55 ${day.events.slice(0, 3).map(e => `
56 <div class="day-event" title="${escAttr(e.title)}">
57 <span class="event-time">${esc(e.formattedTime.split(' ').pop())}</span>
58 ${esc(truncate(e.title, 12))}
59 </div>
60 `).join('')}
61 ${day.events.length > 3 ? `<div class="day-event-more">+${day.events.length - 3}</div>` : ''}
62 </div>
63 ` : ''}
64 </div>
65 `).join('')}
66 </div>
67 </div>
68 `;
69 }
70
71 function getTodayLabel(days) {
72 const today = days.find(d => d.isToday);
73 if (today) {
74 return `Today: ${today.dayName}`;
75 }
76 return '';
77 }
78
79 function renderDayDots(day) {
80 if (day.isVacation) {
81 return '<span class="day-dot vacation-off" title="Day off"></span>';
82 }
83 const dots = [];
84 // Completed tasks (green)
85 for (let i = 0; i < Math.min(day.completedCount, 3); i++) {
86 dots.push('<span class="day-dot completed"></span>');
87 }
88 // Events (purple)
89 for (let i = 0; i < Math.min(day.eventCount, 2); i++) {
90 dots.push('<span class="day-dot event"></span>');
91 }
92 // Overdue (red)
93 for (let i = 0; i < Math.min(day.overdueCount, 2); i++) {
94 dots.push('<span class="day-dot overdue"></span>');
95 }
96 // Due (blue) - for future days
97 if (!day.isPast && day.dueCount > 0) {
98 for (let i = 0; i < Math.min(day.dueCount, 2); i++) {
99 dots.push('<span class="day-dot task"></span>');
100 }
101 }
102 return dots.join('');
103 }
104
105 function renderTimelineEvents(r) {
106 const days = r.timelineDays || [];
107 // Collect all events from timeline days, grouped by day
108 const daysWithEvents = days.filter(d => d.events && d.events.length > 0);
109
110 if (daysWithEvents.length === 0) return '';
111
112 return `
113 <div class="card card--static review-card week-timeline-events">
114 <div class="card-header">
115 <span class="card-title">
116 Week's Events
117 </span>
118 </div>
119 ${daysWithEvents.map(day => `
120 <div class="timeline-events-day">
121 <div class="timeline-events-day-label">${esc(day.dayName)} ${day.dayNumber}</div>
122 ${day.events.map(e => renderEventItemCompact(e)).join('')}
123 </div>
124 `).join('')}
125 </div>
126 `;
127 }
128
129 /**
130 * Render the Accomplished card with completed task count and list.
131 * @param {Object} r - Weekly review data
132 * @returns {string} HTML string
133 */
134 function renderAccomplished(r) {
135
136 const count = r.tasksCompletedCount || 0;
137 const eventCount = r.eventsOccurredCount || 0;
138
139 return `
140 <div class="card card--static review-card">
141 <div class="card-header">
142 <span class="card-title">
143 Accomplished
144 </span>
145 <span class="card-badge card-badge--success">
146 ${count} completed
147 </span>
148 </div>
149
150 ${count > 0 ? `
151 <div class="accomplishment-highlight">
152 <span class="accomplishment-text">
153 You completed <strong>${count} task${count !== 1 ? 's' : ''}</strong>
154 ${eventCount > 0 ? ` and attended <strong>${eventCount} event${eventCount !== 1 ? 's' : ''}</strong>` : ''}.
155 </span>
156 </div>
157 ` : ''}
158
159 <ul class="task-list">
160 ${(r.tasksCompleted || []).slice(0, 6).map(t => renderCompletedTaskItem(t)).join('')}
161 </ul>
162 </div>
163 `;
164 }
165
166 /**
167 * Render the Needs Attention card with overdue and carried-over tasks.
168 * @param {Object} r - Weekly review data
169 * @returns {string} HTML string
170 */
171 function renderNeedsAttention(r) {
172 const overdueCount = r.tasksOverdueCount || 0;
173 const carriedOverCount = r.carriedOverCount || 0;
174
175 return `
176 <div class="card card--static review-card">
177 <div class="card-header">
178 <span class="card-title">
179 Needs Attention
180 </span>
181 ${overdueCount > 0 ? `
182 <span class="card-badge card-badge--danger">
183 ${overdueCount} overdue
184 </span>
185 ` : ''}
186 </div>
187
188 <div class="stats-row">
189 <div class="stat-box">
190 <div class="stat-number ${overdueCount > 0 ? 'red' : ''}">${overdueCount}</div>
191 <div class="stat-label">Overdue</div>
192 </div>
193 <div class="stat-box">
194 <div class="stat-number blue">${carriedOverCount}</div>
195 <div class="stat-label">Carried Over</div>
196 </div>
197 </div>
198
199 <ul class="task-list">
200 ${(r.tasksOverdue || []).slice(0, 3).map(t => renderTaskItemCompact(t, true)).join('')}
201 ${(r.carriedOverTasks || []).slice(0, 3).map(t => renderTaskItemCompact(t, false)).join('')}
202 </ul>
203 </div>
204 `;
205 }
206
207 /**
208 * Render the Due This Week card with tasks due in the next 7 days.
209 * @param {Object} r - Weekly review data
210 * @returns {string} HTML string
211 */
212 function renderDueThisWeek(r) {
213 const count = r.tasksDueNextWeekCount || 0;
214
215 return `
216 <div class="card card--static review-card">
217 <div class="card-header">
218 <span class="card-title">
219 Due This Week
220 </span>
221 <span class="card-badge card-badge--info">
222 ${count} task${count !== 1 ? 's' : ''}
223 </span>
224 </div>
225
226 <ul class="task-list">
227 ${(r.tasksDueNextWeek || []).slice(0, 6).map(t => renderTaskItemCompact(t, false)).join('')}
228 </ul>
229 ${count === 0 ? '<p class="empty-italic--muted">No tasks due this week</p>' : ''}
230 </div>
231 `;
232 }
233
234 /**
235 * Render the Focus section with 3 priority slots and suggested tasks.
236 * @param {Object} r - Weekly review data
237 * @returns {string} HTML string
238 */
239 function renderFocusSection(r) {
240
241 const focused = r.focusedTasks || [];
242 const available = r.availableForFocus || [];
243
244 const slots = [];
245 for (let i = 0; i < 3; i++) {
246 const task = focused[i];
247 if (task) {
248 const isPrimary = i === 0;
249 slots.push(`
250 <div class="scope-slot focus-slot filled ${isPrimary ? 'primary' : ''}"
251 tabindex="0"
252 role="listitem"
253 aria-label="Priority ${i + 1}: ${esc(task.description)}"
254 data-slot="${i}"
255 data-task-id="${task.id}"
256 onkeydown="GoingsOn.weeklyReview.handleSlotKeydown(event, '${escAttr(task.id)}', ${i})">
257 <span class="scope-slot-label focus-label">Priority #${i + 1}</span>
258 <span class="scope-slot-title focus-task">${esc(task.description)}</span>
259 <span class="scope-slot-meta focus-meta">
260 ${task.projectName ? esc(task.projectName) : 'No project'}
261 ${task.dueFormatted ? ' &middot; Due ' + task.dueFormatted : ''}
262 </span>
263 <button class="btn btn-sm btn-secondary" style="margin-top: auto; align-self: flex-start;"
264 onclick="GoingsOn.weeklyReview.toggleFocus('${escAttr(task.id)}', false)">
265 Remove
266 </button>
267 </div>
268 `);
269 } else {
270 slots.push(`
271 <div class="scope-slot focus-slot empty"
272 tabindex="0"
273 role="listitem"
274 aria-label="Priority ${i + 1}: Empty slot"
275 data-slot="${i}"
276 onkeydown="GoingsOn.weeklyReview.handleSlotKeydown(event, null, ${i})">
277 <span class="scope-slot-label focus-label">Priority #${i + 1}</span>
278 <span class="scope-slot-empty focus-empty">Press Enter or click a task to add focus</span>
279 </div>
280 `);
281 }
282 }
283
284 return `
285 <div class="card card--static review-card scope-card focus-section full-width">
286 <div class="card-header">
287 <span class="card-title">
288 This Week's Focus
289 </span>
290 <span class="card-badge card-badge--warning">
291 Pick up to 3 priorities
292 </span>
293 </div>
294
295 <div class="scope-slots focus-grid">
296 ${slots.join('')}
297 </div>
298
299 ${available.length > 0 && focused.length < 3 ? `
300 <div class="review-history-block">
301 <h4 class="review-history-heading">
302 Suggested tasks to focus on:
303 </h4>
304 <div style="display: flex; gap: 0.5rem; flex-wrap: wrap;">
305 ${available.slice(0, 5).map(t => `
306 <button class="btn btn-sm btn-secondary"
307 onclick="GoingsOn.weeklyReview.toggleFocus('${escAttr(t.id)}', true)">
308 + ${esc(truncate(t.description, 30))}
309 </button>
310 `).join('')}
311 </div>
312 </div>
313 ` : ''}
314
315 ${focused.length > 0 ? `
316 <div style="margin-top: 1rem; text-align: right;">
317 <button class="btn btn-sm btn-secondary" onclick="GoingsOn.weeklyReview.clearAllFocus()">
318 Clear All Focus
319 </button>
320 </div>
321 ` : ''}
322 </div>
323 `;
324 }
325
326 /**
327 * Render the Projects Health card with per-project status.
328 * @param {Object} r - Weekly review data
329 * @returns {string} HTML string
330 */
331 function renderProjectsHealth(r) {
332
333 const projects = r.projectHealth || [];
334
335 if (projects.length === 0) {
336 return '';
337 }
338
339 return `
340 <div class="card card--static review-card">
341 <div class="card-header">
342 <span class="card-title">
343 Projects Health
344 </span>
345 </div>
346
347 <div class="projects-grid">
348 ${projects.slice(0, 6).map(p => `
349 <div class="project-health ${p.status}">
350 <div class="project-name">${esc(p.name)}</div>
351 <div class="project-stats">
352 ${p.overdueCount > 0 ? `${p.overdueCount} overdue &middot; ` : ''}
353 ${p.activeCount} active &middot; ${p.totalCount} total
354 </div>
355 </div>
356 `).join('')}
357 </div>
358 </div>
359 `;
360 }
361
362 /**
363 * Render the reflection section using the shared helper.
364 * Draft handling lives in weekly-review.js; this just resolves displayed values.
365 * @param {Object} r - Weekly review data
366 * @param {Function} getDraft - Returns saved draft from localStorage
367 * @returns {string} HTML string
368 */
369 function renderReflection(r, getDraft) {
370 const notes = r.notes || '';
371 let wentWell = '';
372 let improve = '';
373
374 const wentWellMatch = notes.match(/What went well:\s*([\s\S]*?)(?:What could be improved:|$)/i);
375 const improveMatch = notes.match(/What could be improved:\s*([\s\S]*?)$/i);
376
377 if (wentWellMatch) wentWell = wentWellMatch[1].trim();
378 if (improveMatch) improve = improveMatch[1].trim();
379 if (!wentWellMatch && !improveMatch && notes) wentWell = notes;
380
381 const draft = getDraft();
382 if (draft.weekStart === r.weekStart || (!r.isCompleted && draft.savedAt)) {
383 if (draft.wentWell !== undefined) wentWell = draft.wentWell;
384 if (draft.improve !== undefined) improve = draft.improve;
385 }
386
387 return GoingsOn.planReviewToggle.renderReflection({
388 idPrefix: 'weekly',
389 prompts: [
390 { key: 'went-well', label: 'What went well?', placeholder: 'Completed the budget ahead of schedule...', value: wentWell },
391 { key: 'improve', label: 'What could be improved?', placeholder: 'Need to block more focus time...', value: improve },
392 ],
393 });
394 }
395
396 /**
397 * Render the vacation day toggle buttons (Mon-Sun).
398 * @param {Object} r - Weekly review data with vacationDays array
399 * @returns {string} HTML string
400 */
401 function renderVacationToggles(r) {
402 const dayLabels = ['M', 'T', 'W', 'T', 'F', 'S', 'S'];
403 const vacationDays = r.vacationDays || [];
404
405 return `
406 <div class="card card--static review-card vacation-card">
407 <div class="card-header">
408 <span class="card-title">Days Off</span>
409 </div>
410 <div class="vacation-toggles">
411 ${dayLabels.map((label, i) => `
412 <button class="vacation-toggle ${vacationDays.includes(i) ? 'active' : ''}"
413 onclick="GoingsOn.weeklyReview.toggleVacationDay(${i})"
414 title="${['Monday','Tuesday','Wednesday','Thursday','Friday','Saturday','Sunday'][i]}">
415 ${label}
416 </button>
417 `).join('')}
418 </div>
419 </div>
420 `;
421 }
422
423 // ============ Task/Event Item Renderers ============
424
425 /**
426 * Render a completed task list item with checkmark.
427 * @param {Object} task - Task object
428 * @returns {string} HTML string
429 */
430 function renderCompletedTaskItem(task) {
431
432 return `
433 <li class="task-item completed">
434 <span class="task-checkbox checked">&#x2713;</span>
435 <span class="task-text">${esc(task.description)}</span>
436 ${task.projectName ? `<span class="task-project">${esc(task.projectName)}</span>` : ''}
437 </li>
438 `;
439 }
440
441 /**
442 * Render a compact task list item with optional overdue indicator.
443 * @param {Object} task - Task object
444 * @param {boolean} showOverdue - true to show overdue badge
445 * @returns {string} HTML string
446 */
447 function renderTaskItemCompact(task, showOverdue) {
448
449 const dueText = task.dueFormatted || '';
450
451 return `
452 <li class="task-item">
453 <span class="task-checkbox"></span>
454 <span class="task-text">${esc(task.description)}</span>
455 ${showOverdue && task.isOverdue
456 ? `<span class="task-due overdue">${dueText}</span>`
457 : (task.projectName ? `<span class="task-project">${esc(task.projectName)}</span>` : '')
458 }
459 ${!showOverdue && dueText ? `<span class="task-due">${dueText}</span>` : ''}
460 </li>
461 `;
462 }
463
464 /**
465 * Render a compact event item with time and title.
466 * @param {Object} event - Event object with formattedTime, title, projectName
467 * @returns {string} HTML string
468 */
469 function renderEventItemCompact(event) {
470
471 return `
472 <div class="event-item">
473 <span class="event-time">${esc(event.formattedTime)}</span>
474 <span class="event-title">${esc(event.title)}</span>
475 ${event.projectName ? `<span class="task-project">${esc(event.projectName)}</span>` : ''}
476 </div>
477 `;
478 }
479
480 // ============ Populate Namespace ============
481
482 GoingsOn.weeklyReviewRender = {
483 renderWeekTimeline,
484 renderTimelineEvents,
485 renderAccomplished,
486 renderNeedsAttention,
487 renderDueThisWeek,
488 renderFocusSection,
489 renderProjectsHealth,
490 renderReflection,
491 renderVacationToggles,
492 renderCompletedTaskItem,
493 renderTaskItemCompact,
494 renderEventItemCompact,
495 truncate,
496 };
497
498 })();
499