Skip to main content

max / goingson

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