feat(web): Phase D.3 tempo mode engine + 11 tests
This commit is contained in:
parent
3cda171b68
commit
333f0e5316
76
web/src/lib/__tests__/tempo-mode.test.ts
Normal file
76
web/src/lib/__tests__/tempo-mode.test.ts
Normal file
@ -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');
|
||||
}
|
||||
});
|
||||
});
|
||||
97
web/src/lib/tempo-mode.ts
Normal file
97
web/src/lib/tempo-mode.ts
Normal file
@ -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<TempoLevel, TempoConfig> = {
|
||||
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;
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user