diff --git a/docs/wiki/4.15-Timers-and-Focus-Mode.md b/docs/wiki/4.15-Timers-and-Focus-Mode.md index cd9c092ba8..6ddcafdbc0 100644 --- a/docs/wiki/4.15-Timers-and-Focus-Mode.md +++ b/docs/wiki/4.15-Timers-and-Focus-Mode.md @@ -78,7 +78,7 @@ So “session done” means something different per mode: in Pomodoro it trigger ### Focus Balance and Sustainability -The app’s **productivity and sustainability metrics** assume that **more focus time is not always better**. They use an **inverted-V curve** for focus balance: up to the optimal amount (e.g. 4 hours), more focus improves the score; beyond that, **excessive focus is penalized** (e.g. with an exponential decay). That reflects the reality that **cognitive fatigue is non-linear**: pushing far beyond the sustainable range does not improve outcomes and can hurt sustainability. So the system encourages you to value **sustainable** focus, not maximum hours. +The app’s **sustainability metric** assumes that **more focus time is not always better**. It uses an **inverted-V curve** for focus balance: up to the optimal amount (e.g. 4 hours), more focus improves sustainability; beyond that, **excessive focus is penalized** (e.g. with an exponential decay). The **productivity metric** works differently: focus beyond the target receives no further focus credit and is not penalized, while the overall productivity score may still change through total-work credit. That reflects the reality that **cognitive fatigue is non-linear**: pushing far beyond the sustainable range does not improve outcomes and can hurt sustainability. So the system encourages you to value **sustainable** focus, not maximum hours. ## How Timers Influence Your Behavior diff --git a/src/app/features/metric/dialog-productivity-breakdown/dialog-productivity-breakdown.component.ts b/src/app/features/metric/dialog-productivity-breakdown/dialog-productivity-breakdown.component.ts index bdf608a230..f9ce3b2f53 100644 --- a/src/app/features/metric/dialog-productivity-breakdown/dialog-productivity-breakdown.component.ts +++ b/src/app/features/metric/dialog-productivity-breakdown/dialog-productivity-breakdown.component.ts @@ -112,12 +112,7 @@ export class DialogProductivityBreakdownComponent { totalWorkMinutes: number, energyCheckin: number, ): number { - return calculateSustainabilityScore( - focusedMinutes, - totalWorkMinutes, - 600, - energyCheckin, - ); + return calculateSustainabilityScore(focusedMinutes, totalWorkMinutes, energyCheckin); } close(): void { diff --git a/src/app/features/metric/evaluation-sheet/evaluation-sheet.component.ts b/src/app/features/metric/evaluation-sheet/evaluation-sheet.component.ts index 0d0dc7331d..793d288c61 100644 --- a/src/app/features/metric/evaluation-sheet/evaluation-sheet.component.ts +++ b/src/app/features/metric/evaluation-sheet/evaluation-sheet.component.ts @@ -159,12 +159,7 @@ export class EvaluationSheetComponent { const totalMinutes = this.totalWorkMinutes(); const energyCheckin = this.metricForDay()?.energyCheckin ?? undefined; - return calculateSustainabilityScore( - focusedMinutes, - totalMinutes, - 600, // workloadLinearZeroAt: 10h → score 0 - energyCheckin, - ); + return calculateSustainabilityScore(focusedMinutes, totalMinutes, energyCheckin); }); readonly dailyStateInfo = computed(() => { diff --git a/src/app/features/metric/metric-scoring.util.spec.ts b/src/app/features/metric/metric-scoring.util.spec.ts index 61ab0e88b5..4db8b8ebf3 100644 --- a/src/app/features/metric/metric-scoring.util.spec.ts +++ b/src/app/features/metric/metric-scoring.util.spec.ts @@ -51,10 +51,19 @@ describe('metric-scoring.util', () => { expect(high - low).toBeLessThanOrEqual(55); }); - it('soft-caps focus beyond the target to avoid perfect scores from overwork', () => { - const score = calculateProductivityScore(4, 360, 600); - expect(score).toBeGreaterThanOrEqual(97); - expect(score).toBeLessThan(100); + it('gives full but no extra focus credit beyond the target', () => { + const atTarget = calculateProductivityScore(4, 240, 600); + const beyondTarget = calculateProductivityScore(4, 360, 600); + expect(atTarget).toBe(100); + expect(beyondTarget).toBe(100); + }); + + it('never decreases when focus continues past the target', () => { + const atTarget = calculateProductivityScore(3, 240, 360); + const justPastTarget = calculateProductivityScore(3, 241, 360); + const farPastTarget = calculateProductivityScore(3, 300, 360); + expect(justPastTarget).toBeGreaterThanOrEqual(atTarget); + expect(farPastTarget).toBeGreaterThanOrEqual(justPastTarget); }); it('keeps progress as a secondary factor relative to consistent impact', () => { @@ -67,41 +76,41 @@ describe('metric-scoring.util', () => { describe('calculateSustainabilityScore', () => { it('establishes a balanced baseline for typical workdays', () => { - const score = calculateSustainabilityScore(240, 420, 600, 2); + const score = calculateSustainabilityScore(240, 420, 2); expectApproximately(score, 49, 3); }); it('applies only a mild penalty when focus exceeds the target slightly', () => { - const baseline = calculateSustainabilityScore(240, 420, 600, 2); - const excessiveFocus = calculateSustainabilityScore(300, 420, 600, 2); + const baseline = calculateSustainabilityScore(240, 420, 2); + const excessiveFocus = calculateSustainabilityScore(300, 420, 2); expect(excessiveFocus).toBeLessThanOrEqual(baseline); }); it('rewards high energy levels while keeping effort reasonable', () => { - const score = calculateSustainabilityScore(240, 420, 600, 3); + const score = calculateSustainabilityScore(240, 420, 3); expectApproximately(score, 73, 3); }); it('penalizes very excessive focus more than mild excess', () => { - const mildlyExcessive = calculateSustainabilityScore(300, 420, 600, 2); - const veryExcessive = calculateSustainabilityScore(360, 420, 600, 2); + const mildlyExcessive = calculateSustainabilityScore(300, 420, 2); + const veryExcessive = calculateSustainabilityScore(360, 420, 2); expect(veryExcessive).toBeLessThan(mildlyExcessive); }); it('keeps extreme focus scores within a reasonable range', () => { - const score = calculateSustainabilityScore(480, 420, 600, 2); + const score = calculateSustainabilityScore(480, 420, 2); expect(score).toBeGreaterThan(0); expect(score).toBeLessThan(50); }); it('penalizes overwork even when focus time is optimal', () => { - const score = calculateSustainabilityScore(240, 600, 600, 2); + const score = calculateSustainabilityScore(240, 600, 2); expect(score).toBeGreaterThan(0); expect(score).toBeLessThan(40); }); - it('detects burnout risk when long days combine with exhaustion', () => { - const score = calculateSustainabilityScore(360, 600, 600, 1); + it('detects burnout risk when long days combine with low energy', () => { + const score = calculateSustainabilityScore(360, 600, 1); expect(score).toBeLessThanOrEqual(30); }); }); diff --git a/src/app/features/metric/metric-scoring.util.ts b/src/app/features/metric/metric-scoring.util.ts index c139c3da19..8b6bdec302 100644 --- a/src/app/features/metric/metric-scoring.util.ts +++ b/src/app/features/metric/metric-scoring.util.ts @@ -1,7 +1,7 @@ /** * Utility functions for calculating productivity and sustainability metrics. - * v2.7 - Productivity: Impact-driven with recognition for non-focus work (65/30/5 split) - * v2.4 - Sustainability: Exponential decay + sigmoid workload model + * v2.8 - Productivity: Impact-driven with recognition for non-focus work (65/30/5 split) + * v2.5 - Sustainability: Exponential decay + sigmoid workload model * Includes robust fallbacks and heuristics for missing data. */ @@ -11,7 +11,7 @@ import { Metric } from './metric.model'; export const PRODUCTIVITY_WEIGHTS = { IMPACT: 0.65, // User's assessment of work significance (primary driver) - FOCUS: 0.3, // Progress toward deep work target (soft-capped) + FOCUS: 0.3, // Progress toward deep work target (full credit at target, flat beyond) TOTAL_WORK: 0.05, // Credit for overall effort (capped at 10h) } as const; @@ -20,7 +20,7 @@ export const PRODUCTIVITY_WEIGHTS = { * Freshness leads to emphasize energy and burnout prevention. */ export const SUSTAINABILITY_WEIGHTS = { - FRESHNESS: 0.45, // Energy level (from simple check-in or detailed exhaustion) + FRESHNESS: 0.45, // Energy level (from check-in or neutral fallback) WORKLOAD: 0.4, // Reasonable work hours (penalizes overwork) FOCUS_BALANCE: 0.15, // Inverted-V curve - optimal at 4h, penalizes excess } as const; @@ -30,7 +30,7 @@ export const SUSTAINABILITY_WEIGHTS = { */ export const TIME_TARGETS = { DEFAULT_TARGET_FOCUSED_MINUTES: 240, // 4 hours - optimal deep work target - DEFAULT_WORKLOAD_LINEAR_ZERO_AT: 600, // 10 hours - point where workload score becomes 0 + DEFAULT_MAX_WORK_MINUTES: 600, // 10 hours - cap for total work credit in productivity FOCUS_BALANCE_PEAK_MINUTES: 240, // 4 hours - optimal focus time for sustainability } as const; @@ -39,18 +39,10 @@ export const TIME_TARGETS = { */ export const SCALE_CONVERSIONS = { IMPACT_SCALE_MAX: 4, // Impact rating is 1-4 (simplified from 1-5) - EXHAUSTION_SCALE_MAX: 5, // Exhaustion is 1-5 (higher = worse) ENERGY_CHECKIN_MIN: 1, // Energy check-in is 1-3 ENERGY_CHECKIN_DIVISOR: 2, // Convert (value - 1) / 2 to get 0-1 range } as const; -/** - * Soft-cap and decay parameters - */ -export const SOFT_CAP = { - K_VALUE: 2.2, // Decay constant for exponential soft-cap (higher = faster diminishing returns) -} as const; - export const FOCUS_BALANCE_DECAY = { BETA: 0.00001, // Exponential decay rate for excessive focus (β parameter) } as const; @@ -97,17 +89,6 @@ export const COLOR_GRADIENT = { const clamp = (value: number, min: number = 0, max: number = 1): number => Math.max(min, Math.min(max, value)); -/** - * Helper: Soft-cap function that provides diminishing returns above target. - * Uses exponential decay: 1 - exp(-k * clamp(x)) - * @param x - Input value (typically a ratio like focusedMinutes to target minutes) - * @param k - Decay constant (uses SOFT_CAP.K_VALUE by default) - * @returns Soft-capped value between 0 and ~1 - */ -const softCap = (x: number, k: number = SOFT_CAP.K_VALUE): number => { - return 1 - Math.exp(-k * clamp(x)); -}; - /** * Helper: Safe division with fallback for zero denominator. * @param numerator - Value to divide @@ -132,14 +113,18 @@ export const focusSessionsToMinutes = (focusSessions: number[]): number => { /** * Calculates a productivity score (0-100) with impact rating as the primary driver. * - * v2.7 - Impact-driven model with recognition for non-focus work: + * v2.8 - Impact-driven model with recognition for non-focus work: * - 65% Impact Rating: User's assessment of work value/significance (1-4, required) - * - 30% Focus Progress: Progress toward deep work goal (soft-capped for diminishing returns) + * - 30% Focus Progress: Progress toward deep work goal (full credit at target, flat beyond) * - 5% Total Work: Credit for overall effort (capped at 10 hours) * * Making impact rating mandatory ensures users reflect on work value, not just time spent, * while still rewarding meaningful focus time and broader contributions beyond deep work. * + * Focus beyond the target earns no extra credit, but is never penalized either — the + * score must not drop when focus time increases (overwork is penalized by the + * sustainability score instead). + * * @param impactRating - User's assessment of work impact (1-4 scale, REQUIRED) * @param focusedMinutes - Total focused time for the day (in minutes) * @param totalWorkMinutes - Total work time including non-focus tasks (in minutes) @@ -153,30 +138,27 @@ export const focusSessionsToMinutes = (focusSessions: number[]): number => { * * @example * // Medium impact, typical day - * calculateProductivityScore(2, 180, 360) // Returns ~60 + * calculateProductivityScore(2, 180, 360) // Returns ~58 * * @example * // Low impact despite good focus = moderate score - * calculateProductivityScore(1, 240, 360) // Returns ~48 + * calculateProductivityScore(1, 240, 360) // Returns ~49 */ export const calculateProductivityScore = ( impactRating: number, focusedMinutes: number, totalWorkMinutes: number = focusedMinutes, targetFocusedMinutes: number = TIME_TARGETS.DEFAULT_TARGET_FOCUSED_MINUTES, - maxWorkMinutes: number = TIME_TARGETS.DEFAULT_WORKLOAD_LINEAR_ZERO_AT, + maxWorkMinutes: number = TIME_TARGETS.DEFAULT_MAX_WORK_MINUTES, ): number => { // Impact: User's reflection on work value (1-4 scale normalized to 0-1) const impact = clamp(impactRating / SCALE_CONVERSIONS.IMPACT_SCALE_MAX); - // Progress to Target: - // - Linear growth from 0 to target (0-1.0) - // - Soft-cap for diminishing returns beyond target (>1.0) + // Progress to Target: linear growth from 0 to target, full credit at target, + // flat beyond it. Must be continuous and non-decreasing in focusedMinutes so the + // score never drops for doing more focused work. const progressRatio = safeDiv(focusedMinutes, targetFocusedMinutes, 0); - const targetProgress = - progressRatio <= 1.0 - ? progressRatio // Linear up to target: full credit for meeting goal - : softCap(progressRatio); // Soft-cap beyond target: diminishing returns + const targetProgress = clamp(progressRatio); // Total work contribution (capped at maxWorkMinutes to avoid rewarding overwork) const totalWorkRatioRaw = safeDiv(totalWorkMinutes, maxWorkMinutes, 0); @@ -196,46 +178,38 @@ export const calculateProductivityScore = ( /** * Calculates a sustainability score (0-100) to assess work-life balance and burnout risk. * - * v2.4 - Improved balance model with exponential decay and sigmoid workload: - * - 45% Freshness: Energy levels from check-in or exhaustion (inverted) + * v2.5 - Improved balance model with exponential decay and sigmoid workload: + * - 45% Freshness: Energy levels from check-in (neutral fallback when missing) * - 40% Workload: Sigmoid function (inflection at 8h, asymptotic decay) * - 15% Focus Balance: Linear growth to 4h, then exponential penalty for excess * - * Changes from v2.3: - * - Workload: Sigmoid instead of linear (avoids harsh 0 at 10h) - * - Focus Balance: Exponential decay instead of linear (matches fatigue research) - * * The focus balance curve rewards building up to 4h of focused work, but then * applies exponential penalty beyond 4h (cognitive fatigue is non-linear). * This prevents burnout from excessive deep work sessions. * * @param focusedMinutes - Total focused time for the day (in minutes) * @param totalWorkMinutes - Total work time including meetings, emails, etc. (in minutes) - * @param workloadLinearZeroAt - Minutes at which workload score becomes 0 (deprecated, kept for API compatibility) * @param energyCheckin - Simple energy check-in (1=exhausted, 2=ok, 3=good) - optional - * @param exhaustion - Detailed exhaustion level (1-5 scale, higher = more exhausted) - optional * @returns Sustainability score from 0-100 * * @example - * // Target case: 7h work, 4h focus, medium energy = ~50 - * calculateSustainabilityScore(240, 420, 600, 2) // Returns ~52 + * // Target case: 7h work, 4h focus, medium energy + * calculateSustainabilityScore(240, 420, 2) // Returns ~49 * * @example - * // Excessive focus (5h): exponential penalty vs 4h - * calculateSustainabilityScore(300, 420, 600, 2) // Returns ~48 (lower than 4h) + * // Excessive focus (6h): exponential penalty vs 4h + * calculateSustainabilityScore(360, 420, 2) // Returns ~47 (lower than 4h) * * @example * // Good energy, reasonable hours, optimal focus = high score - * calculateSustainabilityScore(240, 420, 600, 3) // Returns ~73 + * calculateSustainabilityScore(240, 420, 3) // Returns ~72 */ export const calculateSustainabilityScore = ( focusedMinutes: number, totalWorkMinutes: number, - workloadLinearZeroAt: number = TIME_TARGETS.DEFAULT_WORKLOAD_LINEAR_ZERO_AT, energyCheckin?: number, ): number => { - // Freshness: Energy level normalized to 0-1 - // Priority: energyCheckin (simpler) > exhaustion (detailed) > neutral fallback + // Freshness: Energy level normalized to 0-1 (neutral fallback when missing) let freshness = DEFAULTS.FRESHNESS_NEUTRAL; if (energyCheckin !== undefined) { // energyCheckin: 1=exhausted, 2=ok, 3=good → (value-1)/2 → 0, 0.5, 1 diff --git a/src/app/features/metric/metric.component.ts b/src/app/features/metric/metric.component.ts index 0ea28655eb..68792a36b0 100644 --- a/src/app/features/metric/metric.component.ts +++ b/src/app/features/metric/metric.component.ts @@ -94,7 +94,6 @@ export class MetricComponent { ? calculateSustainabilityScore( item.focusedMinutes, item.totalWorkMinutes, - 600, item.energyCheckin, ) : null, diff --git a/src/app/features/metric/metric.service.ts b/src/app/features/metric/metric.service.ts index 31ace9e9d0..4f4ba4e37e 100644 --- a/src/app/features/metric/metric.service.ts +++ b/src/app/features/metric/metric.service.ts @@ -333,7 +333,6 @@ export class MetricService { return calculateSustainabilityScore( focusedMinutes, totalWorkMinutes, - 600, metric.energyCheckin, ); } diff --git a/src/app/features/metric/utils/get-daily-state-info.util.spec.ts b/src/app/features/metric/utils/get-daily-state-info.util.spec.ts new file mode 100644 index 0000000000..9b378fe9af --- /dev/null +++ b/src/app/features/metric/utils/get-daily-state-info.util.spec.ts @@ -0,0 +1,45 @@ +import { + calculateProductivityScore, + calculateSustainabilityScore, +} from '../metric-scoring.util'; +import { T } from '../../../t.const'; +import { getDailyStateInfo } from './get-daily-state-info.util'; + +describe('getDailyStateInfo', () => { + it('classifies minimum-impact extended-focus days as impact mismatch', () => { + for (const focusedMinutes of [241, 300, 360]) { + const totalWorkMinutes = 510; + const productivityScore = calculateProductivityScore( + 1, + focusedMinutes, + totalWorkMinutes, + ); + const sustainabilityScore = calculateSustainabilityScore( + focusedMinutes, + totalWorkMinutes, + 3, + ); + const state = getDailyStateInfo( + { + id: '2026-07-19', + impactOfWork: 1, + focusSessions: [focusedMinutes * 60_000], + totalWorkMinutes, + energyCheckin: 3, + }, + productivityScore, + sustainabilityScore, + ); + + expect(productivityScore) + .withContext(`${focusedMinutes} focused minutes should cross the high threshold`) + .toBeGreaterThan(50); + expect(sustainabilityScore) + .withContext(`${focusedMinutes} focused minutes should remain sustainable`) + .toBeGreaterThanOrEqual(50); + expect(state.headlineKey) + .withContext(`${focusedMinutes} focused minutes`) + .toBe(T.F.METRIC.EVAL_FORM.STATE_IMPACT_MISMATCH_HEADLINE); + } + }); +}); diff --git a/src/app/features/metric/utils/get-daily-state-info.util.ts b/src/app/features/metric/utils/get-daily-state-info.util.ts index dc37603b79..38f30a4abf 100644 --- a/src/app/features/metric/utils/get-daily-state-info.util.ts +++ b/src/app/features/metric/utils/get-daily-state-info.util.ts @@ -39,10 +39,10 @@ export const getDailyStateInfo = ( | 'IMPACT_MISMATCH' | 'DRIFT'; - if (isHighProductivity && isHighSustainability) { - stateKey = 'DEEP_FLOW'; - } else if (isHighSustainability && isLowImpact && isExtendedFocus) { + if (isHighSustainability && isLowImpact && isExtendedFocus) { stateKey = 'IMPACT_MISMATCH'; + } else if (isHighProductivity && isHighSustainability) { + stateKey = 'DEEP_FLOW'; } else if (isMidProductivity && isHighSustainability) { stateKey = 'STEADY'; } else if (isHighProductivity) {