Skip to main content

max / goingson

10.2 KB · 279 lines History Blame Raw
1 /**
2 * GoingsOn - Monthly Review Render Module
3 * Pure rendering functions for the Month view.
4 */
5
6 (function() {
7 'use strict';
8
9 const esc = GoingsOn.utils.escapeHtml;
10 const escAttr = GoingsOn.utils.escapeAttr;
11
12 /**
13 * Truncate a string with an ellipsis.
14 * @param {string} str - String to truncate
15 * @param {number} len - Maximum length
16 * @returns {string} Truncated string
17 */
18 function truncate(str, len) {
19 if (!str) return '';
20 return str.length > len ? str.substring(0, len) + '...' : str;
21 }
22
23 // ============ Heat Map Calendar ============
24
25 /**
26 * Render the month heat-map calendar grid.
27 * @param {Object} r - Monthly review data with days, firstDayOffset
28 * @returns {string} HTML string
29 */
30 function renderHeatMap(r) {
31 const dayHeaders = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
32 let html = '<div class="month-heatmap">';
33 html += '<div class="month-heatmap-header">';
34 for (const d of dayHeaders) {
35 html += `<div class="month-heatmap-day-header">${d}</div>`;
36 }
37 html += '</div>';
38 html += '<div class="month-heatmap-grid">';
39
40 // Empty cells before the 1st
41 for (let i = 0; i < r.firstDayOffset; i++) {
42 html += '<div class="month-heatmap-cell empty"></div>';
43 }
44
45 for (const day of r.days) {
46 const classes = ['month-heatmap-cell'];
47 if (day.isToday) classes.push('today');
48 if (day.isPast) classes.push('past');
49 if (day.isVacation) classes.push('vacation');
50 classes.push(`intensity-${day.intensity}`);
51
52 html += `<div class="${classes.join(' ')}" onclick="GoingsOn.monthlyReview.showDaySummary('${escAttr(day.date)}')" title="${day.completedCount} completed, ${day.eventCount} events" tabindex="0" role="button">`;
53 html += `<span class="month-heatmap-day-number">${day.dayNumber}</span>`;
54 if (day.completedCount > 0 || day.eventCount > 0) {
55 html += '<div class="month-heatmap-dots">';
56 if (day.completedCount > 0) html += `<span class="month-dot completed">${day.completedCount}</span>`;
57 if (day.eventCount > 0) html += `<span class="month-dot event">${day.eventCount}</span>`;
58 html += '</div>';
59 }
60 html += '</div>';
61 }
62
63 // Empty cells after the last day
64 const totalCells = r.firstDayOffset + r.days.length;
65 const remainder = totalCells % 7;
66 if (remainder > 0) {
67 for (let i = 0; i < 7 - remainder; i++) {
68 html += '<div class="month-heatmap-cell empty"></div>';
69 }
70 }
71
72 html += '</div></div>';
73 return html;
74 }
75
76 // ============ Accomplished ============
77
78 /**
79 * Render the Accomplished card with a sample of completed tasks for the month.
80 * @param {Object} r - Monthly review data
81 * @returns {string} HTML string
82 */
83 function renderAccomplished(r) {
84 const count = r.tasksCompletedCount || 0;
85 const top = r.tasksCompletedTop || [];
86
87 if (count === 0) return '';
88
89 const items = top.map(t => `
90 <li class="task-item completed">
91 <span class="task-checkbox checked">&#x2713;</span>
92 <span class="task-text">${esc(t.description)}</span>
93 ${t.projectName ? `<span class="task-project">${esc(t.projectName)}</span>` : ''}
94 </li>
95 `).join('');
96
97 return `
98 <div class="card card--static review-card month-accomplished-card">
99 <div class="card-header">
100 <span class="card-title">Accomplished</span>
101 <span class="card-badge card-badge--success">
102 ${count} completed
103 </span>
104 </div>
105 <ul class="task-list">${items}</ul>
106 ${count > top.length ? `<p class="review-more-line">&hellip; and ${count - top.length} more</p>` : ''}
107 </div>
108 `;
109 }
110
111 // ============ Month in Numbers ============
112
113 /**
114 * Render the "Month in Numbers" stats card.
115 * @param {Object} r - Monthly review data
116 * @returns {string} HTML string
117 */
118 function renderStats(r) {
119 let html = '<div class="card card--static review-card month-stats-card">';
120 html += '<h3 class="review-card-title">Month in Numbers</h3>';
121 html += '<div class="month-stats-grid">';
122 html += renderStatItem('Tasks Completed', r.tasksCompletedCount, 'completed');
123 html += renderStatItem('Tasks Created', r.tasksCreatedCount, 'created');
124 html += renderStatItem('Events', r.eventsCount, 'events');
125 html += renderStatItem('Best Streak', r.completionStreak + 'd', 'streak');
126 html += '</div>';
127
128 if (r.busiestDay || r.quietestDay) {
129 html += '<div class="month-stats-highlights">';
130 if (r.busiestDay) html += `<span class="stat-highlight">Busiest: ${formatDateShort(r.busiestDay)}</span>`;
131 if (r.quietestDay) html += `<span class="stat-highlight">Quietest: ${formatDateShort(r.quietestDay)}</span>`;
132 html += '</div>';
133 }
134
135 html += '</div>';
136 return html;
137 }
138
139 function renderStatItem(label, value, type) {
140 return `<div class="month-stat-item ${type}"><span class="month-stat-value">${value}</span><span class="month-stat-label">${label}</span></div>`;
141 }
142
143 function formatDateShort(dateStr) {
144 if (!dateStr) return '';
145 const d = new Date(dateStr + 'T12:00:00');
146 return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
147 }
148
149 // ============ Project Health ============
150
151 /**
152 * Render the Project Health card showing per-project activity direction.
153 * @param {Object} r - Monthly review data with projectPulse array
154 * @returns {string} HTML string
155 */
156 function renderProjectPulse(r) {
157 if (!r.projectPulse || r.projectPulse.length === 0) return '';
158
159 let html = '<div class="card card--static review-card month-pulse-card">';
160 html += '<h3 class="review-card-title">Project Health</h3>';
161 html += '<div class="month-pulse-list">';
162
163 for (const p of r.projectPulse) {
164 const dirClass = p.direction === 'shrinking' ? 'positive' : p.direction === 'growing' ? 'negative' : 'neutral';
165 const arrow = p.direction === 'shrinking' ? '&#x2193;' : p.direction === 'growing' ? '&#x2191;' : '&#x2194;';
166 html += `<div class="month-pulse-item ${dirClass}">`;
167 html += `<span class="pulse-name">${esc(truncate(p.name, 24))}</span>`;
168 html += `<span class="pulse-stats">+${p.completed} done / +${p.created} new</span>`;
169 html += `<span class="pulse-arrow">${arrow}</span>`;
170 html += '</div>';
171 }
172
173 html += '</div></div>';
174 return html;
175 }
176
177 // ============ Monthly Goals ============
178
179 /**
180 * Render the Monthly Goals card with 3 goal slots.
181 * @param {Object} r - Monthly review data with goals array
182 * @returns {string} HTML string
183 */
184 function renderGoals(r) {
185 let html = '<div class="card card--static review-card month-goals-card scope-card full-width">';
186 html += '<div class="card-header">';
187 html += '<span class="card-title">Monthly Goals</span>';
188 html += '<span class="card-badge card-badge--warning">Set up to 3 goals</span>';
189 html += '</div>';
190 html += '<div class="scope-slots month-goals-list">';
191
192 for (let pos = 1; pos <= 3; pos++) {
193 const goal = r.goals.find(g => g.position === pos);
194 if (goal) {
195 html += renderGoalItem(goal, pos);
196 } else {
197 html += renderEmptyGoalSlot(r.month, pos);
198 }
199 }
200
201 html += '</div></div>';
202 return html;
203 }
204
205 function renderGoalItem(goal, position) {
206 const statusIcons = { active: '&#x25CB;', done: '&#x2713;', abandoned: '&#x2717;' };
207 const icon = statusIcons[goal.status] || '&#x25CB;';
208
209 let html = `<div class="scope-slot month-goal-item filled ${goal.status}">`;
210 html += `<span class="scope-slot-label">Goal #${position}</span>`;
211 html += `<div class="month-goal-body">`;
212 html += `<button class="btn-icon month-goal-status-btn" onclick="GoingsOn.monthlyReview.cycleGoalStatus('${escAttr(goal.id)}')" title="Cycle status">${icon}</button>`;
213 html += `<span class="scope-slot-title month-goal-text">${esc(goal.text)}</span>`;
214 html += `<button class="btn-icon month-goal-delete-btn" onclick="GoingsOn.monthlyReview.deleteGoal('${escAttr(goal.id)}')" title="Delete goal">&#x2715;</button>`;
215 html += `</div>`;
216 html += '</div>';
217 return html;
218 }
219
220 function renderEmptyGoalSlot(month, position) {
221 return `<div class="scope-slot month-goal-item empty"
222 onclick="GoingsOn.monthlyReview.addGoal('${escAttr(month)}', ${position})"
223 tabindex="0" role="button">
224 <span class="scope-slot-label">Goal #${position}</span>
225 <span class="scope-slot-empty">+ Add goal</span>
226 </div>`;
227 }
228
229 // ============ Reflection ============
230
231 /**
232 * Render the reflection card using the shared helper.
233 * @param {Object} r - Monthly review data with reflection object
234 * @returns {string} HTML string
235 */
236 function renderReflection(r) {
237 return GoingsOn.planReviewToggle.renderReflection({
238 idPrefix: 'monthly',
239 prompts: [
240 { key: 'highlight', label: 'What was the highlight of this month?', placeholder: "Something you're proud of...", value: r.reflection?.highlightText || '' },
241 { key: 'change', label: 'What would you change?', placeholder: 'Something to improve next month...', value: r.reflection?.changeText || '' },
242 ],
243 });
244 }
245
246 // ============ Patterns ============
247
248 /**
249 * Render the Patterns card with observed behavioral patterns.
250 * @param {Object} r - Monthly review data with patterns array
251 * @returns {string} HTML string
252 */
253 function renderPatterns(r) {
254 if (!r.patterns || r.patterns.length === 0) return '';
255
256 let html = '<div class="card card--static review-card month-patterns-card">';
257 html += '<h3 class="review-card-title">Patterns</h3>';
258 html += '<ul class="month-patterns-list">';
259 for (const p of r.patterns) {
260 html += `<li class="month-pattern-item">${esc(p)}</li>`;
261 }
262 html += '</ul></div>';
263 return html;
264 }
265
266 // ============ Exports ============
267
268 GoingsOn.monthlyReviewRender = {
269 renderHeatMap,
270 renderAccomplished,
271 renderStats,
272 renderProjectPulse,
273 renderGoals,
274 renderReflection,
275 renderPatterns,
276 };
277
278 })();
279