PageSourceSearch

https://roxima.io/blocks/analytics/habits.mjs?v=13

js roxima.io collected 2026-10-02 01:58:55 UTC 38,698 bytes, 782 lines download raw bytes

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('<', '&lt;')
128        .replaceAll('>', '&gt;')
129        .replaceAll('"', '&quot;')
130        .replaceAll("'", '&#039;');
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.