Skip to main content

max / goingson

15.5 KB · 437 lines History Blame Raw
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 // ============ Navigation ============
13
14 // `null` means "current week" — let the backend resolve it. Set to a Monday
15 // YYYY-MM-DD string when viewing past/future weeks.
16 let currentWeekStart = null;
17
18 function shiftWeek(deltaDays) {
19 const base = currentWeekStart
20 ? new Date(currentWeekStart + 'T12:00:00')
21 : new Date();
22 // Snap to Monday of the resulting week
23 const result = new Date(base);
24 result.setDate(result.getDate() + deltaDays);
25 const dayIdx = (result.getDay() + 6) % 7; // 0 = Monday
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 // ============ Auto-Save ============
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 // ============ Load Functions ============
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 // ============ Render Functions ============
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">&larr;</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">&rarr;</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 &amp; 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 // ============ Helpers ============
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 // ============ Vacation Toggles ============
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 // ============ Keyboard Navigation ============
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 // If slot has a task, remove it
291 toggleFocus(taskId, false);
292 } else {
293 // If slot is empty, focus the first suggested task button
294 const firstSuggestion = document.querySelector('.focus-section .btn.btn-secondary');
295 if (firstSuggestion) {
296 firstSuggestion.focus();
297 }
298 }
299 break;
300 }
301 }
302
303 // ============ Actions ============
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(); // Reload to get updated data
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 // Build structured notes from reflection prompts
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 // ============ Badge / Nudge ============
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 // Badge goes on the Time tab in the top nav
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 // Also update the mobile tab bar
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 // ============ Populate Namespace ============
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