1import { 2 HABIT_PERIODS, 3 HABIT_STATUSES, 4 HABIT_STORAGE_KEY, 5 addDays, 6 calculateConsistency, 7 createEmptyHabitState, 8 createSeededHabitState, 9 fromDateKey, 10 getDateKeyInTimeZone, 11 getHabitStorageKey, 12 getPeriodDates, 13 nextHabitStatus, 14 removeHabit, 15 sanitizeHabitState, 16 setHabitStatus, 17 shiftPeriodAnchor, 18 syncDayPlanningCompletion, 19 syncFocusHistory, 20 toDateKey, 21 updateHabitDetails 22} from './habits_model.mjs?v=10'; 23 24const ICON_ROOT = new URL('../../assets/icons/feather/', import.meta.url).href; 25const MAX_FUTURE_SKEW_MS = 12 * 60 * 60 * 1000; 26 27const COPY = { 28 fr: { 29 title: 'Habitudes', 30 continuity: 'Votre continuité', 31 consistency: 'de régularité', 32 support: 'Vous revenez vite, même après une pause.', 33 periods: { day: 'Jour', month: 'Mois', year: 'Année' }, 34 legend: { done: 'Fait', partial: 'Partiel', pause: 'Pause', missed: 'à reprendre' }, 35 bestMoment: 'Meilleur moment', 36 evolution: 'Ãvolution sur 30 jours', 37 personalizedAdvice: 'Conseil personnalisé', 38 shortVersion: 'Faire la version courte aujourdâhui', 39 newHabit: 'Nouvelle habitude', 40 editHabit: 'Modifier', 41 automatic: { focus: 'Focus auto', planning: 'Planning auto' }, 42 hour: 'h', 43 checkIn: 'Point du jour', 44 checkInPrompt: 'Choisissez lâétat de cette journée', 45 interactionHint: 'Cliquez sur une case ou utilisez le point du jour pour mettre à jour votre suivi.', 46 yearHint: 'Un trait par jour : repérez immédiatement les périodes tenues, partielles ou mises en pause.', 47 addNote: 'Ajouter une note', 48 editNote: 'Modifier la note', 49 noteTitle: 'Contexte du jour', 50 noteLabel: 'Ce qui a influencé cette habitude', 51 notePlaceholder: 'Ex. Fatigue â journée très chargée', 52 save: 'Enregistrer', 53 cancel: 'Annuler', 54 createTitle: 'Créer une habitude', 55 habitName: 'Nom de lâhabitude', 56 habitPlaceholder: 'Ex. Méditer', 57 habitGoal: 'Objectif simple', 58 habitGoalPlaceholder: 'Ex. 10 min', 59 create: 'Créer lâhabitude', 60 editTitle: 'Modifier lâhabitude', 61 update: 'Enregistrer les modifications', 62 deleteHabit: 'Supprimer', 63 confirmDelete: 'Confirmer la suppression', 64 statusLabel: { done: 'fait', partial: 'partiel', pause: 'pause planifiée', missed: 'à reprendre' }, 65 statusAction: { done: 'Fait', partial: 'Partiel', pause: 'Pause', missed: 'à reprendre' }, 66 statusUpdatedToast: 'Suivi mis à jour.', 67 completedToast: 'Version courte enregistrée. Votre continuité est préservée.', 68 habitCreatedToast: 'Nouvelle habitude ajoutée.', 69 habitUpdatedToast: 'Habitude mise à jour.', 70 habitDeletedToast: 'Habitude supprimée.', 71 noteSavedToast: 'Contexte enregistré.', 72 prev: 'Période précédente', 73 next: 'Période suivante' 74 }, 75 en: { 76 title: 'Habits', 77 continuity: 'Your consistency', 78 consistency: 'consistency', 79 support: 'You come back quickly, even after a pause.', 80 periods: { day: 'Day', month: 'Month', year: 'Year' }, 81 legend: { done: 'Done', partial: 'Partial', pause: 'Pause', missed: 'Resume' }, 82 bestMoment: 'Best moment', 83 evolution: '30-day trend', 84 personalizedAdvice: 'Personalized advice', 85 shortVersion: 'Do the short version today', 86 newHabit: 'New habit', 87 editHabit: 'Edit', 88 automatic: { focus: 'Focus auto', planning: 'Planning auto' }, 89 hour: '', 90 checkIn: 'Daily check-in', 91 checkInPrompt: 'Choose the status for this day', 92 interactionHint: 'Click a cell or use the daily check-in to update your progress.', 93 yearHint: 'One mark per day: spot completed, partial, and paused stretches at a glance.', 94 addNote: 'Add a note', 95 editNote: 'Edit note', 96 noteTitle: 'Day context', 97 noteLabel: 'What influenced this habit', 98 notePlaceholder: 'E.g. Tired â a very busy day',
99 save: 'Save', 100 cancel: 'Cancel', 101 createTitle: 'Create a habit', 102 habitName: 'Habit name', 103 habitPlaceholder: 'E.g. Meditate', 104 habitGoal: 'Simple target', 105 habitGoalPlaceholder: 'E.g. 10 min', 106 create: 'Create habit', 107 editTitle: 'Edit habit', 108 update: 'Save changes', 109 deleteHabit: 'Delete', 110 confirmDelete: 'Confirm deletion', 111 statusLabel: { done: 'done', partial: 'partial', pause: 'planned pause', missed: 'resume' }, 112 statusAction: { done: 'Done', partial: 'Partial', pause: 'Pause', missed: 'Resume' }, 113 statusUpdatedToast: 'Progress updated.', 114 completedToast: 'Short version saved. Your consistency is protected.', 115 habitCreatedToast: 'New habit added.', 116 habitUpdatedToast: 'Habit updated.', 117 habitDeletedToast: 'Habit deleted.', 118 noteSavedToast: 'Context saved.', 119 prev: 'Previous period', 120 next: 'Next period' 121 } 122}; 123 124function escapeHtml(value = '') { 125 return String(value) 126 .replaceAll('&', '&') 127 .replaceAll('<', '<') 128 .replaceAll('>', '>') 129 .replaceAll('"', '"') 130 .replaceAll("'", '''); 131} 132 133function getLocale(language) { 134 return language === 'en' ? 'en-US' : 'fr-FR'; 135} 136 137function getLocalized(field, language) { 138 if (!field || typeof field !== 'object') return String(field || ''); 139 return String(field[language] || field.fr || field.en || ''); 140} 141 142function iconUrl(icon = 'check-circle') { 143 return `${ICON_ROOT}${encodeURIComponent(icon)}.svg`; 144} 145 146function startOfToday() { 147 const date = new Date(); 148 date.setHours(0, 0, 0, 0); 149 return date; 150} 151 152function isFutureDate(date) { 153 return date.getTime() > startOfToday().getTime() + MAX_FUTURE_SKEW_MS; 154} 155 156function renderIcon(icon, alt = '') { 157 return `<img src="${escapeHtml(iconUrl(icon))}" alt="${escapeHtml(alt)}" loading="eager">`; 158} 159 160function getTrendPoints(records, habitId, anchorDate) { 161 const values = []; 162 for (let bucket = 6; bucket >= 0; bucket -= 1) { 163 const end = addDays(anchorDate, -(bucket * 4)); 164 const dates = Array.from({ length: 4 }, (_, index) => addDays(end, index - 3)); 165 values.push(calculateConsistency(records, [habitId], dates)); 166 } 167 return values; 168} 169 170function trendPolyline(values, width = 260, height = 92) { 171 const safeValues = values.length ? values : [0]; 172 const step = safeValues.length > 1 ? width / (safeValues.length - 1) : width; 173 return safeValues.map((value, index) => { 174 const x = Math.round(index * step * 10) / 10; 175 const y = Math.round((height - (Math.max(0, Math.min(100, value)) / 100) * height) * 10) / 10; 176 return `${x},${y}`; 177 }).join(' '); 178} 179 180function formatBestHour(hour, language) { 181 if (language === 'en') { 182 const suffix = hour >= 12 ? 'PM' : 'AM'; 183 const displayHour = hour % 12 || 12; 184 return `${displayHour} ${suffix}`; 185 } 186 return `${hour} h`; 187} 188 189function formatRange(period, dates, language) { 190 const locale = getLocale(language); 191 if (!dates.length) return ''; 192 if (period === HABIT_PERIODS.DAY) { 193 return new Intl.DateTimeFormat(locale, { 194 weekday: 'long', 195 day: 'numeric', 196 month: 'long' 197 }).format(dates[0]); 198 } 199 if (period === HABIT_PERIODS.MONTH) { 200 return new Intl.DateTimeFormat(locale, { 201 month: 'long', 202 year: 'numeric' 203 }).format(dates[0]); 204 } 205 if (period === HABIT_PERIODS.YEAR) { 206 return String(dates[0].getFullYear()); 207 } 208 return ''; 209} 210 211function groupMonths(dates, language, monthStyle = 'long') { 212 const locale = getLocale(language); 213 const groups = []; 214 dates.forEach((date, index) => { 215 const key = `${date.getFullYear()}-${date.getMonth()}`; 216 const previous = groups[groups.length - 1]; 217 if (previous?.key === key) { 218 previous.span += 1; 219 return; 220 } 221 groups.push({ 222 key, 223 start: index + 2, 224 span: 1, 225 label: new Intl.DateTimeFormat(locale, { month: monthStyle }).format(date).replace('.', '') 226 }); 227 }); 228 return groups; 229} 230 231function createHabitId(title) { 232 const slug = String(title || '') 233 .normalize('NFD') 234 .replace(/[\u0300-\u036f]/g, '') 235 .toLowerCase() 236 .replace(/[^a-z0-9]+/g, '-') 237 .replace(/^-|-$/g, '') 238 .slice(0, 40) || 'habit'; 239 return `${slug}-${Date.now().toString(36)}`; 240} 241 242function loadState(storage, anchorDate, { ownerId = 'guest', seedDemoData = ownerId === 'guest' } = {}) { 243 try { 244 const storageKey = getHabitStorageKey(ownerId); 245 const scopedRaw = storage?.getItem(storageKey); 246 const legacyRaw = !scopedRaw && ownerId === 'guest' 247 ? storage?.getItem(HABIT_STORAGE_KEY) 248 : null; 249 const raw = scopedRaw || legacyRaw; 250 const parsed = raw ? JSON.parse(raw) : null; 251 return sanitizeHabitState(parsed, anchorDate, { seedFallback: seedDemoData }); 252 } catch (error) { 253 console.warn('[Habits] Could not load local state', error); 254 return seedDemoData ? createSeededHabitState(anchorDate) : createEmptyHabitState(); 255 } 256} 257 258function saveState(storage, state, storageKey) { 259 try { 260 storage?.setItem(storageKey, JSON.stringify(state)); 261 } catch (error) { 262 console.warn('[Habits] Could not persist local state', error); 263 } 264} 265 266export function mountHabitTracker(container, { 267 language = 'fr', 268 storage = globalThis.localStorage, 269 focusHistory = [], 270 ownerId = 'guest', 271 seedDemoData = ownerId === 'guest', 272 onToast = () => {} 273} = {}) { 274 if (!container) return null; 275 276 if (!document.querySelector('link[href*="blocks/analytics/habits.css"]')) { 277 const link = document.createElement('link'); 278 link.rel = 'stylesheet'; 279 link.href = new URL('./habits.css?v=9', import.meta.url).href; 280 document.head.appendChild(link); 281 } 282 283 const today = startOfToday(); 284 let activeLanguage = language === 'en' ? 'en' : 'fr'; 285 let period = HABIT_PERIODS.MONTH; 286 let anchorDate = new Date(today); 287 let selectedDateKey = toDateKey(today); 288 let activeOwnerId = String(ownerId || 'guest').trim() || 'guest'; 289 let activeSeedDemoData = Boolean(seedDemoData); 290 let activeFocusHistory = Array.isArray(focusHistory) ? focusHistory : []; 291 let activeStorageKey = getHabitStorageKey(activeOwnerId); 292 let state = syncFocusHistory(loadState(storage, today, { 293 ownerId: activeOwnerId, 294 seedDemoData: activeSeedDemoData 295 }), activeFocusHistory); 296 let selectedHabitId = state.habits.some(habit => habit.id === 'read') ? 'read' : state.habits[0]?.id; 297 const persistState = () => saveState(storage, state, activeStorageKey); 298 persistState(); 299 300 const getSelectedHabit = () => state.habits.find(habit => habit.id === selectedHabitId) || state.habits[0]; 301 302 function renderMatrix(dates, copy) { 303 const locale = getLocale(activeLanguage); 304 const todayKey = toDateKey(today); 305 const monthGroups = groupMonths( 306 dates, 307 activeLanguage, 308 period === HABIT_PERIODS.YEAR ? 'short' : 'long' 309 ); 310 const columnCount = dates.length; 311 const monthMarkup = monthGroups.map(group => `
312 <span class="habit-matrix__month" style="grid-column:${group.start} / span ${group.span};grid-row:1;"> 313 <span class="habit-matrix__month-label">${escapeHtml(group.label)}</span> 314 <span class="habit-matrix__month-compact" aria-hidden="true">${escapeHtml(group.label.slice(0, 1).toUpperCase())}</span> 315 </span> 316 `).join(''); 317 318 const dateMarkup = dates.map((date, index) => { 319 const dateKey = toDateKey(date); 320 const isSelected = dateKey === selectedDateKey; 321 const isToday = dateKey === todayKey; 322 const isYearDay = period === HABIT_PERIODS.YEAR; 323 const isMonthStart = date.getDate() === 1; 324 const disabled = isYearDay || isFutureDate(date); 325 const label = period === HABIT_PERIODS.YEAR 326 ? '' 327 : String(date.getDate()); 328 const sublabel = period === HABIT_PERIODS.YEAR 329 ? '' 330 : new Intl.DateTimeFormat(locale, { weekday: 'short' }).format(date).replace('.', '').slice(0, 3); 331 return ` 332 <button type="button" class="habit-matrix__date${isSelected ? ' is-selected' : ''}${isToday ? ' is-today' : ''}${isYearDay ? ' is-year-day' : ''}${isMonthStart ? ' is-month-start' : ''}" 333 style="grid-column:${index + 2};grid-row:2;" data-select-date="${dateKey}" aria-label="${escapeHtml(new Intl.DateTimeFormat(locale, { dateStyle: 'full' }).format(date))}" ${disabled ? 'disabled' : ''}> 334 <strong>${escapeHtml(label)}</strong><small>${escapeHtml(sublabel)}</small> 335 </button> 336 `; 337 }).join(''); 338 339 const rowsMarkup = state.habits.map((habit, habitIndex) => { 340 const row = habitIndex + 3; 341 const selected = habit.id === selectedHabitId; 342 const cells = dates.map((date, dateIndex) => { 343 const dateKey = toDateKey(date); 344 const status = state.records?.[dateKey]?.[habit.id] || HABIT_STATUSES.MISSED; 345 const isYearDay = period === HABIT_PERIODS.YEAR; 346 const isMonthStart = date.getDate() === 1; 347 const isFuture = isFutureDate(date); 348 const disabled = isYearDay || isFuture; 349 const selectedCell = selected && dateKey === selectedDateKey; 350 const dayLabel = new Intl.DateTimeFormat(locale, { dateStyle: 'medium' }).format(date); 351 return ` 352 <button type="button" class="habit-matrix__cell is-${status}${selectedCell ? ' is-selected' : ''}${isYearDay ? ' is-year-day' : ''}${isMonthStart ? ' is-month-start' : ''}${isFuture ? ' is-future' : ''}" 353 style="grid-column:${dateIndex + 2};grid-row:${row};" data-habit-cell data-habit-id="${escapeHtml(habit.id)}" data-date-key="${dateKey}" 354 ${isYearDay ? 'data-habit-year-day' : ''} ${disabled ? 'disabled' : ''} aria-label="${escapeHtml(`${getLocalized(habit.title, activeLanguage)}, ${dayLabel}: ${copy.statusLabel[status]}`)}" title="${escapeHtml(`${dayLabel} · ${copy.statusLabel[status]}`)}"> 355 <span aria-hidden="true"></span> 356 </button> 357 `; 358 }).join(''); 359 360 return ` 361 <span class="habit-matrix__row-surface${selected ? ' is-selected' : ''}" style="grid-row:${row};" aria-hidden="true"></span> 362 <button type="button" class="habit-matrix__habit${selected ? ' is-selected' : ''}" style="grid-column:1;grid-row:${row};" data-select-habit="${escapeHtml(habit.id)}"> 363 <span class="habit-icon is-${escapeHtml(habit.tone)}">${renderIcon(habit.icon)}</span> 364 <span class="habit-matrix__habit-copy"><strong>${escapeHtml(getLocalized(habit.title, activeLanguage))}</strong>${habit.automatic ? `<small>${escapeHtml(copy.automatic[habit.automationKind] || copy.automatic.focus)}</small>` : ''}</span> 365 </button> 366 ${cells} 367 `; 368 }).join(''); 369 370 return ` 371 <div class="habit-matrix is-${period}" aria-label="${escapeHtml(copy.continuity)}"> 372 <div class="habit-matrix__scroller"> 373 <div class="habit-matrix__grid" style="--habit-columns:${columnCount};"> 374 ${monthMarkup} 375 ${dateMarkup} 376 ${rowsMarkup} 377 </div> 378 </div> 379 </div> 380 `; 381 } 382 383 function renderNote(copy) { 384 const note = state.notes?.[`${selectedDateKey}:${selectedHabitId}`]; 385 if (!note) return ''; 386 const date = fromDateKey(selectedDateKey); 387 const locale = getLocale(activeLanguage); 388 return ` 389 <button type="button" class="habit-note" data-edit-note aria-label="${escapeHtml(copy.editNote)}">
390 <span><small>${escapeHtml(new Intl.DateTimeFormat(locale, { weekday: 'short', day: 'numeric', month: 'long' }).format(date))}</small><strong>${escapeHtml(getLocalized(note.text, activeLanguage))}</strong></span> 391 ${renderIcon('edit-2')} 392 </button> 393 `; 394 } 395 396 function render() { 397 const copy = COPY[activeLanguage]; 398 const dates = getPeriodDates(period, anchorDate); 399 const habitIds = state.habits.map(habit => habit.id); 400 const consistency = calculateConsistency(state.records, habitIds, dates, period, today); 401 const selectedHabit = getSelectedHabit(); 402 const trend = selectedHabit ? getTrendPoints(state.records, selectedHabit.id, anchorDate) : [0]; 403 const rangeLabel = formatRange(period, dates, activeLanguage); 404 const locale = getLocale(activeLanguage); 405 const todayKey = toDateKey(today); 406 const checkInDateKey = period === HABIT_PERIODS.YEAR ? todayKey : selectedDateKey; 407 const checkInDate = fromDateKey(checkInDateKey); 408 const checkInStatus = selectedHabit 409 ? state.records?.[checkInDateKey]?.[selectedHabit.id] || HABIT_STATUSES.MISSED 410 : HABIT_STATUSES.MISSED; 411 const checkInNote = state.notes?.[`${checkInDateKey}:${selectedHabit?.id}`]; 412 const checkInLabel = new Intl.DateTimeFormat(locale, { weekday: 'short', day: 'numeric', month: 'long' }).format(checkInDate); 413 414 container.innerHTML = ` 415 <section class="habit-tracker" aria-labelledby="habit-tracker-title"> 416 <header class="card-header analytics-card-header habit-tracker__header"> 417 <h2 class="card-title" id="habit-tracker-title">${escapeHtml(copy.title)}</h2> 418 <div class="chart-controls habit-period-switch" data-period="${period}" role="group" aria-label="${escapeHtml(copy.title)}"> 419 ${Object.values(HABIT_PERIODS).map(value => ` 420 <button type="button" data-habit-period="${value}" class="view-btn habit-period-button${period === value ? ' active is-active' : ''}" aria-pressed="${String(period === value)}">${escapeHtml(copy.periods[value])}</button> 421 `).join('')} 422 <span class="control-pill habit-period-switch__indicator" aria-hidden="true"></span> 423 </div> 424 <nav class="chart-nav habit-range-nav" aria-label="${escapeHtml(rangeLabel)}"> 425 <button class="nav-btn" type="button" data-habit-nav="-1" aria-label="${escapeHtml(copy.prev)}"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><polyline points="15 18 9 12 15 6"></polyline></svg></button> 426 <strong class="habit-date-label">${escapeHtml(rangeLabel)}</strong> 427 <button class="nav-btn" type="button" data-habit-nav="1" aria-label="${escapeHtml(copy.next)}" ${shiftPeriodAnchor(period, anchorDate, 1).getTime() > today.getTime() + MAX_FUTURE_SKEW_MS ? 'disabled' : ''}><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><polyline points="9 18 15 12 9 6"></polyline></svg></button> 428 </nav> 429 </header> 430 431 <div class="habit-tracker__content"> 432 <main class="habit-tracker__main"> 433 <div class="habit-overview"> 434 <div> 435 <h3>${escapeHtml(copy.continuity)}</h3> 436 <p><strong>${consistency} %</strong> ${escapeHtml(copy.consistency)}</p> 437 <small>${escapeHtml(copy.support)}</small> 438 <span class="habit-interaction-hint">${escapeHtml(period === HABIT_PERIODS.YEAR ? copy.yearHint : copy.interactionHint)}</span> 439 </div> 440 <div class="habit-legend" aria-label="${escapeHtml(copy.continuity)}"> 441 ${Object.values(HABIT_STATUSES).map(status => `<span><i class="is-${status}" aria-hidden="true"></i>${escapeHtml(copy.legend[status])}</span>`).join('')} 442 </div> 443 </div> 444 445 ${renderMatrix(dates, copy)} 446 ${renderNote(copy)} 447 </main> 448 449 <aside class="habit-detail" aria-live="polite"> 450 <div class="habit-detail__title">
451 <span class="habit-icon habit-icon--large is-${escapeHtml(selectedHabit?.tone || 'lilac')}">${renderIcon(selectedHabit?.icon || 'check-circle')}</span> 452 <div class="habit-detail__heading"> 453 <h3>${escapeHtml(getLocalized(selectedHabit?.title, activeLanguage))}</h3> 454 <p>${escapeHtml(getLocalized(selectedHabit?.goal, activeLanguage))}</p> 455 </div> 456 </div> 457 <section class="habit-checkin" aria-labelledby="habit-checkin-title"> 458 <div class="habit-checkin__header"> 459 <span id="habit-checkin-title">${escapeHtml(copy.checkIn)}</span> 460 <strong>${escapeHtml(checkInLabel)}</strong> 461 </div> 462 <div class="habit-checkin__statuses" role="group" aria-label="${escapeHtml(copy.checkInPrompt)}"> 463 ${Object.values(HABIT_STATUSES).map(status => ` 464 <button type="button" data-set-habit-status="${status}" class="is-${status}${checkInStatus === status ? ' is-active' : ''}" aria-pressed="${String(checkInStatus === status)}"> 465 <i aria-hidden="true"></i><span>${escapeHtml(copy.statusAction[status])}</span> 466 </button> 467 `).join('')} 468 </div> 469 <button type="button" class="habit-checkin__note" data-edit-note data-note-date="${checkInDateKey}"> 470 ${renderIcon('edit-2')}<span>${escapeHtml(checkInNote ? copy.editNote : copy.addNote)}</span> 471 </button> 472 </section> 473 <div class="habit-detail__divider"></div> 474 <section class="habit-detail__moment"> 475 <span>${escapeHtml(copy.bestMoment)}</span> 476 <strong>${escapeHtml(formatBestHour(selectedHabit?.bestHour || 9, activeLanguage))}</strong> 477 </section> 478 <section class="habit-detail__trend"> 479 <span>${escapeHtml(copy.evolution)}</span> 480 <div class="habit-trend-chart"> 481 <span class="habit-trend-chart__top">100 %</span> 482 <span class="habit-trend-chart__bottom">0 %</span> 483 <svg viewBox="0 0 260 92" role="img" aria-label="${escapeHtml(copy.evolution)}" preserveAspectRatio="none"> 484 <line x1="0" y1="1" x2="260" y2="1"></line> 485 <line x1="0" y1="91" x2="260" y2="91"></line> 486 <polyline points="${trendPolyline(trend)}"></polyline> 487 ${trend.map((value, index) => { 488 const x = trend.length > 1 ? (index * 260) / (trend.length - 1) : 0; 489 const y = 92 - (Math.max(0, Math.min(100, value)) / 100) * 92; 490 return `<circle cx="${x}" cy="${y}" r="3"></circle>`; 491 }).join('')} 492 </svg> 493 </div> 494 </section> 495 <section class="habit-advice"> 496 <span class="habit-advice__icon">${renderIcon('lightbulb')}</span> 497 <div><strong>${escapeHtml(copy.personalizedAdvice)}</strong><p>${escapeHtml(getLocalized(selectedHabit?.advice, activeLanguage))}</p></div> 498 </section> 499 <div class="habit-detail__actions"> 500 <button type="button" class="habit-primary-action" data-short-version>${escapeHtml(copy.shortVersion)}</button> 501 <div class="habit-detail__management"> 502 <button type="button" class="habit-secondary-action" data-edit-habit>${renderIcon('edit-2')}<span>${escapeHtml(copy.editHabit)}</span></button> 503 <button type="button" class="habit-secondary-action" data-open-new-habit>${renderIcon('plus')}<span>${escapeHtml(copy.newHabit)}</span></button> 504 </div> 505 </div> 506 </aside> 507 </div> 508 509 <dialog class="habit-dialog" data-new-habit-dialog> 510 <form method="dialog" data-new-habit-form> 511 <div class="habit-dialog__header"><h3>${escapeHtml(copy.createTitle)}</h3><button type="button" data-close-dialog aria-label="${escapeHtml(copy.cancel)}">${renderIcon('x')}</button></div> 512 <label><span>${escapeHtml(copy.habitName)}</span><input name="title" type="text" maxlength="70" placeholder="${escapeHtml(copy.habitPlaceholder)}" required></label> 513 <label><span>${escapeHtml(copy.habitGoal)}</span><input name="goal" type="text" maxlength="30" placeholder="${escapeHtml(copy.habitGoalPlaceholder)}" required></label> 514 <div class="habit-dialog__actions"><button type="button" data-close-dialog>${escapeHtml(copy.cancel)}</button><button type="submit">${escapeHtml(copy.create)}</button></div> 515 </form> 516 </dialog> 517 518 <dialog class="habit-dialog" data-note-dialog> 519 <form method="dialog" data-note-form> 520 <div class="habit-dialog__header"><h3>${escapeHtml(copy.noteTitle)}</h3><button type="button" data-close-dialog aria-label="${escapeHtml(copy.cancel)}">${renderIcon('x')}</button></div> 521 <label><span>${escapeHtml(copy.noteLabel)}</span><textarea name="note" maxlength="180" placeholder="${escapeHtml(copy.notePlaceholder)}"></textarea></label> 522 <div class="habit-dialog__actions"><button type="button" data-close-dialog>${escapeHtml(copy.cancel)}</button><button type="submit">${escapeHtml(copy.save)}</button></div> 523 </form> 524 </dialog> 525 526 <dialog class="habit-dialog" data-edit-habit-dialog> 527 <form method="dialog" data-edit-habit-form> 528 <div class="habit-dialog__header"><h3>${escapeHtml(copy.editTitle)}</h3><button type="button" data-close-dialog aria-label="${escapeHtml(copy.cancel)}">${renderIcon('x')}</button></div> 529 <label><span>${escapeHtml(copy.habitName)}</span><input name="title" type="text" maxlength="70" required></label> 530 <label><span>${escapeHtml(copy.habitGoal)}</span><input name="goal" type="text" maxlength="30" required></label> 531 <div class="habit-dialog__actions"> 532 ${selectedHabit && !selectedHabit.automatic && state.habits.length > 1 ? `<button type="button" class="habit-dialog__danger" data-delete-habit>${escapeHtml(copy.deleteHabit)}</button>` : ''} 533 <button type="button" data-close-dialog>${escapeHtml(copy.cancel)}</button><button type="submit">${escapeHtml(copy.update)}</button> 534 </div> 535 </form> 536 </dialog> 537 </section> 538 `; 539 540 const activePeriodButton = container.querySelector('[data-habit-period].is-active'); 541 const periodIndicator = container.querySelector('.habit-period-switch__indicator'); 542 if (activePeriodButton && periodIndicator) { 543 periodIndicator.style.width = `${activePeriodButton.offsetWidth}px`; 544 periodIndicator.style.left = `${activePeriodButton.offsetLeft}px`; 545 } 546 } 547 548 container.addEventListener('click', (event) => { 549 const periodButton = event.target.closest('[data-habit-period]'); 550 if (periodButton) { 551 period = periodButton.dataset.habitPeriod; 552 anchorDate = new Date(today); 553 selectedDateKey = toDateKey(today); 554 render(); 555 return; 556 } 557 558 const navButton = event.target.closest('[data-habit-nav]'); 559 if (navButton && !navButton.disabled) {
560 anchorDate = shiftPeriodAnchor(period, anchorDate, Number(navButton.dataset.habitNav)); 561 selectedDateKey = toDateKey(anchorDate); 562 render(); 563 return; 564 } 565 566 const dateButton = event.target.closest('[data-select-date]'); 567 if (dateButton && !dateButton.disabled) { 568 selectedDateKey = dateButton.dataset.selectDate; 569 render(); 570 return; 571 } 572 573 const statusButton = event.target.closest('[data-set-habit-status]'); 574 if (statusButton && selectedHabitId) { 575 const dateKey = period === HABIT_PERIODS.YEAR ? toDateKey(today) : selectedDateKey; 576 state = setHabitStatus(state, selectedHabitId, dateKey, statusButton.dataset.setHabitStatus); 577 selectedDateKey = dateKey; 578 persistState(); 579 onToast(COPY[activeLanguage].statusUpdatedToast); 580 render(); 581 return; 582 } 583 584 const cell = event.target.closest('[data-habit-cell]'); 585 if (cell && !cell.disabled) { 586 selectedHabitId = cell.dataset.habitId; 587 selectedDateKey = cell.dataset.dateKey; 588 state = setHabitStatus( 589 state, 590 selectedHabitId, 591 selectedDateKey, 592 nextHabitStatus(state.records?.[selectedDateKey]?.[selectedHabitId]) 593 ); 594 persistState(); 595 onToast(COPY[activeLanguage].statusUpdatedToast); 596 render(); 597 return; 598 } 599 600 const habitButton = event.target.closest('[data-select-habit]'); 601 if (habitButton) { 602 selectedHabitId = habitButton.dataset.selectHabit; 603 render(); 604 return; 605 } 606 607 if (event.target.closest('[data-short-version]')) { 608 const dateKey = toDateKey(today); 609 state = setHabitStatus(state, selectedHabitId, dateKey, HABIT_STATUSES.PARTIAL); 610 selectedDateKey = dateKey; 611 persistState(); 612 onToast(COPY[activeLanguage].completedToast); 613 render(); 614 return; 615 } 616 617 if (event.target.closest('[data-open-new-habit]')) { 618 container.querySelector('[data-new-habit-dialog]')?.showModal(); 619 queueMicrotask(() => container.querySelector('[data-new-habit-form] input')?.focus()); 620 return; 621 } 622 623 if (event.target.closest('[data-edit-habit]')) { 624 const selectedHabit = getSelectedHabit(); 625 const dialog = container.querySelector('[data-edit-habit-dialog]'); 626 const titleInput = dialog?.querySelector('input[name="title"]'); 627 const goalInput = dialog?.querySelector('input[name="goal"]'); 628 if (titleInput) titleInput.value = getLocalized(selectedHabit?.title, activeLanguage); 629 if (goalInput) goalInput.value = getLocalized(selectedHabit?.goal, activeLanguage); 630 dialog?.showModal(); 631 queueMicrotask(() => titleInput?.focus()); 632 return; 633 } 634 635 const deleteButton = event.target.closest('[data-delete-habit]'); 636 if (deleteButton) { 637 if (deleteButton.dataset.confirmed !== 'true') { 638 deleteButton.dataset.confirmed = 'true'; 639 deleteButton.textContent = COPY[activeLanguage].confirmDelete; 640 return; 641 } 642 643 state = removeHabit(state, selectedHabitId); 644 selectedHabitId = state.habits[0]?.id; 645 persistState(); 646 deleteButton.closest('dialog')?.close(); 647 onToast(COPY[activeLanguage].habitDeletedToast); 648 render(); 649 return; 650 } 651 652 const noteButton = event.target.closest('[data-edit-note]'); 653 if (noteButton) { 654 if (noteButton.dataset.noteDate) selectedDateKey = noteButton.dataset.noteDate; 655 const dialog = container.querySelector('[data-note-dialog]'); 656 const textarea = dialog?.querySelector('textarea'); 657 const note = state.notes?.[`${selectedDateKey}:${selectedHabitId}`]; 658 if (textarea) textarea.value = getLocalized(note?.text, activeLanguage); 659 dialog?.showModal(); 660 queueMicrotask(() => textarea?.focus()); 661 return; 662 } 663 664 const closeButton = event.target.closest('[data-close-dialog]'); 665 if (closeButton) closeButton.closest('dialog')?.close(); 666 }); 667 668 container.addEventListener('submit', (event) => { 669 if (event.target.matches('[data-new-habit-form]')) { 670 event.preventDefault(); 671 const data = new FormData(event.target); 672 const title = String(data.get('title') || '').trim(); 673 const goal = String(data.get('goal') || '').trim(); 674 if (!title || !goal) return; 675 const id = createHabitId(title); 676 state.habits.push({ 677 id,
678 title: { fr: title, en: title }, 679 shortTitle: { fr: title, en: title }, 680 goal: { fr: goal, en: goal }, 681 icon: 'check-circle', 682 tone: 'lilac', 683 automatic: false, 684 bestHour: 9, 685 advice: { 686 fr: 'Commencez petit et gardez une version facile les jours chargés.', 687 en: 'Start small and keep an easy version for busy days.' 688 } 689 }); 690 selectedHabitId = id; 691 persistState(); 692 event.target.closest('dialog')?.close(); 693 onToast(COPY[activeLanguage].habitCreatedToast); 694 render(); 695 return; 696 } 697 698 if (event.target.matches('[data-edit-habit-form]')) { 699 event.preventDefault(); 700 const data = new FormData(event.target); 701 state = updateHabitDetails(state, selectedHabitId, { 702 title: data.get('title'), 703 goal: data.get('goal') 704 }); 705 persistState(); 706 event.target.closest('dialog')?.close(); 707 onToast(COPY[activeLanguage].habitUpdatedToast); 708 render(); 709 return; 710 } 711 712 if (event.target.matches('[data-note-form]')) { 713 event.preventDefault(); 714 const data = new FormData(event.target); 715 const noteText = String(data.get('note') || '').trim(); 716 const noteKey = `${selectedDateKey}:${selectedHabitId}`; 717 if (noteText) { 718 state.notes[noteKey] = { mood: 'neutral', text: { fr: noteText, en: noteText } }; 719 } else { 720 delete state.notes[noteKey]; 721 } 722 persistState(); 723 event.target.closest('dialog')?.close(); 724 onToast(COPY[activeLanguage].noteSavedToast); 725 render(); 726 } 727 }); 728 729 render(); 730 731 return { 732 refresh: render, 733 setLanguage(nextLanguage) { 734 activeLanguage = nextLanguage === 'en' ? 'en' : 'fr'; 735 render(); 736 }, 737 syncFocusHistory(sessions = []) { 738 activeFocusHistory = Array.isArray(sessions) ? sessions : []; 739 state = syncFocusHistory(state, activeFocusHistory); 740 persistState(); 741 render(); 742 }, 743 recordDayPlanningCompletion({ ownerId: sourceOwnerId, completedAt = new Date() } = {}) { 744 const dateKey = getDateKeyInTimeZone(completedAt, 'Europe/Paris'); 745 const wasAlreadyCompleted = state.records?.[dateKey]?.['plan-day'] === HABIT_STATUSES.DONE; 746 const nextState = syncDayPlanningCompletion(state, { 747 activeOwnerId, 748 ownerId: sourceOwnerId, 749 completedAt, 750 timeZone: 'Europe/Paris' 751 }); 752 const isNowCompleted = nextState.records?.[dateKey]?.['plan-day'] === HABIT_STATUSES.DONE; 753 if (wasAlreadyCompleted || !isNowCompleted) return false; 754 755 state = nextState; 756 persistState(); 757 render(); 758 return true; 759 }, 760 setOwner(nextOwnerId, options = {}) { 761 const normalizedOwnerId = String(nextOwnerId || 'guest').trim() || 'guest'; 762 const nextSeedDemoData = options.seedDemoData ?? normalizedOwnerId === 'guest'; 763 if (normalizedOwnerId === activeOwnerId && Boolean(nextSeedDemoData) === activeSeedDemoData) return; 764 765 activeOwnerId = normalizedOwnerId; 766 activeSeedDemoData = Boolean(nextSeedDemoData); 767 activeFocusHistory = Array.isArray(options.focusHistory) ? options.focusHistory : activeFocusHistory; 768 activeStorageKey = getHabitStorageKey(activeOwnerId); 769 state = syncFocusHistory(loadState(storage, today, { 770 ownerId: activeOwnerId, 771 seedDemoData: activeSeedDemoData 772 }), activeFocusHistory); 773 selectedDateKey = toDateKey(today); 774 selectedHabitId = state.habits.some(habit => habit.id === 'read') ? 'read' : state.habits[0]?.id; 775 persistState(); 776 render(); 777 }, 778 getState() { 779 return sanitizeHabitState(state, today, { seedFallback: activeSeedDemoData }); 780 } 781 }; 782}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.