1/** 2 * Pomodoro Block with Advanced Settings 3 */ 4import { audioDB } from '../../shared/indexeddb.js?v=2'; 5import { sendGAEvent } from '../../shared/analytics.js'; 6import { i18n } from '../../shared/i18n.js'; 7import { objectifsStore } from '../../shared/objectifs_store.js'; 8import { pomodoroStore } from '../../shared/store_pomodoro.js'; 9import { getLocalDayString } from '../../shared/day_closure.mjs'; 10import { buildFactoryStats } from '../../shared/factoryEconomy.mjs'; 11import { loadSettledFactoryState } from '../../shared/factoryStore.mjs'; 12import { calculateProductivityRank } from '../topbar/ranks.mjs?v=3'; 13import { createFocusAmbience } from './ambience.js?v=5'; 14import { 15 ALARM_RING_DURATION_MS, 16 getAlarmPlaybackPolicy, 17 getAlarmRepeatIntervalMs 18} from './alarm_runtime.mjs?v=1'; 19import { 20 CUSTOM_TIMER_CATEGORY_ID_PREFIX, 21 CUSTOM_TIMER_CATEGORY_LIMITS, 22 CUSTOM_TIMER_CATEGORY_EMOJIS, 23 CUSTOM_TIMER_CATEGORY_COLORS, 24 CUSTOM_TIMER_LIMITS, 25 DEFAULT_CUSTOM_TIMER_CATEGORY_COLOR, 26 DEFAULT_CUSTOM_TIMER_CATEGORY_EMOJI, 27 getCustomTimerCategoryDefinition, 28 getCustomTimerSessionMode, 29 normalizeCustomTimerCategory, 30 normalizeCustomTimerCategories, 31 normalizeCustomTimerCategoryEmoji, 32 normalizeCustomTimerCategoryColor, 33 normalizeCustomTimerCategoryName, 34 normalizeCustomTimerMinutes, 35 normalizeCustomTimerName, 36 normalizeCustomTimers, 37 removeCustomTimer, 38 upsertCustomTimerCategory, 39 upsertCustomTimer 40} from './custom_timers.mjs?v=5'; 41import { 42 addTaskToToday, 43 completeCurrentPriority, 44 getTodayPriorities, 45 reorderTodayPriorities, 46 resolveTaskReference 47} from '../../shared/daily_priorities.js'; 48import { 49 buildDayPlanSegments, 50 getActualDayProgressSeconds, 51 getExpectedDayProgress, 52 reconcileDayPlanTasks, 53 summarizeDayProgress 54} from './day_progress.mjs?v=2'; 55import { 56 DEFAULT_POMODORO_START_SHORTCUT, 57 getPomodoroStartShortcut, 58 normalizePomodoroStartShortcut, 59 shouldHandlePomodoroStartShortcut 60} from './keyboard_shortcut.mjs?v=1'; 61import { 62 buildTwoDayPilotAssistanceMarker, 63 buildTwoDayPilotReminderIcsUrl, 64 buildTwoDayPilotReminderUrl, 65 buildTwoDayPilotServerEvidence, 66 claimTwoDayPilotForUser, 67 createTwoDayPilotServerRefreshCoordinator, 68 getPilotLocalDate, 69 getTwoDayPilotStage, 70 getTwoDayPilotState, 71 ORGANIC_TWO_DAY_PILOT_CAMPAIGN, 72 reconcileTwoDayPilotFromServerEvidenceOnce, 73 shouldRefreshTwoDayPilotForNewDay, 74 shouldReturnToPilotFocus, 75 TWO_DAY_PILOT_MIN_FOCUS_SECONDS, 76 TWO_DAY_PILOT_RETURN_SOURCE_PARAM, 77 TWO_DAY_PILOT_RETURN_TARGET_PARAM 78} from '../../shared/two_day_pilot.mjs?v=3'; 79import { 80 loadTwoDayPilotServerSnapshot, 81 persistTwoDayPilotAssistance 82} from '../../shared/sync.js?v=19'; 83import { 84 ROXIMA_BROWSER_FEEDBACK_EVENT, 85 attachBrowserFeedbackToHistory, 86 dispatchBrowserFocusState, 87 normalizeBrowserFeedback 88} from '../../shared/browser_focus_bridge.mjs'; 89import { 90 POMODORO_TIMER_STORAGE_KEY, 91 POMODORO_TIMER_OWNER_TIMEOUT_MS, 92 readSharedPomodoroTimerState, 93 shouldClaimPomodoroTimerOwnership 94} from '../../shared/pomodoro_timer_sync.mjs?v=1'; 95 96let pilotPriorityPickerReturnPending = false; 97let pilotResumeTaskPending = false; 98let pilotRenderedLocalDate = null; 99let pilotDismissedContextKey = null; 100let pilotServerEvidenceReadyOwnerId = null; 101let pilotServerRefreshOwnerId = null; 102let pilotAssistedReturnCaptureKey = null; 103let pilotPendingAssistedReturnMarker = null; 104const pilotServerRefreshCoordinator = createTwoDayPilotServerRefreshCoordinator({ 105 getCurrentOwnerId: () => globalCtx?.currentUser?.id 106}); 107let dailyPrioritySequence = []; 108let dailyCompletionFeedbackActive = false; 109let dayProgressPlanCache = null; 110let browserFeedbackListenerBound = false; 111let timerStorageListenerBound = false; 112let lastTimerStateUpdatedAt = 0; 113const pendingBrowserFeedback = new Map(); 114 115const COMPLETION_CELEBRATION_MS = 1800; 116const DAY_PROGRESS_STORAGE_KEY = 'rox_pomo_day_progress_v1'; 117const RANK_BADGE_ASSETS = Object.freeze({ 118 seed: './assets/ranks/graine.webp', 119 momentum: './assets/ranks/elan.webp', 120 focus: './assets/ranks/focus.webp', 121 mastery: './assets/ranks/maitrise.webp', 122 elite: './assets/ranks/elite.webp', 123 summit: './assets/ranks/sommet.webp' 124}); 125 126function getDailyPriorityPosition(priorities, currentTaskId) { 127 const taskIds = priorities 128 .map((item) => String(item?.taskId || '').trim()) 129 .filter(Boolean); 130 const availableTaskIds = new Set(taskIds); 131 132 dailyPrioritySequence = dailyPrioritySequence 133 .filter((taskId) => availableTaskIds.has(taskId)); 134 taskIds.forEach((taskId) => { 135 if (!dailyPrioritySequence.includes(taskId)) dailyPrioritySequence.push(taskId); 136 }); 137 138 const currentIndex = dailyPrioritySequence.indexOf(String(currentTaskId || '')); 139 return currentIndex >= 0 ? currentIndex + 1 : 1; 140} 141 142function isDayClosed() { 143 return pomodoroStore.isDayClosed(); 144} 145 146function generateUUID() { 147 if (typeof crypto !== 'undefined' && crypto.randomUUID) return crypto.randomUUID(); 148 return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) { 149 var r = Math.random() * 16 | 0, v = c == 'x' ? r : (r & 0x3 | 0x8); 150 return v.toString(16); 151 }); 152} 153 154function escapeHtml(value = '') { 155 const span = document.createElement('span'); 156 span.textContent = String(value); 157 return span.innerHTML; 158} 159 160function translatePomodoro(key, variables = {}) { 161 return Object.entries(variables).reduce((text, [name, value]) => ( 162 text.replaceAll(`{${name}}`, String(value)) 163 ), i18n.t(key)); 164} 165 166function getDayProgressOwnerId() { 167 return String(globalCtx?.currentUser?.id || 'guest'); 168} 169 170function readDayProgressPlan() { 171 const date = getLocalDayString(); 172 const ownerId = getDayProgressOwnerId(); 173 if (dayProgressPlanCache?.date === date && dayProgressPlanCache?.ownerId === ownerId) { 174 return dayProgressPlanCache; 175 } 176 177 try { 178 const parsed = JSON.parse(localStorage.getItem(DAY_PROGRESS_STORAGE_KEY) || 'null'); 179 dayProgressPlanCache = parsed?.date === date && parsed?.ownerId === ownerId && Array.isArray(parsed.tasks) 180 ? parsed 181 : { date, ownerId, tasks: [] }; 182 } catch (_) { 183 dayProgressPlanCache = { date, ownerId, tasks: [] }; 184 } 185 return dayProgressPlanCache; 186} 187 188function saveDayProgressPlan(plan) { 189 dayProgressPlanCache = plan; 190 try { 191 localStorage.setItem(DAY_PROGRESS_STORAGE_KEY, JSON.stringify(plan)); 192 } catch (error) { 193 console.warn('[Pomodoro] Impossible dâenregistrer la progression du jour.', error); 194 } 195 return plan; 196} 197 198function syncDayProgressPlan(priorities = getTodayPriorities()) { 199 const currentPlan = readDayProgressPlan(); 200 const tasks = reconcileDayPlanTasks(currentPlan.tasks, priorities, state.settings.focusTime); 201 const unchanged = JSON.stringify(tasks) === JSON.stringify(currentPlan.tasks); 202 return unchanged ? currentPlan : saveDayProgressPlan({ ...currentPlan, tasks }); 203} 204 205function markDayProgressTaskCompleted(task) { 206 const taskId = String(task?.taskId || task?.task_id || '').trim(); 207 if (!taskId) return; 208 209 const currentPlan = readDayProgressPlan(); 210 const existing = currentPlan.tasks.find((item) => item.taskId === taskId); 211 const completedTask = { 212 taskId, 213 title: String(task?.text || task?.title || existing?.title || '').trim(), 214 estimatedMinutes: Number(task?.estimatedMinutes ?? task?.estimated_minutes ?? task?.duration_minutes) 215 || existing?.estimatedMinutes 216 || state.settings.focusTime, 217 position: existing?.position || currentPlan.tasks.length + 1, 218 completed: true 219 }; 220 const tasks = existing 221 ? currentPlan.tasks.map((item) => item.taskId === taskId ? completedTask : item) 222 : [...currentPlan.tasks, completedTask]; 223 saveDayProgressPlan({ ...currentPlan, tasks }); 224} 225 226function formatDayProgressDuration(seconds) { 227 const safeSeconds = Math.max(0, Number(seconds || 0)); 228 if (safeSeconds > 0 && safeSeconds < 60) return `${Math.max(1, Math.round(safeSeconds))} s`; 229 const minutes = Math.max(0, Math.round(safeSeconds / 60)); 230 const hours = Math.floor(minutes / 60); 231 const remainingMinutes = minutes % 60; 232 if (hours === 0) return `${minutes} min`; 233 return remainingMinutes > 0 ? `${hours} h ${remainingMinutes} min` : `${hours} h`; 234} 235 236function getDayProgressSnapshot(priorities = getTodayPriorities(), { syncPlan = false, now = new Date() } = {}) { 237 const plan = syncPlan ? syncDayProgressPlan(priorities) : readDayProgressPlan(); 238 const segments = buildDayPlanSegments(plan.tasks, state.settings); 239 const localDate = getLocalDayString(now); 240 const duration = getDuration(); 241 const actualSeconds = getActualDayProgressSeconds({ 242 history: pomodoroStore.getHistory(), 243 localDate, 244 getLocalDate: getLocalDayString, 245 currentMode: getModeCategory(state.mode), 246 currentDurationSeconds: duration,
247 currentTimeLeftSeconds: state.timeLeft 248 }); 249 const expectedRatio = getExpectedDayProgress( 250 now, 251 state.settings.sleepWakeup, 252 state.settings.sleepBedtime 253 ); 254 return { 255 ...summarizeDayProgress({ segments, expectedRatio, actualSeconds }), 256 segments 257 }; 258} 259 260function getDayProgressCopy(snapshot, now = new Date()) { 261 const language = i18n.getLang?.() === 'fr' ? 'fr' : 'en'; 262 const actualPercent = Math.round(snapshot.actualRatio * 100); 263 const expectedPercent = Math.round(snapshot.expectedRatio * 100); 264 const time = now.toLocaleTimeString(language === 'fr' ? 'fr-FR' : 'en-US', { 265 hour: '2-digit', 266 minute: '2-digit' 267 }); 268 if (language === 'fr') { 269 return `Plan du jour : ${formatDayProgressDuration(snapshot.totalSeconds)}. Avancement réel : ${formatDayProgressDuration(snapshot.actualSeconds)} (${actualPercent} %). Rythme conseillé à ${time} : ${formatDayProgressDuration(snapshot.expectedSeconds)} (${expectedPercent} %). Vert : focus, bleu : pause courte, violet : pause longue.`; 270 } 271 return `Day plan: ${formatDayProgressDuration(snapshot.totalSeconds)}. Actual progress: ${formatDayProgressDuration(snapshot.actualSeconds)} (${actualPercent}%). Suggested pace at ${time}: ${formatDayProgressDuration(snapshot.expectedSeconds)} (${expectedPercent}%). Green: focus, blue: short break, purple: long break.`; 272} 273 274function buildDayProgressSegmentsMarkup(segments, { showFocusNumbers = false } = {}) { 275 let focusIndex = 0; 276 return segments.map((segment) => { 277 const width = `${(segment.ratio * 100).toFixed(4)}%`; 278 const isFocus = segment.type === 'focus'; 279 if (isFocus) focusIndex += 1; 280 const label = isFocus 281 ? `Focus · ${segment.minutes} min` 282 : segment.type === 'short-break' 283 ? `${i18n.t('pomo_mode_short')} · ${segment.minutes} min` 284 : `${i18n.t('pomo_mode_long')} · ${segment.minutes} min`; 285 const focusNumberMarkup = showFocusNumbers && isFocus 286 ? `<span class="pomo-day-progress__focus-number">${focusIndex}</span>` 287 : ''; 288 return `<span class="pomo-day-progress__segment is-${segment.type}" style="--pomo-day-segment-width:${width}" title="${escapeHtml(label)}">${focusNumberMarkup}</span>`; 289 }).join(''); 290} 291 292function buildDayProgressMarkup(snapshot) { 293 if (!snapshot.totalSeconds || snapshot.segments.length === 0) return ''; 294 const expectedPercent = `${(snapshot.expectedRatio * 100).toFixed(3)}%`; 295 const actualPercent = `${(snapshot.actualRatio * 100).toFixed(3)}%`; 296 const segmentsMarkup = buildDayProgressSegmentsMarkup(snapshot.segments); 297 const focusNumbersMarkup = buildDayProgressSegmentsMarkup(snapshot.segments, { showFocusNumbers: true }); 298 const copy = getDayProgressCopy(snapshot); 299 return ` 300 <div class="pomo-day-progress" data-pomo-day-progress role="progressbar" tabindex="0" aria-valuemin="0" aria-valuemax="100" aria-valuenow="${Math.round(snapshot.actualRatio * 100)}" aria-valuetext="${escapeHtml(copy)}" title="${escapeHtml(copy)}" style="--pomo-day-expected:${expectedPercent};--pomo-day-actual:${actualPercent}"> 301 <div class="pomo-day-progress__layer is-plan" aria-hidden="true">${segmentsMarkup}</div> 302 <div class="pomo-day-progress__layer is-expected" aria-hidden="true">${segmentsMarkup}</div> 303 <div class="pomo-day-progress__layer is-actual" aria-hidden="true">${segmentsMarkup}</div> 304 <div class="pomo-day-progress__layer is-labels" aria-hidden="true">${focusNumbersMarkup}</div> 305 </div> 306 `; 307} 308 309function updateDayProgressGauge(now = new Date()) { 310 const gauge = containerEl?.querySelector('[data-pomo-day-progress]'); 311 if (!gauge) return; 312 const snapshot = getDayProgressSnapshot(getTodayPriorities(), { now }); 313 const copy = getDayProgressCopy(snapshot, now); 314 gauge.style.setProperty('--pomo-day-expected', `${(snapshot.expectedRatio * 100).toFixed(3)}%`); 315 gauge.style.setProperty('--pomo-day-actual', `${(snapshot.actualRatio * 100).toFixed(3)}%`); 316 gauge.setAttribute('aria-valuenow', String(Math.round(snapshot.actualRatio * 100))); 317 gauge.setAttribute('aria-valuetext', copy); 318 gauge.title = copy; 319} 320 321function getGeneralPriorityPresentation(priority) { 322 const normalized = String(priority || 'none').toLowerCase(); 323 if (normalized === 'p1' || normalized === '3' || normalized === 'high') { 324 return { label: 'P1', className: 'is-p1', title: i18n.t('daily_priority_level_p1') }; 325 } 326 if (normalized === 'p2' || normalized === '2' || normalized === 'medium') { 327 return { label: 'P2', className: 'is-p2', title: i18n.t('daily_priority_level_p2') }; 328 } 329 if (normalized === 'p3' || normalized === '1' || normalized === 'low') { 330 return { label: 'P3', className: 'is-p3', title: i18n.t('daily_priority_level_p3') }; 331 } 332 return null; 333} 334 335function getCurrentDailyPriority() { 336 return getTodayPriorities()[0] || null; 337} 338 339function getTaskFocusMinutes() { 340 const sessionMins = Number(state.activeTask?.sessionMinutes || state.activeTask?.session_minutes); 341 if (Number.isFinite(sessionMins) && sessionMins >
341 0) return Math.min(Math.max(Math.round(sessionMins), 1), 1440); 342 return normalizeNumericSetting('focusTime', state.settings.focusTime); 343} 344 345const NUMERIC_SETTING_RULES = { 346 focusTime: { fallback: 25, min: 1, max: 180 }, 347 shortBreakTime: { fallback: 5, min: 1, max: 60 }, 348 longBreakTime: { fallback: 15, min: 1, max: 180 }, 349 longBreakInterval: { fallback: 4, min: 1, max: 10 }, 350 dailyGoalMin: { fallback: 120, min: 10, max: 1440 }, 351 volume: { fallback: 50, min: 0, max: 100 } 352}; 353 354function normalizeNumericSetting(key, value, fallbackOverride = undefined) { 355 const rule = NUMERIC_SETTING_RULES[key]; 356 if (!rule) return value; 357 358 const parsed = Number.parseInt(value, 10); 359 const parsedFallback = Number.parseInt(fallbackOverride ?? rule.fallback, 10); 360 const fallback = Number.isFinite(parsedFallback) ? parsedFallback : rule.fallback; 361 const safeValue = Number.isFinite(parsed) ? parsed : fallback; 362 363 return Math.min(Math.max(safeValue, rule.min), rule.max); 364} 365 366function syncNumberInputValue(input, key) { 367 const value = normalizeNumericSetting(key, input.value, state.settings[key]); 368 input.value = String(value); 369 return value; 370} 371 372let state = { 373 mode: 'focus', // focus, short-break, long-break 374 timeLeft: 25 * 60, 375 duration: 25 * 60, 376 isActive: false, 377 interval: null, 378 pomoCount: 0, // Number of completed pomodoros 379 activeTask: null, 380 pendingReview: null, 381 settingsOpen: false, 382 audioPreview: null, // Audio object for preview 383 sessionEndMs: null, 384 browserFocusSessionId: null, 385 timerOwnerTabId: null, 386 timerOwnerHeartbeatAt: 0, 387 timerHeartbeatTicks: 0, 388 isTimerFollower: false, 389 focusDurationOverrideSeconds: null, 390 isDemoSession: false, 391 settings: { 392 focusTime: 25, 393 shortBreakTime: 5, 394 longBreakTime: 15, 395 longBreakInterval: 4, 396 autoStartBreaks: false, 397 autoStartPomo: false, 398 dailyGoalMin: 120, // 2 hours default 399 alarmSound: 'bell', // 'none', 'bell', 'chime', 'digital', 'custom' 400 volume: 50, 401 customSoundId: null, 402 customSoundName: null, 403 sleepBedtime: "23:00", 404 sleepWakeup: "07:00", 405 startShortcut: DEFAULT_POMODORO_START_SHORTCUT, 406 customTimers: [], 407 customTimerCategories: [] 408 } 409}; 410let dailyActionPending = false; 411let dailyRestoreFocusId = null; 412let customTimerEditorId = null; 413let customTimerEditorReturnFocus = null; 414 415const MODES = { 416 'focus': { labelKey: 'pomo_mode_focus', color: '#10B981' }, 417 'short-break': { labelKey: 'pomo_mode_short', color: '#3B82F6' }, 418 'long-break': { labelKey: 'pomo_mode_long', color: '#8B5CF6' } 419}; 420const CUSTOM_MODE_PREFIX = 'custom:'; 421 422function getBrowserFocusTaskText() { 423 return String( 424 state.activeTask?.text 425 || getCurrentDailyPriority()?.text 426 || '' 427 ).trim(); 428} 429 430function syncBrowserFocusProtection(active, status = active ? 'active' : 'paused') { 431 if (state.isDemoSession || !isFocusMode(state.mode) || !state.browserFocusSessionId) return false; 432 433 return dispatchBrowserFocusState({ 434 active, 435 status, 436 sessionId: state.browserFocusSessionId, 437 taskText: getBrowserFocusTaskText(), 438 endsAt: active 439 ? (state.sessionEndMs || Date.now() + state.timeLeft * 1000) 440 : Date.now() 441 }); 442} 443 444function handleBrowserFeedback(value) { 445 const feedback = normalizeBrowserFeedback(value); 446 if (!feedback) return false; 447 448 pendingBrowserFeedback.set(feedback.sessionId, feedback); 449 const result = attachBrowserFeedbackToHistory(pomodoroStore.getHistory(), feedback); 450 if (!result.updated) return false; 451 452 pomodoroStore.updateHistory(result.history); 453 pendingBrowserFeedback.delete(feedback.sessionId); 454 return true; 455} 456 457function getCustomTimers() { 458 return normalizeCustomTimers(state.settings.customTimers); 459} 460 461function getCustomTimerCategories() { 462 return normalizeCustomTimerCategories(state.settings.customTimerCategories); 463} 464 465function getTimerCategoryMeta(categoryId) { 466 const customCategory = getCustomTimerCategoryDefinition(categoryId, getCustomTimerCategories()); 467 if (customCategory) { 468 return { ...customCategory, custom: true }; 469 } 470 471 const builtIn = { 472 focus: { id: 'focus', name: i18n.t('pomo_mode_focus'), emoji: '', color: MODES.focus.color }, 473 'short-break': { id: 'short-break', name: i18n.t('pomo_mode_short'), emoji: '', color: MODES['short-break'].color }, 474 'long-break': { id: 'long-break', name: i18n.t('pomo_mode_long'), emoji: '', color: MODES['long-break'].color } 475 }; 476 return builtIn[categoryId] || builtIn.focus; 477} 478 479function renderTimerCategoryOptionsMarkup(selectedCategory = 'focus') { 480 const builtInCategories = ['focus', 'short-break', 'long-break'].map((id) => getTimerCategoryMeta(id)); 481 const categories = [ 482 ...builtInCategories, 483 ...getCustomTimerCategories().map((category) => ({ ...category, custom: true })) 484 ]; 485 const selected = categories.some((category) => category.id === selectedCategory) 486 ? selectedCategory 487 : 'focus'; 488 489 return categories.map((category) => ` 490 <label> 491 <input type="radio" name="category" value="${escapeHtml(category.id)}" ${category.id === selected ? 'checked' : ''}>
492 <span style="--category-color:${escapeHtml(category.color)}">${category.emoji ? `<b aria-hidden="true">${escapeHtml(category.emoji)}</b>` : ''}${escapeHtml(category.name)}</span> 493 </label> 494 `).join(''); 495} 496 497function renderCustomCategoryEmojiPickerMarkup(value = DEFAULT_CUSTOM_TIMER_CATEGORY_EMOJI) { 498 const selectedEmoji = normalizeCustomTimerCategoryEmoji(value) || DEFAULT_CUSTOM_TIMER_CATEGORY_EMOJI; 499 const suggestedEmojis = new Set(CUSTOM_TIMER_CATEGORY_EMOJIS.map((option) => option.value)); 500 const manualValue = suggestedEmojis.has(selectedEmoji) ? '' : selectedEmoji; 501 502 return ` 503 <details class="pomo-category-emoji-picker" data-custom-category-emoji-picker> 504 <summary aria-label="${escapeHtml(i18n.t('pomo_custom_category_emoji_choose'))}" title="${escapeHtml(i18n.t('pomo_custom_category_emoji_choose'))}"> 505 <span data-custom-category-emoji-preview aria-hidden="true">${escapeHtml(selectedEmoji)}</span> 506 <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m7 10 5 5 5-5"/></svg> 507 </summary> 508 <div class="pomo-category-emoji-menu"> 509 <div class="pomo-category-emoji-grid" role="group" aria-label="${escapeHtml(i18n.t('pomo_custom_category_emoji_suggestions'))}"> 510 ${CUSTOM_TIMER_CATEGORY_EMOJIS.map((option) => ` 511 <button type="button" 512 data-custom-category-emoji-option="${escapeHtml(option.value)}" 513 aria-label="${escapeHtml(i18n.t(option.labelKey))}" 514 title="${escapeHtml(i18n.t(option.labelKey))}" 515 aria-pressed="${String(option.value === selectedEmoji)}">${escapeHtml(option.value)}</button> 516 `).join('')} 517 </div> 518 <label class="pomo-category-emoji-manual"> 519 <span data-i18n="pomo_custom_category_emoji_custom">${escapeHtml(i18n.t('pomo_custom_category_emoji_custom'))}</span> 520 <input type="text" inputmode="text" maxlength="${CUSTOM_TIMER_CATEGORY_LIMITS.maxEmojiLength}" value="${escapeHtml(manualValue)}" placeholder="ð" data-custom-category-emoji-manual> 521 </label> 522 </div> 523 <input id="pomo-custom-category-emoji" type="hidden" value="${escapeHtml(selectedEmoji)}" data-custom-category-emoji-value> 524 </details> 525 `; 526} 527 528function setCustomCategoryEmoji(picker, value, { emit = false, close = false } = {}) { 529 if (!picker) return ''; 530 const emoji = normalizeCustomTimerCategoryEmoji(value); 531 if (!emoji) return ''; 532 533 const valueInput = picker.querySelector('[data-custom-category-emoji-value]'); 534 const preview = picker.querySelector('[data-custom-category-emoji-preview]'); 535 const manualInput = picker.querySelector('[data-custom-category-emoji-manual]'); 536 const isSuggested = CUSTOM_TIMER_CATEGORY_EMOJIS.some((option) => option.value === emoji); 537 if (valueInput) valueInput.value = emoji; 538 if (preview) preview.textContent = emoji; 539 if (manualInput && (isSuggested || manualInput.value !== emoji)) { 540 manualInput.value = isSuggested ? '' : emoji; 541 } 542 picker.querySelectorAll('[data-custom-category-emoji-option]').forEach((button) => { 543 button.setAttribute('aria-pressed', String(button.dataset.customCategoryEmojiOption === emoji)); 544 }); 545 if (close) picker.open = false; 546 if (emit && valueInput) valueInput.dispatchEvent(new Event('change', { bubbles: true })); 547 return emoji; 548} 549 550function renderCustomCategoryColorPickerMarkup(value = DEFAULT_CUSTOM_TIMER_CATEGORY_COLOR) { 551 const selectedColor = normalizeCustomTimerCategoryColor(value); 552 return ` 553 <div class="pomo-category-color-options" role="group" aria-label="${escapeHtml(i18n.t('pomo_custom_category_color_options'))}"> 554 ${CUSTOM_TIMER_CATEGORY_COLORS.map((option) => ` 555 <button type="button" 556 data-custom-category-color-option="${escapeHtml(option.value)}" 557 style="--category-color:${escapeHtml(option.value)}" 558 aria-label="${escapeHtml(i18n.t(option.labelKey))}" 559 title="${escapeHtml(i18n.t(option.labelKey))}" 560 aria-pressed="${String(option.value === selectedColor)}"><span aria-hidden="true"></span></button> 561 `).join('')} 562 </div> 563 <input id="pomo-custom-category-color" type="hidden" value="${escapeHtml(selectedColor)}" data-custom-category-color-value> 564 `; 565} 566 567function setCustomCategoryColor(root, value, { emit = false } = {}) { 568 if (!root) return DEFAULT_CUSTOM_TIMER_CATEGORY_COLOR; 569 const color = normalizeCustomTimerCategoryColor(value); 570 const valueInput = root.querySelector('[data-custom-category-color-value]'); 571 if (valueInput) valueInput.value = color; 572 root.querySelectorAll('[data-custom-category-color-option]').forEach((button) => { 573 button.setAttribute('aria-pressed', String(button.dataset.customCategoryColorOption === color)); 574 }); 575 if (emit && valueInput) valueInput.dispatchEvent(new Event('change', { bubbles: true })); 576 return color; 577} 578 579function getCustomTimerId(mode) { 580 return String(mode || '').startsWith(CUSTOM_MODE_PREFIX) 581 ? String(mode).slice(CUSTOM_MODE_PREFIX.length) 582 : null; 583} 584 585function getCustomTimer(mode) { 586 const timerId = getCustomTimerId(mode); 587 return timerId ? getCustomTimers().find((timer) => timer.id === timerId) || null : null; 588} 589 590function isCustomMode(mode) { 591 return Boolean(getCustomTimer(mode)); 592} 593 594function getModeCategory(mode) { 595 const customTimer = getCustomTimer(mode); 596 if (customTimer) return getCustomTimerSessionMode(customTimer) || 'focu
596s'; 597 return MODES[mode] ? mode : 'focus'; 598} 599 600function isFocusMode(mode) { 601 return getModeCategory(mode) === 'focus'; 602} 603 604function getModeLabel(mode) { 605 const customTimer = getCustomTimer(mode); 606 if (customTimer) { 607 const category = getTimerCategoryMeta(customTimer.category); 608 return `${category.custom && category.emoji ? `${category.emoji} ` : ''}${customTimer.name}`; 609 } 610 return i18n.t(MODES[mode]?.labelKey || MODES.focus.labelKey); 611} 612 613function getModeColor(mode) { 614 return getTimerCategoryMeta(getModeCategory(mode)).color; 615} 616 617function syncModeBadge(badge, mode) { 618 if (!badge) return; 619 620 const modeDefinition = MODES[mode]; 621 if (modeDefinition) { 622 badge.dataset.i18n = modeDefinition.labelKey; 623 } else { 624 badge.removeAttribute('data-i18n'); 625 } 626 badge.textContent = getModeLabel(mode); 627 badge.className = 'badge-pomo active'; 628 badge.dataset.mode = mode; 629 badge.dataset.timerCategory = getModeCategory(mode); 630} 631 632function renderModeOptionsMarkup() { 633 const defaultModes = Object.keys(MODES).map((mode) => ` 634 <button class="badge-pomo pomo-mode-trigger" type="button" data-mode="${mode}" data-timer-category="${mode}">${escapeHtml(getModeLabel(mode))}</button> 635 `).join(''); 636 const customModes = getCustomTimers().map((timer) => { 637 const mode = `${CUSTOM_MODE_PREFIX}${timer.id}`; 638 const category = getTimerCategoryMeta(timer.category); 639 return ` 640 <button class="badge-pomo pomo-mode-trigger" type="button" data-mode="${escapeHtml(mode)}" data-timer-category="${escapeHtml(timer.category)}" style="--custom-category-color:${escapeHtml(category.color)}">${escapeHtml(timer.name)}</button> 641 `; 642 }).join(''); 643 const atLimit = getCustomTimers().length >= CUSTOM_TIMER_LIMITS.maxCount; 644 const addLabel = atLimit ? i18n.t('pomo_custom_timer_limit_reached') : i18n.t('pomo_custom_timer_add_aria'); 645 646 return `${defaultModes}${customModes} 647 <button class="pomo-mode-add pomo-mode-trigger" type="button" data-custom-timer-add aria-label="${escapeHtml(addLabel)}" title="${escapeHtml(addLabel)}" ${atLimit ? 'disabled' : ''}> 648 <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.25" stroke-linecap="round" aria-hidden="true"><path d="M12 5v14M5 12h14"></path></svg> 649 </button>`; 650} 651const FOCUS_GUIDE_COPY = { 652 fr: { 653 activeTaskFallback: 'Action en focus', 654 button: 'Choisir la priorite du jour', 655 chooseAction: 'Choisir une priorit\u00e9', 656 changeTaskLabel: 'Changer la priorite', 657 flowLabel: 'Parcours guide vers le bilan de focus', 658 focus: '2. Focus', 659 hint: 'Pas besoin de tout organiser. Choisissez une seule action, lancez 25 min, puis notez ce qui a avance.', 660 kicker: 'Prochaine etape', 661 pauseAction: 'Mettre en pause', 662 pausedHint: 'Ta prochaine etape : reprends cette session, sans changer de priorite.', 663 pausedKicker: 'Session en pause', 664 priority: '1. Priorite', 665 recommendationAction: 'Adopter cette priorit\u00e9', 666 recommendationHint: 'Roxima te propose la premi\u00e8re action de ta liste. Adopte-la pour passer au focus, ou touche-la pour en choisir une autre.', 667 recommendationKicker: 'Suggestion Roxima', 668 recommendationLabel: 'Priorit\u00e9 sugg\u00e9r\u00e9e', 669 quickStartAction: 'Commencer 5 min', 670 quickStartAria: 'Commencer une session Focus de 5 minutes sur {task}', 671 quickStartTitle: "Commencer petit, sans s'engager sur toute la dur\u00e9e", 672 readyHint: 'Ta prochaine etape : lance 25 min sur cette action.', 673 readyKicker: 'Priorite choisie', 674 resumeAction: 'Reprendre', 675 reviewHint: 'Note ce qui a avance avant de repartir sur une autre action.', 676 reviewKicker: 'Apr\u00e8s la session', 677 reviewCta: 'Voir le bilan', 678 reviewError: "La note n'a pas pu \u00eatre enregistr\u00e9e.", 679 reviewHelper: 'Cette note reste dans ce navigateur avec la session.', 680 reviewLater: 'Plus tard', 681 reviewPlaceholder: 'Ex. brouillon clarifi\u00e9, d\u00e9cision envoy\u00e9e, prochaine \u00e9tape d\u00e9finie...', 682 reviewQuestion: "Qu'est-ce qui a avanc\u00e9 concr\u00e8tement ?", 683 reviewSaved: 'Note enregistr\u00e9e localement.', 684 runningHint: 'Reste sur cette seule action. A la fin, note ce qui a vraiment avance.', 685 runningKicker: 'Focus en cours', 686 startBreakAction: 'Lancer la pause', 687 startFocusAction: 'Lancer {minutes} min', 688 review: '3. Bilan' 689 }, 690 en: { 691 activeTaskFallback: 'Focus action', 692 button: 'Choose today priority', 693 chooseAction: 'Choose one priority', 694 changeTaskLabel: 'Change priority', 695 flowLabel: 'Guided path to the focus review', 696 focus: '2. Focus', 697 hint: 'No need to organize everything. Choose one action, run 25 min, then note what moved forward.', 698 kicker: 'Next step', 699 pauseAction: 'Pause', 700 pausedHint: 'Your next step: resume this session without changing priority.', 701 pausedKicker: 'Session paused', 702 priority: '1. Priority', 703 recommendationAction: 'Use this priority', 704 recommendationHint: 'Roxima suggests the first action on your list. Use it to move into focus, or tap it to choose another.', 705 recommendationKicker: 'Roxima suggestion', 706 recommendationLabel: 'Suggested priority', 707 quickStartAction: 'Start with 5 min',
708 quickStartAria: 'Start a 5-minute Focus session on {task}', 709 quickStartTitle: 'Start small without committing to the full duration', 710 readyHint: 'Your next step: run 25 min on this action.', 711 readyKicker: 'Priority chosen', 712 resumeAction: 'Resume', 713 reviewHint: 'Note what moved forward before starting another action.', 714 reviewKicker: 'After the session', 715 reviewCta: 'View the review', 716 reviewError: 'The note could not be saved.', 717 reviewHelper: 'This note stays in this browser with the session.', 718 reviewLater: 'Later', 719 reviewPlaceholder: 'E.g. draft clarified, decision sent, next step defined...', 720 reviewQuestion: 'What moved forward concretely?', 721 reviewSaved: 'Note saved locally.', 722 runningHint: 'Stay on this one action. At the end, note what really moved forward.', 723 runningKicker: 'Focus running', 724 startBreakAction: 'Start the break', 725 startFocusAction: 'Start {minutes} min', 726 review: '3. Review' 727 } 728}; 729 730const SETTINGS_FOCUSABLE_SELECTOR = 'a[href], button, input, select, textarea, [tabindex]'; 731const SETTINGS_PREVIOUS_TABINDEX_ATTRIBUTE = 'data-pomo-previous-tabindex'; 732const SETTINGS_NO_TABINDEX_VALUE = '__none__'; 733 734let containerEl, ringEl, timeEl, playBtn, resetBtn, settingsPanel; 735let mobileMiniEl = null; 736let globalCtx; 737let focusAmbience = null; 738 739function restorePomodoroAfterDayClosure(ctx = globalCtx) { 740 state.isActive = false; 741 if (state.interval) { 742 clearInterval(state.interval); 743 state.interval = null; 744 } 745 746 renderLayout(); 747 bindEvents(ctx); 748 bindSettingsEvents(); 749 setMode(state.mode, false); 750 updateUI(); 751 752 if (ctx?.bus) { 753 ctx.bus.emit('history:changed'); 754 } 755} 756 757function refreshDayClosureForCurrentDate(ctx = globalCtx) { 758 const isClosedScreenVisible = Boolean(containerEl?.querySelector('.pomo-closed-panel')); 759 if (!isClosedScreenVisible || isDayClosed()) return false; 760 761 restorePomodoroAfterDayClosure(ctx); 762 return true; 763} 764 765function getFocusGuideCopy() { 766 return FOCUS_GUIDE_COPY[i18n.getLang?.()] || FOCUS_GUIDE_COPY.en; 767} 768 769function getRecommendedTask() { 770 const todos = globalCtx?.store?.get('todos') || []; 771 const todoItems = todos.filter(todo => todo && typeof todo === 'object' && !todo.completed && String(todo.text || '').trim()); 772 const nextTask = todoItems[0] || null; 773 const taskText = String(nextTask?.text || '').trim(); 774 775 return nextTask && taskText 776 ? { ...nextTask, text: taskText } 777 : null; 778} 779 780function buildSelectedTaskButtonHtml(taskText) { 781 return ` 782 <div class="pomo-focus-task-selected"> 783 <span>${escapeHtml(taskText)}</span> 784 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true" focusable="false"> 785 <path d="M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z"></path> 786 </svg> 787 </div> 788 `; 789} 790 791function buildRecommendedTaskButtonHtml(taskText, label) { 792 return ` 793 <div class="pomo-focus-task-recommended"> 794 <small>${escapeHtml(label)}</small> 795 <span>${escapeHtml(taskText)}</span> 796 </div> 797 `; 798} 799 800function getFocusGuideState() { 801 const guideCopy = getFocusGuideCopy(); 802 const taskText = String(state.activeTask?.text || '').trim(); 803 804 if (!taskText) { 805 const recommendedTask = getRecommendedTask(); 806 if (recommendedTask) { 807 return { 808 buttonAria: `${guideCopy.recommendationLabel}: ${recommendedTask.text}`, 809 buttonHtml: buildRecommendedTaskButtonHtml(recommendedTask.text, guideCopy.recommendationLabel), 810 completeSteps: [], 811 hint: guideCopy.recommendationHint, 812 kicker: guideCopy.recommendationKicker, 813 recommended: true, 814 selected: false, 815 step: 'priority' 816 }; 817 } 818 819 return { 820 buttonAria: guideCopy.button, 821 buttonHtml: escapeHtml(guideCopy.button), 822 completeSteps: [], 823 hint: guideCopy.hint, 824 kicker: guideCopy.kicker, 825 recommended: false, 826 selected: false, 827 step: 'priority' 828 }; 829 } 830 831 if (!isFocusMode(state.mode)) { 832 return { 833 buttonAria: `${guideCopy.changeTaskLabel}: ${taskText}`, 834 buttonHtml: buildSelectedTaskButtonHtml(taskText || guideCopy.activeTaskFallback), 835 completeSteps: ['priority', 'focus'], 836 hint: guideCopy.reviewHint, 837 kicker: guideCopy.reviewKicker, 838 recommended: false, 839 selected: true, 840 step: 'review' 841 }; 842 } 843 844 const isPaused = !state.isActive && state.timeLeft < getDuration(); 845 846 return { 847 buttonAria: `${guideCopy.changeTaskLabel}: ${taskText}`, 848 buttonHtml: buildSelectedTaskButtonHtml(taskText || guideCopy.activeTaskFallback), 849 completeSteps: ['priority'], 850 hint: state.isActive 851 ? guideCopy.runningHint 852 : isPaused 853 ? guideCopy.pausedHint 854 : guideCopy.readyHint, 855 kicker: state.isActive 856 ? guideCopy.runningKicker 857 : isPaused 858 ? guideCopy.pausedKicker 859 : guideCopy.readyKicker, 860 recommended: false, 861 selected: true, 862 step: 'focus' 863 }; 864} 865 866function getActiveProjects() { 867 return objectifsStore.getPages() || []; 868} 869 870function getSelectedProject() { 871 const projects = getActiveProjects(); 872 if (!projects.length) return null; 873 874 const selectedId = state.settings.selectedProjectId; 875 const project = projects.find(p => p.id === selectedId); 876 return project || projects[0]; 877} 878 879function updateProjectSelectorUI() { 880 const roots = [document]; 881 if ('documentPictureInPicture' in window && window.documentPictureInPicture.window) { 882 roots.push(window.documentPictureInPicture.window.document); 883 } 884 885 const currentProj = getSelectedProject(); 886
887 roots.forEach(root => { 888 const iconEl = root.querySelector('#pomo-project-icon'); 889 const nameEl = root.querySelector('#pomo-project-name'); 890 const iconPipEl = root.querySelector('#pomo-project-icon-pip'); 891 892 if (currentProj) { 893 if (iconEl) iconEl.textContent = currentProj.icon || 'ð'; 894 if (nameEl) nameEl.textContent = currentProj.title || i18n.t('common_untitled'); 895 if (iconPipEl) iconPipEl.textContent = currentProj.icon || 'ð'; 896 } else { 897 if (iconEl) iconEl.textContent = 'ð'; 898 if (nameEl) nameEl.textContent = i18n.t('pomo_project_none'); 899 if (iconPipEl) iconPipEl.textContent = 'ð'; 900 } 901 }); 902} 903 904function showSingleProjectTooltip(targetEl) { 905 if (!targetEl) return; 906 907 // Prevent multiple tooltips 908 if (targetEl._hasTooltip) return; 909 targetEl._hasTooltip = true; 910 911 const tooltip = document.createElement('div'); 912 tooltip.className = 'pomo-single-project-tooltip'; 913 tooltip.textContent = "Vous avez que un seul projet."; 914 915 // Style tooltip dynamically 916 Object.assign(tooltip.style, { 917 position: 'absolute', 918 background: 'rgba(28, 28, 30, 0.95)', 919 color: '#fff', 920 fontSize: '0.78rem', 921 fontWeight: '600', 922 padding: '6px 10px', 923 borderRadius: '8px', 924 boxShadow: '0 4px 12px rgba(0,0,0,0.15)', 925 whiteSpace: 'nowrap', 926 pointerEvents: 'none', 927 opacity: '0', 928 transform: 'translate(-50%, -100%) translateY(8px)', 929 transition: 'opacity 0.2s ease, transform 0.2s ease', 930 zIndex: '99999' 931 }); 932 933 document.body.appendChild(tooltip); 934 935 // Position tooltip relative to targetEl 936 const rect = targetEl.getBoundingClientRect(); 937 938 // Center above the targetEl using translate(-50%, -100%) 939 tooltip.style.left = `${rect.left + window.scrollX + (rect.width / 2)}px`; 940 tooltip.style.top = `${rect.top + window.scrollY - 8}px`; 941 942 // Trigger animate-in 943 requestAnimationFrame(() => { 944 tooltip.style.opacity = '1'; 945 tooltip.style.transform = 'translate(-50%, -100%) translateY(0)'; 946 }); 947 948 // Animate out and remove 949 setTimeout(() => { 950 tooltip.style.opacity = '0'; 951 tooltip.style.transform = 'translate(-50%, -100%) translateY(-4px)'; 952 const onTransitionEnd = () => { 953 tooltip.removeEventListener('transitionend', onTransitionEnd); 954 tooltip.remove(); 955 targetEl._hasTooltip = false; 956 }; 957 tooltip.addEventListener('transitionend', onTransitionEnd); 958 }, 2000); 959} 960 961function cycleProject(direction, e) { 962 const projects = getActiveProjects(); 963 if (projects.length === 1) { 964 if (e && e.currentTarget) { 965 showSingleProjectTooltip(e.currentTarget); 966 } 967 return; 968 } 969 if (!projects.length) return; 970 971 const currentProj = getSelectedProject(); 972 const currentIndex = projects.findIndex(p => p.id === currentProj.id); 973 974 let nextIndex = currentIndex + direction; 975 if (nextIndex < 0) nextIndex = projects.length - 1; 976 if (nextIndex >= projects.length) nextIndex = 0; 977 978 state.settings.selectedProjectId = projects[nextIndex].id; 979 saveSettings(); 980 updateProjectSelectorUI(); 981} 982 983function syncFocusGuide() { 984 if (!containerEl) return; 985 986 const guideCopy = getFocusGuideCopy(); 987 const projectChoice = containerEl.querySelector('#pomo-project-choice'); 988 const projectSelector = containerEl.querySelector('#pomo-project-selector'); 989 const reviewPanel = containerEl.querySelector('#pomo-session-review'); 990 const pendingReview = state.pendingReview; 991 992 if (pendingReview) { 993 if (projectChoice) projectChoice.hidden = true; 994 if (projectSelector) projectSelector.style.display = 'none'; 995 if (reviewPanel) reviewPanel.hidden = false; 996 997 const taskText = pendingReview.taskText || guideCopy.activeTaskFallback; 998 const reviewQuestion = containerEl.querySelector('#pomo-review-question'); 999 const reviewInput = containerEl.querySelector('#pomo-review-input'); 1000 const reviewCta = containerEl.querySelector('#pomo-review-bilan'); 1001 const reviewLater = containerEl.querySelector('#pomo-review-later'); 1002 1003 if (reviewQuestion) reviewQuestion.textContent = guideCopy.reviewQuestion; 1004 if (reviewInput) { 1005 reviewInput.placeholder = guideCopy.reviewPlaceholder; 1006 if (document.activeElement !== reviewInput) { 1007 reviewInput.value = pendingReview.outcome || ''; 1008 } 1009 } 1010 if (reviewCta) reviewCta.querySelector('span').textContent = guideCopy.reviewCta; 1011 if (reviewLater) reviewLater.textContent = guideCopy.reviewLater; 1012 syncSessionReviewStatus(); 1013 return; 1014 } 1015 1016 const hasCurrentPriority = Boolean(getCurrentDailyPriority()); 1017 if (projectChoice) projectChoice.hidden = hasCurrentPriority; 1018 if (projectSelector) projectSelector.style.display = hasCurrentPriority ? 'none' : 'flex'; 1019 if (reviewPanel) reviewPanel.hidden = true; 1020 updateProjectSelectorUI(); 1021} 1022 1023function openPilotPriorityPicker() { 1024 pilotPriorityPickerReturnPending = true; 1025 globalCtx?.bus?.emit('app:show-panel', { panelId: 'region-projects' }); 1026 document.querySelector('.v2-dock button[data-target="v2-future"]')?.click(); 1027 globalCtx?.bus?.emit('ui:toast', i18n.t('pilot_progress_priority_required')); 1028} 1029 1030function returnToPilotFocusAfterPrioritySelection(items = []) { 1031 const pilotState = getTwoDayPilotState(); 1032 const currentUserId = String(globalCtx?.currentUser?.id || ''); 1033 const ownsPilot = Boolean(currentUserId && pilotState?.ownerId === currentUserId); 1034 const shouldReturn = ownsPilot && shouldReturnToPilotFocus({ 1035 pending: pilotPriorityPickerReturnPending, 1036 state: pilotState, 1037 items 1038 }); 1039 1040 if (!shouldReturn) return; 1041 1042 pilotPriorityPickerReturnPending = false;
1043 renderTwoDayPilotCard(); 1044 document.querySelector('.v2-dock button[data-target="v2-present"]')?.click(); 1045 requestAnimationFrame(() => { 1046 containerEl?.querySelector('#pomo-pilot-action')?.focus({ preventScroll: true }); 1047 }); 1048} 1049 1050function startFiveMinuteFocusSession({ 1051 eventAction = 'priority_quick_start_5min', 1052 eventLabel = 'daily_priority' 1053} = {}) { 1054 const current = getCurrentDailyPriority(); 1055 if (!current || current.missing || state.isActive) return false; 1056 if (!isFocusMode(state.mode)) setMode('focus', true); 1057 1058 const hasPausedProgress = state.timeLeft > 0 && state.timeLeft < getDuration(); 1059 if (!hasPausedProgress) { 1060 state.focusDurationOverrideSeconds = TWO_DAY_PILOT_MIN_FOCUS_SECONDS; 1061 state.timeLeft = TWO_DAY_PILOT_MIN_FOCUS_SECONDS; 1062 state.sessionEndMs = null; 1063 updateUI(); 1064 updateControls(); 1065 saveTimerState(); 1066 } 1067 1068 sendGAEvent(eventAction, 'activation', eventLabel); 1069 startTimer(); 1070 return true; 1071} 1072 1073function startExternalFocusSession({ durationMinutes = 25, source = 'link' } = {}) { 1074 const safeDurationMinutes = Math.min(Math.max(Number(durationMinutes) || 25, 1), 180); 1075 1076 if (state.isActive) { 1077 globalCtx?.bus?.emit('ui:toast', i18n.t('pomo_external_focus_already_active')); 1078 return false; 1079 } 1080 if (isDayClosed()) { 1081 globalCtx?.bus?.emit('ui:toast', i18n.t('pomo_alert_closed_desc')); 1082 return false; 1083 } 1084 if (isFocusMode(state.mode) && getCurrentDailyPriority()?.missing) { 1085 globalCtx?.bus?.emit('ui:toast', 'Cette priorité doit être resynchronisée ou retirée avant de lancer un focus.'); 1086 return false; 1087 } 1088 1089 const currentDuration = getDuration(); 1090 const hasPausedFocus = isFocusMode(state.mode) 1091 && state.timeLeft > 0 1092 && state.timeLeft < currentDuration; 1093 1094 if (!hasPausedFocus) { 1095 if (state.mode !== 'focus') setMode('focus', true); 1096 state.focusDurationOverrideSeconds = safeDurationMinutes * 60; 1097 state.timeLeft = state.focusDurationOverrideSeconds; 1098 state.sessionEndMs = null; 1099 updateModeButtons(); 1100 updateUI(); 1101 updateControls(); 1102 saveTimerState(); 1103 } 1104 1105 startTimer(); 1106 globalCtx?.bus?.emit( 1107 'ui:toast', 1108 hasPausedFocus 1109 ? i18n.t('pomo_external_focus_resumed') 1110 : i18n.t('pomo_external_focus_started').replace('{minutes}', String(safeDurationMinutes)) 1111 ); 1112 sendGAEvent( 1113 hasPausedFocus ? 'external_focus_resumed' : 'external_focus_started', 1114 'productivity', 1115 ['nfc', 'qr', 'link'].includes(source) ? source : 'link' 1116 ); 1117 return true; 1118} 1119 1120function startPilotFocusSession() { 1121 const current = getCurrentDailyPriority(); 1122 if (!current || current.missing) { 1123 openPilotPriorityPicker(); 1124 return; 1125 } 1126 1127 const pilotState = getTwoDayPilotState(); 1128 startFiveMinuteFocusSession({ 1129 eventAction: 'pilot_focus_started', 1130 eventLabel: pilotState?.campaign || 'two_day_pilot' 1131 }); 1132 renderTwoDayPilotCard(); 1133} 1134 1135function isCompletedPilotResumeTask(task) { 1136 const executionStatus = String(task?.executionStatus ?? task?.execution_status ?? '').toLowerCase(); 1137 return Boolean(task?.checked || task?.completed || executionStatus === 'done'); 1138} 1139 1140function getClosedDayPilotResumeContext() { 1141 const pilotState = getTwoDayPilotState(); 1142 const currentUserId = String(globalCtx?.currentUser?.id || '').trim(); 1143 if ( 1144 !currentUserId 1145 || !pilotState 1146 || pilotState.ownerId !== currentUserId 1147 || getTwoDayPilotStage(pilotState) !== 'day2' 1148 ) { 1149 return null; 1150 } 1151 1152 const resumeTaskId = String(pilotState.resumeTaskId || '').trim(); 1153 if (!resumeTaskId) return null; 1154 1155 const task = resolveTaskReference(resumeTaskId); 1156 if (!task || task.missing || isCompletedPilotResumeTask(task)) return null; 1157 1158 return { pilotState, task }; 1159} 1160 1161async function restorePilotTaskAndStart(pilotState) { 1162 const resumeTaskId = String(pilotState?.resumeTaskId || '').trim(); 1163 if ( 1164 pilotResumeTaskPending 1165 || getTwoDayPilotStage(pilotState) !== 'day2' 1166 || !resumeTaskId 1167 ) { 1168 return false;
1169 } 1170 1171 const task = resolveTaskReference(resumeTaskId); 1172 if (!task || task.missing || isCompletedPilotResumeTask(task)) return false; 1173 1174 pilotResumeTaskPending = true; 1175 const actionButton = containerEl?.querySelector('#pomo-pilot-action'); 1176 if (actionButton) actionButton.disabled = true; 1177 1178 try { 1179 const result = await addTaskToToday(task.taskId); 1180 let restored = result?.item; 1181 let current = getCurrentDailyPriority(); 1182 if ( 1183 restored 1184 && !restored.missing 1185 && current 1186 && !current.missing 1187 && String(current.taskId) !== String(restored.taskId) 1188 ) { 1189 const orderedTaskIds = getTodayPriorities() 1190 .filter((item) => item && !item.missing) 1191 .map((item) => String(item.taskId)); 1192 const restoredTaskId = String(restored.taskId); 1193 if (orderedTaskIds.includes(restoredTaskId)) { 1194 await reorderTodayPriorities([ 1195 restoredTaskId, 1196 ...orderedTaskIds.filter((taskId) => taskId !== restoredTaskId) 1197 ]); 1198 restored = resolveTaskReference(restoredTaskId); 1199 current = getCurrentDailyPriority(); 1200 } 1201 } 1202 const restoredIsCurrent = Boolean( 1203 restored 1204 && !restored.missing 1205 && !isCompletedPilotResumeTask(restored) 1206 && current 1207 && !current.missing 1208 && !isCompletedPilotResumeTask(current) 1209 && String(current.taskId) === String(restored.taskId) 1210 ); 1211 if (!restoredIsCurrent) return false; 1212 1213 syncDailyPriorityState(); 1214 1215 return startFiveMinuteFocusSession({ 1216 eventAction: 'pilot_focus_resumed_from_previous_task', 1217 eventLabel: pilotState.campaign || 'two_day_pilot' 1218 }); 1219 } catch (error) { 1220 if (!['TASK_NOT_FOUND', 'MAX_PRIORITIES'].includes(error?.code)) { 1221 console.warn('[Pomodoro] Unable to restore the Day 1 task for Day 2.', error); 1222 } 1223 return false; 1224 } finally { 1225 pilotResumeTaskPending = false; 1226 } 1227} 1228 1229const timerTabId = generateUUID(); 1230 1231function getAssistedReturnMarkerFromUrl(ownerId) { 1232 try { 1233 const params = new URLSearchParams(window.location.search); 1234 const type = String(params.get(TWO_DAY_PILOT_RETURN_SOURCE_PARAM) || '').trim(); 1235 if (!['calendar_reminder', 'human_reminder'].includes(type)) return null; 1236 const now = new Date(); 1237 const targetDate = String(params.get(TWO_DAY_PILOT_RETURN_TARGET_PARAM) || '').trim() 1238 || getPilotLocalDate(now); 1239 return buildTwoDayPilotAssistanceMarker({ 1240 ownerId, 1241 type, 1242 targetDate, 1243 now 1244 }); 1245 } catch (_) { 1246 return null; 1247 } 1248} 1249 1250function clearAssistedReturnParams() { 1251 try { 1252 const url = new URL(window.location.href); 1253 url.searchParams.delete(TWO_DAY_PILOT_RETURN_SOURCE_PARAM); 1254 url.searchParams.delete(TWO_DAY_PILOT_RETURN_TARGET_PARAM); 1255 window.history.replaceState({}, document.title, `${url.pathname}${url.search}${url.hash}`); 1256 } catch (_) { 1257 // The marker is already captured locally/server-side; URL cleanup is cosmetic. 1258 } 1259} 1260 1261async function captureAssistedReturnFromUrl(ownerId) { 1262 const urlMarker = getAssistedReturnMarkerFromUrl(ownerId); 1263 if (!urlMarker) return { marker: null, durable: true }; 1264 1265 const pendingMarker = pilotPendingAssistedReturnMarker; 1266 const marker = pendingMarker 1267 && pendingMarker.ownerId === urlMarker.ownerId 1268 && pendingMarker.type === urlMarker.type 1269 && pendingMarker.targetDate === urlMarker.targetDate 1270 ? pendingMarker 1271 : urlMarker; 1272 pilotPendingAssistedReturnMarker = marker; 1273 1274 const captureKey = `${marker.ownerId}:${marker.type}:${marker.targetDate}:${marker.createdAt}`; 1275 if (pilotAssistedReturnCaptureKey === captureKey) { 1276 clearAssistedReturnParams(); 1277 return { marker, durable: true }; 1278 } 1279 1280 const result = await persistTwoDayPilotAssistance(ownerId, marker); 1281 if (result.durable) { 1282 pilotAssistedReturnCaptureKey = captureKey; 1283 clearAssistedReturnParams(); 1284 } 1285 return { marker, durable: result.durable === true }; 1286} 1287 1288function recordPilotReminderAssistance(ownerId) { 1289 const marker = buildTwoDayPilotAssistanceMarker({ 1290 ownerId, 1291 type: 'calendar_reminder', 1292 now: new Date() 1293 }); 1294 if (!marker) return; 1295 void persistTwoDayPilotAssistance(ownerId, marker); 1296} 1297 1298async function runTwoDayPilotServerRefresh(ownerId, { 1299 flushHistory = false, 1300 resumeTaskId = null 1301} = {}, isCurrent = () => true) { 1302 const assistedReturn = await captureAssistedReturnFromUrl(ownerId); 1303 if (!isCurrent()) {
1304 return { state: null, advanced: false, evidence: null, snapshot: null, stale: true }; 1305 } 1306 const snapshot = await loadTwoDayPilotServerSnapshot(ownerId, { flushHistory }); 1307 if (!isCurrent()) { 1308 return { state: null, advanced: false, evidence: null, snapshot, stale: true }; 1309 } 1310 1311 if (!snapshot.ok) { 1312 pilotServerEvidenceReadyOwnerId = null; 1313 renderTwoDayPilotCard(); 1314 return { state: null, advanced: false, evidence: null, snapshot }; 1315 } 1316 1317 const evidence = buildTwoDayPilotServerEvidence(snapshot.sessions, { 1318 ownerId, 1319 assistance: snapshot.assistance || assistedReturn.marker, 1320 assistanceKnown: snapshot.assistanceKnown && assistedReturn.durable !== false 1321 }); 1322 const result = await reconcileTwoDayPilotFromServerEvidenceOnce(evidence, { 1323 resumeTaskId, 1324 now: new Date() 1325 }); 1326 pilotServerEvidenceReadyOwnerId = ownerId; 1327 renderTwoDayPilotCard(); 1328 globalCtx?.bus?.emit('two-day-pilot:changed'); 1329 return { ...result, evidence, snapshot }; 1330} 1331 1332function refreshTwoDayPilotFromServer(options = {}) { 1333 const ownerId = String(globalCtx?.currentUser?.id || '').trim(); 1334 if (!ownerId) { 1335 pilotServerEvidenceReadyOwnerId = null; 1336 renderTwoDayPilotCard(); 1337 return Promise.resolve({ state: null, advanced: false, evidence: null, snapshot: null }); 1338 } 1339 1340 // A completed Focus must own the reconciliation that attributes J1/J2. 1341 // Invalidate any background hydration already reading an older snapshot; 1342 // it can finish its network request but cannot update local evidence. 1343 if (options.flushHistory === true) pilotServerRefreshCoordinator.invalidate(); 1344 1345 return pilotServerRefreshCoordinator 1346 .request(ownerId, ({ isCurrent }) => ( 1347 runTwoDayPilotServerRefresh(ownerId, options, isCurrent) 1348 )) 1349 .catch((error) => { 1350 if (String(globalCtx?.currentUser?.id || '').trim() === ownerId) { 1351 pilotServerEvidenceReadyOwnerId = null; 1352 renderTwoDayPilotCard(); 1353 } 1354 console.warn('[Pomodoro] Unable to refresh server-confirmed J1/J2 evidence.', error); 1355 return { 1356 state: null, 1357 advanced: false, 1358 evidence: null, 1359 snapshot: { ok: false, historyPersisted: false, reason: 'snapshot_exception', error } 1360 }; 1361 }); 1362} 1363 1364function renderTwoDayPilotCard() { 1365 pilotRenderedLocalDate = getPilotLocalDate(); 1366 const root = containerEl?.querySelector('#pomo-pilot-progress'); 1367 if (!root) return; 1368 1369 const pilotState = getTwoDayPilotState(); 1370 const currentUserId = String(globalCtx?.currentUser?.id || ''); 1371 if ( 1372 !currentUserId 1373 || pilotServerEvidenceReadyOwnerId !== currentUserId 1374 || !pilotState 1375 || pilotState.ownerId !== currentUserId 1376 || pilotState.serverConfirmed !== true 1377 ) { 1378 root.hidden = true; 1379 root.innerHTML = ''; 1380 return; 1381 } 1382 const dismissalContextKey = `${currentUserId}:${pilotRenderedLocalDate}`; 1383 if (pilotDismissedContextKey === dismissalContextKey) { 1384 root.hidden = true; 1385 return; 1386 } 1387 1388 // The pilot state is owner-scoped. The general Pomodoro history is not, 1389 // so it must never decide whether another user's J+1 invitation is shown.
1390 const stage = getTwoDayPilotStage(pilotState); 1391 1392 if (stage === 'inactive') { 1393 root.hidden = true; 1394 root.innerHTML = ''; 1395 return; 1396 } 1397 const copyByStage = { 1398 day1: { 1399 badge: 'pilot_progress_day1_badge', 1400 title: 'pilot_progress_day1_title', 1401 copy: 'pilot_progress_day1_copy' 1402 }, 1403 wait_day2: { 1404 badge: 'pilot_progress_wait_badge', 1405 title: 'pilot_progress_wait_title', 1406 copy: 'pilot_progress_wait_copy' 1407 }, 1408 day2: { 1409 badge: 'pilot_progress_day2_badge', 1410 title: 'pilot_progress_day2_title', 1411 copy: 'pilot_progress_day2_copy' 1412 }, 1413 complete: { 1414 badge: 'pilot_progress_complete_badge', 1415 title: 'pilot_progress_complete_title', 1416 copy: 'pilot_progress_complete_copy' 1417 } 1418 }; 1419 const organicCopyByStage = { 1420 wait_day2: { 1421 badge: 'organic_progress_wait_badge', 1422 title: 'organic_progress_wait_title', 1423 copy: 'organic_progress_wait_copy' 1424 }, 1425 day2: { 1426 badge: 'organic_progress_day2_badge', 1427 title: 'organic_progress_day2_title', 1428 copy: 'organic_progress_day2_copy' 1429 }, 1430 complete: { 1431 badge: 'organic_progress_complete_badge', 1432 title: 'organic_progress_complete_title', 1433 copy: 'organic_progress_complete_copy' 1434 } 1435 }; 1436 const copy = pilotState.campaign === ORGANIC_TWO_DAY_PILOT_CAMPAIGN 1437 ? (organicCopyByStage[stage] || copyByStage[stage]) 1438 : copyByStage[stage]; 1439 if (!copy) { 1440 root.hidden = true; 1441 root.innerHTML = ''; 1442 return; 1443 } 1444 1445 const current = getCurrentDailyPriority(); 1446 const actionable = stage === 'day1' || stage === 'day2'; 1447 const canResumePreviousTask = stage === 'day2' && Boolean(pilotState.resumeTaskId); 1448 const reminderOptions = stage === 'wait_day2' 1449 ? { 1450 google: buildTwoDayPilotReminderUrl({ 1451 title: i18n.t('pilot_progress_reminder_title'), 1452 details: i18n.t('pilot_progress_reminder_details') 1453 }), 1454 calendarFile: buildTwoDayPilotReminderIcsUrl({ 1455 title: i18n.t('pilot_progress_reminder_title'), 1456 details: i18n.t('pilot_progress_reminder_details') 1457 }) 1458 } 1459 : { google: null, calendarFile: null }; 1460 const hasPausedProgress = isFocusMode(state.mode) 1461 && !state.isActive 1462 && state.timeLeft > 0 1463 && state.timeLeft < getDuration(); 1464 const actionLabel = (!current || current.missing) && !canResumePreviousTask 1465 ? i18n.t('pilot_progress_choose_cta') 1466 : hasPausedProgress 1467 ? i18n.t('pilot_progress_resume_cta') 1468 : i18n.t('pilot_progress_start_cta'); 1469 1470 root.hidden = false; 1471 root.dataset.stage = stage; 1472 root.setAttribute('aria-label', i18n.t('pilot_progress_aria')); 1473 root.innerHTML = ` 1474 <button class="pomo-pilot-progress__close" type="button" aria-label="Fermer la notification" title="Fermer"> 1475 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg> 1476 </button> 1477 <div class="pomo-pilot-progress__body"> 1478 <span class="pomo-pilot-progress__badge">${escapeHtml(i18n.t(copy.badge))}</span> 1479 <strong>${escapeHtml(i18n.t(copy.title))}</strong> 1480 <p>${escapeHtml(i18n.t(copy.copy))}</p> 1481 </div> 1482 ${actionable 1483 ? (state.isActive 1484 ? `<span class="pomo-pilot-progress__status is-running" aria-label="${escapeHtml(i18n.t('pilot_progress_running'))}">â¦</span>` 1485 : `<button id="pomo-pilot-action" class="pomo-pilot-progress__action" type="button">${escapeHtml(actionLabel)}</button>`) 1486 : (reminderOptions.google || reminderOptions.calendarFile) 1487 ? `<span class="pomo-pilot-progress__actions"> 1488 ${reminderOptions.google 1489 ? `<a id="pomo-pilot-reminder" class="pomo-pilot-progress__action pomo-pilot-progress__action--secondary" href="${escapeHtml(reminderOptions.google)}" target="_blank" rel="noopener noreferrer">${escapeHtml(i18n.t('pilot_progress_reminder_google_cta'))}</a>` 1490 : ''} 1491 ${reminderOptions.calendarFile 1492 ? `<a id="pomo-pilot-reminder-file" class="pomo-pilot-progress__calendar-file" href="${escapeHtml(reminderOptions.calendarFile)}" download="roxima-focus-jour-2.ics" type="text/calendar">${escapeHtml(i18n.t('pilot_progress_reminder_file_cta'))}</a>` 1493 : ''} 1494 </span>` 1495 : '<span class="pomo-pilot-progress__status" aria-hidden="true">â</span>'} 1496 `; 1497 1498 root.querySelector('.pomo-pilot-progress__close')?.addEventListener('click', (e) => { 1499 e.stopPropagation(); 1500 pilotDismissedContextKey = dismissalContextKey; 1501 root.hidden = true; 1502 }); 1503 1504 root.querySelector('#pomo-pilot-action')?.addEventListener('click', async () => { 1505 if (pilotResumeTaskPending) return; 1506 if (!current || current.missing) { 1507 if (await restorePilotTaskAndStart(pilotState)) { 1508 renderTwoDayPilotCard(); 1509 return; 1510 } 1511 openPilotPriorityPicker(); 1512 return; 1513 } 1514 startPilotFocusSession(); 1515 }); 1516 1517 root.querySelector('#pomo-pilot-reminder')?.addEventListener('click', () => { 1518 recordPilotReminderAssistance(currentUserId); 1519 sendGAEvent('pilot_day2_reminder_opened', 'activation', `${pilotState.campaign || 'two_day_pilot'}:google`); 1520 }); 1521 1522 root.querySelector('#pomo-pilot-reminder-file')?.addEventListener('click', () => { 1523 recordPilotReminderAssistance(currentUserId); 1524 sendGAEvent('pilot_day2_reminder_opened', 'activation', `${pilotState.campaign || 'two_day_pilot'}:ics`); 1525 }); 1526} 1527 1528function refreshTwoDayPilotCardForCurrentDay(now = new Date()) { 1529 if (!shouldRefreshTwoDayPilotForNewDay(pilotRenderedLocalDate, now)) return false;
1530 1531 // A dismissal belongs to one account and one local day. If Roxima stays 1532 // open overnight, the Day 2 invitation must become visible again. 1533 pilotDismissedContextKey = null; 1534 pilotRenderedLocalDate = getPilotLocalDate(now); 1535 renderTwoDayPilotCard(); 1536 return true; 1537} 1538 1539function renderDailyPriorityCard() { 1540 const root = containerEl?.querySelector('#pomo-daily-priority'); 1541 if (!root) return; 1542 if (dailyCompletionFeedbackActive && dailyActionPending) return; 1543 const focusedControlId = root.contains(document.activeElement) ? document.activeElement.id : null; 1544 const restoreControlId = focusedControlId || dailyRestoreFocusId; 1545 const restoreControlFocus = () => { 1546 if (!restoreControlId) return; 1547 const nextControl = root.querySelector(`#${CSS.escape(restoreControlId)}`); 1548 nextControl?.focus?.(); 1549 if (dailyRestoreFocusId === restoreControlId) dailyRestoreFocusId = null; 1550 }; 1551 1552 const priorities = getTodayPriorities(); 1553 const dayProgress = getDayProgressSnapshot(priorities, { syncPlan: true }); 1554 const dayProgressMarkup = buildDayProgressMarkup(dayProgress); 1555 const current = priorities[0] || null; 1556 1557 if (!current) { 1558 dailyPrioritySequence = []; 1559 root.innerHTML = ` 1560 <div class="pomo-daily-empty"> 1561 ${dayProgressMarkup} 1562 <strong>${escapeHtml(i18n.t('pomo_daily_empty_title'))}</strong> 1563 <p>${escapeHtml(i18n.t('pomo_daily_empty_copy'))}</p> 1564 <button id="pomo-choose-daily-priority" class="pomo-daily-secondary pomo-daily-secondary--primary" type="button"> 1565 <span>${escapeHtml(i18n.t('pomo_daily_open_project'))}</span> 1566 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"> 1567 <path d="M5 12h14"></path> 1568 <path d="m13 6 6 6-6 6"></path> 1569 </svg> 1570 </button> 1571 </div> 1572 `; 1573 root.querySelector('#pomo-choose-daily-priority')?.addEventListener('click', () => { 1574 globalCtx?.bus?.emit('app:show-panel', { panelId: 'region-projects' }); 1575 document.querySelector('.v2-dock button[data-target="v2-future"]')?.click(); 1576 }); 1577 restoreControlFocus(); 1578 return; 1579 } 1580 1581 if (current.missing) { 1582 root.innerHTML = ` 1583 <div class="pomo-daily-empty is-unavailable"> 1584 ${dayProgressMarkup} 1585 <strong>${escapeHtml(i18n.t('pomo_daily_missing_title'))}</strong> 1586 <p>${escapeHtml(i18n.t('pomo_daily_missing_copy'))}</p> 1587 <button id="pomo-open-missing-priority" class="pomo-daily-secondary" type="button">${escapeHtml(i18n.t('pomo_daily_open_project'))}</button> 1588 </div> 1589 `; 1590 root.querySelector('#pomo-open-missing-priority')?.addEventListener('click', () => { 1591 globalCtx?.bus?.emit('app:show-panel', { panelId: 'region-projects' }); 1592 document.querySelector('.v2-dock button[data-target="v2-future"]')?.click(); 1593 }); 1594 restoreControlFocus(); 1595 return; 1596 } 1597 1598 const priority = getGeneralPriorityPresentation(current.generalPriority); 1599 const breakActive = state.isActive && !isFocusMode(state.mode); 1600 const actionBusyAttributes = dailyActionPending 1601 ? ' disabled aria-busy="true"' 1602 : (breakActive ? ' disabled' : ''); 1603 1604 const rawTitle = current.text || current.title || i18n.t('daily_priority_untitled_task'); 1605 const cleanTitle = rawTitle.replace(/\s*-\s*\d+\s*min\b/gi, '').replace(/\s*\(\d+\s*min\)/gi, '').trim() || rawTitle; 1606 const projectIcon = current.projectIcon || 'ð'; 1607 const currentPosition = getDailyPriorityPosition(priorities, current.taskId); 1608 const guideCopy = getFocusGuideCopy(); 1609 const canQuickStart = !state.isActive 1610 && isFocusMode(state.mode) 1611 && state.timeLeft === getDuration() 1612 && !dailyActionPending; 1613 const navigationDisabledAttributes = priorities.length <= 1 1614 ? ' disabled' 1615 : actionBusyAttributes; 1616 1617 const flagSvg = `<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z"></path><line x1="4" y1="22" x2="4" y2="15"></line></svg>`; 1618 const previousSvg = `<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.25" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="lucide lucide-chevron-left"><path d="m15 18-6-6 6-6"></path></svg>`; 1619 const nextSvg = `<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.25" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="lucide lucide-chevron-right"><path d="m9 18 6-6-6-6"></path></svg>`; 1620 const quickStartSvg = `<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="m13 2-9 12h7l-1 8 9-12h-7l1-8Z"></path></svg>`; 1621 1622 root.innerHTML = ` 1623 <div class="pomo-daily-current"> 1624 ${dayProgressMarkup} 1625 <div class="pomo-daily-heading"> 1626 <div class="pomo-daily-context">
1627 <span class="pomo-daily-label">${escapeHtml(i18n.t('pomo_daily_priority_label'))}</span> 1628 <span class="pomo-daily-celebration" role="status" aria-live="polite" hidden>${escapeHtml(i18n.t('pomo_daily_celebration'))}</span> 1629 </div> 1630 <div class="pomo-daily-controls"> 1631 <div class="pomo-daily-navigation" aria-label="${escapeHtml(i18n.t('pomo_daily_navigation'))}"> 1632 <span class="pomo-daily-counter" aria-label="${escapeHtml(translatePomodoro('pomo_daily_counter_aria', { current: currentPosition, total: priorities.length }))}">${escapeHtml(translatePomodoro('pomo_daily_counter', { current: currentPosition, total: priorities.length }))}</span> 1633 <button id="pomo-previous-current-priority" class="pomo-daily-nav-button" type="button" aria-label="${escapeHtml(i18n.t('pomo_daily_previous'))}" title="${escapeHtml(i18n.t('pomo_daily_previous'))}"${navigationDisabledAttributes}>${previousSvg}</button> 1634 <button id="pomo-next-current-priority" class="pomo-daily-nav-button" type="button" aria-label="${escapeHtml(i18n.t('pomo_daily_next'))}" title="${escapeHtml(i18n.t('pomo_daily_next'))}"${navigationDisabledAttributes}>${nextSvg}</button> 1635 </div>
1636 ${priority ? `<span class="pomo-daily-flag ${priority.className}" title="${escapeHtml(translatePomodoro('pomo_daily_priority_title', { priority: priority.title }))}">${flagSvg}</span>` : ''} 1637 </div> 1638 </div> 1639 <div class="pomo-daily-task-line"> 1640 <input id="pomo-complete-current-priority" class="pomo-daily-complete" type="checkbox" aria-label="${escapeHtml(translatePomodoro('pomo_daily_complete_aria', { task: cleanTitle }))}" title="${escapeHtml(i18n.t('pomo_daily_complete_title'))}"${actionBusyAttributes}> 1641 <button class="pomo-daily-title" id="pomo-open-current-priority" type="button" title="${escapeHtml(i18n.t('pomo_daily_open_title'))}"> 1642 <span class="pomo-daily-project-icon" aria-hidden="true">${escapeHtml(projectIcon)}</span><span>${escapeHtml(cleanTitle)}</span> 1643 </button> 1644 ${canQuickStart ? ` 1645 <button 1646 id="pomo-quick-start-current-priority" 1647 class="pomo-daily-quick-start" 1648 type="button" 1649 aria-label="${escapeHtml(guideCopy.quickStartAria.replace('{task}', cleanTitle))}" 1650 title="${escapeHtml(guideCopy.quickStartTitle)}" 1651 > 1652 ${quickStartSvg} 1653 <span>${escapeHtml(guideCopy.quickStartAction)}</span> 1654 </button> 1655 ` : ''} 1656 </div> 1657 </div> 1658 `; 1659 1660 const runAsyncAction = async (button, action, successMessage) => { 1661 if (!button || button.disabled || dailyActionPending) return; 1662 dailyActionPending = true; 1663 dailyRestoreFocusId = button.id || null; 1664 button.disabled = true; 1665 button.setAttribute('aria-busy', 'true'); 1666 const preserveCompletionCheck = button.classList?.contains('pomo-daily-complete'); 1667 if (preserveCompletionCheck) { 1668 dailyCompletionFeedbackActive = true; 1669 root.querySelector('.pomo-daily-current')?.classList.add('is-completing'); 1670 const celebration = root.querySelector('.pomo-daily-celebration'); 1671 if (celebration) celebration.hidden = false; 1672 root.querySelectorAll('.pomo-daily-nav-button').forEach((navigationButton) => { 1673 navigationButton.disabled = true; 1674 }); 1675 } else { 1676 renderDailyPriorityCard(); 1677 } 1678 try { 1679 await action(); 1680 if (successMessage) globalCtx?.bus?.emit('ui:toast', successMessage); 1681 } catch (error) { 1682 console.error('[Daily priorities]', error); 1683 globalCtx?.bus?.emit('ui:toast', error?.message || "L'action n'a pas pu être enregistrée. Réessayez."); 1684 } finally { 1685 dailyActionPending = false; 1686 dailyCompletionFeedbackActive = false; 1687 if (button.isConnected) { 1688 button.disabled = false; 1689 button.removeAttribute('aria-busy'); 1690 } 1691 renderDailyPriorityCard(); 1692 } 1693 }; 1694 1695 root.querySelector('#pomo-open-current-priority')?.addEventListener('click', () => { 1696 globalCtx?.bus?.emit('daily-priority:open-source', { taskId: current.taskId }); 1697 }); 1698 1699 root.querySelector('#pomo-quick-start-current-priority')?.addEventListener('click', () => { 1700 startFiveMinuteFocusSession(); 1701 }); 1702 1703 const completeButton = root.querySelector('#pomo-complete-current-priority'); 1704 completeButton?.addEventListener('change', () => { 1705 if (!completeButton.checked) return; 1706 const celebrationStartedAt = Date.now(); 1707 runAsyncAction( 1708 completeButton, 1709 async () => { 1710 if (state.isActive) pauseTimer(); 1711 await completeCurrentPriority(); 1712 markDayProgressTaskCompleted(current); 1713 const remainingCelebrationMs = Math.max(0, COMPLETION_CELEBRATION_MS - (Date.now() - celebrationStartedAt)); 1714 if (remainingCelebrationMs > 0) { 1715 await new Promise((resolve) => window.setTimeout(resolve, remainingCelebrationMs)); 1716 } 1717 resetTimer(); 1718 }, 1719 'Tâche terminée. La priorité suivante est prête.' 1720 ); 1721 }); 1722 1723 const navigatePriority = (button, direction) => runAsyncAction( 1724 button, 1725 async () => { 1726 if (state.isActive) pauseTimer(); 1727 const orderedTaskIds = direction === 'previous' 1728 ? [priorities.at(-1).taskId, ...priorities.slice(0, -1).map((item) => item.taskId)] 1729 : [...priorities.slice(1).map((item) => item.taskId), priorities[0].taskId]; 1730 await reorderTodayPriorities(orderedTaskIds); 1731 resetTimer(); 1732 }, 1733 direction === 'previous' ? 'Tâche précédente activée.' : 'Tâche suivante activée.' 1734 ); 1735 1736 const previousButton = root.querySelector('#pomo-previous-current-priority'); 1737 previousButton?.addEventListener('click', () => navigatePriority(previousButton, 'previous')); 1738 1739 const nextButton = root.querySelector('#pomo-next-current-priority'); 1740 nextButton?.addEventListener('click', () => navigatePriority(nextButton, 'next')); 1741 restoreControlFocus(); 1742} 1743 1744function syncDailyPriorityState({ preserveTimer = true } = {}) { 1745 const current = getCurrentDailyPriority(); 1746 const previousDuration = getDuration(); 1747 const wasIdle = !state.isActive && state.timeLeft === previousDuration; 1748 1749 if (!current) { 1750 const hadDailyPriority = Boolean(state.activeTask?.dailyPriority); 1751 if (hadDailyPriority && state.isActive && isFocusMode(state.mode)) pauseTimer(); 1752 if (hadDailyPriority) { 1753 state.activeTask = null; 1754 state.focusDurationOverrideSeconds = null; 1755 if (isFocusMode(state.mode)) resetTimer(); 1756 } 1757 renderDailyPriorityCard(); 1758 syncFocusGuide(); 1759 updateControls(); 1760 saveTimerState(); 1761 return; 1762 } 1763 1764 if (current.missing) { 1765 const hadDailyPriority = Boolean(state.activeTask?.dailyPriority); 1766 if (hadDailyPriority && state.isActive && isFocusMode(state.mode)) pauseTimer(); 1767 state.activeTask = null; 1768 state.focusDurationOverrideSeconds = null; 1769 if (hadDailyPriority && isFocusMode(state.mode)) resetTimer(); 1770 renderDailyPriorityCard(); 1771 syncFocusGuide(); 1772 updateControls(); 1773 saveTimerState(); 1774 return; 1775 } 1776 1777 const changedTask = String(state.activeTask?.taskId || '') !== String(current.taskId); 1778 const changedWhileActive = changedTask && state.isActive && isFocusMode(state.mode); 1779 if (changedWhileActive) pauseTimer(); 1780 if (changedTask) state.focusDurationOverrideSeconds = null; 1781 state.activeTask = { 1782 ...current, 1783 id: current.sourceBlockId || current.taskId, 1784 text: current.text || current.title, 1785 dailyPriority: true 1786 }; 1787 1788 if (current.sourcePageId && state.settings.selectedProjectId !== current.sourcePageId) { 1789 state.settings.selectedProjectId = current.sourcePageId; 1790 saveSettings(); 1791 } 1792 1793 const durationChanged = previousDuration !== getDuration(); 1794 if (isFocusMode(state.mode) && ( 1795 changedWhileActive || 1796 changedTask || 1797 (!state.isActive && wasIdle && durationChanged) 1798 )) { 1799 state.timeLeft = getDuration(); 1800 state.sessionEndMs = null; 1801 updateUI(); 1802 } 1803 1804 renderDailyPriorityCard(); 1805 syncFocusGuide(); 1806 updateControls(); 1807 saveTimerState(); 1808} 1809
1810function syncSessionReviewStatus() { 1811 if (!containerEl || !state.pendingReview) return; 1812 1813 const guideCopy = getFocusGuideCopy(); 1814 const helper = containerEl.querySelector('#pomo-review-helper'); 1815 const count = containerEl.querySelector('#pomo-review-count'); 1816 const outcomeLength = String(state.pendingReview.outcome || '').length; 1817 1818 if (helper) { 1819 helper.textContent = state.pendingReview.saveError 1820 ? guideCopy.reviewError 1821 : state.pendingReview.saved 1822 ? guideCopy.reviewSaved 1823 : guideCopy.reviewHelper; 1824 helper.classList.toggle('is-error', Boolean(state.pendingReview.saveError)); 1825 } 1826 if (count) count.textContent = `${outcomeLength}/180`; 1827} 1828 1829export function mount(container, ctx) { 1830 containerEl = container; 1831 globalCtx = ctx; 1832 pomodoroStore.setBus(ctx.bus); 1833 const mountedOwnerId = String(ctx.currentUser?.id || '').trim(); 1834 if (pilotServerRefreshOwnerId !== mountedOwnerId) { 1835 pilotServerRefreshOwnerId = mountedOwnerId || null; 1836 pilotServerRefreshCoordinator.invalidate(); 1837 pilotAssistedReturnCaptureKey = null; 1838 pilotPendingAssistedReturnMarker = null; 1839 } 1840 if (ctx.currentUser?.id) { 1841 claimTwoDayPilotForUser(ctx.currentUser.id); 1842 pilotServerEvidenceReadyOwnerId = null; 1843 void refreshTwoDayPilotFromServer(); 1844 } 1845 1846 if (!browserFeedbackListenerBound) { 1847 window.addEventListener(ROXIMA_BROWSER_FEEDBACK_EVENT, (event) => { 1848 handleBrowserFeedback(event?.detail); 1849 }); 1850 browserFeedbackListenerBound = true; 1851 } 1852 1853 if (!timerStorageListenerBound) { 1854 window.addEventListener('storage', handlePomodoroTimerStorageEvent); 1855 timerStorageListenerBound = true; 1856 } 1857 1858 // Load Styles 1859 const link = document.createElement('link'); 1860 link.rel = 'stylesheet'; 1861 link.href = new URL('./styles.css?v=37', import.meta.url).href; 1862 document.head.appendChild(link); 1863 1864 if (!document.querySelector('link[data-pomo-ambience-styles]')) { 1865 const ambienceLink = document.createElement('link'); 1866 ambienceLink.rel = 'stylesheet'; 1867 ambienceLink.dataset.pomoAmbienceStyles = 'true'; 1868 ambienceLink.href = new URL('./ambience.css?v=13', import.meta.url).href; 1869 document.head.appendChild(ambienceLink); 1870 } 1871 1872 focusAmbience = createFocusAmbience({ 1873 getSnapshot: () => ({ 1874 mode: state.mode, 1875 timeLeft: state.timeLeft, 1876 duration: getDuration(), 1877 timeText: formatTimerText(state.timeLeft), 1878 isActive: state.isActive, 1879 taskText: state.activeTask?.text || getCurrentDailyPriority()?.text || '', 1880 completedFocusCount: getTodayStats().focusCount 1881 }), 1882 getLanguage: () => i18n.getLang(), 1883 onToggleTimer: toggleTimer, 1884 onReset: resetTimer, 1885 onModeChange: (mode) => setMode(mode, true), 1886 onOpen: () => { 1887 sendGAEvent('focus_ambience_opened', 'productivity', state.mode); 1888 globalCtx?.bus?.emit('pomodoro:ambience-state', { open: true }); 1889 }, 1890 onClose: () => globalCtx?.bus?.emit('pomodoro:ambience-state', { open: false }) 1891 }); 1892 1893 loadSettings(); 1894 renderLayout(); 1895 bindEvents(ctx); 1896 1897 if (!isDayClosed()) { 1898 bindSettingsEvents(); 1899 restorePersistedCustomAlarm(); 1900 // Restore first: resetting the default mode here would overwrite an 1901 // active timer started in another Roxima tab. 1902 if (!restoreTimerState()) setMode(state.mode, false); 1903 syncDailyPriorityState(); 1904 renderTwoDayPilotCard(); 1905 } 1906 1907 if (globalCtx.bus) { 1908 globalCtx.bus.on('pomodoro:external-start', startExternalFocusSession); 1909 globalCtx.bus.on('i18n:changed', () => { 1910 // Re-render layout but keep state 1911 // Not doing full re-render to preserve input focus and timer, just update texts 1912 updateI18nTexts(); 1913 renderTwoDayPilotCard(); 1914 updateProjectSelectorUI(); 1915 }); 1916 globalCtx.bus.on('two-day-pilot:changed', renderTwoDayPilotCard); 1917 } 1918 1919 // Periodically refresh ETA when timer is inactive to keep it aligned with actual time 1920 setInterval(() => { 1921 if (refreshDayClosureForCurrentDate()) return; 1922 refreshTwoDayPilotCardForCurrentDay(); 1923 if (containerEl && !state.isActive) { 1924 updateUI(); 1925 } 1926 }, 10000); 1927 1928 document.addEventListener('keydown', (event) => { 1929 if (!shouldHandlePomodoroStartShortcut(event, { 1930 shortcut: state.settings.startShortcut, 1931 blocked: isPomodoroKeyboardShortcutBlocked() 1932 })) return; 1933 1934 event.preventDefault(); 1935 toggleTimer(); 1936 sendGAEvent('pomodoro_keyboard_shortcut_used', 'productivity', state.settings.startShortcut); 1937 }); 1938 1939 // Global Keyboard Shortcut: Double Press Shift (2x â§) to toggle PiP mode 1940 let lastShiftTime = 0; 1941 document.addEventListener('keydown', (e) => { 1942 if (isDayClosed()) return; 1943 1944 if (e.key === 'Shift') { 1945 const currentTime = Date.now(); 1946 if (currentTime - lastShiftTime < 500) { // 500ms window (generous margin of error) 1947 e.preventDefault(); 1948 if (window.documentPictureInPicture && window.documentPictureInPicture.window) { 1949 window.documentPictureInPicture.window.close(); 1950 } else { 1951 requestPiP(); 1952 } 1953 lastShiftTime = 0; 1954 } else { 1955 lastShiftTime = currentTime; 1956 } 1957 } else { 1958 // Any other key resets the double press sequence 1959 lastShiftTime = 0; 1960 } 1961 }); 1962} 1963 1964function updateI18nTexts() { 1965 if (!containerEl) return; 1966 // Modes 1967 renderModeOptions(); 1968 1969 // Settings title 1970 const settingsTitle = containerEl.querySelector('.pomo-settings-title'); 1971 if (settingsTitle) settingsTitle.textContent = i18n.t('pomo_settings_title'); 1972 1973 const modeBadge = containerEl.querySelector('#pomo-mode-badge'); 1974 syncModeBadge(modeBadge, state.mode); 1975 1976 // Settings Labels 1977 const labels = containerEl.querySelectorAll('.pomo-label'); 1978 if (labels.length > 0) { 1979 labels[0].textContent = i18n.t('pomo_focus_min'); 1980 labels[1].textContent = i18n.t('pomo_short_min'); 1981 labels[2].textContent = i18n.t('pomo_long_min'); 1982 labels[3].textContent = i18n.t('pomo_long_interval'); 1983 labels[4].textContent = i18n.t('pomo_auto_breaks'); 1984 labels[5].textContent = i18n.t('pomo_auto_pomo'); 1985 labels[6].textContent = i18n.t('pomo_daily_goal'); 1986 labels[7].textContent = i18n.t('pomo_alarm'); 1987 // Volume label needs special care because of the span 1988 } 1989 1990 // Preview btn 1991 const previewBtn = containerEl.querySelector('#preview-custom-btn'); 1992 if (previewBtn) previewBtn.textContent = i18n.t('pomo_custom_preview'); 1993 1994 const shortcutLabel = containerEl.querySelector('.pomo-shortcut-label'); 1995 if (shortcutLabel) shortcutLabel.textContent = i18n.t('pomo_start_shortcut'); 1996 const shortcutHelp = containerEl.querySelector('.pomo-shortcut-sett
1996ing-help'); 1997 if (shortcutHelp) shortcutHelp.textContent = i18n.t('pomo_start_shortcut_help'); 1998 const shortcutSelect = containerEl.querySelector('#set-start-shortcut'); 1999 if (shortcutSelect) shortcutSelect.innerHTML = renderStartShortcutOptionsMarkup(); 2000 updateControls(); 2001 2002 const priorityStep = containerEl.querySelector('[data-pomo-guide-step="priority"]'); 2003 const focusStep = containerEl.querySelector('[data-pomo-guide-step="focus"]'); 2004 const reviewStep = containerEl.querySelector('[data-pomo-guide-step="review"]'); 2005 const guideCopy = getFocusGuideCopy(); 2006 if (priorityStep) priorityStep.textContent = guideCopy.priority; 2007 if (focusStep) focusStep.textContent = guideCopy.focus; 2008 if (reviewStep) reviewStep.textContent = guideCopy.review; 2009 2010 const pipTitle = containerEl.querySelector('#pomo-pip-info-title'); 2011 if (pipTitle) pipTitle.textContent = i18n.t('pomo_pip_info_title'); 2012 const pipDesc = containerEl.querySelector('#pomo-pip-info-desc'); 2013 if (pipDesc) pipDesc.textContent = i18n.t('pomo_pip_info_desc'); 2014 2015 const closedStatus = containerEl.querySelector('[data-pomo-day-closed-time]'); 2016 if (closedStatus) { 2017 closedStatus.textContent = i18n.t('pomo_day_closed_status') 2018 .replace('{time}', closedStatus.dataset.pomoDayClosedTime || ''); 2019 } 2020 2021 syncFocusGuide(); 2022 updateControls(); 2023} 2024 2025function loadSettings() { 2026 try { 2027 const saved = pomodoroStore.getSettings(); 2028 if (saved) { 2029 state.settings = { ...state.settings, ...saved }; 2030 Object.keys(NUMERIC_SETTING_RULES).forEach((key) => { 2031 state.settings[key] = normalizeNumericSetting(key, state.settings[key]); 2032 }); 2033 state.settings.customTimers = normalizeCustomTimers(state.settings.customTimers); 2034 state.settings.customTimerCategories = normalizeCustomTimerCategories(state.settings.customTimerCategories); 2035 state.settings.startShortcut = normalizePomodoroStartShortcut(state.settings.startShortcut); 2036 2037 // Adjust current state if needed (re-init timer if idle) 2038 if (!state.isActive) { 2039 // We don't force reset here to avoid annoying jumps, but next reset will use new times 2040 } 2041 } 2042 } catch (e) { 2043 console.error("Failed to load settings", e); 2044 } 2045} 2046 2047function saveSettings() { 2048 try { 2049 pomodoroStore.updateSettings(state.settings); 2050 } catch (e) { 2051 console.warn("Failed to save settings", e); 2052 } 2053} 2054 2055function renderLayout() { 2056 const guideCopy = getFocusGuideCopy(); 2057 const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0; 2058 const shortcutLabel = '2x â§'; 2059 2060 if (isDayClosed()) { 2061 let closureTime = ''; 2062 let closureNote = ''; 2063 const closedDayResume = getClosedDayPilotResumeContext(); 2064 const closedDescriptionKey = closedDayResume 2065 ? 'pomo_day_closed_resume_desc' 2066 : 'pomo_day_closed_desc'; 2067 const closedActionKey = closedDayResume 2068 ? 'pomo_btn_resume_day2' 2069 : 'pomo_btn_reopen'; 2070 try { 2071 const parsed = pomodoroStore.getDayClosure(); 2072 if (parsed) { 2073 closureTime = new Date(parsed.closedAt).toLocaleTimeString(i18n.getLang() === 'fr' ? 'fr-FR' : 'en-US', { hour: '2-digit', minute: '2-digit' }); 2074 closureNote = parsed.shutdownNote || ''; 2075 } 2076 } catch (e) {} 2077 2078 containerEl.innerHTML = ` 2079 <div class="card" style="height: 100%; display:flex; flex-direction:column; position:relative; overflow:hidden;"> 2080 <div class="card-header"> 2081 <div style="display:flex; align-items:center; gap:8px;"> 2082 <h2 class="card-title" data-i18n="pomo_title">Pomodoro</h2> 2083 <span class="badge" style="background: rgba(16, 185, 129, 0.1); color: #10B981; border: 1px solid rgba(16, 185, 129, 0.2);" data-i18n="pomo_day_closed_title">${i18n.t('pomo_day_closed_title')}</span> 2084 </div> 2085 </div> 2086 <div class="pomo-container pomo-closed-panel" style="justify-content: center; gap: 16px; padding: 24px; text-align: center; height:100%; box-sizing:border-box;"> 2087 <div style="width: 72px; height: 72px; border-radius: 50%; background: rgba(16, 185, 129, 0.1); color: #10B981; display: flex; align-items: center; justify-content: center; margin: 0 auto 8px;"> 2088 <svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"> 2089 <path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"></path> 2090 <polyline points="22 4 12 14.01 9 11.01"></polyline> 2091 </svg> 2092 </div> 2093 2094 <h3 style="margin: 0; font-size: 1.25rem; font-weight: 700; color: var(--text-main);" data-i18n="pomo_day_closed_title">${i18n.t('pomo_day_closed_title')}</h3> 2095 <p style="margin: 0; font-size: 0.85rem; color: var(--text-muted); line-height: 1.5; max-width: 280px; margin: 0 auto;" data-i18n="${closedDescriptionKey}">${i18n.t(closedDescriptionKey)}</p> 2096 2097 ${closedDayResume ? ` 2098 <div class="pomo-closed-resume-task" aria-label="${escapeHtml(i18n.t('pomo_day_closed_resume_task_aria'))}">
2099 <span data-i18n="pomo_day_closed_resume_task_label">${i18n.t('pomo_day_closed_resume_task_label')}</span> 2100 <strong>${escapeHtml(closedDayResume.task.text || closedDayResume.task.title || '')}</strong> 2101 ${closedDayResume.task.projectName 2102 ? `<small>${escapeHtml(closedDayResume.task.projectName)}</small>` 2103 : ''} 2104 </div> 2105 ` : ''} 2106 2107 <div style="margin-top: 12px; font-size: 0.8rem; color: var(--text-muted);"> 2108 <span data-pomo-day-closed-time="${escapeHtml(closureTime)}">${i18n.t('pomo_day_closed_status').replace('{time}', closureTime)}</span> 2109 </div> 2110 2111 ${closureNote ? ` 2112 <div style="margin-top: 16px; padding: 12px; background: rgba(0,0,0,0.02); border: 1px solid rgba(0,0,0,0.03); border-radius: 12px; text-align: left; font-size: 0.85rem; max-width: 320px; width: 100%; box-sizing: border-box; margin-left: auto; margin-right: auto;"> 2113 <div style="font-weight: 600; color: var(--text-main); margin-bottom: 4px;" data-i18n="pomo_modal_closure_q">${i18n.t('pomo_modal_closure_q')}</div> 2114 <div style="color: #555; font-style: italic; word-break: break-word;">${escapeHtml(closureNote)}</div> 2115 </div> 2116 ` : ''} 2117 2118 <button class="pomo-reopen-btn${closedDayResume ? ' pomo-reopen-btn--resume' : ''}" id="pomo-btn-reopen" type="button" data-pomo-resume-day2="${closedDayResume ? 'true' : 'false'}" data-i18n="${closedActionKey}"> 2119 ${i18n.t(closedActionKey)} 2120 </button> 2121 </div> 2122 </div> 2123 `; 2124 return; 2125 } 2126 2127 containerEl.innerHTML = ` 2128 <div class="card" style="height: 100%; display:flex; flex-direction:column; position:relative; overflow:hidden;"> 2129 <div class="card-header" style="position: relative;"> 2130 <div style="display:flex; align-items:center; gap:8px;"> 2131 <h2 class="card-title" data-i18n="pomo_title">Pomodoro</h2> 2132 </div> 2133 <div style="position: absolute; left: 50%; transform: translateX(-50%);"> 2134 <button class="pomo-end-day-btn" id="pomo-end-day-btn" type="button" data-i18n-aria-label="pomo_end_day_hold_aria" aria-label="${escapeHtml(i18n.t('pomo_end_day_hold_aria'))}"> 2135 <div class="pomo-end-day-progress" id="pomo-end-day-progress"></div> 2136 <span class="pomo-end-day-text" data-i18n="pomo_btn_day_closure">${i18n.t('pomo_btn_day_closure')}</span> 2137 </button> 2138 </div> 2139 <div class="card-actions" style="display:flex; align-items:center; gap:4px;"> 2140 <span class="pomo-shortcut-hint" style="font-size: 0.65rem; color: var(--text-muted); opacity: 0.65; font-weight: 500; user-select: none; margin-right: 2px;">${shortcutLabel}</span> 2141 <button class="icon-btn" id="pomo-btn-pip" type="button" data-i18n-title="pomo_float" data-i18n-aria-label="pomo_float" title="${i18n.t('pomo_float')}" aria-label="${i18n.t('pomo_float')}"> 2142 <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h9"></path><rect x="15" y="5" width="6" height="6" rx="1" ry="1"></rect></svg> 2143 </button> 2144 <button class="icon-btn" id="pomo-btn-settings" type="button" data-i18n-title="common_settings" data-i18n-aria-label="common_settings" title="${i18n.t('common_settings')}" aria-label="${i18n.t('common_settings')}"> 2145 <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2
21450 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg> 2146 </button> 2147 </div> 2148 </div> 2149 2150 <div class="pomo-container"> 2151 <div class="pomo-ring" id="pomo-ring"> 2152 <svg class="pomo-ring-svg" viewBox="0 0 100 100" style="position: absolute; inset: 0; width: 100%; height: 100%; transform: rotate(-90deg); pointer-events: none; z-index: 1;"> 2153 <circle cx="50" cy="50" r="25" style="fill: none; stroke: #E5E5EA; stroke-width: 50;"></circle> 2154 <circle cx="50" cy="50" r="25" id="pomo-progress-circle" style="fill: none; stroke: #10B981; stroke-width: 50; stroke-dasharray: 157.08; stroke-dashoffset: 157.08; transition: stroke-dashoffset 1s linear, stroke 0.3s ease;"></circle> 2155 </svg> 2156 <div class="pomo-time" id="pomo-time">25:00</div> 2157 <div class="pomo-eta" id="pomo-eta">
2158 <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0;"> 2159 <path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/> 2160 <path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/> 2161 </svg> 2162 <span id="pomo-eta-time">--:--</span> 2163 </div> 2164 </div> 2165 2166 <div class="pomo-controls"> 2167 <button class="btn-pomo filled" id="pomo-play-btn" type="button" aria-label="${escapeHtml(guideCopy.chooseAction)}"> 2168 <svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="2" stroke-linejoin="round"><path d="M8 5v14l11-7z" /></svg> 2169 </button> 2170 <button class="btn-pomo secondary" id="pomo-reset-btn" type="button" data-i18n-aria-label="pomo_reset" aria-label="${i18n.t('pomo_reset')}"> 2171 <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/></svg> 2172 </button> 2173 </div> 2174 2175 <div class="pomo-modes" aria-label="${escapeHtml(i18n.t('pomo_timer_modes_aria'))}"> 2176 ${renderModeOptionsMarkup()} 2177 </div> 2178 </div> 2179 2180 <div id="pomo-focus-task-area" class="pomo-focus-task-area"> 2181 <section id="pomo-pilot-progress" class="pomo-pilot-progress" aria-live="polite" hidden></section> 2182 <section id="pomo-daily-priority" class="pomo-daily-priority" aria-live="polite" data-i18n-aria-label="pomo_focus_task_aria" aria-label="${escapeHtml(i18n.t('pomo_focus_task_aria'))}"></section> 2183 <div id="pomo-project-choice" class="pomo-project-choice"> 2184 <span class="pomo-project-choice__label">${escapeHtml(i18n.t('pomo_project_session_choice'))}</span> 2185 <div id="pomo-project-selector" class="pomo-project-selector"> 2186 <button id="pomo-project-prev" class="pomo-project-arrow-btn" type="button" data-i18n-aria-label="pomo_project_previous" aria-label="${escapeHtml(i18n.t('pomo_project_previous'))}"> 2187 <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"> 2188 <polyline points="15 18 9 12 15 6"></polyline> 2189 </svg> 2190 </button> 2191 <div class="pomo-project-display"> 2192 <span id="pomo-project-icon" class="pomo-project-icon">ð</span> 2193 <span id="pomo-project-name" class="pomo-project-name">${escapeHtml(i18n.t('pomo_project_none'))}</span> 2194 </div> 2195 <button id="pomo-project-next" class="pomo-project-arrow-btn" type="button" data-i18n-aria-label="pomo_project_next" aria-label="${escapeHtml(i18n.t('pomo_project_next'))}"> 2196 <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"> 2197 <polyline points="9 18 15 12 9 6"></polyline> 2198 </svg> 2199 </button> 2200 </div> 2201 </div> 2202 <div id="pomo-session-review" class="pomo-session-review" hidden aria-live="polite"> 2203 <label id="pomo-review-question" for="pomo-review-input" class="pomo-session-review-question">${escapeHtml(guideCopy.reviewQuestion)}</label> 2204 <textarea id="pomo-review-input" maxlength="180" rows="2" placeholder="${escapeHtml(guideCopy.reviewPlaceholder)}"></textarea> 2205 <div class="pomo-session-review-meta">
2206 <span id="pomo-review-helper">${escapeHtml(guideCopy.reviewHelper)}</span> 2207 <span id="pomo-review-count">0/180</span> 2208 </div> 2209 <div class="pomo-session-review-actions"> 2210 <button id="pomo-review-bilan" class="pomo-session-review-primary" type="button"> 2211 <span>${escapeHtml(guideCopy.reviewCta)}</span> 2212 <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"> 2213 <path d="M5 12h14"></path> 2214 <path d="m13 6 6 6-6 6"></path> 2215 </svg> 2216 </button> 2217 <button id="pomo-review-later" class="pomo-session-review-secondary" type="button">${escapeHtml(guideCopy.reviewLater)}</button> 2218 </div> 2219 </div> 2220 </div> 2221 2222 2223 <div id="pomo-project-selector-pip" class="pomo-project-selector-pip"> 2224 <button id="pomo-project-prev-pip" class="pomo-project-pip-arrow-btn" type="button" data-i18n-aria-label="pomo_project_previous" aria-label="${escapeHtml(i18n.t('pomo_project_previous'))}"> 2225 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"> 2226 <polyline points="18 15 12 9 6 15"></polyline> 2227 </svg> 2228 </button> 2229 <span id="pomo-project-icon-pip" class="pomo-project-pip-icon">ð</span> 2230 <button id="pomo-project-next-pip" class="pomo-project-pip-arrow-btn" type="button" data-i18n-aria-label="pomo_project_next" aria-label="${escapeHtml(i18n.t('pomo_project_next'))}"> 2231 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"> 2232 <polyline points="6 9 12 15 18 9"></polyline> 2233 </svg> 2234 </button> 2235 </div> 2236 </div> 2237 2238 <div class="pomo-custom-timer-layer" id="pomo-custom-timer-layer" hidden aria-hidden="true"> 2239 <button class="pomo-custom-timer-backdrop" type="button" data-custom-timer-close tabindex="-1" aria-hidden="true"></button> 2240 <section class="pomo-custom-timer-dialog" role="dialog" aria-modal="true" aria-labelledby="pomo-custom-timer-title"> 2241 <form id="pomo-custom-timer-form" novalidate> 2242 <div class="pomo-custom-timer-header"> 2243 <div> 2244 <span class="pomo-custom-timer-kicker" data-i18n="pomo_custom_timer_kicker">${i18n.t('pomo_custom_timer_kicker')}</span> 2245 <h3 id="pomo-custom-timer-title" data-i18n="pomo_custom_timer_create_title">${i18n.t('pomo_custom_timer_create_title')}</h3> 2246 </div> 2247 <button class="pomo-custom-timer-close" type="button" data-custom-timer-close aria-label="${escapeHtml(i18n.t('common_close'))}"> 2248 <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="m6 6 12 12M18 6 6 18"></path></svg> 2249 </button> 2250 </div> 2251 <div class="pomo-custom-timer-body"> 2252 <label class="pomo-custom-timer-field" for="pomo-custom-timer-name"> 2253 <span data-i18n="pomo_custom_timer_name">${i18n.t('pomo_custom_timer_name')}</span> 2254 <input id="pomo-custom-timer-name" name="name" type="text" maxlength="${CUSTOM_TIMER_LIMITS.maxNameLength}" autocomplete="off" data-i18n-placeholder="pomo_custom_timer_name_placeholder" placeholder="${escapeHtml(i18n.t('pomo_custom_timer_name_placeholder'))}"> 2255 </label> 2256 <label class="pomo-custom-timer-field" for="pomo-custom-timer-minutes">
2257 <span data-i18n="pomo_custom_timer_minutes">${i18n.t('pomo_custom_timer_minutes')}</span> 2258 <div class="pomo-custom-timer-duration"> 2259 <input id="pomo-custom-timer-minutes" name="minutes" type="number" inputmode="numeric" min="${CUSTOM_TIMER_LIMITS.minMinutes}" max="${CUSTOM_TIMER_LIMITS.maxMinutes}" step="1" value="25"> 2260 <span data-i18n="pomo_custom_timer_min_unit">${i18n.t('pomo_custom_timer_min_unit')}</span> 2261 </div> 2262 </label> 2263 <fieldset class="pomo-custom-timer-category"> 2264 <legend data-i18n="pomo_custom_timer_category">${i18n.t('pomo_custom_timer_category')}</legend> 2265 <div class="pomo-custom-timer-category-options" id="pomo-custom-timer-category-options"> 2266 ${renderTimerCategoryOptionsMarkup('focus')} 2267 </div> 2268 <button class="pomo-custom-category-toggle" type="button" data-custom-category-toggle> 2269 <span aria-hidden="true">+</span> 2270 <span data-i18n="pomo_custom_category_new">${i18n.t('pomo_custom_category_new')}</span> 2271 </button> 2272 <div class="pomo-custom-category-creator" id="pomo-custom-category-creator" hidden> 2273 <div class="pomo-custom-category-emoji-field"> 2274 <span data-i18n="pomo_custom_category_emoji">${i18n.t('pomo_custom_category_emoji')}</span> 2275 ${renderCustomCategoryEmojiPickerMarkup()} 2276 </div> 2277 <label class="pomo-custom-category-name-field"> 2278 <span data-i18n="pomo_custom_category_name">${i18n.t('pomo_custom_category_name')}</span> 2279 <input id="pomo-custom-category-name" type="text" maxlength="${CUSTOM_TIMER_CATEGORY_LIMITS.maxNameLength}" autocomplete="off" data-i18n-placeholder="pomo_custom_category_name_placeholder" placeholder="${escapeHtml(i18n.t('pomo_custom_category_name_placeholder'))}"> 2280 </label> 2281 <button class="pomo-custom-category-add" type="button" data-custom-category-add data-i18n="pomo_custom_category_add">${i18n.t('pomo_custom_category_add')}</button> 2282 <div class="pomo-custom-category-color-field"> 2283 <span data-i18n="pomo_custom_category_color">${i18n.t('pomo_custom_category_color')}</span> 2284 ${renderCustomCategoryColorPickerMarkup()} 2285 </div> 2286 </div> 2287 </fieldset> 2288 <p class="pomo-custom-timer-note" data-i18n="pomo_custom_timer_focus_note">${i18n.t('pomo_custom_timer_focus_note')}</p> 2289 <p class="pomo-custom-timer-error" id="pomo-custom-timer-error" role="alert" hidden></p> 2290 </div> 2291 <div class="pomo-custom-timer-actions"> 2292 <button class="pomo-custom-timer-delete" id="pomo-custom-timer-delete" type="button" data-i18n="pomo_custom_timer_delete" hidden>${i18n.t('pomo_custom_timer_delete')}</button> 2293 <span class="pomo-custom-timer-actions-spacer"></span> 2294 <button class="pomo-custom-timer-cancel" type="button" data-custom-timer-close data-i18n="common_cancel">${i18n.t('common_cancel')}</button> 2295 <button class="pomo-custom-timer-submit" id="pomo-custom-timer-submit" type="submit" data-i18n="pomo_custom_timer_create">${i18n.t('pomo_custom_timer_create')}</button> 2296 </div> 2297 </form> 2298 </section> 2299 </div> 2300 2301 <!-- Settings Panel --> 2302 <div class="pomo-settings-panel" id="pomo-settings-panel"> 2303 <div class="pomo-settings-header"> 2304 <div class="pomo-settings-title">${i18n.t('pomo_settings_title')}</div> 2305 <button class="icon-btn" id="pomo-settings-close" type="button" data-i18n-aria-label="common_close" aria-label="${i18n.t('common_close')}"><svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><line x1="2.5" y1="2.5" x2="9.5" y2="9.5"></line><line x1="9.5" y1="2.5" x2="2.5" y2="9.5"></line></svg></button> 2306 </div> 2307 <div class="pomo-settings-body"> 2308 2309 <!-- SECTION TIMER --> 2310 <div class="pomo-section"> 2311 <div class="pomo-section-title">${i18n.t('pomo_timer_section')}</div> 2312 2313 <div class="pomo-form-group"> 2314 <label class="pomo-label">${i18n.t('pomo_focus_min')}</label> 2315 <input type="number" class="pomo-input" id="set-focus-time" value="${state.settings.focusTime}" min="1" max="180"> 2316 </div> 2317 <div class="pomo-form-group"> 2318 <label class="pomo-label">${i18n.t('pomo_short_min')}</label> 2319 <input type="number" class="pomo-input" id="set-short-break" value="${state.settings.shortBreakTime}" min="1" max="60"> 2320 </div> 2321 <div class="pomo-form-group"> 2322 <label class="pomo-label">${i18n.t('pomo_long_min')}</label> 2323 <input type="number" class="pomo-input" id="set-long-break" value="${state.settings.longBreakTime}" min="1" max="180"> 2324 </div> 2325 <div class="pomo-form-group pomo-shortcut-sett
2325ing"> 2326 <div class="pomo-setting-copy"> 2327 <label class="pomo-shortcut-label" for="set-start-shortcut">${i18n.t('pomo_start_shortcut')}</label> 2328 <p class="pomo-shortcut-setting-help">${i18n.t('pomo_start_shortcut_help')}</p> 2329 </div> 2330 <select class="pomo-select pomo-shortcut-select" id="set-start-shortcut"> 2331 ${renderStartShortcutOptionsMarkup()} 2332 </select> 2333 </div> 2334 <div class="pomo-form-group pomo-form-group-with-help"> 2335 <div class="pomo-setting-copy"> 2336 <div class="pomo-setting-label-row"> 2337 <label class="pomo-label" for="set-long-interval">${i18n.t('pomo_long_interval')}</label> 2338 <button class="pomo-help-toggle" type="button" 2339 data-help-target="pomo-help-long-interval" 2340 aria-expanded="false" 2341 aria-controls="pomo-help-long-interval" 2342 data-i18n-aria-label="pomo_long_interval_help_label" 2343 aria-label="${i18n.t('pomo_long_interval_help_label')}"> 2344 <span aria-hidden="true">i</span> 2345 </button> 2346 </div> 2347 <p class="pomo-setting-help" id="pomo-help-long-interval" data-i18n="pomo_long_interval_help" hidden>${i18n.t('pomo_long_interval_help')}</p> 2348 </div> 2349 <input type="number" class="pomo-input" id="set-long-interval" value="${state.settings.longBreakInterval}" min="1" max="10"> 2350 </div> 2351 2352 <div class="pomo-form-group pomo-form-group-with-help"> 2353 <div class="pomo-setting-copy"> 2354 <div class="pomo-setting-label-row"> 2355 <label class="pomo-label" for="toggle-auto-break">${i18n.t('pomo_auto_breaks')}</label> 2356 <button class="pomo-help-toggle" type="button" 2357 data-help-target="pomo-help-auto-breaks" 2358 aria-expanded="false" 2359 aria-controls="pomo-help-auto-breaks" 2360 data-i18n-aria-label="pomo_auto_breaks_help_label" 2361 aria-label="${i18n.t('pomo_auto_breaks_help_label')}"> 2362 <span aria-hidden="true">i</span> 2363 </button> 2364 </div> 2365 <p class="pomo-setting-help" id="pomo-help-auto-breaks" data-i18n="pomo_auto_breaks_help" hidden>${i18n.t('pomo_auto_breaks_help')}</p> 2366 </div> 2367 <button class="pomo-toggle ${state.settings.autoStartBreaks ? 'active' : ''}" id="toggle-auto-break" type="button" role="switch" aria-checked="${state.settings.autoStartBreaks}" data-i18n-aria-label="pomo_auto_breaks" aria-label="${i18n.t('pomo_auto_breaks')}"> 2368 <span class="pomo-toggle-handle" aria-hidden="true"></span> 2369 </button> 2370 </div> 2371 <div class="pomo-form-group pomo-form-group-with-help"> 2372 <div class="pomo-setting-copy"> 2373 <div class="pomo-setting-label-row"> 2374 <label class="pomo-label" for="toggle-auto-pomo">${i18n.t('pomo_auto_pomo')}</label> 2375 <button class="pomo-help-toggle" type="button" 2376 data-help-target="pomo-help-auto-pomo" 2377 aria-expanded="false" 2378 aria-controls="pomo-help-auto-pomo" 2379 data-i18n-aria-label="pomo_auto_pomo_help_label" 2380 aria-label="${i18n.t('pomo_auto_pomo_help_label')}">
2381 <span aria-hidden="true">i</span> 2382 </button> 2383 </div> 2384 <p class="pomo-setting-help" id="pomo-help-auto-pomo" data-i18n="pomo_auto_pomo_help" hidden>${i18n.t('pomo_auto_pomo_help')}</p> 2385 </div> 2386 <button class="pomo-toggle ${state.settings.autoStartPomo ? 'active' : ''}" id="toggle-auto-pomo" type="button" role="switch" aria-checked="${state.settings.autoStartPomo}" data-i18n-aria-label="pomo_auto_pomo" aria-label="${i18n.t('pomo_auto_pomo')}"> 2387 <span class="pomo-toggle-handle" aria-hidden="true"></span> 2388 </button> 2389 </div> 2390 </div> 2391 2392 <!-- SECTION GOAL --> 2393 <div class="pomo-section"> 2394 <div class="pomo-section-title">${i18n.t('pomo_goal_section')}</div> 2395 <div class="pomo-form-group"> 2396 <label class="pomo-label">${i18n.t('pomo_daily_goal')}</label> 2397 <input type="number" class="pomo-input" id="set-daily-goal" value="${state.settings.dailyGoalMin}" min="10" step="10"> 2398 </div> 2399 </div> 2400 2401 <!-- SECTION SLEEP GOALS --> 2402 <div class="pomo-section"> 2403 <div class="pomo-section-title">Objectif Sommeil</div> 2404 <div class="pomo-form-group"> 2405 <label class="pomo-label">Coucher</label> 2406 <input type="time" class="pomo-input" id="set-sleep-bedtime" value="${state.settings.sleepBedtime || '23:00'}"> 2407 </div> 2408 <div class="pomo-form-group" style="margin-top: 10px;"> 2409 <label class="pomo-label">Réveil</label> 2410 <input type="time" class="pomo-input" id="set-sleep-wakeup" value="${state.settings.sleepWakeup || '07:00'}"> 2411 </div> 2412 </div> 2413 2414 <!-- SECTION SOUND --> 2415 <div class="pomo-section"> 2416 <div class="pomo-section-title">${i18n.t('pomo_sound_section')}</div> 2417 2418 <div class="pomo-form-group"> 2419 <label class="pomo-label">${i18n.t('pomo_alarm')}</label> 2420 <select class="pomo-select" id="set-alarm-sound"> 2421 <option value="none">${i18n.t('pomo_sound_none')}</option> 2422 <option value="bell">${i18n.t('pomo_sound_bell')}</option> 2423 <option value="chime">${i18n.t('pomo_sound_chime')}</option> 2424 <option value="digital">${i18n.t('pomo_sound_digital')}</option> 2425 <option value="custom" ${state.settings.alarmSound === 'custom' ? 'selected' : ''}> 2426 ${state.settings.customSoundName ? i18n.t('pomo_sound_custom') + ' (' + state.settings.customSoundName + ')' : i18n.t('pomo_sound_custom')} 2427 </option> 2428 </select> 2429 </div> 2430 2431 <div class="pomo-form-group-volume"> 2432 <label class="pomo-label">${i18n.t('pomo_volume')}: <span id="vol-display">${state.settings.volume}%</span></label> 2433 <input type="range" class="pomo-slider" id="set-volume" min="0" max="100" value="${state.settings.volume}"> 2434 </div> 2435 2436 <div class="pomo-custom-sound-box"> 2437 <label class="pomo-custom-sound-btn"> 2438 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg> 2439 ${i18n.t('pomo_custom_file')} 2440 <input type="file" id="upload-alarm" accept="audio/mpeg,audio/mp3" style="display:none;"> 2441 </label> 2442 <div id="upload-info" class="pomo-custom-sound-info"> 2443 ${state.settings.customSoundName || 'Max 10MB'} 2444 </div> 2445 <div id="custom-sound-actions" class="pomo-custom-sound-actions"> 2446 <button class="pomo-custom-sound-preview-btn" id="preview-custom-btn" type="button">${i18n.t('pomo_custom_preview')}</button> 2447 </div> 2448 </div> 2449 2450 </div> 2451 2452 <!-- SECTION PIP INFO --> 2453 <div class="pomo-section" style="border-top: 1px solid #f3f4f6; padding-top: 12px; margin-top: 12px;"> 2454 <div class="pomo-section-title" id="pomo-pip-info-title">${i18n.t('pomo_pip_info_title')}</div> 2455 <div class="pomo-pip-tip-text" id="pomo-pip-info-desc" style="font-size: 0.75rem; line-height: 1.4; color: #666; margin-top: 6px;"> 2456 ${i18n.t('pomo_pip_info_desc')} 2457 </div> 2458 </div> 2459 2460 </div> 2461 </div> 2462 </div> 2463 `; 2464 2465 ringEl = containerEl.querySelector('#pomo-ring'); 2466 timeEl = containerEl.querySelector('#pomo-time'); 2467 playBtn = containerEl.querySelector('#pomo-play-btn'); 2468 resetBtn = containerEl.querySelector('#pomo-reset-btn'); 2469 settingsPanel = containerEl.querySelector('#pomo-settings-panel'); 2470 syncSettingsPanelVisibility(); 2471 } 2472
2473function renderStartShortcutOptionsMarkup() { 2474 const options = [ 2475 ['space', 'pomo_shortcut_space'], 2476 ['keyP', 'pomo_shortcut_key_p'], 2477 ['keyF', 'pomo_shortcut_key_f'], 2478 ['disabled', 'pomo_shortcut_disabled'] 2479 ]; 2480 const selectedShortcut = normalizePomodoroStartShortcut(state.settings.startShortcut); 2481 2482 return options.map(([value, labelKey]) => ( 2483 `<option value="${value}" ${selectedShortcut === value ? 'selected' : ''}>${i18n.t(labelKey)}</option>` 2484 )).join(''); 2485} 2486 2487function syncCustomAlarmSettingsUI() { 2488 if (!containerEl) return; 2489 2490 const name = state.settings.customSoundName; 2491 const alarmSelect = containerEl.querySelector('#set-alarm-sound'); 2492 const customOption = alarmSelect?.querySelector('option[value="custom"]'); 2493 const uploadInfo = containerEl.querySelector('#upload-info'); 2494 2495 if (customOption) { 2496 customOption.textContent = name 2497 ? `${i18n.t('pomo_sound_custom')} (${name})` 2498 : i18n.t('pomo_sound_custom'); 2499 } 2500 if (uploadInfo && name) uploadInfo.textContent = name; 2501 if (alarmSelect) alarmSelect.value = state.settings.alarmSound; 2502} 2503 2504async function restorePersistedCustomAlarm() { 2505 try { 2506 const saved = await audioDB.getCustomAlarm(state.settings.customSoundId); 2507 if (!saved?.blob) return null; 2508 2509 let restored = saved; 2510 if (saved.legacy) { 2511 restored = await audioDB.saveCustomAlarm(saved.blob, { 2512 selected: state.settings.alarmSound === 'custom', 2513 name: saved.name 2514 }); 2515 } 2516 2517 const changed = state.settings.customSoundId !== restored.id 2518 || state.settings.customSoundName !== restored.name; 2519 state.settings.customSoundId = restored.id; 2520 state.settings.customSoundName = restored.name; 2521 2522 if (changed) saveSettings(); 2523 syncCustomAlarmSettingsUI(); 2524 return restored; 2525 } catch (error) { 2526 console.warn('Failed to restore custom Pomodoro alarm', error); 2527 return null; 2528 } 2529} 2530 2531function renderModeOptions() { 2532 const roots = [containerEl]; 2533 if ('documentPictureInPicture' in window && window.documentPictureInPicture.window) { 2534 roots.push(window.documentPictureInPicture.window.document); 2535 } 2536 2537 roots.forEach((root) => { 2538 const modes = root?.querySelector?.('.pomo-modes'); 2539 if (!modes) return; 2540 modes.setAttribute('aria-label', i18n.t('pomo_timer_modes_aria')); 2541 modes.innerHTML = renderModeOptionsMarkup(); 2542 bindModeButtons(modes); 2543 }); 2544 updateModeButtons(); 2545} 2546 2547function renderCustomTimerCategoryOptions(selectedCategory = 'focus') { 2548 const options = containerEl?.querySelector('#pomo-custom-timer-category-options'); 2549 if (!options) return; 2550 options.innerHTML = renderTimerCategoryOptionsMarkup(selectedCategory); 2551} 2552 2553function showCustomTimerError(message, field = null) { 2554 const error = containerEl?.querySelector('#pomo-custom-timer-error'); 2555 if (error) { 2556 error.textContent = message; 2557 error.hidden = false; 2558 } 2559 field?.setAttribute('aria-invalid', 'true'); 2560 field?.focus(); 2561} 2562 2563function clearCustomTimerError() { 2564 const error = containerEl?.querySelector('#pomo-custom-timer-error'); 2565 if (error) { 2566 error.textContent = ''; 2567 error.hidden = true; 2568 } 2569 containerEl?.querySelectorAll('#pomo-custom-timer-form [aria-invalid="true"]').forEach((field) => { 2570 field.removeAttribute('aria-invalid'); 2571 }); 2572} 2573 2574function openCustomTimerEditor(timerId = null, trigger = null) { 2575 const timers = getCustomTimers(); 2576 const timer = timerId ? timers.find((item) => item.id === timerId) : null; 2577 if (timerId && !timer) return; 2578 if (!timer && timers.length >= CUSTOM_TIMER_LIMITS.maxCount) { 2579 globalCtx?.bus?.emit('ui:toast', i18n.t('pomo_custom_timer_limit_reached')); 2580 return; 2581 } 2582 2583 const layer = containerEl?.querySelector('#pomo-custom-timer-layer'); 2584 const nameInput = containerEl?.querySelector('#pomo-custom-timer-name'); 2585 const minutesInput = containerEl?.querySelector('#pomo-custom-timer-minutes'); 2586 const title = containerEl?.querySelector('#pomo-custom-timer-title'); 2587 const submit = containerEl?.querySelector('#pomo-custom-timer-submit'); 2588 const deleteButton = containerEl?.querySelector('#pomo-custom-timer-delete'); 2589 if (!layer || !nameInput || !minutesInput || !title || !submit || !deleteButton) return; 2590 2591 customTimerEditorId = timer?.id || null; 2592 customTimerEditorReturnFocus = trigger || document.activeElement; 2593 clearCustomTimerError(); 2594 2595 const titleKey = timer ? 'pomo_custom_timer_edit_title' : 'pomo_custom_timer_create_title'; 2596 const submitKey = timer ? 'pomo_custom_timer_save' : 'pomo_custom_timer_create'; 2597 title.dataset.i18n = titleKey; 2598 title.textContent = i18n.t(titleKey); 2599 submit.dataset.i18n = submitKey; 2600 submit.textContent = i18n.t(submitKey); 2601 deleteButton.hidden = !timer; 2602 nameInput.value = timer?.name || '';
2603 minutesInput.value = String(timer?.minutes || 25); 2604 const selectedCategory = timer?.category || 'focus'; 2605 renderCustomTimerCategoryOptions(selectedCategory); 2606 2607 const categoryCreator = containerEl?.querySelector('#pomo-custom-category-creator'); 2608 if (categoryCreator) { 2609 categoryCreator.hidden = true; 2610 setCustomCategoryEmoji( 2611 categoryCreator.querySelector('[data-custom-category-emoji-picker]'), 2612 DEFAULT_CUSTOM_TIMER_CATEGORY_EMOJI 2613 ); 2614 setCustomCategoryColor(categoryCreator, DEFAULT_CUSTOM_TIMER_CATEGORY_COLOR); 2615 } 2616 2617 layer.hidden = false; 2618 layer.setAttribute('aria-hidden', 'false'); 2619 window.setTimeout(() => { 2620 nameInput.focus(); 2621 if (timer) nameInput.select(); 2622 }, 0); 2623} 2624 2625function closeCustomTimerEditor() { 2626 const layer = containerEl?.querySelector('#pomo-custom-timer-layer'); 2627 if (!layer || layer.hidden) return; 2628 2629 layer.hidden = true; 2630 layer.setAttribute('aria-hidden', 'true'); 2631 clearCustomTimerError(); 2632 customTimerEditorId = null; 2633 2634 const returnFocus = customTimerEditorReturnFocus; 2635 customTimerEditorReturnFocus = null; 2636 if (returnFocus?.isConnected) returnFocus.focus(); 2637} 2638 2639function persistCustomTimers(timers) { 2640 state.settings.customTimers = normalizeCustomTimers(timers); 2641 saveSettings(); 2642 renderModeOptions(); 2643} 2644 2645function persistCustomTimerCategories(categories) { 2646 state.settings.customTimerCategories = normalizeCustomTimerCategories(categories); 2647 saveSettings(); 2648 renderModeOptions(); 2649} 2650 2651function bindCustomTimerEditorEvents() { 2652 const layer = containerEl?.querySelector('#pomo-custom-timer-layer'); 2653 const form = containerEl?.querySelector('#pomo-custom-timer-form'); 2654 const deleteButton = containerEl?.querySelector('#pomo-custom-timer-delete'); 2655 if (!layer || !form || !deleteButton) return; 2656 2657 const categoryCreator = layer.querySelector('#pomo-custom-category-creator'); 2658 const categoryNameInput = layer.querySelector('#pomo-custom-category-name'); 2659 const categoryEmojiInput = layer.querySelector('#pomo-custom-category-emoji'); 2660 const categoryColorInput = layer.querySelector('#pomo-custom-category-color'); 2661 const categoryEmojiPicker = layer.querySelector('[data-custom-category-emoji-picker]'); 2662 const categoryEmojiManualInput = layer.querySelector('[data-custom-category-emoji-manual]'); 2663 2664 categoryEmojiPicker?.querySelectorAll('[data-custom-category-emoji-option]').forEach((button) => { 2665 button.addEventListener('click', () => { 2666 setCustomCategoryEmoji(categoryEmojiPicker, button.dataset.customCategoryEmojiOption, { close: true }); 2667 categoryEmojiPicker.querySelector('summary')?.focus(); 2668 }); 2669 }); 2670 categoryEmojiManualInput?.addEventListener('input', () => { 2671 setCustomCategoryEmoji(categoryEmojiPicker, categoryEmojiManualInput.value); 2672 }); 2673 categoryEmojiManualInput?.addEventListener('keydown', (event) => { 2674 if (event.key !== 'Enter') return; 2675 event.preventDefault(); 2676 if (setCustomCategoryEmoji(categoryEmojiPicker, categoryEmojiManualInput.value, { close: true })) { 2677 categoryEmojiPicker.querySelector('summary')?.focus(); 2678 } 2679 }); 2680 layer.querySelectorAll('[data-custom-category-color-option]').forEach((button) => { 2681 button.addEventListener('click', () => { 2682 setCustomCategoryColor(categoryCreator, button.dataset.customCategoryColorOption); 2683 }); 2684 }); 2685 2686 layer.querySelector('[data-custom-category-toggle]')?.addEventListener('click', () => { 2687 if (!categoryCreator) return; 2688 const opening = categoryCreator.hidden; 2689 categoryCreator.hidden = !opening; 2690 if (!opening && categoryEmojiPicker) categoryEmojiPicker.open = false; 2691 if (opening) categoryNameInput?.focus(); 2692 }); 2693 2694 layer.querySelector('[data-custom-category-add]')?.addEventListener('click', () => { 2695 clearCustomTimerError(); 2696 const categories = getCustomTimerCategories(); 2697 if (categories.length >= CUSTOM_TIMER_CATEGORY_LIMITS.maxCount) { 2698 showCustomTimerError(i18n.t('pomo_custom_category_limit'), categoryNameInput); 2699 return; 2700 } 2701 2702 const name = normalizeCustomTimerCategoryName(categoryNameInput?.value); 2703 const emoji = normalizeCustomTimerCategoryEmoji(categoryEmojiInput?.value); 2704 const color = normalizeCustomTimerCategoryColor(categoryColorInput?.value); 2705 if (!name) { 2706 showCustomTimerError(i18n.t('pomo_custom_category_name_required'), categoryNameInput); 2707 return; 2708 } 2709 if (!emoji) { 2710 showCustomTimerError(i18n.t('pomo_custom_category_emoji_required'), categoryEmojiInput); 2711 return; 2712 } 2713 2714 const category = { 2715 id: `${CUSTOM_TIMER_CATEGORY_ID_PREFIX}${generateUUID()}`, 2716 name, 2717 emoji, 2718 color 2719 };
2720 persistCustomTimerCategories(upsertCustomTimerCategory(categories, category)); 2721 renderCustomTimerCategoryOptions(category.id); 2722 if (categoryNameInput) categoryNameInput.value = ''; 2723 setCustomCategoryEmoji(categoryEmojiPicker, DEFAULT_CUSTOM_TIMER_CATEGORY_EMOJI); 2724 setCustomCategoryColor(categoryCreator, DEFAULT_CUSTOM_TIMER_CATEGORY_COLOR); 2725 if (categoryCreator) categoryCreator.hidden = true; 2726 }); 2727 2728 layer.querySelectorAll('[data-custom-timer-close]').forEach((button) => { 2729 button.addEventListener('click', closeCustomTimerEditor); 2730 }); 2731 layer.addEventListener('keydown', (event) => { 2732 if (event.key !== 'Escape') return; 2733 event.preventDefault(); 2734 if (categoryEmojiPicker?.open) { 2735 categoryEmojiPicker.open = false; 2736 categoryEmojiPicker.querySelector('summary')?.focus(); 2737 return; 2738 } 2739 closeCustomTimerEditor(); 2740 }); 2741 layer.addEventListener('click', (event) => { 2742 if (categoryEmojiPicker?.open && !event.target.closest('[data-custom-category-emoji-picker]')) { 2743 categoryEmojiPicker.open = false; 2744 } 2745 }); 2746 2747 form.addEventListener('submit', (event) => { 2748 event.preventDefault(); 2749 clearCustomTimerError(); 2750 2751 const nameInput = form.elements.namedItem('name'); 2752 const minutesInput = form.elements.namedItem('minutes'); 2753 const categoryInput = form.elements.namedItem('category'); 2754 const name = normalizeCustomTimerName(nameInput?.value); 2755 const minutes = normalizeCustomTimerMinutes(minutesInput?.value); 2756 const category = normalizeCustomTimerCategory(categoryInput?.value); 2757 2758 if (!name) { 2759 showCustomTimerError(i18n.t('pomo_custom_timer_name_required'), nameInput); 2760 return; 2761 } 2762 if (minutes === null) { 2763 showCustomTimerError( 2764 i18n.t('pomo_custom_timer_minutes_invalid') 2765 .replace('{min}', CUSTOM_TIMER_LIMITS.minMinutes) 2766 .replace('{max}', CUSTOM_TIMER_LIMITS.maxMinutes), 2767 minutesInput 2768 ); 2769 return; 2770 } 2771 if (!category) { 2772 showCustomTimerError(i18n.t('pomo_custom_timer_category_required'), form.querySelector('input[name="category"]')); 2773 return; 2774 } 2775 2776 const timerId = customTimerEditorId || `timer-${generateUUID()}`; 2777 const mode = `${CUSTOM_MODE_PREFIX}${timerId}`; 2778 const wasEditing = Boolean(customTimerEditorId); 2779 const timers = upsertCustomTimer(getCustomTimers(), { id: timerId, name, minutes, category }); 2780 2781 persistCustomTimers(timers); 2782 closeCustomTimerEditor(); 2783 if (!wasEditing || state.mode === mode) { 2784 setMode(mode, true); 2785 } else { 2786 updateModeButtons(); 2787 } 2788 }); 2789 2790 deleteButton.addEventListener('click', () => { 2791 const timer = customTimerEditorId 2792 ? getCustomTimers().find((item) => item.id === customTimerEditorId) 2793 : null; 2794 if (!timer) return; 2795 2796 const confirmation = i18n.t('pomo_custom_timer_delete_confirm').replace('{name}', timer.name); 2797 if (!window.confirm(confirmation)) return; 2798 2799 const deletedMode = `${CUSTOM_MODE_PREFIX}${timer.id}`; 2800 persistCustomTimers(removeCustomTimer(getCustomTimers(), timer.id)); 2801 closeCustomTimerEditor(); 2802 if (state.mode === deletedMode) { 2803 setMode('focus', true); 2804 } 2805 }); 2806} 2807 2808function syncSettingsPanelVisibility() { 2809 if (!settingsPanel) return; 2810 2811 settingsPanel.classList.toggle('open', state.settingsOpen); 2812 settingsPanel.setAttribute('aria-hidden', String(!state.settingsOpen)); 2813 settingsPanel.inert = !state.settingsOpen; 2814 2815 settingsPanel.querySelectorAll(SETTINGS_FOCUSABLE_SELECTOR).forEach((element) => { 2816 if (!state.settingsOpen) { 2817 if (!element.hasAttribute(SETTINGS_PREVIOUS_TABINDEX_ATTRIBUTE)) { 2818 const previousTabIndex = element.hasAttribute('tabindex') 2819 ? element.getAttribute('tabindex') 2820 : SETTINGS_NO_TABINDEX_VALUE; 2821 element.setAttribute(SETTINGS_PREVIOUS_TABINDEX_ATTRIBUTE, previousTabIndex); 2822 } 2823 element.setAttribute('tabindex', '-1'); 2824 return; 2825 } 2826 2827 const previousTabIndex = element.getAttribute(SETTINGS_PREVIOUS_TABINDEX_ATTRIBUTE); 2828 if (previousTabIndex === null) return; 2829 2830 if (previousTabIndex === SETTINGS_NO_TABINDEX_VALUE) { 2831 element.removeAttribute('tabindex'); 2832 } else { 2833 element.setAttribute('tabindex', previousTabIndex); 2834 } 2835 element.removeAttribute(SETTINGS_PREVIOUS_TABINDEX_ATTRIBUTE); 2836 }); 2837} 2838 2839function isPomodoroKeyboardShortcutBlocked() { 2840 if (isDayClosed() || state.settingsOpen || document.hidden) return true; 2841 if (document.getElementById('modal-layer')?.classList.contains('active')) return true; 2842 if (document.getElementById('v2-quick-capture-layer')?.classList.contains('active')) return true; 2843 if (document.getElementById('onboarding-layer')?.classList.contains('active')) return true; 2844 if (containerEl?.querySelector('#pomo-custom-timer-layer:not([hidden])')) return true; 2845 return false;
2846} 2847 2848function bindEvents(ctx) { 2849 if (isDayClosed()) { 2850 const reopenBtn = containerEl.querySelector('#pomo-btn-reopen'); 2851 if (reopenBtn) { 2852 reopenBtn.addEventListener('click', async () => { 2853 if (reopenBtn.dataset.pomoResumeDay2 === 'true') { 2854 const pilotState = getTwoDayPilotState(); 2855 pomodoroStore.startNewDay(); 2856 restorePomodoroAfterDayClosure(ctx); 2857 if (!await restorePilotTaskAndStart(pilotState)) { 2858 openPilotPriorityPicker(); 2859 } 2860 return; 2861 } 2862 2863 if (confirm(`${i18n.t('pomo_confirm_reopen_title')}\n\n${i18n.t('pomo_confirm_reopen_desc')}`)) { 2864 pomodoroStore.startNewDay(); 2865 restorePomodoroAfterDayClosure(ctx); 2866 } 2867 }); 2868 } 2869 return; 2870 } 2871 2872 playBtn.addEventListener('click', toggleTimer); 2873 resetBtn.addEventListener('click', resetTimer); 2874 2875 // Settings Toggle 2876 const settingsBtn = containerEl.querySelector('#pomo-btn-settings'); 2877 const closeSettingsBtn = containerEl.querySelector('#pomo-settings-close'); 2878 2879 settingsBtn.addEventListener('click', () => { 2880 state.settingsOpen = !state.settingsOpen; 2881 syncSettingsPanelVisibility(); 2882 }); 2883 2884 closeSettingsBtn.addEventListener('click', () => { 2885 state.settingsOpen = false; 2886 syncSettingsPanelVisibility(); 2887 settingsBtn.focus(); 2888 }); 2889 2890 // PiP 2891 containerEl.querySelector('#pomo-btn-pip').addEventListener('click', () => requestPiP()); 2892 2893 // Project navigation (including PiP) 2894 bindProjectNavigation(containerEl); 2895 2896 // Global Mode Helper 2897 window.setPomoMode = (m) => setMode(m, true); 2898 2899 // Mode buttons (event delegation - works in both main window and PiP) 2900 bindModeButtons(containerEl); 2901 bindCustomTimerEditorEvents(); 2902 2903 // Event Bus Listeners 2904 if (ctx.bus) { 2905 ctx.bus.on('pomodoro:ambience:open', () => focusAmbience?.open()); 2906 ctx.bus.on('pomodoro:ambience:close', () => focusAmbience?.close()); 2907 ctx.bus.on('pomodoro:custom-timer:open', ({ timerId = null } = {}) => { 2908 containerEl?.scrollIntoView({ behavior: 'smooth', block: 'center' }); 2909 window.setTimeout(() => openCustomTimerEditor(timerId), 120); 2910 }); 2911 ctx.bus.on('settings:changed', ({ settings } = {}) => { 2912 if (!settings || typeof settings !== 'object') return; 2913 state.settings = { ...state.settings, ...settings }; 2914 state.settings.customTimers = normalizeCustomTimers(state.settings.customTimers); 2915 state.settings.customTimerCategories = normalizeCustomTimerCategories(state.settings.customTimerCategories); 2916 state.settings.startShortcut = normalizePomodoroStartShortcut(state.settings.startShortcut); 2917 renderModeOptions(); 2918 renderDailyPriorityCard(); 2919 updateUI(); 2920 }); 2921 ctx.bus.on('focus:task:changed', (task) => { 2922 const currentPriority = getCurrentDailyPriority(); 2923 state.activeTask = currentPriority 2924 ? { ...currentPriority, id: currentPriority.sourceBlockId || currentPriority.taskId, text: currentPriority.text || currentPriority.title, dailyPriority: true } 2925 : (task || null); 2926 updateControls(); 2927 renderDailyPriorityCard(); 2928 saveTimerState(); 2929 }); 2930 ctx.bus.on('daily-priorities:changed', ({ items = [] } = {}) => { 2931 syncDailyPriorityState(); 2932 renderTwoDayPilotCard(); 2933 returnToPilotFocusAfterPrioritySelection(items); 2934 }); 2935 ctx.bus.on('history:changed', () => updateDayProgressGauge()); 2936 ctx.bus.on('auth:state', ({ user }) => { 2937 const nextOwnerId = String(user?.id || '').trim(); 2938 if (pilotServerRefreshOwnerId !== nextOwnerId) { 2939 pilotServerRefreshOwnerId = nextOwnerId || null; 2940 pilotServerRefreshCoordinator.invalidate(); 2941 pilotAssistedReturnCaptureKey = null; 2942 pilotPendingAssistedReturnMarker = null; 2943 } 2944 if (user?.id) claimTwoDayPilotForUser(user.id); 2945 pilotServerEvidenceReadyOwnerId = null; 2946 dayProgressPlanCache = null; 2947 renderDailyPriorityCard(); 2948 renderTwoDayPilotCard(); 2949 if (user?.id) void refreshTwoDayPilotFromServer(); 2950 }); 2951 ctx.bus.on('store:changed', () => { 2952 if (!state.activeTask) updateControls(); 2953 }); 2954 ctx.bus.on('sync:loaded', () => { 2955 dayProgressPlanCache = null; 2956 if (!state.activeTask) updateControls(); 2957 else updateDayProgressGauge(); 2958 }); 2959 ctx.bus.on('sync:account-hydrated', ({ userId } = {}) => { 2960 if (String(userId || '') !== String(globalCtx?.currentUser?.id || '')) return; 2961 void refreshTwoDayPilotFromServer(); 2962 }); 2963 2964 const handleProjectsChange = () => { 2965 updateProjectSelectorUI(); 2966 }; 2967 ctx.bus.on('objectifs:changed', handleProjectsChange); 2968 ctx.bus.on('objectifs:refresh', handleProjectsChange); 2969 } 2970 2971 const reviewInput = containerEl.querySelector('#pomo-review-input'); 2972 const reviewBilanBtn = containerEl.querySelector('#pomo-review-bilan'); 2973 const reviewLaterBtn = containerEl.querySelector('#pomo-review-later'); 2974 2975 reviewInput?.addEventListener('input', (event) => { 2976 if (!state.pendingReview) return; 2977 2978 state.pendingReview.outcome = event.target.value.slice(0, 180); 2979 state.pendingReview.saved = false;
2980 state.pendingReview.saveError = false; 2981 syncSessionReviewStatus(); 2982 }); 2983 reviewInput?.addEventListener('blur', savePendingReviewOutcome); 2984 reviewBilanBtn?.addEventListener('click', () => { 2985 savePendingReviewOutcome(); 2986 document.querySelector('.v2-dock button[data-target="v2-past"]')?.click(); 2987 }); 2988 reviewLaterBtn?.addEventListener('click', () => { 2989 savePendingReviewOutcome(); 2990 state.pendingReview = null; 2991 syncFocusGuide(); 2992 }); 2993 2994 const endDayBtn = containerEl.querySelector('#pomo-end-day-btn'); 2995 if (endDayBtn) { 2996 let pressTimer = null; 2997 let progressInterval = null; 2998 const holdDuration = 3000; // Prevent accidental day closure. 2999 const updateRate = 50; 3000 let currentProgress = 0; 3001 3002 const startPress = (e) => { 3003 if (e.button !== undefined && e.button !== 0) return; // Only left click 3004 if (pressTimer) return; 3005 3006 currentProgress = 0; 3007 endDayBtn.style.setProperty('--progress', '0%'); 3008 3009 const progressStep = (updateRate / holdDuration) * 100; 3010 3011 progressInterval = setInterval(() => { 3012 currentProgress += progressStep; 3013 if (currentProgress >= 100) { 3014 currentProgress = 100; 3015 endDayBtn.style.setProperty('--progress', '100%'); 3016 clearInterval(progressInterval); 3017 } else { 3018 endDayBtn.style.setProperty('--progress', `${currentProgress}%`); 3019 } 3020 }, updateRate); 3021 3022 pressTimer = setTimeout(() => { 3023 cancelPress(); 3024 openDayClosureModal(); 3025 }, holdDuration); 3026 }; 3027 3028 const cancelPress = () => { 3029 if (pressTimer) { 3030 clearTimeout(pressTimer); 3031 pressTimer = null; 3032 } 3033 if (progressInterval) { 3034 clearInterval(progressInterval); 3035 progressInterval = null; 3036 } 3037 currentProgress = 0; 3038 endDayBtn.style.setProperty('--progress', '0%'); 3039 }; 3040 3041 endDayBtn.addEventListener('mousedown', startPress); 3042 endDayBtn.addEventListener('touchstart', startPress, { passive: true }); 3043 3044 window.addEventListener('mouseup', cancelPress); 3045 endDayBtn.addEventListener('mouseleave', cancelPress); 3046 endDayBtn.addEventListener('touchend', cancelPress); 3047 endDayBtn.addEventListener('touchcancel', cancelPress); 3048 endDayBtn.addEventListener('contextmenu', e => e.preventDefault()); 3049 } 3050} 3051 3052function bindSettingsEvents() { 3053 const helpToggles = [...containerEl.querySelectorAll('.pomo-help-toggle')]; 3054 const setHelpOpen = (button, isOpen) => { 3055 const helpId = button.dataset.helpTarget; 3056 const help = helpId ? containerEl.querySelector(`#${helpId}`) : null; 3057 if (!help) return; 3058 3059 button.setAttribute('aria-expanded', String(isOpen)); 3060 help.hidden = !isOpen; 3061 }; 3062 const closeOtherHelp = (activeButton = null) => { 3063 helpToggles.forEach((button) => { 3064 if (button !== activeButton) setHelpOpen(button, false); 3065 }); 3066 }; 3067 3068 helpToggles.forEach((button) => { 3069 button.addEventListener('click', () => { 3070 const shouldOpen = button.getAttribute('aria-expanded') !== 'true'; 3071 closeOtherHelp(button); 3072 setHelpOpen(button, shouldOpen); 3073 }); 3074 }); 3075 3076 settingsPanel?.addEventListener('keydown', (event) => { 3077 if (event.key !== 'Escape') return; 3078 const openButton = helpToggles.find((button) => button.getAttribute('aria-expanded') === 'true'); 3079 if (!openButton) return; 3080 3081 event.preventDefault(); 3082 setHelpOpen(openButton, false); 3083 openButton.focus(); 3084 }); 3085 3086 // Inputs Change (Live real-time updates) 3087 const bindInput = (id, key) => { 3088 const el = containerEl.querySelector(id); 3089 if (!el) return; 3090 3091 const handleUpdate = (e, isFinal) => { 3092 const rawVal = e.target.value ? e.target.value.trim() : ''; 3093 if (!rawVal && !isFinal) return; 3094 const val = isFinal 3095 ? syncNumberInputValue(e.target, key) 3096 : normalizeNumericSetting(key, e.target.value, state.settings[key]); 3097 state.settings[key] = val; 3098 saveSettings(); 3099 if (state.mode === 'focus' && key === 'focusTime' && !state.isActive) resetTimer(); 3100 if (state.mode === 'short-break' && key === 'shortBreakTime' && !state.isActive) resetTimer(); 3101 if (state.mode === 'long-break' && key === 'longBreakTime' && !state.isActive) resetTimer(); 3102 updateModeButtons(); 3103 syncFocusGuide(); 3104 }; 3105 3106 el.addEventListener('input', (e) => handleUpdate(e, false)); 3107 el.addEventListener('change', (e) => handleUpdate(e, true)); 3108 }; 3109 3110 bindInput('#set-focus-time', 'focusTime'); 3111 bindInput('#set-short-break', 'shortBreakTime'); 3112 bindInput('#set-long-break', 'longBreakTime'); 3113 bindInput('#set-long-interval', 'longBreakInterval'); 3114 bindInput('#set-daily-goal', 'dailyGoalMin'); 3115 3116 const startShortcutSelect = containerEl.querySelector('#set-start-shortcut'); 3117 startShortcutSelect?.addEventListener('change', (event) => { 3118 state.settings.startShortcut = normalizePomodoroStartShortcut(event.target.value); 3119 saveSettings(); 3120 updateControls(); 3121 }); 3122 3123 const bindStringInput = (id, key) => { 3124 const el = containerEl.querySelector(id); 3125 if (!el) return; 3126 el.addEventListener('change', (e) => { 3127 state.settings[key] = e.target.value; 3128 saveSettings(); 3129 }); 3130 }; 3131 3132 bindStringInput('#set-sleep-bedtime', 'sleepBedtime'); 3133 bindStringInput('#set-sleep-wakeup', 'sleepWakeup'); 3134 3135 // Toggles 3136 const bindToggle = (id, key) => { 3137 const el = containerEl.querySelector(id); 3138 if (!el) return; 3139 const syncToggleState = () => { 3140 el.classList.toggle('active', state.settings[key]); 3141 el.setAttribute('aria-checked', String(state.settings[key])); 3142 }; 3143 3144 syncToggleState(); 3145 el.addEventListener('click', () => { 3146 state.settings[key] = !state.settings[key]; 3147 syncToggleState(); 3148 saveSettings(); 3149 }); 3150 }; 3151 3152 bindToggle('#toggle-auto-break', 'autoStartBreaks'); 3153 bindToggle('#toggle-auto-pomo', 'autoStartPomo'); 3154 3155 // Volume 3156 const volSlider = containerEl.querySelector('#set-volume'); 3157 const volDisplay = containerEl.querySelector('#vol-display'); 3158 if (volSlider && volDisplay) { 3159 volSlider.addEventListener('input', (e) => { 3160 state.settings.volume = syncNumberInputValue(e.target, 'volume');
3161 volDisplay.textContent = state.settings.volume + '%'; 3162 saveSettings(); 3163 // Preview volume change? 3164 // beep(440, 100, state.settings.volume); 3165 }); 3166 } 3167 3168 // Alarm Select 3169 const alarmSelect = containerEl.querySelector('#set-alarm-sound'); 3170 alarmSelect.addEventListener('change', (e) => { 3171 state.settings.alarmSound = e.target.value; 3172 saveSettings(); 3173 if (state.settings.customSoundId) { 3174 audioDB.setCustomAlarmSelected(e.target.value === 'custom').catch((error) => { 3175 console.warn('Failed to persist custom alarm selection', error); 3176 }); 3177 } 3178 playAlarm(true); // Preview selection 3179 }); 3180 3181 // --- File Upload & DnD --- 3182 const uploadArea = containerEl.querySelector('#upload-alarm').closest('div'); 3183 const fileInput = containerEl.querySelector('#upload-alarm'); 3184 const infoText = containerEl.querySelector('#upload-info'); 3185 3186 // Helper: Handle File 3187 const handleFileUpload = async (file) => { 3188 if (!file) return; 3189 if (!file.type.startsWith('audio/')) { 3190 alert(i18n.t('pomo_audio_required')); 3191 return; 3192 } 3193 if (file.size > 10 * 1024 * 1024) { 3194 alert(i18n.t('pomo_file_too_big')); 3195 return; 3196 } 3197 3198 infoText.textContent = i18n.t('pomo_uploading'); 3199 3200 try { 3201 const previousId = state.settings.customSoundId; 3202 const saved = await audioDB.saveCustomAlarm(file); 3203 3204 state.settings.customSoundId = saved.id; 3205 state.settings.customSoundName = saved.name; 3206 state.settings.alarmSound = 'custom'; 3207 saveSettings(); 3208 3209 syncCustomAlarmSettingsUI(); 3210 3211 // Visual confirmation 3212 uploadArea.style.background = '#f0fdf4'; // Light green 3213 setTimeout(() => uploadArea.style.background = '#f9fafb', 1000); 3214 3215 if (previousId && previousId !== saved.id) { 3216 audioDB.deleteFile(previousId).catch(() => {}); 3217 } 3218 } catch (err) { 3219 console.error("Upload failed", err); 3220 infoText.textContent = "Erreur: " + err.message; 3221 alert(i18n.t('pomo_save_error')); 3222 } 3223 }; 3224 3225 // Input Change 3226 fileInput.addEventListener('change', (e) => handleFileUpload(e.target.files[0])); 3227 3228 // Drag and Drop Events 3229 uploadArea.addEventListener('dragover', (e) => { 3230 e.preventDefault(); 3231 uploadArea.style.background = '#eef2ff'; // Light blue highlight 3232 uploadArea.style.border = '1px dashed var(--accent-blue)'; 3233 }); 3234 3235 uploadArea.addEventListener('dragleave', (e) => { 3236 e.preventDefault(); 3237 uploadArea.style.background = '#f9fafb'; 3238 uploadArea.style.border = 'none'; 3239 }); 3240 3241 uploadArea.addEventListener('drop', (e) => { 3242 e.preventDefault(); 3243 uploadArea.style.background = '#f9fafb'; 3244 uploadArea.style.border = 'none'; 3245 3246 if (e.dataTransfer.files && e.dataTransfer.files.length > 0) { 3247 handleFileUpload(e.dataTransfer.files[0]); 3248 } 3249 }); 3250 3251 // Preview Button (Custom Sound Specific) 3252 containerEl.querySelector('#preview-custom-btn').addEventListener('click', async () => { 3253 if (!state.settings.customSoundId) { 3254 alert(i18n.t('pomo_no_custom_sound')); 3255 return; 3256 } 3257 // Force play custom sound even if another preset is selected globally 3258 playAlarm(true, true); 3259 }); 3260} 3261 3262 3263function getTodayStats() { 3264 const todayStr = getLocalDayString(); 3265 3266 // 1. Focus sessions and breaks from history 3267 let focusDuration = 0; 3268 let focusCount = 0; 3269 let shortBreakDuration = 0; 3270 let longBreakDuration = 0; 3271 3272 try { 3273 const history = pomodoroStore.getHistory(); 3274 if (Array.isArray(history)) {
3275 history.forEach(session => { 3276 if (session && session.timestamp) { 3277 const sessionDay = getLocalDayString(new Date(session.timestamp)); 3278 if (sessionDay === todayStr) { 3279 const duration = Number(session.duration) || 0; 3280 if (session.mode === 'focus') { 3281 focusDuration += duration; 3282 focusCount++; 3283 } else if (session.mode === 'short-break') { 3284 shortBreakDuration += duration; 3285 } else if (session.mode === 'long-break') { 3286 longBreakDuration += duration; 3287 } 3288 } 3289 } 3290 }); 3291 } 3292 } catch (e) { 3293 console.error("[Pomo] Error parsing history for stats", e); 3294 } 3295 3296 // 2. Priorities completed through today's execution flow. 3297 let completedCount = 0; 3298 try { 3299 const dayPlan = syncDayProgressPlan(getTodayPriorities()); 3300 completedCount = dayPlan.tasks.filter(task => task?.completed === true).length; 3301 } catch (e) { 3302 console.error("[Pomo] Error counting completed priorities", e); 3303 } 3304 3305 // 3. Goal reached check 3306 const focusMinutes = Math.round(focusDuration / 60); 3307 const dailyGoalMin = state.settings.dailyGoalMin || 120; 3308 const goalReached = focusMinutes >= dailyGoalMin; 3309 const productivityScore = Math.min(100, Math.round((focusMinutes / Math.max(1, dailyGoalMin)) * 100)); 3310 3311 return { 3312 focusDuration, 3313 focusCount, 3314 shortBreakDuration, 3315 longBreakDuration, 3316 completedCount, 3317 goalReached, 3318 productivityScore 3319 }; 3320} 3321 3322function formatDurationMinutes(seconds) { 3323 const mins = Math.round(seconds / 60); 3324 if (mins < 60) { 3325 return `${mins} min`; 3326 } 3327 const hrs = Math.floor(mins / 60); 3328 const remMins = mins % 60; 3329 return remMins > 0 ? `${hrs}h ${remMins}m` : `${hrs}h`; 3330} 3331 3332function getDayClosureFlameMarkup(className = '') { 3333 return ` 3334 <span class="${className}" aria-hidden="true"> 3335 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"> 3336 <path d="M8.5 14.5A2.5 2.5 0 0 0 11 12c0-1.38-.5-2-1-3-1.07-2.14-.22-4.05 2-6 .5 2.5 2 4.9 4 6.5 2 1.6 3 3.5 3 5.5a7 7 0 1 1-14 0c0-1.1.2-2.2.5-3.3.3 1.4 1 2.2 2 2.8z"></path> 3337 </svg> 3338 </span> 3339 `; 3340} 3341 3342function getDayClosureCoinMarkup(className = '') { 3343 return ` 3344 <span class="${className}" aria-hidden="true"> 3345 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"> 3346 <circle cx="12" cy="12" r="9"></circle> 3347 <path d="M15.5 8.5h-5a2 2 0 1 0 0 4h3a2 2 0 1 1 0 4H8.5"></path> 3348 <path d="M12 6.5v11"></path> 3349 </svg> 3350 </span> 3351 `; 3352} 3353 3354function translateSummary(key, values = {}) { 3355 return Object.entries(values).reduce((text, [name, value]) => ( 3356 text.replaceAll(`{${name}}`, String(value)) 3357 ), i18n.t(key)); 3358} 3359 3360function formatRewardNumber(value) { 3361 return Math.max(0, Math.round(Number(value) || 0)) 3362 .toLocaleString(i18n.locale()) 3363 .replace(/\s/g, ' '); 3364} 3365 3366function updateStreakBadgeValue(streak) { 3367 const value = document.getElementById('streak-val'); 3368 if (!value || !Number.isFinite(Number(streak))) return; 3369 const dayKey = Number(streak) > 1 ? 'topbar_days_plural' : 'topbar_days_singular'; 3370 value.textContent = `${streak} ${i18n.t(dayKey)} `; 3371} 3372 3373function updateCreditsBadgeValue(credits) { 3374 const value = document.getElementById('coins-val'); 3375 if (!value || !Number.isFinite(Number(credits))) return; 3376 value.textContent = formatRewardNumber(credits); 3377} 3378 3379function animateDaySummaryNumber(element, from, to, duration = 900) { 3380 if (!element) return; 3381 const startValue = Math.max(0, Math.round(Number(from) || 0)); 3382 const endValue = Math.max(0, Math.round(Number(to) || 0)); 3383 const reduceMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches; 3384 if (reduceMotion || startValue === endValue) { 3385 element.textContent = formatRewardNumber(endValue); 3386 return; 3387 } 3388 3389 const startedAt = performance.now(); 3390 const renderFrame = (now) => { 3391 const progress = Math.min(1, (now - startedAt) / duration); 3392 const eased = 1 - Math.pow(1 - progress, 3); 3393 element.textContent = formatRewardNumber(startValue + ((endValue - startValue) * eased)); 3394 if (progress < 1) requestAnimationFrame(renderFrame); 3395 }; 3396 requestAnimationFrame(renderFrame); 3397} 3398 3399function animateDayClosureRewardToTopbar({ 3400 sourceSelector, 3401 targetId, 3402 value, 3403 delay, 3404 flyingClass, 3405 awardedClass,
3406 updateValue 3407}) { 3408 const source = document.querySelector(sourceSelector); 3409 const target = document.getElementById(targetId); 3410 const reduceMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches; 3411 if (!source || !target || reduceMotion || typeof source.animate !== 'function') { 3412 updateValue(value); 3413 return; 3414 } 3415 3416 const sourceRect = source.getBoundingClientRect(); 3417 const targetRect = target.getBoundingClientRect(); 3418 if (!sourceRect.width || !targetRect.width) { 3419 updateValue(value); 3420 return; 3421 } 3422 3423 const flyingReward = source.cloneNode(true); 3424 flyingReward.classList.add(flyingClass); 3425 flyingReward.setAttribute('aria-hidden', 'true'); 3426 Object.assign(flyingReward.style, { 3427 left: `${sourceRect.left}px`, 3428 top: `${sourceRect.top}px`, 3429 width: `${sourceRect.width}px`, 3430 height: `${sourceRect.height}px` 3431 }); 3432 document.body.appendChild(flyingReward); 3433 3434 const translateX = targetRect.left + (targetRect.width / 2) - (sourceRect.left + sourceRect.width / 2); 3435 const translateY = targetRect.top + (targetRect.height / 2) - (sourceRect.top + sourceRect.height / 2); 3436 const animation = flyingReward.animate([ 3437 { transform: 'translate3d(0, 0, 0) scale(1)', opacity: 1 }, 3438 { offset: 0.72, transform: `translate3d(${translateX * 0.78}px, ${translateY * 0.78}px, 0) scale(0.72)`, opacity: 1 }, 3439 { transform: `translate3d(${translateX}px, ${translateY}px, 0) scale(0.38)`, opacity: 0.15 } 3440 ], { 3441 duration: 980, 3442 delay, 3443 easing: 'cubic-bezier(0.3, 0.8, 0.2, 1)', 3444 fill: 'forwards' 3445 }); 3446 3447 const finishReward = () => { 3448 flyingReward.remove(); 3449 updateValue(value); 3450 target.classList.add(awardedClass); 3451 window.setTimeout(() => target.classList.remove(awardedClass), 850); 3452 }; 3453 animation.finished.then(finishReward, finishReward); 3454} 3455 3456function getDayClosureRewardSnapshot(stats, closureResult) { 3457 const now = new Date(); 3458 const sessions = pomodoroStore.getHistory(); 3459 const factoryStats = buildFactoryStats( 3460 sessions, 3461 now, 3462 loadSettledFactoryState(sessions, now) 3463 ); 3464 const rankState = calculateProductivityRank(sessions, now); 3465 const streakCurrent = Number(closureResult.closure?.protectedStreak) || pomodoroStore.getProtectedStreak(); 3466 const streakGain = closureResult.streakAdded ? 1 : 0; 3467 const creditsCurrent = Math.max(0, Math.round(Number(factoryStats.availableCredits) || 0)); 3468 const creditsGain = Math.max(0, Math.round(Number(factoryStats.creditsToday) || 0)); 3469 3470 return { 3471 streakBefore: Math.max(0, streakCurrent - streakGain), 3472 streakGain, 3473 streakCurrent, 3474 score: Math.max(0, Math.min(100, Number(stats.productivityScore) || 0)), 3475 rankState, 3476 creditsBefore: Math.max(0, creditsCurrent - creditsGain), 3477 creditsGain, 3478 creditsCurrent 3479 }; 3480} 3481 3482function getNextRankMarkup(rankState) { 3483 if (!rankState.nextRank) { 3484 return `<p class="pomo-day-summary__rank-next">${i18n.t('pomo_summary_rank_max')}</p>`; 3485 } 3486 3487 const nextRankName = i18n.t(rankState.nextRank.labelKey); 3488 return ` 3489 <p class="pomo-day-summary__rank-next"> 3490 <strong>${translateSummary('pomo_summary_rank_next', { rank: nextRankName })}</strong> 3491 <span>${translateSummary('pomo_summary_rank_conditions', { 3492 minutes: rankState.nextRank.minAverage, 3493 active: rankState.nextRank.minActiveDays, 3494 history: rankState.nextRank.minHistoryDays 3495 })}</span> 3496 </p> 3497 `; 3498} 3499 3500function animateDaySummaryRewards(rewardSnapshot) { 3501 const summary = document.querySelector('.pomo-day-summary'); 3502 if (!summary) return; 3503 3504 updateStreakBadgeValue(rewardSnapshot.streakBefore); 3505 updateCreditsBadgeValue(rewardSnapshot.creditsBefore); 3506 requestAnimationFrame(() => summary.classList.add('is-rewarding')); 3507 3508 animateDaySummaryNumber( 3509 summary.querySelector('.pomo-day-summary__score-value'), 3510 0, 3511 rewardSnapshot.score, 3512 950 3513 ); 3514 animateDaySummaryNumber( 3515 summary.querySelector('.pomo-day-summary__credits-value'), 3516 rewardSnapshot.creditsBefore, 3517 rewardSnapshot.creditsCurrent, 3518 1150 3519 ); 3520 3521 animateDayClosureRewardToTopbar({ 3522 sourceSelector: '.pomo-day-summary__streak-symbol svg', 3523 targetId: 'user-streak', 3524 value: rewardSnapshot.streakCurrent, 3525 delay: 520, 3526 flyingClass: 'pomo-day-summary__flying-flame', 3527 awardedClass: 'is-streak-awarded',
3528 updateValue: updateStreakBadgeValue 3529 }); 3530 animateDayClosureRewardToTopbar({ 3531 sourceSelector: '.pomo-day-summary__coin-icon svg', 3532 targetId: 'user-coins', 3533 value: rewardSnapshot.creditsCurrent, 3534 delay: 880, 3535 flyingClass: 'pomo-day-summary__flying-coin', 3536 awardedClass: 'is-credits-awarded', 3537 updateValue: updateCreditsBadgeValue 3538 }); 3539 3540 const scoreDisplay = document.getElementById('score-display'); 3541 window.setTimeout(() => { 3542 scoreDisplay?.classList.add('is-score-awarded'); 3543 window.setTimeout(() => scoreDisplay?.classList.remove('is-score-awarded'), 850); 3544 }, 880); 3545} 3546 3547function openDayClosureModal() { 3548 const stats = getTodayStats(); 3549 3550 const closureData = { 3551 closedAt: new Date().toISOString(), 3552 date: getLocalDayString(), 3553 shutdownNote: '', 3554 statsSnapshot: stats 3555 }; 3556 3557 const closureResult = pomodoroStore.setDayClosure(closureData); 3558 const rewardSnapshot = getDayClosureRewardSnapshot(stats, closureResult); 3559 3560 if (state.isActive) { 3561 pauseTimer(); 3562 } 3563 3564 renderLayout(); 3565 bindEvents(globalCtx); 3566 3567 const focusTimeFormatted = formatDurationMinutes(stats.focusDuration); 3568 const focusSessionCount = stats.focusCount; 3569 const completedTasksCount = stats.completedCount; 3570 const currentRankName = i18n.t(rewardSnapshot.rankState.labelKey); 3571 const rankAsset = RANK_BADGE_ASSETS[rewardSnapshot.rankState.id] || RANK_BADGE_ASSETS.seed; 3572 3573 const html = ` 3574 <div class="pomo-day-summary" role="status" aria-labelledby="pomo-day-summary-title"> 3575 <header class="pomo-day-summary__header"> 3576 <span class="pomo-day-summary__eyebrow">${i18n.t('pomo_summary_eyebrow')}</span> 3577 <h2 id="pomo-day-summary-title">${i18n.t('pomo_summary_title')}</h2> 3578 </header> 3579 3580 <section class="pomo-day-summary__streak" aria-label="${i18n.t('pomo_summary_streak_label')}"> 3581 <div class="pomo-day-summary__streak-heading"> 3582 ${getDayClosureFlameMarkup('pomo-day-summary__streak-symbol')} 3583 <span>${i18n.t('pomo_summary_streak_label')}</span> 3584 </div> 3585 <div class="pomo-day-summary__streak-flow"> 3586 <span class="pomo-day-summary__streak-value pomo-day-summary__streak-before"> 3587 <small>${i18n.t('pomo_summary_before')}</small> 3588 <strong>${rewardSnapshot.streakBefore}</strong> 3589 </span> 3590 <span class="pomo-day-summary__streak-gain">+${rewardSnapshot.streakGain}</span> 3591 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12h14"></path><path d="m14 7 5 5-5 5"></path></svg> 3592 <span class="pomo-day-summary__streak-value pomo-day-summary__streak-current"> 3593 <small>${i18n.t('pomo_summary_now')}</small> 3594 <strong>${rewardSnapshot.streakCurrent}</strong> 3595 </span> 3596 </div> 3597 </section> 3598 3599 <div class="pomo-day-summary__rewards"> 3600 <section class="pomo-day-summary__reward-card pomo-day-summary__score-card"> 3601 <span class="pomo-day-summary__reward-label">${i18n.t('pomo_summary_score')}</span> 3602 <div class="pomo-day-summary__score"> 3603 <strong class="pomo-day-summary__score-value">0</strong><span>/100</span> 3604 </div> 3605 <div class="pomo-day-summary__rank"> 3606 <img src="${rankAsset}" alt="" aria-hidden="true"> 3607 <span><small>${i18n.t('pomo_summary_rank')}</small><strong>${currentRankName}</strong></span> 3608 </div> 3609 ${getNextRankMarkup(rewardSnapshot.rankState)} 3610 </section> 3611 3612 <section class="pomo-day-summary__reward-card pomo-day-summary__credits-card"> 3613 <div class="pomo-day-summary__credits-heading"> 3614 ${getDayClosureCoinMarkup('pomo-day-summary__coin-icon')}
3615 <span class="pomo-day-summary__reward-label">${i18n.t('pomo_summary_credits')}</span> 3616 </div> 3617 <strong class="pomo-day-summary__credits-value">${formatRewardNumber(rewardSnapshot.creditsBefore)}</strong> 3618 <span class="pomo-day-summary__credits-gain">${translateSummary('pomo_summary_credits_gain', { value: formatRewardNumber(rewardSnapshot.creditsGain) })}</span> 3619 </section> 3620 </div> 3621 3622 <dl class="pomo-day-summary__stats"> 3623 <div><dt>${i18n.t('pomo_summary_sessions')}</dt><dd>${focusSessionCount}</dd></div> 3624 <div><dt>${i18n.t('pomo_summary_focus_time')}</dt><dd>${focusTimeFormatted}</dd></div> 3625 <div><dt>${i18n.t('pomo_summary_priorities')}</dt><dd>${completedTasksCount}</dd></div> 3626 </dl> 3627 3628 <button id="pomo-day-summary-prepare" class="pomo-day-summary__button" type="button">${i18n.t('pomo_summary_prepare_tomorrow')}</button> 3629 </div> 3630 `; 3631 3632 globalCtx.bus.emit('ui:modal:open', { 3633 titleKey: 'pomo_modal_closure_title', 3634 className: 'pomo-day-summary-modal', 3635 content: html 3636 }); 3637 3638 setTimeout(() => { 3639 animateDaySummaryRewards(rewardSnapshot); 3640 const prepareTomorrow = document.getElementById('pomo-day-summary-prepare'); 3641 if (prepareTomorrow) { 3642 prepareTomorrow.addEventListener('click', () => { 3643 document.getElementById('modal-close-btn')?.click(); 3644 globalCtx.bus?.emit('planning-session:start', { 3645 ownerId: String(globalCtx.currentUser?.id || 'guest'), 3646 requestedAt: new Date().toISOString() 3647 }); 3648 const organisationButton = document.querySelector('.dock-btn[data-target="v2-future"]'); 3649 if (organisationButton instanceof HTMLElement) { 3650 organisationButton.click(); 3651 return; 3652 } 3653 document.getElementById('v2-future')?.scrollIntoView({ behavior: 'smooth' }); 3654 }); 3655 prepareTomorrow.focus({ preventScroll: true }); 3656 } 3657 }, 0); 3658 window.setTimeout(() => globalCtx.bus?.emit('history:changed'), 1900); 3659} 3660 3661 3662/* --- Timer Logic --- */ 3663 3664function toggleTimer() { 3665 stopAlarm(); // Stop ringing if ringing 3666 if (isDayClosed()) { 3667 globalCtx.bus.emit('ui:toast', i18n.t('pomo_alert_closed_desc')); 3668 return; 3669 } 3670 if (state.isActive) { 3671 pauseTimer(); 3672 } else { 3673 startTimer(); 3674 } 3675} 3676 3677function selectNextTaskForFocus() { 3678 const nextTask = getRecommendedTask(); 3679 3680 if (nextTask) { 3681 globalCtx.bus.emit('app:push-task', { blockId: nextTask.blockId || nextTask.id, blockText: nextTask.text }); 3682 return true; 3683 } 3684 3685 globalCtx.bus.emit('ui:quick-capture:open'); 3686 return true; 3687} 3688 3689function finishFollowerTimer() { 3690 state.isActive = false; 3691 state.timeLeft = 0; 3692 state.sessionEndMs = null; 3693 clearInterval(state.interval); 3694 updateUI(); 3695 updateControls(); 3696 globalCtx?.bus?.emit('timer:stopped'); 3697} 3698 3699function claimPomodoroTimerOwnership() { 3700 state.timerOwnerTabId = timerTabId; 3701 state.timerOwnerHeartbeatAt = Date.now(); 3702 state.timerHeartbeatTicks = 0; 3703 state.isTimerFollower = false; 3704 saveTimerState(); 3705 syncBrowserFocusProtection(true, 'active'); 3706} 3707 3708function startCountdownInterval() { 3709 if (state.interval) clearInterval(state.interval); 3710 3711 const delay = state.isDemoSession ? 40 : 1000; 3712 state.interval = setInterval(() => { 3713 if ( 3714 state.isTimerFollower 3715 && shouldClaimPomodoroTimerOwnership({ 3716 isActive: state.isActive, 3717 ownerTabId: state.timerOwnerTabId, 3718 ownerHeartbeatAt: state.timerOwnerHeartbeatAt 3719 }, timerTabId) 3720 ) { 3721 claimPomodoroTimerOwnership(); 3722 } 3723 3724 if (state.timeLeft > 0) { 3725 if (state.isDemoSession) { 3726 state.timeLeft = Math.max(0, state.timeLeft - 60); 3727 } else { 3728 state.timeLeft--; 3729 } 3730 updateUI(); 3731 3732 if (!state.isDemoSession && !state.isTimerFollower) { 3733 state.timerHeartbeatTicks++; 3734 if (state.timerHeartbeatTicks >= 5) { 3735 state.timerHeartbeatTicks = 0; 3736 saveTimerState(); 3737 } 3738 } 3739 3740 // Broadcast Tick for Real-time Analytics 3741 if (globalCtx && globalCtx.bus) { 3742 const total = getDuration();
3743 const elapsed = total - state.timeLeft; 3744 globalCtx.bus.emit('timer:tick', { 3745 mode: state.mode, 3746 timeLeft: state.timeLeft, 3747 duration: total, 3748 elapsed: elapsed 3749 }); 3750 } 3751 } else if (state.isTimerFollower) { 3752 finishFollowerTimer(); 3753 } else { 3754 completeTimer(); 3755 } 3756 }, delay); 3757} 3758 3759function startTimer({ preserveAlarm = false } = {}) { 3760 if (!preserveAlarm) stopAlarm(); // Ensure ring resets 3761 if (isDayClosed()) { 3762 globalCtx.bus.emit('ui:toast', i18n.t('pomo_alert_closed_desc')); 3763 return; 3764 } 3765 if (isFocusMode(state.mode) && getCurrentDailyPriority()?.missing) { 3766 globalCtx?.bus?.emit('ui:toast', 'Cette priorité doit être resynchronisée ou retirée avant de lancer un focus.'); 3767 return; 3768 } 3769 if (isFocusMode(state.mode) && state.pendingReview) { 3770 savePendingReviewOutcome(); 3771 state.pendingReview = null; 3772 syncFocusGuide(); 3773 } 3774 const isDemo = !!document.getElementById('onboarding-layer')?.classList.contains('active'); 3775 state.isDemoSession = isDemo; 3776 state.isActive = true; 3777 state.isTimerFollower = false; 3778 state.timerOwnerTabId = timerTabId; 3779 state.timerOwnerHeartbeatAt = Date.now(); 3780 state.timerHeartbeatTicks = 0; 3781 state.sessionEndMs = Date.now() + state.timeLeft * 1000; 3782 if (!state.isDemoSession && isFocusMode(state.mode)) { 3783 state.browserFocusSessionId ||= generateUUID(); 3784 syncBrowserFocusProtection(true, 'active'); 3785 } 3786 updateControls(); 3787 saveTimerState(); 3788 3789 startCountdownInterval(); 3790 3791 // Analytics (Activation Moment): only a real Focus session counts here. 3792 if (!state.isDemoSession && isFocusMode(state.mode) && typeof window.gtag === 'function') { 3793 window.gtag('event', 'focus_session_start', { 3794 'event_category': 'usage', 3795 'event_label': state.mode, 3796 'duration_minutes': Math.round(getDuration() / 60) 3797 }); 3798 } 3799} 3800 3801function pauseTimer({ preserveAlarm = false, browserStatus = 'paused' } = {}) { 3802 if (!preserveAlarm) stopAlarm(); // Can also stop if pause 3803 state.timerOwnerTabId = timerTabId; 3804 state.timerOwnerHeartbeatAt = Date.now(); 3805 state.timerHeartbeatTicks = 0; 3806 state.isTimerFollower = false; 3807 syncBrowserFocusProtection(false, browserStatus); 3808 state.isActive = false; 3809 state.sessionEndMs = null; 3810 clearInterval(state.interval); 3811 updateControls(); 3812 saveTimerState(); 3813 // Notify analytics that timer stopped (clear active session) 3814 if (globalCtx && globalCtx.bus) { 3815 globalCtx.bus.emit('timer:stopped'); 3816 } 3817} 3818 3819function resetTimer({ preserveAlarm = false, browserStatus = 'cancelled' } = {}) { 3820 if (!preserveAlarm) stopAlarm(); // Key: Stop alarm if ringing 3821 pauseTimer({ preserveAlarm: true, browserStatus }); 3822 state.browserFocusSessionId = null; 3823 3824 // Get Time based on mode and settings 3825 state.timeLeft = getDuration(); 3826 updateUI(); 3827 updateControls(); 3828 saveTimerState(); 3829} 3830 3831function setMode(mode, manual = false, { preserveAlarm = false } = {}) { 3832 const nextMode = (MODES[mode] || getCustomTimer(mode)) ? mode : 'focus'; 3833 const modeChanged = state.mode !== nextMode; 3834 if (nextMode !== 'focus' || modeChanged || manual) { 3835 state.focusDurationOverrideSeconds = null; 3836 } 3837 state.mode = nextMode; 3838 resetTimer({ preserveAlarm }); 3839 3840 // Update Badge (card header - main window) 3841 const badge = containerEl.querySelector('#pomo-mode-badge'); 3842 syncModeBadge(badge, nextMode); 3843 3844 // Update mode buttons active state (works in both main & PiP) 3845 updateModeButtons(); 3846 syncFocusGuide(); 3847} 3848 3849function updateModeButtons() { 3850 // The .pomo-modes may be in main window or PiP window 3851 // Search in both containerEl and any active PiP 3852 const roots = [containerEl]; 3853 if ('documentPictureInPicture' in window && window.documentPictureInPicture.window) { 3854 roots.push(window.documentPictureInPicture.window.document); 3855 } 3856 3857 // Calculate current durations in minutes 3858 const override = Number(state.focusDurationOverrideSeconds); 3859 const focusMins = Math.round((Number.isFinite(override) && override > 0 ? override : getTaskFocusMinutes() * 60) / 60); 3860 const shortMins = state.settings.shortBreakTime; 3861 const longMins = state.settings.longBreakTime; 3862
3863 roots.forEach(root => { 3864 const buttons = root.querySelectorAll('.badge-pomo[data-mode]'); 3865 buttons.forEach(btn => { 3866 const m = btn.dataset.mode; 3867 btn.classList.toggle('active', m === state.mode); 3868 3869 const customTimer = getCustomTimer(m); 3870 const mins = customTimer?.minutes 3871 ?? (m === 'focus' ? focusMins : m === 'short-break' ? shortMins : longMins); 3872 const label = getModeLabel(m); 3873 const editLabel = i18n.t('pomo_custom_timer_edit_aria').replace('{name}', label); 3874 3875 btn.innerHTML = `<span class="badge-pomo-label">${escapeHtml(label)}</span><button class="mode-duration-badge" type="button" data-timer-edit="${escapeHtml(m)}" aria-label="${escapeHtml(editLabel)}" title="${escapeHtml(editLabel)}"> 3876 <span class="mode-duration-normal"> 3877 <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="margin-right:2px"><circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16 14"></polyline></svg> 3878 <span class="mode-duration-text">${mins}m</span> 3879 </span> 3880 <span class="mode-duration-pencil" aria-hidden="true">
3881 <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"></path><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"></path></svg> 3882 </span> 3883 </button>`; 3884 }); 3885 }); 3886} 3887 3888async function completeTimer() { 3889 pauseTimer({ browserStatus: 'completed' }); 3890 const completedMode = state.mode; 3891 const completedTaskText = String(state.activeTask?.text || '').trim(); 3892 const completedTaskId = String( 3893 state.activeTask?.taskId 3894 || getCurrentDailyPriority()?.taskId 3895 || '' 3896 ).trim() || null; 3897 3898 if (state.isDemoSession) { 3899 // The accelerated onboarding demo must advance without polluting 3900 // the user's history or the real Focus analytics. 3901 globalCtx?.bus?.emit('pomo:completed'); 3902 state.isDemoSession = false; 3903 state.isActive = false; 3904 resetTimer(); 3905 return; 3906 } 3907 3908 playAlarm(); 3909 3910 // A pause can be persisted in history, but it is not a completed Focus. 3911 if (isFocusMode(completedMode)) { 3912 sendGAEvent('focus_session_complete', 'productivity', completedMode); 3913 } 3914 3915 // Notification 3916 if ('Notification' in window) { 3917 if (Notification.permission === "granted") { 3918 new Notification(i18n.t('pomo_timer_done'), { body: getModeLabel(state.mode) }); 3919 } else if (Notification.permission !== "denied") { 3920 Notification.requestPermission(); 3921 } 3922 } 3923 3924 // Auto-switch Logic 3925 const completedSession = saveSession(); // Save real session (Focus or Break) 3926 if (isFocusMode(completedMode)) state.browserFocusSessionId = null; 3927 3928 if (isFocusMode(completedMode) && completedSession && globalCtx?.currentUser?.id) { 3929 // J1/J2 only advances after this Focus has been persisted and read back 3930 // from the authenticated server history. Browser history is never the 3931 // retention source of truth. 3932 const completedLocalDate = getPilotLocalDate(completedSession.timestamp); 3933 const pilotResult = await refreshTwoDayPilotFromServer({ 3934 flushHistory: true, 3935 resumeTaskId: completedTaskId 3936 }); 3937 const pilotAfter = pilotResult.state; 3938 const evidence = pilotResult.evidence; 3939 const advancedThisCompletion = Boolean( 3940 completedLocalDate
3941 && pilotAfter?.serverConfirmed === true 3942 && pilotResult.snapshot?.historyPersisted === true 3943 && pilotResult.advancedDates?.includes(completedLocalDate) 3944 ); 3945 const completedDayCount = completedLocalDate === evidence?.j1Date 3946 ? 1 3947 : completedLocalDate === evidence?.j2Date 3948 ? 2 3949 : 0; 3950 3951 if (advancedThisCompletion && completedDayCount > 0) { 3952 const campaign = pilotAfter?.campaign || 'two_day_pilot'; 3953 if (completedDayCount === 1) { 3954 sendGAEvent('activation_first_focus_completed', 'activation', 'server_confirmed_j1'); 3955 } 3956 sendGAEvent('pilot_day_completed', 'activation', `${campaign}:day_${completedDayCount}`); 3957 3958 if (completedDayCount === 2) { 3959 const classification = evidence?.j2Classification || 'UNVERIFIED_J2'; 3960 sendGAEvent( 3961 'pilot_two_day_completed', 3962 'retention', 3963 `${campaign}:${classification.toLowerCase()}` 3964 ); 3965 if (classification === 'NATURAL_J2') { 3966 sendGAEvent('retention_second_day_focus_completed', 'retention', 'natural_two_day_return'); 3967 sendGAEvent('activation_two_day_completed', 'activation', 'natural_two_day_return'); 3968 } else if (classification === 'ASSISTED_J2') { 3969 const assistanceType = evidence?.assistance?.type || 'assisted_return'; 3970 sendGAEvent('retention_assisted_second_day_focus_completed', 'retention', assistanceType); 3971 sendGAEvent('activation_assisted_two_day_completed', 'activation', assistanceType); 3972 } else { 3973 sendGAEvent( 3974 'retention_unverified_second_day_focus_completed', 3975 'retention', 3976 'server_assistance_unavailable' 3977 ); 3978 } 3979 } 3980 3981 const isOrganicReturn = campaign === ORGANIC_TWO_DAY_PILOT_CAMPAIGN; 3982 const progressTitleKey = completedDayCount >= 2 3983 ? (isOrganicReturn ? 'organic_progress_complete_title' : 'pilot_progress_complete_title') 3984 : (isOrganicReturn ? 'organic_progress_wait_title' : 'pilot_progress_wait_title'); 3985 globalCtx?.bus?.emit('ui:toast', i18n.t(progressTitleKey)); 3986 globalCtx?.bus?.emit('two-day-pilot:changed'); 3987 } 3988 } 3989 3990 if (isFocusMode(completedMode)) { 3991 // Post-session review card deactivated as requested 3992 state.pomoCount++; 3993 // Check for long break 3994 if (state.pomoCount % state.settings.longBreakInterval === 0) { 3995 setMode('long-break', false, { preserveAlarm: true }); 3996 } else { 3997 setMode('short-break', false, { preserveAlarm: true }); 3998 } 3999 4000 if (state.settings.autoStartBreaks) { 4001 startTimer({ preserveAlarm: true }); 4002 } 4003 4004 } else { 4005 // Break is over, back to focus 4006 setMode('focus', false, { preserveAlarm: true }); 4007 if (state.settings.autoStartPomo) { 4008 startTimer({ preserveAlarm: true }); 4009 } 4010 } 4011} 4012 4013function saveSession() { 4014 try { 4015 const duration = getDuration(); 4016 const activeProj = getSelectedProject(); 4017 const customTimer = getCustomTimer(state.mode); 4018 const timerCategory = customTimer ? getTimerCategoryMeta(customTimer.category) : null; 4019 const browserFocusSessionId = isFocusMode(state.mode) 4020 ? String(state.browserFocusSessionId || '') 4021 : ''; 4022 const browserFeedback = browserFocusSessionId 4023 ? pendingBrowserFeedback.get(browserFocusSessionId) 4024 : null; 4025 const newSession = { 4026 id: generateUUID(), 4027 timestamp: Date.now() - (duration * 1000), // Save Start Time, not End Time 4028 duration: duration, 4029 mode: getModeCategory(state.mode), 4030 ...(customTimer ? { 4031 customTimerId: customTimer.id, 4032 customTimerName: customTimer.name, 4033 customTimerCategory: customTimer.category, 4034 ...(timerCategory?.custom ? { 4035 customTimerCategoryName: timerCategory.name, 4036 customTimerCategoryEmoji: timerCategory.emoji, 4037 customTimerCategoryColor: timerCategory.color 4038 } : {}) 4039 } : {}), 4040 ...(isFocusMode(state.mode) ? {
4041 projectId: activeProj ? activeProj.id : null, 4042 projectName: activeProj ? activeProj.title : '', 4043 projectIcon: activeProj ? activeProj.icon : '', 4044 outcome: '', 4045 ...(browserFocusSessionId ? { 4046 browserFocusSessionId, 4047 ...(browserFeedback ? { browserFeedback } : {}) 4048 } : {}) 4049 } : {}) 4050 }; 4051 pomodoroStore.addSession(newSession); 4052 if (browserFeedback) pendingBrowserFeedback.delete(browserFocusSessionId); 4053 4054 // Notify Analytics & Sync 4055 if (globalCtx && globalCtx.bus) { 4056 globalCtx.bus.emit('pomo:completed'); 4057 } 4058 return newSession; 4059 } catch (e) { 4060 console.error("Failed to save session", e); 4061 return null; 4062 } 4063} 4064 4065function savePendingReviewOutcome() { 4066 if (!state.pendingReview) return false; 4067 if (state.pendingReview.saved && !state.pendingReview.saveError) return true; 4068 4069 try { 4070 const history = pomodoroStore.getHistory(); 4071 const outcome = String(state.pendingReview.outcome || '').trim(); 4072 let sessionFound = false; 4073 const updatedHistory = history.map((session) => { 4074 if (session?.id !== state.pendingReview.sessionId) return session; 4075 4076 sessionFound = true; 4077 return { ...session, outcome }; 4078 }); 4079 4080 if (!sessionFound) throw new Error('Completed session not found'); 4081 4082 pomodoroStore.updateHistory(updatedHistory); 4083 state.pendingReview.outcome = outcome; 4084 state.pendingReview.saved = true; 4085 state.pendingReview.saveError = false; 4086 syncSessionReviewStatus(); 4087 4088 if (globalCtx?.bus) { 4089 globalCtx.bus.emit('history:changed'); 4090 } 4091 return true; 4092 } catch (e) { 4093 console.error("Failed to save review outcome", e); 4094 state.pendingReview.saved = false; 4095 state.pendingReview.saveError = true; 4096 syncSessionReviewStatus(); 4097 return false; 4098 } 4099} 4100 4101function formatTimerText(seconds) { 4102 const minutes = Math.floor(seconds / 60).toString().padStart(2, '0'); 4103 const remainingSeconds = (seconds % 60).toString().padStart(2, '0'); 4104 return `${minutes}:${remainingSeconds}`; 4105} 4106 4107function updateUI() { 4108 const totalSeconds = getDuration(); 4109 const pct = 1 - (state.timeLeft / totalSeconds); 4110 4111 // Time 4112 const timeText = formatTimerText(state.timeLeft); 4113 if (timeEl) timeEl.textContent = timeText; 4114 document.title = state.isActive || state.timeLeft < totalSeconds 4115 ? `${timeText} - Pomodoro` 4116 : i18n.t('app_document_title'); 4117 4118 // Ring 4119 if (ringEl) { 4120 const circle = ringEl.querySelector('#pomo-progress-circle'); 4121 if (circle) { 4122 if (state.isDemoSession) { 4123 circle.style.transition = 'none'; 4124 } else { 4125 circle.style.transition = 'stroke-dashoffset 1s linear, stroke 0.3s ease'; 4126 } 4127 const color = getModeColor(state.mode); 4128 circle.style.stroke = color; 4129 // Circumference is 157.08 for r=25 (filled Pac-Man style) 4130 const circumference = 157.08; 4131 const offset = circumference * (1 - pct); 4132 circle.style.strokeDashoffset = offset; 4133 4134 // Clean up old conic gradient style to prevent any interaction/background bleeding 4135 if (ringEl.style.background) { 4136 ringEl.style.background = ''; 4137 } 4138 } else { 4139 const deg = pct * 360; 4140 const color = getModeColor(state.mode); 4141 ringEl.style.background = `conic-gradient(${color} ${deg}deg, #E5E5EA ${deg}deg)`; 4142 } 4143 } 4144 4145 // ETA Finish Time 4146 if (ringEl) { 4147 const etaEl = ringEl.querySelector('#pomo-eta'); 4148 const etaTimeEl = ringEl.querySelector('#pomo-eta-time'); 4149 if (etaEl && etaTimeEl) { 4150 const endMs = (state.isActive && state.sessionEndMs) ? state.sessionEndMs : (Date.now() + state.timeLeft * 1000); 4151 const endStr = new Date(endMs).toLocaleTimeString(i18n.getLang() === 'fr' ? 'fr-FR' : 'en-US', { hour: '2-digit', minute: '2-digit' }); 4152 etaTimeEl.textContent = endStr; 4153 etaEl.style.display = 'flex'; 4154 } 4155 } 4156 4157 updateMobileMiniTimer(); 4158 updateDayProgressGauge(); 4159 focusAmbience?.sync(); 4160} 4161 4162function updateControls() { 4163 if (!playBtn) return; 4164 const guideCopy = getFocusGuideCopy();
4165 const hasProgress = state.timeLeft < getDuration(); 4166 let actionLabel = guideCopy.pauseAction; 4167 let actionIcon = '<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="4" width="4" height="16" rx="2"></rect><rect x="14" y="4" width="4" height="16" rx="2"></rect></svg>'; 4168 4169 if (!state.isActive) { 4170 actionIcon = '<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="2" stroke-linejoin="round"><path d="M8 5v14l11-7z" /></svg>'; 4171 4172 if (hasProgress) { 4173 actionLabel = guideCopy.resumeAction; 4174 } else if (isFocusMode(state.mode)) { 4175 actionLabel = guideCopy.startFocusAction.replace('{minutes}', Math.round(getDuration() / 60)); 4176 } else { 4177 actionLabel = guideCopy.startBreakAction; 4178 } 4179 } 4180 4181 playBtn.removeAttribute('data-i18n-aria-label'); 4182 playBtn.setAttribute('aria-label', actionLabel); 4183 const startShortcut = getPomodoroStartShortcut(state.settings.startShortcut); 4184 const shortcutLabelKey = { 4185 space: 'pomo_shortcut_space', 4186 keyP: 'pomo_shortcut_key_p', 4187 keyF: 'pomo_shortcut_key_f' 4188 }[normalizePomodoroStartShortcut(state.settings.startShortcut)]; 4189 if (startShortcut.ariaKey && shortcutLabelKey) { 4190 const shortcutLabel = i18n.t(shortcutLabelKey); 4191 playBtn.setAttribute('aria-keyshortcuts', startShortcut.ariaKey); 4192 playBtn.title = translatePomodoro('pomo_start_shortcut_action_hint', { 4193 action: actionLabel, 4194 shortcut: shortcutLabel 4195 }); 4196 } else { 4197 playBtn.removeAttribute('aria-keyshortcuts'); 4198 playBtn.title = actionLabel; 4199 } 4200 playBtn.innerHTML = actionIcon; 4201 4202 if (state.isActive) { 4203 playBtn.classList.add('secondary'); 4204 playBtn.classList.remove('filled'); 4205 } else { 4206 playBtn.classList.add('filled'); 4207 playBtn.classList.remove('secondary'); 4208 } 4209 4210 syncFocusGuide(); 4211 renderDailyPriorityCard(); 4212 renderTwoDayPilotCard(); 4213 updateMobileMiniTimer(); 4214 focusAmbience?.sync(); 4215} 4216 4217function getDuration() { 4218 if (state.mode === 'focus') { 4219 const override = Number(state.focusDurationOverrideSeconds); 4220 return Number.isFinite(override) && override > 0 4221 ? override 4222 : getTaskFocusMinutes() * 60; 4223 } 4224 const customTimer = getCustomTimer(state.mode); 4225 if (customTimer) return customTimer.minutes * 60; 4226 if (state.mode === 'short-break') return state.settings.shortBreakTime * 60; 4227 if (state.mode === 'long-break') return state.settings.longBreakTime * 60; 4228 return 25 * 60; 4229} 4230 4231/* --- Audio System --- */ 4232 4233let currentAudio = null; 4234let currentAudioUrl = null; 4235let activeOscillators = []; 4236let alarmRepeatTimer = null; 4237let alarmStopTimer = null; 4238let alarmPlaybackToken = 0; 4239 4240function stopAlarm() { 4241 alarmPlaybackToken += 1; 4242 if (alarmRepeatTimer) { 4243 clearInterval(alarmRepeatTimer); 4244 alarmRepeatTimer = null; 4245 } 4246 if (alarmStopTimer) { 4247 clearTimeout(alarmStopTimer); 4248 alarmStopTimer = null; 4249 } 4250 // 1. Stop Audio Element 4251 if (currentAudio) { 4252 currentAudio.pause(); 4253 currentAudio.currentTime = 0; 4254 currentAudio = null; 4255 } 4256 if (currentAudioUrl) { 4257 URL.revokeObjectURL(currentAudioUrl); 4258 currentAudioUrl = null; 4259 } 4260 // 2. Stop Oscillators 4261 activeOscillators.forEach(node => { 4262 try { node.stop(); } catch (e) { } 4263 try { node.disconnect(); } catch (e) { } 4264 }); 4265 activeOscillators = []; 4266} 4267 4268function scheduleAlarmStop(durationMs = ALARM_RING_DURATION_MS) { 4269 alarmStopTimer = setTimeout(() => stopAlarm(), durationMs); 4270} 4271 4272// Melodies for Oscillator Presets 4273const MELODIES = { 4274 'bell': [ 4275 { freq: 523.25, type: 'triangle', duration: 0.8 }, // C5 4276 { freq: 0, duration: 0.1 }, 4277 ], 4278 'chime': [ 4279 { freq: 659.25, type: 'sine', duration: 0.2 }, // E5 4280 { freq: 880.00, type: 'sine', duration: 0.4 }, // A5 4281 { freq: 659.25, type: 'sine', duration: 0.2 }, // E5 4282 { freq: 1046.50, type: 'sine', duration: 0.6 } // C6 4283 ], 4284 'digital': [ 4285 { freq: 880, type: 'square', duration: 0.1 }, 4286 { freq: 880, type: 'square', duration: 0.1 }, 4287 { freq: 880, type: 'square', duration: 0.1 }, 4288 { freq: 0, duration: 0.2 }, 4289 { freq: 1760, type: 'square', duration: 0.4 } 4290 ] 4291}; 4292 4293async function playAlarm(preview = false, forceCustom = false) { 4294 stopAlarm(); // Stop previous sound 4295 const playbackToken = alarmPlaybackToken; 4296 4297 if (state.settings.alarmSound === 'none' && !forceCustom) { 4298 if (preview) alert(i18n.t('pomo_no_sound')); 4299 return; 4300 } 4301 4302 // 1. Custom Sound (File) 4303 if ((state.settings.alarmSound === 'custom' || forceCustom) && state.settings.customSoundId) { 4304 try { 4305 const saved = await audioDB.getCustomAlarm(state.settings.customSoundId); 4306 if (playbackToken !== alarmPlaybackToken) return; 4307 if (saved?.blob) { 4308 const policy = getAlarmPlaybackPolicy({ preview, custom: true }); 4309 currentAudioUrl = URL.createObjectURL(saved.blob); 4310 currentAudio = new Audio(currentAudioUrl); 4311 currentAudio.volume = state.settings.volume / 100; 4312 currentAudio.loop = policy.loop; 4313 currentAudio.play().catch((error) => { 4314 console.warn('Pomodoro custom alarm playback was blocked', error); 4315 }); 4316 if (policy.stopAfterMs) scheduleAlarmStop(policy.stopAfterMs); 4317 return; 4318 } 4319 } catch (e) { 4320 console.error(e); 4321 } 4322 } 4323 4324 // 2. Presets (Oscillators) 4325 const melody = MELODIES[state.settings.alarmSound] || MELODIES.bell; 4326 if (melody) { 4327 playMelody(melody, state.settings.volume); 4328 const policy = getAlarmPlaybackPolicy({ preview, custom: false }); 4329 if (policy.repeat) { 4330 alarmRepeatTimer = setInterval( 4331 () => playMelody(melody, state.settings.volume), 4332 getAlarmRepeatIntervalMs(melody) 4333 ); 4334 } 4335 if (policy.stopAfterMs) scheduleAlarmStop(policy.stopAfterMs); 4336 } 4337} 4338 4339let audioCtx = null; 4340 4341function getAudioContext() { 4342 const AudioContextCtor = window.AudioContext || window.webkitAudioContext; 4343 if (!AudioContextCtor) return null; 4344 if (!audioCtx) audioCtx = new AudioContextCtor(); 4345 return audioCtx; 4346} 4347 4348function playMelody(notes, volume) { 4349 const ctx = getAudioContext(); 4350 if (!ctx) return; 4351 if (ctx.state === 'suspended') ctx.resume(); 4352 4353 let startTime = ctx.currentTime; 4354
4355 notes.forEach(note => { 4356 if (note.freq > 0) { 4357 const osc = ctx.createOscillator(); 4358 const gain = ctx.createGain(); 4359 4360 osc.type = note.type || 'sine'; 4361 osc.frequency.value = note.freq; 4362 4363 // Volume logic 4364 gain.gain.setValueAtTime((volume / 100) * 0.2, startTime); 4365 gain.gain.exponentialRampToValueAtTime(0.0001, startTime + note.duration); 4366 4367 osc.connect(gain); 4368 gain.connect(ctx.destination); 4369 4370 osc.start(startTime); 4371 osc.stop(startTime + note.duration); 4372 4373 activeOscillators.push(osc); 4374 osc.addEventListener('ended', () => { 4375 const index = activeOscillators.indexOf(osc); 4376 if (index >= 0) activeOscillators.splice(index, 1); 4377 try { osc.disconnect(); } catch (e) {} 4378 try { gain.disconnect(); } catch (e) {} 4379 }, { once: true }); 4380 } 4381 startTime += note.duration; 4382 }); 4383} 4384 4385function playTone(freq, type, duration, volume) { 4386 playMelody([{ freq, type, duration }], volume); 4387} 4388 4389window.playPomoAlarm = playAlarm; 4390window.stopPomoAlarm = stopAlarm; // Expose for debugging logic 4391 4392/* --- PiP --- */ 4393async function requestPiP() { 4394 if (window.Roxima?.isTouchLayout?.() || !('documentPictureInPicture' in window)) { 4395 showMobileMiniTimer(); 4396 return; 4397 } 4398 4399 // Re-implement if needed, straightforward copy from previous version 4400 if (!('documentPictureInPicture' in window)) return alert(i18n.t('pomo_not_supported')); 4401 try { 4402 const pipWindow = await window.documentPictureInPicture.requestWindow({ width: 250, height: 96 }); 4403 // Style copy... 4404 [...document.styleSheets].forEach((styleSheet) => { 4405 try { 4406 const style = document.createElement('style'); 4407 style.textContent = [...styleSheet.cssRules].map(r => r.cssText).join(''); 4408 pipWindow.document.head.appendChild(style); 4409 } catch (e) { } 4410 }); 4411 4412 const source = containerEl.querySelector('.pomo-container'); 4413 source.classList.add('is-pip'); // Add dedicated class for CSS 4414 4415 // Temporarily remove Day Closure button from DOM for PiP mode 4416 const closureBtn = source.querySelector('.pomo-day-closure-container'); 4417 if (closureBtn) { 4418 closureBtn.remove(); 4419 } 4420 4421 // We need to keep the ORIGINAL in place or settings might break interaction? 4422 // Actually move it is better for state sync. 4423 const placeholder = document.createElement('div'); 4424 placeholder.innerText = i18n.t('pomo_mobile_mini_active'); 4425 placeholder.style.cssText = 'height:100%; display:flex; align-items:center; justify-content:center; color:#999;'; 4426 4427 const parent = source.parentNode; 4428 parent.insertBefore(placeholder, source); 4429 pipWindow.document.body.appendChild(source); 4430 // Attempt transparent background and full width/height 4431 pipWindow.document.body.style.cssText = "display:flex; width:100%; height:100%; margin:0; padding:0; background:transparent; overflow:hidden;"; 4432 4433 4434 pipWindow.addEventListener('pagehide', () => { 4435 source.classList.remove('is-pip'); // Remove class on exit 4436 4437 // Restore Day Closure button when returning to main document 4438 if (closureBtn) { 4439 const pipProjectSelector = source.querySelector('#pomo-project-selector-pip'); 4440 if (pipProjectSelector) { 4441 source.insertBefore(closureBtn, pipProjectSelector); 4442 } else { 4443 source.appendChild(closureBtn); 4444 } 4445 } 4446 4447 placeholder.replaceWith(source); 4448 // Re-bind mode and project buttons back in main window 4449 bindModeButtons(containerEl); 4450 bindProjectNavigation(containerEl); 4451 }); 4452 4453 // Bind mode and project buttons in PiP window context 4454 bindModeButtons(source); 4455 bindProjectNavigation(source); 4456 4457 // Bind double Shift shortcut inside PiP window to close it 4458 let pipLastShiftTime = 0; 4459 pipWindow.document.addEventListener('keydown', (e) => { 4460 if (e.key === 'Shift') { 4461 const currentTime = Date.now(); 4462 if (currentTime - pipLastShiftTime < 500) { 4463 e.preventDefault(); 4464 pipWindow.close(); 4465 } else { 4466 pipLastShiftTime = currentTime; 4467 } 4468 } else { 4469 pipLastShiftTime = 0; 4470 } 4471 }); 4472 4473 setTimeout(() => { 4474 fetch('/debug-pip', { method: 'POST', body: pipWindow.document.documentElement.outerHTML }).catch(e=>console.log(e)); 4475 // Or just write to localstorage 4476 localStorage.setItem('pip_debug_html', pipWindow.document.documentElement.outerHTML); 4477 }, 1000); 4478 4479 } catch (err) { console.error(err); } 4480} 4481 4482function showMobileMiniTimer() { 4483 if (mobileMiniEl) { 4484 mobileMiniEl.classList.toggle('open'); 4485 if (mobileMiniEl.classList.contains('open')) updateMobileMiniTimer(); 4486 return; 4487 } 4488 4489 mobileMiniEl = document.createElement('div'); 4490 mobileMiniEl.className = 'pomo-mobile-mini open'; 4491 mobileMiniEl.innerHTML = ` 4492 <button class="pomo-mini-close" type="button" data-i18n-aria-label="common_close" aria-label="${i18n.t('common_close')}">x</button> 4493 <div class="pomo-mini-copy">
4494 <span class="pomo-mini-label" data-i18n="pomo_mobile_mini">${i18n.t('pomo_mobile_mini')}</span> 4495 <strong class="pomo-mini-time">25:00</strong> 4496 </div> 4497 <button class="pomo-mini-play" type="button" data-i18n-aria-label="pomo_start" aria-label="${i18n.t('pomo_start')}"></button> 4498 `; 4499 4500 mobileMiniEl.querySelector('.pomo-mini-close')?.addEventListener('click', () => { 4501 mobileMiniEl.classList.remove('open'); 4502 }); 4503 mobileMiniEl.querySelector('.pomo-mini-play')?.addEventListener('click', toggleTimer); 4504 document.body.appendChild(mobileMiniEl); 4505 updateMobileMiniTimer(); 4506 globalCtx.bus.emit('ui:toast', i18n.t('pomo_mobile_mini_active')); 4507} 4508 4509function updateMobileMiniTimer() { 4510 if (!mobileMiniEl) return; 4511 4512 const timeText = formatTimerText(state.timeLeft); 4513 const time = mobileMiniEl.querySelector('.pomo-mini-time'); 4514 const play = mobileMiniEl.querySelector('.pomo-mini-play'); 4515 4516 if (time) time.textContent = timeText; 4517 if (play) { 4518 const playLabelKey = state.isActive ? 'pomo_pause' : 'pomo_start'; 4519 play.setAttribute('data-i18n-aria-label', playLabelKey); 4520 play.setAttribute('aria-label', i18n.t(playLabelKey)); 4521 play.innerHTML = state.isActive 4522 ? '<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="4" width="4" height="16" rx="2"></rect><rect x="14" y="4" width="4" height="16" rx="2"></rect></svg>' 4523 : '<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"></path></svg>'; 4524 } 4525} 4526 4527/** Bind click handlers on .badge-pomo mode buttons within a container */ 4528function bindModeButtons(root) { 4529 const modes = root.matches?.('.pomo-modes') ? root : root.querySelector?.('.pomo-modes'); 4530 if (!modes) return; 4531 4532 if (modes.dataset.pomoModesBound === 'true') return; 4533 modes.dataset.pomoModesBound = 'true'; 4534 4535 modes.addEventListener('click', (e) => { 4536 const editBtn = e.target.closest('[data-timer-edit]'); 4537 if (editBtn) { 4538 e.stopPropagation(); 4539 e.preventDefault(); 4540 editBtn.blur?.(); 4541 const mode = editBtn.dataset.timerEdit; 4542 const customTimer = getCustomTimer(mode); 4543 if (customTimer) { 4544 openCustomTimerEditor(customTimer.id, editBtn); 4545 } else { 4546 state.settingsOpen = true; 4547 syncSettingsPanelVisibility(); 4548 4549 const inputId = mode === 'focus' 4550 ? '#set-focus-time' 4551 : mode === 'short-break' 4552 ? '#set-short-break-time' 4553 : '#set-long-break-time'; 4554 4555 const field = containerEl?.querySelector(inputId); 4556 if (field) { 4557 window.setTimeout(() => { 4558 field.focus(); 4559 field.select?.(); 4560 }, 120); 4561 } 4562 } 4563 return; 4564 } 4565 4566 const modeBtn = e.target.closest('.pomo-mode-trigger'); 4567 if (modeBtn) { 4568 modeBtn.blur?.(); 4569 if (modeBtn.hasAttribute('data-custom-timer-add')) { 4570 openCustomTimerEditor(null, modeBtn); 4571 return; 4572 } 4573 if (modeBtn.dataset.mode) { 4574 setMode(modeBtn.dataset.mode, true); 4575 } 4576 } 4577 }); 4578} 4579 4580/** Bind project navigation handlers within a container (essential for PiP context switching) */ 4581function bindProjectNavigation(root) { 4582 const prevBtn = root.querySelector('#pomo-project-prev'); 4583 const nextBtn = root.querySelector('#pomo-project-next'); 4584 const prevPipBtn = root.querySelector('#pomo-project-prev-pip'); 4585 const nextPipBtn = root.querySelector('#pomo-project-next-pip'); 4586 const iconPipBtn = root.querySelector('#pomo-project-icon-pip'); 4587 4588 if (prevBtn) { 4589 const newBtn = prevBtn.cloneNode(true); 4590 prevBtn.parentNode.replaceChild(newBtn, prevBtn); 4591 newBtn.addEventListener('click', (e) => cycleProject(-1, e)); 4592 } 4593 if (nextBtn) { 4594 const newBtn = nextBtn.cloneNode(true); 4595 nextBtn.parentNode.replaceChild(newBtn, nextBtn); 4596 newBtn.addEventListener('click', (e) => cycleProject(1, e)); 4597 } 4598 if (prevPipBtn) { 4599 const newBtn = prevPipBtn.cloneNode(true); 4600 prevPipBtn.parentNode.replaceChild(newBtn, prevPipBtn); 4601 newBtn.addEventListener('click', (e) => cycleProject(-1, e)); 4602 } 4603 if (nextPipBtn) { 4604 const newBtn = nextPipBtn.cloneNode(true); 4605 nextPipBtn.parentNode.replaceChild(newBtn, nextPipBtn); 4606 newBtn.addEventListener('click', (e) => cycleProject(1, e)); 4607 } 4608 if (iconPipBtn) { 4609 const newIcon = iconPipBtn.cloneNode(true); 4610 iconPipBtn.parentNode.replaceChild(newIcon, iconPipBtn); 4611 newIcon.addEventListener('click', (e) => cycleProject(1, e)); 4612 } 4613} 4614 4615function saveTimerState() { 4616 try { 4617 const now = Date.now(); 4618 const lastUpdated = Math.max(now, lastTimerStateUpdatedAt + 1); 4619 if (state.isActive && state.timerOwnerTabId === timerTabId) { 4620 state.timerOwnerHeartbeatAt = now; 4621 } 4622 const endTime = state.isActive
4623 ? (state.sessionEndMs || now + state.timeLeft * 1000) 4624 : null; 4625 const timerState = { 4626 mode: state.mode, 4627 isActive: state.isActive, 4628 timeLeft: state.timeLeft, 4629 endTime: endTime, 4630 activeTask: state.activeTask, 4631 browserFocusSessionId: state.browserFocusSessionId, 4632 focusDurationOverrideSeconds: state.focusDurationOverrideSeconds, 4633 pomoCount: state.pomoCount, 4634 ownerTabId: state.timerOwnerTabId, 4635 ownerHeartbeatAt: state.timerOwnerHeartbeatAt, 4636 lastUpdated 4637 }; 4638 lastTimerStateUpdatedAt = lastUpdated; 4639 localStorage.setItem(POMODORO_TIMER_STORAGE_KEY, JSON.stringify(timerState)); 4640 } catch (e) { 4641 console.error("Failed to save Pomodoro timer state", e); 4642 } 4643} 4644 4645function applySharedPomodoroTimerState(saved, { restore = false } = {}) { 4646 if (!saved) return false; 4647 4648 state.mode = (MODES[saved.mode] || getCustomTimer(saved.mode)) ? saved.mode : 'focus'; 4649 state.pomoCount = saved.pomoCount; 4650 state.activeTask = saved.activeTask; 4651 state.browserFocusSessionId = saved.browserFocusSessionId; 4652 state.focusDurationOverrideSeconds = state.mode === 'focus' 4653 && Number.isFinite(saved.focusDurationOverrideSeconds) 4654 && saved.focusDurationOverrideSeconds > 0 4655 ? saved.focusDurationOverrideSeconds 4656 : null; 4657 state.timerOwnerTabId = saved.ownerTabId || null; 4658 state.timerOwnerHeartbeatAt = saved.ownerHeartbeatAt; 4659 4660 const badge = containerEl.querySelector('#pomo-mode-badge'); 4661 syncModeBadge(badge, state.mode); 4662 updateModeButtons(); 4663 syncFocusGuide(); 4664 4665 if (saved.isActive) { 4666 const claimsOwnership = shouldClaimPomodoroTimerOwnership(saved, timerTabId); 4667 state.isActive = true; 4668 state.isDemoSession = false; 4669 state.timeLeft = saved.timeLeft; 4670 state.sessionEndMs = saved.endTime; 4671 state.isTimerFollower = !claimsOwnership; 4672 state.timerHeartbeatTicks = 0; 4673 4674 if (claimsOwnership) { 4675 state.timerOwnerTabId = timerTabId; 4676 state.timerOwnerHeartbeatAt = Date.now(); 4677 saveTimerState(); 4678 syncBrowserFocusProtection(true, 'active'); 4679 } 4680 4681 updateUI(); 4682 updateControls(); 4683 startCountdownInterval(); 4684 return true; 4685 } 4686 4687 const expiredOwnerCanComplete = saved.expired && restore && ( 4688 !saved.ownerTabId 4689 || saved.ownerTabId === timerTabId 4690 || Date.now() - saved.ownerHeartbeatAt > POMODORO_TIMER_OWNER_TIMEOUT_MS 4691 ); 4692 if (expiredOwnerCanComplete) { 4693 state.isActive = true; 4694 state.isTimerFollower = false; 4695 state.timerOwnerTabId = timerTabId; 4696 state.timeLeft = 0; 4697 void completeTimer(); 4698 return true; 4699 } 4700 4701 state.isActive = false; 4702 state.isTimerFollower = false; 4703 state.timeLeft = saved.timeLeft || getDuration(); 4704 state.sessionEndMs = null; 4705 clearInterval(state.interval); 4706 updateUI(); 4707 updateControls(); 4708 globalCtx?.bus?.emit('timer:stopped'); 4709 return true; 4710} 4711 4712function handlePomodoroTimerStorageEvent(event) { 4713 if (event?.key !== POMODORO_TIMER_STORAGE_KEY || !event.newValue) return; 4714 const saved = readSharedPomodoroTimerState(event.newValue); 4715 if (!saved || saved.lastUpdated <= lastTimerStateUpdatedAt) return; 4716 4717 lastTimerStateUpdatedAt = saved.lastUpdated; 4718 if (saved.ownerTabId === timerTabId && state.timerOwnerTabId === timerTabId) return; 4719 applySharedPomodoroTimerState(saved); 4720} 4721 4722function restoreTimerState() { 4723 try { 4724 const raw = localStorage.getItem(POMODORO_TIMER_STORAGE_KEY); 4725 if (!raw) return false; 4726 const saved = readSharedPomodoroTimerState(raw); 4727 if (!saved) return false; 4728 lastTimerStateUpdatedAt = saved.lastUpdated; 4729 return applySharedPomodoroTimerState(saved, { restore: true }); 4730 } catch (e) { 4731 console.error("Failed to restore Pomodoro timer state", e); 4732 return false; 4733 } 4734} 4735 4736function releasePomodoroTimerOwnership() { 4737 if (!state.isActive || state.timerOwnerTabId !== timerTabId) return; 4738 state.timerOwnerTabId = null; 4739 state.timerOwnerHeartbeatAt = 0; 4740 state.isTimerFollower = true; 4741 saveTimerState(); 4742} 4743 4744window.addEventListener('beforeunload', () => { 4745 releasePomodoroTimerOwnership(); 4746}); 4747 4748document.addEventListener('visibilitychange', () => { 4749 if (document.visibilityState === 'visible') { 4750 if (refreshDayClosureForCurrentDate()) return; 4751 refreshTwoDayPilotCardForCurrentDay(); 4752 } 4753}); 4754 4755// --- Z-Order Refocus Fallback for PiP Window (Deactivated to prevent Chrome focus protection/gray screen) --- 4756/* 4757let isRefocusingPiP = false;
4758function handleRefocusPiP() { 4759 if (isRefocusingPiP) return; 4760 if (window.documentPictureInPicture && window.documentPictureInPicture.window) { 4761 const pipWindow = window.documentPictureInPicture.window; 4762 if (!pipWindow.closed) { 4763 isRefocusingPiP = true; 4764 try { 4765 // Focus PiP to bring it on top of other desktop windows, 4766 // then focus main window to keep keyboard input active 4767 pipWindow.focus(); 4768 window.focus(); 4769 } catch (e) { 4770 console.error('[PiP Refocus] Failed to bring PiP to front:', e); 4771 } finally { 4772 setTimeout(() => { 4773 isRefocusingPiP = false; 4774 }, 50); 4775 } 4776 } 4777 } 4778} 4779 4780window.addEventListener('focus', handleRefocusPiP); 4781document.addEventListener('visibilitychange', () => { 4782 if (document.visibilityState === 'visible') { 4783 handleRefocusPiP(); 4784 } 4785}); 4786*/
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.