From f7f8056c50ffd27dfa1a0bb0d85fd16c19b5b7f0 Mon Sep 17 00:00:00 2001 From: Het Savani <114601993+Hetsavani@users.noreply.github.com> Date: Thu, 14 May 2026 15:31:16 +0530 Subject: [PATCH] feat(focus-mode): show pomodoro timer in browser tab title (#7579) * feat(focus-mode): show pomodoro timer in browser tab title * fix(focus-mode): handle overtime and translate browser title labels --- src/app/app.component.ts | 2 + .../browser-title.service.spec.ts | 231 ++++++++++++++++++ .../browser-title/browser-title.service.ts | 65 +++++ src/app/t.const.ts | 2 + src/assets/i18n/en.json | 2 + 5 files changed, 302 insertions(+) create mode 100644 src/app/core/browser-title/browser-title.service.spec.ts create mode 100644 src/app/core/browser-title/browser-title.service.ts diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 5804d36586..50524cd845 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -77,6 +77,7 @@ import { OnboardingPresetSelectionComponent } from './features/onboarding/onboar import { OnboardingHintComponent } from './features/onboarding/onboarding-hint.component'; import { OnboardingHintService } from './features/onboarding/onboarding-hint.service'; import { MaterialIconsLoaderService } from './ui/material-icons-loader.service'; +import { BrowserTitleService } from './core/browser-title/browser-title.service'; const ONBOARDING_PRESET_EXIT_DELAY = 1000; const ONBOARDING_ENTRANCE_COMPLETE_DELAY = 2000; @@ -153,6 +154,7 @@ export class AppComponent implements OnDestroy, AfterViewInit { readonly globalThemeService = inject(GlobalThemeService); readonly _store = inject(Store); private _sectionService = inject(SectionService); + private _browserTitleService = inject(BrowserTitleService); readonly T = T; readonly TODAY_TAG_ID = TODAY_TAG.id; readonly isShowMobileButtonNav = this.layoutService.isShowMobileBottomNav; diff --git a/src/app/core/browser-title/browser-title.service.spec.ts b/src/app/core/browser-title/browser-title.service.spec.ts new file mode 100644 index 0000000000..862b1df804 --- /dev/null +++ b/src/app/core/browser-title/browser-title.service.spec.ts @@ -0,0 +1,231 @@ +import { signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { Title } from '@angular/platform-browser'; +import { TranslateService } from '@ngx-translate/core'; +import { BrowserTitleService } from './browser-title.service'; +import { FocusModeMode } from '../../features/focus-mode/focus-mode.model'; +import { FocusModeService } from '../../features/focus-mode/focus-mode.service'; + +describe('BrowserTitleService', () => { + let service: BrowserTitleService; + let titleService: jasmine.SpyObj; + let translateService: jasmine.SpyObj<TranslateService>; + let focusModeServiceMock: any; + + beforeEach(() => { + titleService = jasmine.createSpyObj('Title', ['setTitle']); + + translateService = jasmine.createSpyObj('TranslateService', ['instant']); + + translateService.instant.and.callFake((key: string) => { + if (key.includes('BREAK')) { + return 'Break'; + } + + if (key.includes('PAUSED')) { + return 'Paused'; + } + + return key; + }); + + focusModeServiceMock = { + mode: signal(FocusModeMode.Pomodoro), + timeRemaining: signal(1500000), + timeElapsed: signal(0), + isBreakActive: signal(false), + isRunning: signal(false), + isSessionPaused: signal(false), + isInOvertime: signal(false), + }; + + TestBed.configureTestingModule({ + providers: [ + BrowserTitleService, + { provide: Title, useValue: titleService }, + { provide: TranslateService, useValue: translateService }, + { provide: FocusModeService, useValue: focusModeServiceMock }, + ], + }); + + service = TestBed.inject(BrowserTitleService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + describe('_getTitle', () => { + it('should return base title when not in Pomodoro mode', () => { + const result = (service as any)._getTitle( + FocusModeMode.Flowtime, + 1500000, + false, + true, + false, + false, + 0, + ); + + expect(result).toBe('Super Productivity'); + }); + + it('should return base title when in Pomodoro but not running or paused', () => { + const result = (service as any)._getTitle( + FocusModeMode.Pomodoro, + 1500000, + false, + false, + false, + false, + 0, + ); + + expect(result).toBe('Super Productivity'); + }); + + it('should show remaining time when running', () => { + const result = (service as any)._getTitle( + FocusModeMode.Pomodoro, + 1500000, + false, + true, + false, + false, + 0, + ); + + expect(result).toBe('(25:00) Super Productivity'); + }); + + it('should show "Paused" when paused', () => { + const result = (service as any)._getTitle( + FocusModeMode.Pomodoro, + 1500000, + false, + false, + true, + false, + 0, + ); + + expect(result).toBe('(Paused 25:00) Super Productivity'); + }); + + it('should show "Break" when break is active', () => { + const result = (service as any)._getTitle( + FocusModeMode.Pomodoro, + 300000, + true, + true, + false, + false, + 0, + ); + + expect(result).toBe('(05:00 Break) Super Productivity'); + }); + + it('should show both "Paused" and "Break" when both are active', () => { + const result = (service as any)._getTitle( + FocusModeMode.Pomodoro, + 300000, + true, + false, + true, + false, + 0, + ); + + expect(result).toBe('(Paused 05:00 Break) Super Productivity'); + }); + + it('should show elapsed time during overtime', () => { + const result = (service as any)._getTitle( + FocusModeMode.Pomodoro, + 0, + false, + true, + false, + true, + 1560000, + ); + + expect(result).toBe('(26:00) Super Productivity'); + }); + + it('should show elapsed break time during overtime', () => { + const result = (service as any)._getTitle( + FocusModeMode.Pomodoro, + 0, + true, + true, + false, + true, + 360000, + ); + + expect(result).toBe('(06:00 Break) Super Productivity'); + }); + + it('should pad single digit minutes correctly', () => { + const result = (service as any)._getTitle( + FocusModeMode.Pomodoro, + 300000, + false, + true, + false, + false, + 0, + ); + + expect(result).toBe('(05:00) Super Productivity'); + }); + }); + + describe('effect integration', () => { + it('should update document title when signals change', () => { + focusModeServiceMock.isRunning.set(true); + focusModeServiceMock.timeRemaining.set(1499000); + + TestBed.flushEffects(); + + expect(titleService.setTitle).toHaveBeenCalledWith('(24:59) Super Productivity'); + + focusModeServiceMock.isBreakActive.set(true); + focusModeServiceMock.timeRemaining.set(299000); + + TestBed.flushEffects(); + + expect(titleService.setTitle).toHaveBeenCalledWith( + '(04:59 Break) Super Productivity', + ); + + focusModeServiceMock.isRunning.set(false); + focusModeServiceMock.isSessionPaused.set(true); + + TestBed.flushEffects(); + + expect(titleService.setTitle).toHaveBeenCalledWith( + '(Paused 04:59 Break) Super Productivity', + ); + + focusModeServiceMock.isSessionPaused.set(false); + focusModeServiceMock.isRunning.set(true); + focusModeServiceMock.isInOvertime.set(true); + focusModeServiceMock.timeElapsed.set(1560000); + + TestBed.flushEffects(); + + expect(titleService.setTitle).toHaveBeenCalledWith( + '(26:00 Break) Super Productivity', + ); + + focusModeServiceMock.mode.set(FocusModeMode.Flowtime); + + TestBed.flushEffects(); + + expect(titleService.setTitle).toHaveBeenCalledWith('Super Productivity'); + }); + }); +}); diff --git a/src/app/core/browser-title/browser-title.service.ts b/src/app/core/browser-title/browser-title.service.ts new file mode 100644 index 0000000000..8b5d19d46e --- /dev/null +++ b/src/app/core/browser-title/browser-title.service.ts @@ -0,0 +1,65 @@ +import { effect, inject, Injectable } from '@angular/core'; +import { Title } from '@angular/platform-browser'; +import { FocusModeService } from '../../features/focus-mode/focus-mode.service'; +import { msToMinuteClockString } from '../../ui/duration/ms-to-minute-clock-string.pipe'; +import { FocusModeMode } from '../../features/focus-mode/focus-mode.model'; +import { T } from 'src/app/t.const'; +import { TranslateService } from '@ngx-translate/core'; + +@Injectable({ + providedIn: 'root', +}) +export class BrowserTitleService { + private _titleService = inject(Title); + private _focusModeService = inject(FocusModeService); + private _translateService = inject(TranslateService); + + private readonly _baseTitle = 'Super Productivity'; + + constructor() { + effect(() => { + this._titleService.setTitle( + this._getTitle( + this._focusModeService.mode(), + this._focusModeService.timeRemaining(), + this._focusModeService.isBreakActive(), + this._focusModeService.isRunning(), + this._focusModeService.isSessionPaused(), + this._focusModeService.isInOvertime(), + this._focusModeService.timeElapsed(), + ), + ); + }); + } + + private _getTitle( + mode: FocusModeMode, + timeRemaining: number, + isBreakActive: boolean, + isRunning: boolean, + isSessionPaused: boolean, + isInOvertime: boolean, + timeElapsed: number, + ): string { + if (mode === FocusModeMode.Pomodoro && (isRunning || isSessionPaused)) { + const displayTime = isInOvertime ? timeElapsed : timeRemaining; + + const timeStr = msToMinuteClockString(displayTime); + + const [minutes, seconds] = timeStr.split(':'); + const formattedTime = `${minutes.padStart(2, '0')}:${seconds}`; + + const breakStr = isBreakActive + ? ` ${this._translateService.instant(T.F.FOCUS_MODE.BROWSER_TITLE_BREAK)}` + : ''; + + const pausedStr = isSessionPaused + ? `${this._translateService.instant(T.F.FOCUS_MODE.BROWSER_TITLE_PAUSED)} ` + : ''; + + return `(${pausedStr}${formattedTime}${breakStr}) ${this._baseTitle}`; + } + + return this._baseTitle; + } +} diff --git a/src/app/t.const.ts b/src/app/t.const.ts index 63dbb312d1..d37932c71c 100644 --- a/src/app/t.const.ts +++ b/src/app/t.const.ts @@ -278,6 +278,8 @@ const T = { }, BACK_TO_PLANNING: 'F.FOCUS_MODE.BACK_TO_PLANNING', BREAK_RELAX_MSG: 'F.FOCUS_MODE.BREAK_RELAX_MSG', + BROWSER_TITLE_BREAK: 'F.FOCUS_MODE.BROWSER_TITLE_BREAK', + BROWSER_TITLE_PAUSED: 'F.FOCUS_MODE.BROWSER_TITLE_PAUSED', CLICK_TO_EDIT_DURATION: 'F.FOCUS_MODE.CLICK_TO_EDIT_DURATION', COMPLETE_FOCUS_SESSION: 'F.FOCUS_MODE.COMPLETE_FOCUS_SESSION', CONTINUE_TO_NEXT_SESSION: 'F.FOCUS_MODE.CONTINUE_TO_NEXT_SESSION', diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index 94feed3944..958ca2f853 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -277,6 +277,8 @@ }, "BACK_TO_PLANNING": "Back to Planning", "BREAK_RELAX_MSG": "Take a moment to relax", + "BROWSER_TITLE_BREAK": "Break", + "BROWSER_TITLE_PAUSED": "Paused", "CLICK_TO_EDIT_DURATION": "Click to edit duration", "COMPLETE_FOCUS_SESSION": "Complete focus session", "CONTINUE_TO_NEXT_SESSION": "Continue to next session",