| 1 |
|
| 2 |
* GoingsOn - Weekly Review Module (V2) |
| 3 |
* |
| 4 |
* A guided weekly review workflow with grid-based layout. |
| 5 |
* All data computation is done in Rust; this module only handles rendering. |
| 6 |
* Section renderers are in weekly-review-render.js. |
| 7 |
|
| 8 |
|
| 9 |
(function() { |
| 10 |
'use strict'; |
| 11 |
|
| 12 |
|
| 13 |
|
| 14 |
|
| 15 |
|
| 16 |
let currentWeekStart = null; |
| 17 |
|
| 18 |
function shiftWeek(deltaDays) { |
| 19 |
const base = currentWeekStart |
| 20 |
? new Date(currentWeekStart + 'T12:00:00') |
| 21 |
: new Date(); |
| 22 |
|
| 23 |
const result = new Date(base); |
| 24 |
result.setDate(result.getDate() + deltaDays); |
| 25 |
const dayIdx = (result.getDay() + 6) % 7; |
| 26 |
result.setDate(result.getDate() - dayIdx); |
| 27 |
const y = result.getFullYear(); |
| 28 |
const m = String(result.getMonth() + 1).padStart(2, '0'); |
| 29 |
const d = String(result.getDate()).padStart(2, '0'); |
| 30 |
currentWeekStart = `${y}-${m}-${d}`; |
| 31 |
load(); |
| 32 |
} |
| 33 |
|
| 34 |
function previousWeek() { shiftWeek(-7); } |
| 35 |
function nextWeek() { shiftWeek(7); } |
| 36 |
function goToCurrentWeek() { |
| 37 |
currentWeekStart = null; |
| 38 |
load(); |
| 39 |
} |
| 40 |
|
| 41 |
|
| 42 |
* Returns 'past', 'current', or 'future' for the currently viewed week. |
| 43 |
* Compares against today's Monday. |
| 44 |
|
| 45 |
function currentPeriodState() { |
| 46 |
if (currentWeekStart === null) return 'current'; |
| 47 |
const today = new Date(); |
| 48 |
const dayIdx = (today.getDay() + 6) % 7; |
| 49 |
const monday = new Date(today); |
| 50 |
monday.setDate(today.getDate() - dayIdx); |
| 51 |
const y = monday.getFullYear(); |
| 52 |
const m = String(monday.getMonth() + 1).padStart(2, '0'); |
| 53 |
const d = String(monday.getDate()).padStart(2, '0'); |
| 54 |
const todayMonday = `${y}-${m}-${d}`; |
| 55 |
if (currentWeekStart === todayMonday) return 'current'; |
| 56 |
return currentWeekStart < todayMonday ? 'past' : 'future'; |
| 57 |
} |
| 58 |
|
| 59 |
|
| 60 |
|
| 61 |
const DRAFT_STORAGE_KEY = 'weekly-review-draft'; |
| 62 |
|
| 63 |
function getDraft() { |
| 64 |
try { |
| 65 |
return JSON.parse(localStorage.getItem(DRAFT_STORAGE_KEY) || '{}'); |
| 66 |
} catch { |
| 67 |
return {}; |
| 68 |
} |
| 69 |
} |
| 70 |
|
| 71 |
const REFLECTION_PROMPTS = [ |
| 72 |
{ key: 'went-well' }, |
| 73 |
{ key: 'improve' }, |
| 74 |
]; |
| 75 |
|
| 76 |
function setupAutoSave() { |
| 77 |
GoingsOn.planReviewToggle.wireReflectionAutosave({ |
| 78 |
idPrefix: 'weekly', |
| 79 |
prompts: REFLECTION_PROMPTS, |
| 80 |
onChange: (values) => { |
| 81 |
const draft = { |
| 82 |
wentWell: values['went-well'], |
| 83 |
improve: values['improve'], |
| 84 |
savedAt: Date.now(), |
| 85 |
weekStart: GoingsOn.state.weeklyReview?.weekStart || null, |
| 86 |
}; |
| 87 |
localStorage.setItem(DRAFT_STORAGE_KEY, JSON.stringify(draft)); |
| 88 |
}, |
| 89 |
}); |
| 90 |
} |
| 91 |
|
| 92 |
function clearDraft() { |
| 93 |
localStorage.removeItem(DRAFT_STORAGE_KEY); |
| 94 |
} |
| 95 |
|
| 96 |
|
| 97 |
|
| 98 |
async function load() { |
| 99 |
try { |
| 100 |
GoingsOn.state.set('weeklyReview', await GoingsOn.api.weeklyReview.get(currentWeekStart)); |
| 101 |
render(); |
| 102 |
} catch (err) { |
| 103 |
console.error('Failed to load weekly review:', err); |
| 104 |
showError('Failed to load weekly review data'); |
| 105 |
GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to load weekly review'), 'error', { |
| 106 |
action: { label: 'Retry', fn: load }, |
| 107 |
duration: 8000, |
| 108 |
}); |
| 109 |
} |
| 110 |
} |
| 111 |
|
| 112 |
|
| 113 |
|
| 114 |
function render() { |
| 115 |
const container = document.getElementById('weekly-review-content'); |
| 116 |
if (!container || !GoingsOn.state.weeklyReview) return; |
| 117 |
|
| 118 |
const r = GoingsOn.state.weeklyReview; |
| 119 |
const esc = GoingsOn.utils.escapeHtml; |
| 120 |
const wr = GoingsOn.weeklyReviewRender; |
| 121 |
|
| 122 |
container.innerHTML = ` |
| 123 |
<div class="weekly-review-header"> |
| 124 |
<div class="weekly-review-nav"> |
| 125 |
<button class="btn btn-secondary" onclick="GoingsOn.weeklyReview.previousWeek()" title="Previous week">←</button> |
| 126 |
<button class="btn btn-secondary" onclick="GoingsOn.weeklyReview.goToCurrentWeek()">This Week</button> |
| 127 |
<button class="btn btn-secondary" onclick="GoingsOn.weeklyReview.nextWeek()" title="Next week">→</button> |
| 128 |
<span class="week-dates">${esc(r.weekDisplay)}</span> |
| 129 |
</div> |
| 130 |
<span id="week-review-status-badge" class="review-status hidden"></span> |
| 131 |
</div> |
| 132 |
|
| 133 |
${!r.isCompleted ? '<p class="review-intro review-intro--weekly">Plan your week, then close it out with Finish & Review.</p>' : ''} |
| 134 |
|
| 135 |
<div class="review-grid"> |
| 136 |
${wr.renderWeekTimeline(r)} |
| 137 |
${wr.renderVacationToggles(r)} |
| 138 |
${wr.renderFocusSection(r)} |
| 139 |
${wr.renderAccomplished(r)} |
| 140 |
${wr.renderNeedsAttention(r)} |
| 141 |
${wr.renderDueThisWeek(r)} |
| 142 |
${wr.renderProjectsHealth(r)} |
| 143 |
</div> |
| 144 |
|
| 145 |
${renderFinishReviewBar(r)} |
| 146 |
`; |
| 147 |
|
| 148 |
const periodState = currentPeriodState(); |
| 149 |
GoingsOn.planReviewToggle.setStatusBadge('week-review-status-badge', periodState, r.isCompleted); |
| 150 |
|
| 151 |
const settings = GoingsOn.planReviewToggle.getSettings(); |
| 152 |
const isMonday = new Date().getDay() === 1; |
| 153 |
const isCurrentWeek = periodState === 'current'; |
| 154 |
if (isCurrentWeek && settings.reviewNudges && isMonday && !r.isCompleted) { |
| 155 |
GoingsOn.planReviewToggle.updateDot('week', true); |
| 156 |
} else { |
| 157 |
GoingsOn.planReviewToggle.updateDot('week', false); |
| 158 |
} |
| 159 |
} |
| 160 |
|
| 161 |
function renderFinishReviewBar(r) { |
| 162 |
const state = currentPeriodState(); |
| 163 |
if (state === 'future') return ''; |
| 164 |
if (state === 'current') { |
| 165 |
return ` |
| 166 |
<div class="finish-review-bar"> |
| 167 |
<button class="btn btn-primary finish-review-btn" id="week-finish-review-btn" onclick="GoingsOn.weeklyReview.openFinishReviewModal()" ${r.isCompleted ? 'disabled' : ''}> |
| 168 |
${r.isCompleted ? 'Review Completed' : 'Finish & Review'} |
| 169 |
</button> |
| 170 |
</div> |
| 171 |
`; |
| 172 |
} |
| 173 |
return ` |
| 174 |
<div class="finish-review-bar"> |
| 175 |
<button class="btn btn-secondary finish-review-btn" id="week-finish-review-btn" onclick="GoingsOn.weeklyReview.openFinishReviewModal()"> |
| 176 |
${r.isCompleted ? 'View Past Review' : 'Review Past Week'} |
| 177 |
</button> |
| 178 |
</div> |
| 179 |
`; |
| 180 |
} |
| 181 |
|
| 182 |
|
| 183 |
* Open the end-of-week reflection modal: timeline events recap + reflection prompts. |
| 184 |
|
| 185 |
function openFinishReviewModal() { |
| 186 |
const r = GoingsOn.state.weeklyReview; |
| 187 |
if (!r) return; |
| 188 |
const wr = GoingsOn.weeklyReviewRender; |
| 189 |
const isPast = currentPeriodState() === 'past'; |
| 190 |
const esc = GoingsOn.utils.escapeHtml; |
| 191 |
|
| 192 |
const banner = isPast |
| 193 |
? `<div class="past-review-banner">You are reviewing a past week (${esc(r.weekDisplay)}), not the current one.</div>` |
| 194 |
: ''; |
| 195 |
|
| 196 |
const content = ` |
| 197 |
<div class="finish-review-modal-content"> |
| 198 |
${banner} |
| 199 |
${wr.renderTimelineEvents(r)} |
| 200 |
${wr.renderReflection(r, getDraft)} |
| 201 |
<div class="review-actions-grid"> |
| 202 |
<button class="btn btn-primary" onclick="GoingsOn.weeklyReview.complete()" ${r.isCompleted ? 'disabled' : ''}> |
| 203 |
${r.isCompleted ? 'Review Completed' : (isPast ? 'Save Review' : 'Complete Review')} |
| 204 |
</button> |
| 205 |
</div> |
| 206 |
</div> |
| 207 |
`; |
| 208 |
|
| 209 |
const title = isPast ? `Reviewing Past: ${r.weekDisplay}` : `Wrap Up: ${r.weekDisplay}`; |
| 210 |
GoingsOn.ui.openModal(title, content, { large: true }); |
| 211 |
|
| 212 |
setupAutoSave(); |
| 213 |
GoingsOn.planReviewToggle.autoGrowReflection({ |
| 214 |
idPrefix: 'weekly', |
| 215 |
prompts: REFLECTION_PROMPTS, |
| 216 |
}); |
| 217 |
} |
| 218 |
|
| 219 |
|
| 220 |
|
| 221 |
function showError(message) { |
| 222 |
const container = document.getElementById('weekly-review-content'); |
| 223 |
if (container) { |
| 224 |
GoingsOn.utils.showError(container, message); |
| 225 |
} |
| 226 |
} |
| 227 |
|
| 228 |
|
| 229 |
|
| 230 |
|
| 231 |
* Toggle a day as vacation/non-vacation in the weekly review. |
| 232 |
* @param {number} dayIndex - Day of week index (0 = Monday, 6 = Sunday) |
| 233 |
|
| 234 |
async function toggleVacationDay(dayIndex) { |
| 235 |
if (!GoingsOn.state.weeklyReview) return; |
| 236 |
const current = GoingsOn.state.weeklyReview.vacationDays || []; |
| 237 |
let updated; |
| 238 |
if (current.includes(dayIndex)) { |
| 239 |
updated = current.filter(d => d !== dayIndex); |
| 240 |
} else { |
| 241 |
updated = [...current, dayIndex].sort(); |
| 242 |
} |
| 243 |
try { |
| 244 |
await GoingsOn.api.weeklyReview.setVacationDays(updated, currentWeekStart); |
| 245 |
await load(); |
| 246 |
} catch (err) { |
| 247 |
console.error('Failed to set vacation days:', err); |
| 248 |
GoingsOn.ui.showToast('Failed to update vacation days', 'error'); |
| 249 |
} |
| 250 |
} |
| 251 |
|
| 252 |
|
| 253 |
|
| 254 |
|
| 255 |
* Handle keyboard navigation within focus slots. |
| 256 |
* @param {KeyboardEvent} event |
| 257 |
* @param {string|null} taskId - Task ID in this slot, or null if empty |
| 258 |
* @param {number} slotIndex - Index of the focus slot (0-2) |
| 259 |
|
| 260 |
function handleSlotKeydown(event, taskId, slotIndex) { |
| 261 |
const slots = document.querySelectorAll('.focus-slot'); |
| 262 |
|
| 263 |
switch (event.key) { |
| 264 |
case 'ArrowRight': |
| 265 |
case 'ArrowDown': |
| 266 |
event.preventDefault(); |
| 267 |
const nextSlot = slots[Math.min(slotIndex + 1, slots.length - 1)]; |
| 268 |
if (nextSlot) nextSlot.focus(); |
| 269 |
break; |
| 270 |
|
| 271 |
case 'ArrowLeft': |
| 272 |
case 'ArrowUp': |
| 273 |
event.preventDefault(); |
| 274 |
const prevSlot = slots[Math.max(slotIndex - 1, 0)]; |
| 275 |
if (prevSlot) prevSlot.focus(); |
| 276 |
break; |
| 277 |
|
| 278 |
case 'Delete': |
| 279 |
case 'Backspace': |
| 280 |
event.preventDefault(); |
| 281 |
if (taskId) { |
| 282 |
toggleFocus(taskId, false); |
| 283 |
} |
| 284 |
break; |
| 285 |
|
| 286 |
case 'Enter': |
| 287 |
case ' ': |
| 288 |
event.preventDefault(); |
| 289 |
if (taskId) { |
| 290 |
|
| 291 |
toggleFocus(taskId, false); |
| 292 |
} else { |
| 293 |
|
| 294 |
const firstSuggestion = document.querySelector('.focus-section .btn.btn-secondary'); |
| 295 |
if (firstSuggestion) { |
| 296 |
firstSuggestion.focus(); |
| 297 |
} |
| 298 |
} |
| 299 |
break; |
| 300 |
} |
| 301 |
} |
| 302 |
|
| 303 |
|
| 304 |
|
| 305 |
|
| 306 |
* Set or unset a task as a weekly focus priority. |
| 307 |
* @param {string} taskId - Task ID |
| 308 |
* @param {boolean} isFocus - true to add focus, false to remove |
| 309 |
|
| 310 |
async function toggleFocus(taskId, isFocus) { |
| 311 |
try { |
| 312 |
await GoingsOn.api.weeklyReview.setFocus(taskId, isFocus); |
| 313 |
await load(); |
| 314 |
} catch (err) { |
| 315 |
console.error('Failed to toggle focus:', err); |
| 316 |
GoingsOn.ui.showToast('Failed to update focus', 'error'); |
| 317 |
} |
| 318 |
} |
| 319 |
|
| 320 |
|
| 321 |
* Remove focus from all tasks after confirmation. |
| 322 |
|
| 323 |
async function clearAllFocus() { |
| 324 |
const confirmed = await GoingsOn.ui.confirmDelete('Clear focus from all tasks?'); |
| 325 |
if (!confirmed) return; |
| 326 |
|
| 327 |
try { |
| 328 |
await GoingsOn.api.weeklyReview.clearAllFocus(); |
| 329 |
await load(); |
| 330 |
GoingsOn.ui.showToast('Focus cleared', 'success'); |
| 331 |
} catch (err) { |
| 332 |
console.error('Failed to clear focus:', err); |
| 333 |
GoingsOn.ui.showToast('Failed to clear focus', 'error'); |
| 334 |
} |
| 335 |
} |
| 336 |
|
| 337 |
|
| 338 |
* Complete the weekly review, saving reflection notes to the backend. |
| 339 |
|
| 340 |
async function complete() { |
| 341 |
|
| 342 |
const wentWellInput = document.getElementById('weekly-went-well'); |
| 343 |
const improveInput = document.getElementById('weekly-improve'); |
| 344 |
|
| 345 |
let notes = ''; |
| 346 |
if (wentWellInput && wentWellInput.value.trim()) { |
| 347 |
notes += 'What went well:\n' + wentWellInput.value.trim() + '\n\n'; |
| 348 |
} |
| 349 |
if (improveInput && improveInput.value.trim()) { |
| 350 |
notes += 'What could be improved:\n' + improveInput.value.trim(); |
| 351 |
} |
| 352 |
notes = notes.trim(); |
| 353 |
|
| 354 |
try { |
| 355 |
await GoingsOn.api.weeklyReview.complete(notes, currentWeekStart); |
| 356 |
clearDraft(); |
| 357 |
GoingsOn.ui.closeModal(); |
| 358 |
await load(); |
| 359 |
GoingsOn.ui.showToast('Weekly review completed!', 'success'); |
| 360 |
updateBadge(false); |
| 361 |
} catch (err) { |
| 362 |
console.error('Failed to complete review:', err); |
| 363 |
GoingsOn.ui.showToast('Failed to complete review', 'error'); |
| 364 |
} |
| 365 |
} |
| 366 |
|
| 367 |
|
| 368 |
|
| 369 |
|
| 370 |
* Show or hide the review-pending badge on the Time tab. |
| 371 |
* @param {boolean} showBadge - true to show, false to hide |
| 372 |
|
| 373 |
function updateBadge(showBadge) { |
| 374 |
|
| 375 |
const tab = document.querySelector('.tab-navigation [data-view="time"]'); |
| 376 |
if (tab) { |
| 377 |
const existingBadge = tab.querySelector('.tab-badge'); |
| 378 |
if (showBadge && !existingBadge) { |
| 379 |
const badge = document.createElement('span'); |
| 380 |
badge.className = 'tab-badge'; |
| 381 |
badge.setAttribute('aria-label', 'Review pending'); |
| 382 |
tab.appendChild(badge); |
| 383 |
} else if (!showBadge && existingBadge) { |
| 384 |
existingBadge.remove(); |
| 385 |
} |
| 386 |
} |
| 387 |
|
| 388 |
|
| 389 |
const mobileTab = document.querySelector('.mobile-tab-bar [data-view="time"]'); |
| 390 |
if (mobileTab) { |
| 391 |
const existing = mobileTab.querySelector('.tab-badge'); |
| 392 |
if (showBadge && !existing) { |
| 393 |
const badge = document.createElement('span'); |
| 394 |
badge.className = 'tab-badge'; |
| 395 |
badge.setAttribute('aria-label', 'Review pending'); |
| 396 |
mobileTab.appendChild(badge); |
| 397 |
} else if (!showBadge && existing) { |
| 398 |
existing.remove(); |
| 399 |
} |
| 400 |
} |
| 401 |
} |
| 402 |
|
| 403 |
|
| 404 |
* Check if the user should be nudged to do their weekly review. |
| 405 |
|
| 406 |
async function checkNudge() { |
| 407 |
try { |
| 408 |
const showNudge = await GoingsOn.api.weeklyReview.checkNudge(); |
| 409 |
updateBadge(showNudge); |
| 410 |
if (showNudge) { |
| 411 |
GoingsOn.ui.showToast('Time for your weekly review!', 'info'); |
| 412 |
} |
| 413 |
} catch (err) { |
| 414 |
console.error('Failed to check weekly review nudge:', err); |
| 415 |
} |
| 416 |
} |
| 417 |
|
| 418 |
|
| 419 |
|
| 420 |
GoingsOn.weeklyReview = { |
| 421 |
load, |
| 422 |
render, |
| 423 |
previousWeek, |
| 424 |
nextWeek, |
| 425 |
goToCurrentWeek, |
| 426 |
openFinishReviewModal, |
| 427 |
toggleFocus, |
| 428 |
clearAllFocus, |
| 429 |
toggleVacationDay, |
| 430 |
complete, |
| 431 |
checkNudge, |
| 432 |
updateBadge, |
| 433 |
handleSlotKeydown, |
| 434 |
}; |
| 435 |
|
| 436 |
})(); |
| 437 |
|