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.