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
This commit is contained in:
Het Savani 2026-05-14 15:31:16 +05:30 committed by GitHub
parent deab927bb7
commit f7f8056c50
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 302 additions and 0 deletions

View file

@ -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;

View file

@ -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<Title>;
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');
});
});
});

View file

@ -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;
}
}

View file

@ -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',

View file

@ -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",