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
This commit is contained in:
David Vornholt 2026-07-20 17:57:11 +02:00 committed by GitHub
parent 03a326a042
commit 87b0742183
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 100 additions and 84 deletions

View file

@ -78,7 +78,7 @@ So “session done” means something different per mode: in Pomodoro it trigger
### Focus Balance and Sustainability
The apps **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 apps **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

View file

@ -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 {

View file

@ -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(() => {

View file

@ -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);
});
});

View file

@ -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

View file

@ -94,7 +94,6 @@ export class MetricComponent {
? calculateSustainabilityScore(
item.focusedMinutes,
item.totalWorkMinutes,
600,
item.energyCheckin,
)
: null,

View file

@ -333,7 +333,6 @@ export class MetricService {
return calculateSustainabilityScore(
focusedMinutes,
totalWorkMinutes,
600,
metric.energyCheckin,
);
}

View file

@ -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);
}
});
});

View file

@ -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) {