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