Skip to main content

max / goingson

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