| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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">✓</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">… and ${count - top.length} more</p>` : ''} |
| 107 |
</div> |
| 108 |
`; |
| 109 |
} |
| 110 |
|
| 111 |
|
| 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 |
|
| 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' ? '↓' : p.direction === 'growing' ? '↑' : '↔'; |
| 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 |
|
| 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: '○', done: '✓', abandoned: '✗' }; |
| 207 |
const icon = statusIcons[goal.status] || '○'; |
| 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">✕</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 |
|
| 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 |
|
| 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 |
|
| 267 |
|
| 268 |
GoingsOn.monthlyReviewRender = { |
| 269 |
renderHeatMap, |
| 270 |
renderAccomplished, |
| 271 |
renderStats, |
| 272 |
renderProjectPulse, |
| 273 |
renderGoals, |
| 274 |
renderReflection, |
| 275 |
renderPatterns, |
| 276 |
}; |
| 277 |
|
| 278 |
})(); |
| 279 |
|