PageSourceSearch

https://roxima.io/blocks/analytics/habits_model.mjs?v=10

js roxima.io collected 2026-10-02 01:59:07 UTC 19,466 bytes, 494 lines download raw bytes

1export const HABIT_STORAGE_KEY = 'roxima_habit_tracker_v1';
2
3export function getHabitStorageKey(ownerId = 'guest') {
4    const normalizedOwnerId = String(ownerId || 'guest').trim().slice(0, 160) || 'guest';
5    return `${HABIT_STORAGE_KEY}:${encodeURIComponent(normalizedOwnerId)}`;
6}
7
8export const HABIT_PERIODS = Object.freeze({
9    DAY: 'day',
10    MONTH: 'month',
11    YEAR: 'year'
12});
13
14export const HABIT_STATUSES = Object.freeze({
15    DONE: 'done',
16    PARTIAL: 'partial',
17    PAUSE: 'pause',
18    MISSED: 'missed'
19});
20
21export const DEFAULT_HABITS = Object.freeze([
22    {
23        id: 'plan-day',
24        title: { fr: 'Planifier la journée', en: 'Plan the day' },
25        shortTitle: { fr: 'Planifier', en: 'Plan' },
26        goal: { fr: '5 min', en: '5 min' },
27        icon: 'calendar',
28        tone: 'mint',
29        automatic: true,
30        automationKind: 'planning',
31        bestHour: 8,
32        advice: {
33            fr: 'Les journées planifiées avant 9 h restent plus régulières.',
34            en: 'Days planned before 9 AM stay more consistent.'
35        }
36    },
37    {
38        id: 'deep-focus',
39        title: { fr: 'Focus profond', en: 'Deep focus' },
40        shortTitle: { fr: 'Focus', en: 'Focus' },
41        goal: { fr: '50 min', en: '50 min' },
42        icon: 'target',
43        tone: 'blue',
44        automatic: true,
45        automationKind: 'focus',
46        bestHour: 10,
47        advice: {
48            fr: 'Vos sessions du matin sont les plus faciles à répéter.',
49            en: 'Your morning sessions are the easiest to repeat.'
50        }
51    },
52    {
53        id: 'walk',
54        title: { fr: 'Marcher 7 000 pas', en: 'Walk 7,000 steps' },
55        shortTitle: { fr: 'Marcher', en: 'Walk' },
56        goal: { fr: '7 000 pas', en: '7,000 steps' },
57        icon: 'activity',
58        tone: 'mint',
59        bestHour: 13,
60        advice: {
61            fr: 'Une marche juste après déjeuner tient mieux dans le temps.',
62            en: 'A walk right after lunch is easier to sustain.'
63        }
64    },
65    {
66        id: 'read',
67        title: { fr: 'Lire 20 min', en: 'Read for 20 min' },
68        shortTitle: { fr: 'Lire', en: 'Read' },
69        goal: { fr: '20 min', en: '20 min' },
70        icon: 'book-open',
71        tone: 'lilac',
72        bestHour: 21,
73        advice: {
74            fr: 'Les jours chargés, gardez une version de 5 min.',
75            en: 'On busy days, keep a five-minute version.'
76        }
77    },
78    {
79        id: 'prepare-tomorrow',
80        title: { fr: 'Préparer demain', en: 'Prepare tomorrow' },
81        shortTitle: { fr: 'Préparer', en: 'Prepare' },
82        goal: { fr: '5 min', en: '5 min' },
83        icon: 'moon',
84        tone: 'lilac',
85        bestHour: 22,
86        advice: {
87            fr: 'Votre routine du soir tient mieux quand elle commence avant 22 h.',
88            en: 'Your evening routine sticks better when it starts before 10 PM.'
89        }
90    }
91]);
92
93const VALID_STATUSES = new Set(Object.values(HABIT_STATUSES));
94const SYSTEM_AUTOMATION_KINDS = Object.freeze({
95    'plan-day': 'planning',
96    'deep-focus': 'focus'
97});
98const DAY_MS = 24 * 60 * 60 * 1000;
99
100export function toDateKey(value) {
101    const date = value instanceof Date ? value : new Date(value);
102    if (Number.isNaN(date.getTime())) return '';
103    const year = date.getFullYear();
104    const month = String(date.getMonth() + 1).padStart(2, '0');
105    const day = String(date.getDate()).padStart(2, '0');
106    return `${year}-${month}-${day}`;
107}
108
109export function getDateKeyInTimeZone(value = new Date(), timeZone = 'Europe/Paris') {
110    const date = value instanceof Date ? value : new Date(value);
111    if (Number.isNaN(date.getTime())) return '';
112
113    try {
114        const parts = new Intl.DateTimeFormat('en-CA', {
115            timeZone,
116            year: 'numeric',
117            month: '2-digit',
118            day: '2-digit'
119        }).formatToParts(date);
120        const values = Object.fromEntries(parts.map(part => [part.type, part.value]));
121        if (!values.year || !values.month || !values.day) return '';
122        return `${values.year}-${values.month}-${values.day}`;
123    } catch {
124        return '';
125    }
126}
127
128export function fromDateKey(key) {
129    const [year, month, day] = String(key || '').split('-').map(Number);
130    if (!year || !month || !day) return new Date(NaN);
131    return new Date(year, month - 1, day);
132}
133
134export function addDays(value, amount) {
135    const date = value instanceof Date ? new Date(value) : new Date(value);
136    date.setDate(date.getDate() + amount);
137    return date;
138}
139
140function cloneHabit(habit) {
141    return {
142        ...habit,
143        title: { ...habit.title },
144        shortTitle: { ...habit.shortTitle },
145        goal: { ...habit.goal },
146        advice: { ...habit.advice }
147    };
148}
149
150function sanitizeRemovedHabitIds(value) {
151    if (!Array.isArray(value)) return [];
152
153    return Array.from(new Set(value
154        .map(habitId => String(habitId || '').trim().slice(0, 160))
155        .filter(habitId => habitId && !SYSTEM_AUTOMATION_KINDS[habitId])));
156}
157
158export function createEmptyHabitState() {
159    return {
160        version: 1,
161        habits: DEFAULT_HABITS.map(cloneHabit),
162        records: {},
163        notes: {},
164        removedHabitIds: []
165    };
166}
167
168function seededStatus(dayIndex, habitIndex) {
169    const bucket = (dayIndex * 7 + habitIndex * 3) % 15;
170    if (bucket < 10) return HABIT_STATUSES.DONE;
171    if (bucket < 13) return HABIT_STATUSES.PARTIAL;
172    if (bucket === 13) return HABIT_STATUSES.PAUSE;
173    return HABIT_STATUSES.MISSED;
174}
175
176export function createSeededHabitState(anchorValue = new Date()) {
177    const anchor = anchorValue instanceof Date ? new Date(anchorValue) : new Date(anchorValue);
178    const habits = DEFAULT_HABITS.map(cloneHabit);
179    const records = {};
180
181    for (let dayIndex = 0; dayIndex < 31; dayIndex += 1) {
182        const date = addDays(anchor, dayIndex - 30);
183        const dateKey = toDateKey(date);
184        records[dateKey] = {};
185        habits.forEach((habit, habitIndex) => {
186            records[dateKey][habit.id] = seededStatus(dayIndex, habitIndex);
187        });
188    }
189
190    // Keep the starter view aligned with the selected reference: reading has
191    // sixteen fully completed days in the current month, while the overall
192    // consistency remains unchanged by balancing two planning entries.
193    const currentMonthDates = Array.from({ length: anchor.getDate() }, (_, index) => (
194        new Date(anchor.getFullYear(), anchor.getMonth(), index + 1, 12)
195    ));
196    const readingPartials = currentMonthDates
197        .map(toDateKey)
198        .filter(dateKey => records[dateKey]?.read === HABIT_STATUSES.PARTIAL)
199        .slice(0, 2);
200    const planningDone = currentMonthDates
201        .map(toDateKey)
202        .filter(dateKey => records[dateKey]?.['plan-day'] === HABIT_STATUSES.DONE)
203        .slice(0, readingPartials.length);
204    readingPartials.forEach(dateKey => { records[dateKey].read = HABIT_STATUSES.DONE; });
205    planningDone.forEach(dateKey => { records[dateKey]['plan-day'] = HABIT_STATUSES.PARTIAL; });
206
207    const anchorKey = toDateKey(anchor);
208    return {
209        version: 1,
210        habits,
211        records,
212        notes: {
213            [`${anchorKey}:read`]: {
214                mood: 'tired',
215                text: { fr: 'Fatigue — journée très chargée', en: 'Tired — a very busy day' }
216            }
217        },
218        removedHabitIds: []
219    };
220}
221
222export function sanitizeHabitState(value, anchorValue = new Date(), { seedFallback = true } = {}) {
223    const fallbackState = () => (seedFallback
224        ? createSeededHabitState(anchorValue)
225        : createEmptyHabitState());
226
227    if (!value || typeof value !== 'object' || !Array.isArray(value.habits)) {
228        return fallbackState();
229    }
230
231    const storedHabitIds = new Set(value.habits
232        .filter(habit => habit && typeof habit === 'object')
233        .map(habit => String(habit.id || '').trim())
234        .filter(Boolean));
235    const inferredRemovedDefaultIds = DEFAULT_HABITS
236        .filter(habit => !habit.automatic && !storedHabitIds.has(habit.id))
237        .map(habit => habit.id);
238    const removedHabitIds = sanitizeRemovedHabitIds([
239        ...(Array.isArray(value.removedHabitIds) ? value.removedHabitIds : []),
240        ...inferredRemovedDefaultIds
241    ]);
242    const removedHabitIdSet = new Set(removedHabitIds);
243    const habits = value.habits
244        .filter(habit => habit && typeof habit === 'object' && String(habit.id || '').trim())
245        .filter(habit => !removedHabitIdSet.has(String(habit.id).trim()))
246        .map((habit, index) => ({
247            id: String(habit.id).trim(),
248            title: typeof habit.title === 'object'
249                ? { fr: String(habit.title.fr || habit.title.en || 'Habitude'), en: String(habit.title.en || habit.title.fr || 'Habit') }
250                : { fr: String(habit.title || 'Habitude'), en: String(habit.title || 'Habit') },
251            shortTitle: typeof habit.shortTitle === 'object'
252                ? { fr: String(habit.shortTitle.fr || habit.title?.fr || 'Habitude'), en: String(habit.shortTitle.en || habit.title?.en || 'Habit') }
253                : { fr: String(habit.shortTitle || habit.title?.fr || habit.title || 'Habitude'), en: String(habit.shortTitle || habit.title?.en || habit.title || 'Habit') },
254            goal: typeof habit.goal === 'object'
255                ? { fr: String(habit.goal.fr || habit.goal.en || ''), en: String(habit.goal.en || habit.goal.fr || '') }
256                : { fr: String(habit.goal || ''), en: String(habit.goal || '') },
257            icon: String(habit.icon || 'check-circle'),
258            tone: ['mint', 'blue', 'lilac', 'amber'].includes(habit.tone) ? habit.tone : ['mint', 'blue', 'lilac'][index % 3],
259            automatic: Boolean(SYSTEM_AUTOMATION_KINDS[String(habit.id).trim()] || habit.automatic),
260            automationKind: SYSTEM_AUTOMATION_KINDS[String(habit.id).trim()]
261                || (['focus', 'planning'].includes(habit.automationKind) ? habit.automationKind : ''),
262            bestHour: Number.isFinite(Number(habit.bestHour)) ? Math.min(23, Math.max(0, Number(habit.bestHour))) : 9,
263            advice: typeof habit.advice === 'object'
264                ? { fr: String(habit.advice.fr || habit.advice.en || ''), en: String(habit.advice.en || habit.advice.fr || '') }
265                : { fr: String(habit.advice || ''), en: String(habit.advice || '') }
266        }));
267
268    if (!habits.length) {
269        const fallback = fallbackState();
270        fallback.removedHabitIds = removedHabitIds;
271        fallback.habits = fallback.habits.filter(habit => !removedHabitIdSet.has(habit.id));
272        return fallback;
273    }
274
275    const validHabitIds = new Set(habits.map(habit => habit.id));
276    const records = {};
277    Object.entries(value.records || {}).forEach(([dateKey, dayRecords]) => {
278        if (!/^\d{4}-\d{2}-\d{2}$/.test(dateKey) || !dayRecords || typeof dayRecords !== 'object') return;
279        const sanitizedDay = {};
280        Object.entries(dayRecords).forEach(([habitId, status]) => {
281            if (validHabitIds.has(habitId) && VALID_STATUSES.has(status)) sanitizedDay[habitId] = status;
282        });
283        records[dateKey] = sanitizedDay;
284    });
285
286    const notes = {};
287    Object.entries(value.notes || {}).forEach(([noteKey, note]) => {
288        if (!note || typeof note !== 'object') return;
289        notes[noteKey] = {
290            mood: String(note.mood || 'neutral'),
291            text: typeof note.text === 'object'
292                ? { fr: String(note.text.fr || note.text.en || ''), en: String(note.text.en || note.text.fr || '') }
293                : { fr: String(note.text || ''), en: String(note.text || '') }
294        };
295    });
296
297    return { version: 1, habits, records, notes, removedHabitIds };
298}
299
300export function getPeriodDates(period, anchorValue = new Date()) {
301    const anchor = anchorValue instanceof Date ? new Date(anchorValue) : new Date(anchorValue);
302    anchor.setHours(12, 0, 0, 0);
303
304    if (period === HABIT_PERIODS.DAY) {
305        return [anchor];
306    }
307
308    if (period === HABIT_PERIODS.YEAR) {
309        const daysInYear = Math.round((
310            new Date(anchor.getFullYear() + 1, 0, 1, 12)
311            - new Date(anchor.getFullYear(), 0, 1, 12)
312        ) / DAY_MS);
313        return Array.from({ length: daysInYear }, (_, index) => (
314            addDays(new Date(anchor.getFullYear(), 0, 1, 12), index)
315        ));
316    }
317
318    const daysInMonth = new Date(anchor.getFullYear(), anchor.getMonth() + 1, 0).getDate();
319    return Array.from({ length: daysInMonth }, (_, index) => (
320        new Date(anchor.getFullYear(), anchor.getMonth(), index + 1, 12)
321    ));
322}
323
324export function shiftPeriodAnchor(period, anchorValue, direction) {
325    const anchor = anchorValue instanceof Date ? new Date(anchorValue) : new Date(anchorValue);
326    const amount = direction < 0 ? -1 : 1;
327    if (period === HABIT_PERIODS.DAY) return addDays(anchor, amount);
328    if (period === HABIT_PERIODS.MONTH) {
329        return new Date(anchor.getFullYear(), anchor.getMonth() + amount, 1, 12);
330    }
331    if (period === HABIT_PERIODS.YEAR) return new Date(anchor.getFullYear() + amount, anchor.getMonth(), anchor.getDate(), 12);
332    return anchor;
333}
334
335export function nextHabitStatus(status) {
336    if (status === HABIT_STATUSES.DONE) return HABIT_STATUSES.PARTIAL;
337    if (status === HABIT_STATUSES.PARTIAL) return HABIT_STATUSES.PAUSE;
338    if (status === HABIT_STATUSES.PAUSE) return HABIT_STATUSES.MISSED;
339    return HABIT_STATUSES.DONE;
340}
341
342export function setHabitStatus(state, habitId, dateValue, status) {
343    const dateKey = /^\d{4}-\d{2}-\d{2}$/.test(String(dateValue || ''))
344        ? String(dateValue)
345        : toDateKey(dateValue);
346    const nextState = sanitizeHabitState(state, dateKey ? fromDateKey(dateKey) : new Date());
347    const habitExists = nextState.habits.some(habit => habit.id === habitId);
348
349    if (!dateKey || !habitExists || !VALID_STATUSES.has(status)) return nextState;
350
351    nextState.records[dateKey] = nextState.records[dateKey] || {};
352    nextState.records[dateKey][habitId] = status;
353    return nextState;
354}
355
356export function updateHabitDetails(state, habitId, { title, goal } = {}) {
357    const nextState = sanitizeHabitState(state);
358    const habit = nextState.habits.find(candidate => candidate.id === habitId);
359    if (!habit) return nextState;
360
361    const nextTitle = String(title || '').trim().slice(0, 70);
362    const nextGoal = String(goal || '').trim().slice(0, 30);
363
364    if (nextTitle) {
365        habit.title = { fr: nextTitle, en: nextTitle };
366        habit.shortTitle = { fr: nextTitle, en: nextTitle };
367    }
368    if (nextGoal) habit.goal = { fr: nextGoal, en: nextGoal };
369
370    return nextState;
371}
372
373export function removeHabit(state, habitId) {
374    const nextState = sanitizeHabitState(state);
375    const habit = nextState.habits.find(candidate => candidate.id === habitId);
376    if (!habit || habit.automatic || nextState.habits.length <= 1) return nextState;
377
378    nextState.removedHabitIds = Array.from(new Set([
379        ...(nextState.removedHabitIds || []),
380        habit.id
381    ]));
382    nextState.habits = nextState.habits.filter(candidate => candidate.id !== habitId);
383    Object.values(nextState.records).forEach(dayRecords => {
384        delete dayRecords[habitId];
385    });
386    Object.keys(nextState.notes).forEach(noteKey => {
387        if (noteKey.endsWith(`:${habitId}`)) delete nextState.notes[noteKey];
388    });
389
390    return nextState;
391}
392
393export function calculateConsistency(
394    records,
395    habitIds,
396    dates,
397    period = HABIT_PERIODS.MONTH,
398    cutoffValue = new Date()
399) {
400    let score = 0;
401    let possible = 0;
402    const cutoff = cutoffValue instanceof Date ? new Date(cutoffValue) : new Date(cutoffValue);
403    cutoff.setHours(12, 0, 0, 0);
404
405    const isAfterCutoff = (date) => {
406        const normalized = date instanceof Date ? new Date(date) : new Date(date);
407        if (Number.isNaN(normalized.getTime())) return true;
408        normalized.setHours(12, 0, 0, 0);
409        return normalized.getTime() > cutoff.getTime();
410    };
411
412    dates.forEach((date) => {
413        if (period === HABIT_PERIODS.YEAR) {
414            if (isAfterCutoff(date)) return;
415            const dayRecords = records?.[toDateKey(date)];
416            if (!dayRecords) return;
417            habitIds.forEach((habitId) => {
418                const status = dayRecords[habitId] || HABIT_STATUSES.MISSED;
419                if (status === HABIT_STATUSES.PAUSE) return;
420                possible += 1;
421                score += status === HABIT_STATUSES.DONE ? 1 : status === HABIT_STATUSES.PARTIAL ? 0.5 : 0;
422            });
423            return;
424        }
425
426        if (isAfterCutoff(date)) return;
427        const dayRecords = records?.[toDateKey(date)] || {};
428        habitIds.forEach((habitId) => {
429            const status = dayRecords[habitId] || HABIT_STATUSES.MISSED;
430            if (status === HABIT_STATUSES.PAUSE) return;
431            possible += 1;
432            score += status === HABIT_STATUSES.DONE ? 1 : status === HABIT_STATUSES.PARTIAL ? 0.5 : 0;
433        });
434    });
435
436    return possible > 0 ? Math.round((score / possible) * 100) : 0;
437}
438
439export function countHabitDays(records, habitId, dates) {
440    return dates.reduce((count, date) => {
441        const status = records?.[toDateKey(date)]?.[habitId];
442        return count + (status === HABIT_STATUSES.DONE ? 1 : 0);
443    }, 0);
444}
445
446export function syncFocusHistory(state, sessions = []) {
447    const nextState = sanitizeHabitState(state);
448    const focusHabit = nextState.habits.find(habit => habit.id === 'deep-focus');
449    if (!focusHabit) return nextState;
450
451    sessions.forEach((session) => {
452        if (!session || typeof session !== 'object') return;
453        const type = String(session.mode || session.type || '').toLowerCase();
454        if (type && !['focus', 'work'].includes(type)) return;
455        const dateKey = toDateKey(session.timestamp || session.startedAt || session.startTime);
456        if (!dateKey) return;
457        nextState.records[dateKey] = nextState.records[dateKey] || {};
458        nextState.records[dateKey][focusHabit.id] = HABIT_STATUSES.DONE;
459    });
460
461    return nextState;
462}
463
464export function isCompletedDayPlanningRequest(request) {
465    if (!request || typeof request !== 'object' || request.handled !== true) return false;
466    if (typeof request.changed !== 'boolean') return false;
467
468    const countKeys = ['plannedCount', 'removedCount', 'missingSlotCount'];
469    if (countKeys.some(key => !Number.isInteger(request[key]) || request[key] < 0)) return false;
470    return request.missingSlotCount === 0;
471}
472
473export function syncDayPlanningCompletion(state, {
474    activeOwnerId,
475    ownerId,
476    completedAt = new Date(),
477    timeZone = 'Europe/Paris'
478} = {}) {
479    const nextState = sanitizeHabitState(state);
480    const normalizedActiveOwnerId = String(activeOwnerId || '').trim();
481    const normalizedOwnerId = String(ownerId || '').trim();
482
483    if (!normalizedActiveOwnerId || normalizedActiveOwnerId === 'guest') return nextState;
484    if (normalizedOwnerId !== normalizedActiveOwnerId) return nextState;
485
486    const dateKey = getDateKeyInTimeZone(completedAt, timeZone);
487    const planningHabit = nextState.habits.find(habit => habit.id === 'plan-day');
488    if (!dateKey || !planningHabit) return nextState;
489    if (nextState.records[dateKey]?.[planningHabit.id] === HABIT_STATUSES.DONE) return nextState;
490
491    nextState.records[dateKey] = nextState.records[dateKey] || {};
492    nextState.records[dateKey][planningHabit.id] = HABIT_STATUSES.DONE;
493    return nextState;
494}

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.