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;
+ 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",