From 333f0e53166963c21f8936a96902341278f3dd1f Mon Sep 17 00:00:00 2001 From: saravanakumardb1 Date: Sat, 18 Apr 2026 18:13:45 -0700 Subject: [PATCH] feat(web): Phase D.3 tempo mode engine + 11 tests --- web/src/lib/__tests__/tempo-mode.test.ts | 76 +++++++++++++++++++ web/src/lib/tempo-mode.ts | 97 ++++++++++++++++++++++++ 2 files changed, 173 insertions(+) create mode 100644 web/src/lib/__tests__/tempo-mode.test.ts create mode 100644 web/src/lib/tempo-mode.ts diff --git a/web/src/lib/__tests__/tempo-mode.test.ts b/web/src/lib/__tests__/tempo-mode.test.ts new file mode 100644 index 0000000..b7792ac --- /dev/null +++ b/web/src/lib/__tests__/tempo-mode.test.ts @@ -0,0 +1,76 @@ +import { describe, it, expect } from 'vitest'; +import { + getEffectiveUrgency, + getEffectiveCascade, + shouldMuteNotifications, + TEMPO_CONFIGS, +} from '../tempo-mode'; + +describe('getEffectiveUrgency', () => { + it('normal mode passes through', () => { + expect(getEffectiveUrgency('important', 'normal')).toBe('important'); + expect(getEffectiveUrgency('standard', 'normal')).toBe('standard'); + }); + + it('critical always fires', () => { + expect(getEffectiveUrgency('critical', 'deep_focus')).toBe('critical'); + expect(getEffectiveUrgency('critical', 'winding_down')).toBe('critical'); + expect(getEffectiveUrgency('critical', 'low_energy')).toBe('critical'); + }); + + it('deep_focus downgrades non-critical', () => { + expect(getEffectiveUrgency('important', 'deep_focus')).toBe('standard'); + expect(getEffectiveUrgency('standard', 'deep_focus')).toBe('passive'); + expect(getEffectiveUrgency('gentle', 'deep_focus')).toBe('passive'); + }); + + it('low_energy makes everything gentler', () => { + expect(getEffectiveUrgency('important', 'low_energy')).toBe('standard'); + expect(getEffectiveUrgency('standard', 'low_energy')).toBe('gentle'); + expect(getEffectiveUrgency('gentle', 'low_energy')).toBe('passive'); + }); + + it('winding_down behaves like deep_focus', () => { + expect(getEffectiveUrgency('important', 'winding_down')).toBe('standard'); + expect(getEffectiveUrgency('standard', 'winding_down')).toBe('passive'); + }); +}); + +describe('getEffectiveCascade', () => { + it('normal mode passes through', () => { + expect(getEffectiveCascade('aggressive', 'normal')).toBe('aggressive'); + }); + + it('non-normal modes override to minimal', () => { + expect(getEffectiveCascade('aggressive', 'deep_focus')).toBe('minimal'); + expect(getEffectiveCascade('standard', 'low_energy')).toBe('minimal'); + expect(getEffectiveCascade('aggressive', 'winding_down')).toBe('minimal'); + }); +}); + +describe('shouldMuteNotifications', () => { + it('deep_focus and winding_down mute', () => { + expect(shouldMuteNotifications('deep_focus')).toBe(true); + expect(shouldMuteNotifications('winding_down')).toBe(true); + }); + + it('normal and low_energy do not mute', () => { + expect(shouldMuteNotifications('normal')).toBe(false); + expect(shouldMuteNotifications('low_energy')).toBe(false); + }); +}); + +describe('TEMPO_CONFIGS', () => { + it('has all four levels', () => { + expect(Object.keys(TEMPO_CONFIGS)).toEqual(['deep_focus', 'normal', 'low_energy', 'winding_down']); + }); + + it('each config has required fields', () => { + for (const config of Object.values(TEMPO_CONFIGS)) { + expect(config.level).toBeTruthy(); + expect(config.label).toBeTruthy(); + expect(config.description).toBeTruthy(); + expect(typeof config.notificationMute).toBe('boolean'); + } + }); +}); diff --git a/web/src/lib/tempo-mode.ts b/web/src/lib/tempo-mode.ts new file mode 100644 index 0000000..73f138c --- /dev/null +++ b/web/src/lib/tempo-mode.ts @@ -0,0 +1,97 @@ +/** + * Tempo Mode engine — Phase D.3. + * + * Adjusts timer urgency and cascade presets based on the user's current + * energy/focus level. Pure TS, no React. + */ + +export type TempoLevel = 'deep_focus' | 'normal' | 'low_energy' | 'winding_down'; + +export interface TempoConfig { + level: TempoLevel; + label: string; + description: string; + urgencyOverride: string; + cascadeOverride: string; + notificationMute: boolean; +} + +export const TEMPO_CONFIGS: Record = { + deep_focus: { + level: 'deep_focus', + label: 'Deep Focus', + description: 'Minimal interruptions. Only critical timers fire.', + urgencyOverride: 'passive', + cascadeOverride: 'minimal', + notificationMute: true, + }, + normal: { + level: 'normal', + label: 'Normal', + description: 'Standard timer behavior.', + urgencyOverride: 'standard', + cascadeOverride: 'standard', + notificationMute: false, + }, + low_energy: { + level: 'low_energy', + label: 'Low Energy', + description: 'Gentler reminders. Extended snooze times.', + urgencyOverride: 'gentle', + cascadeOverride: 'minimal', + notificationMute: false, + }, + winding_down: { + level: 'winding_down', + label: 'Winding Down', + description: 'Quiet mode. Non-critical timers deferred.', + urgencyOverride: 'passive', + cascadeOverride: 'minimal', + notificationMute: true, + }, +}; + +/** + * Compute effective urgency based on tempo mode. + */ +export function getEffectiveUrgency( + timerUrgency: string, + tempoLevel: TempoLevel, +): string { + if (tempoLevel === 'normal') return timerUrgency; + + // Critical timers always fire regardless of tempo + if (timerUrgency === 'critical') return 'critical'; + + // In deep_focus or winding_down, downgrade non-critical to passive + if (tempoLevel === 'deep_focus' || tempoLevel === 'winding_down') { + return timerUrgency === 'important' ? 'standard' : 'passive'; + } + + // low_energy: make everything gentler + if (tempoLevel === 'low_energy') { + if (timerUrgency === 'important') return 'standard'; + if (timerUrgency === 'standard') return 'gentle'; + return 'passive'; + } + + return timerUrgency; +} + +/** + * Compute effective cascade preset based on tempo mode. + */ +export function getEffectiveCascade( + cascadePreset: string, + tempoLevel: TempoLevel, +): string { + if (tempoLevel === 'normal') return cascadePreset; + return TEMPO_CONFIGS[tempoLevel].cascadeOverride; +} + +/** + * Check if notifications should be muted for this tempo level. + */ +export function shouldMuteNotifications(tempoLevel: TempoLevel): boolean { + return TEMPO_CONFIGS[tempoLevel].notificationMute; +}