From 87b074218351e1233f83d56d1303b91d1663271b Mon Sep 17 00:00:00 2001 From: David Vornholt <157214705+davidvornholt@users.noreply.github.com> Date: Mon, 20 Jul 2026 17:57:11 +0200 Subject: [PATCH] fix(metrics): remove productivity score drop when focus passes target (#9177) * fix(metrics): remove productivity score drop when focus passes target The focus-progress soft-cap clamped its input to [0,1], so any focus beyond the target collapsed to a constant 0.889 credit: with impact 3, 240 min of focus scored 81 while 241 min scored 77, and the score never recovered no matter how much more focus was logged. Focus progress is now linear to the target and flat beyond it - continuous and non-decreasing - leaving overwork penalties to the sustainability score. Also removes the dead workloadLinearZeroAt parameter from calculateSustainabilityScore (ignored since the sigmoid rewrite) and its misleading call-site comments, renames DEFAULT_WORKLOAD_LINEAR_ZERO_AT to DEFAULT_MAX_WORK_MINUTES to match what it does, and fixes stale JSDoc (removed exhaustion param, outdated example values). * fix(metrics): preserve impact mismatch classification * docs(metrics): clarify productivity focus credit --- docs/wiki/4.15-Timers-and-Focus-Mode.md | 2 +- ...dialog-productivity-breakdown.component.ts | 7 +- .../evaluation-sheet.component.ts | 7 +- .../metric/metric-scoring.util.spec.ts | 37 +++++---- .../features/metric/metric-scoring.util.ts | 78 +++++++------------ src/app/features/metric/metric.component.ts | 1 - src/app/features/metric/metric.service.ts | 1 - .../utils/get-daily-state-info.util.spec.ts | 45 +++++++++++ .../metric/utils/get-daily-state-info.util.ts | 6 +- 9 files changed, 100 insertions(+), 84 deletions(-) create mode 100644 src/app/features/metric/utils/get-daily-state-info.util.spec.ts 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) {