import { parseDateRangeFromTitle, getHierarchyLevel } from '../lib/dates' import { calculateExpectedProgress, calculateHealthStatus, getHealthColor, } from '../lib/progress' // --------------------------------------------------------------------------- // dates.ts // --------------------------------------------------------------------------- describe('parseDateRangeFromTitle', () => { const REF_YEAR = 2025 describe('no tag prefix', () => { it('returns null when title has no bracket tag', () => { expect(parseDateRangeFromTitle('Plain title', REF_YEAR)).toBeNull() }) it('returns null for empty string', () => { expect(parseDateRangeFromTitle('', REF_YEAR)).toBeNull() }) }) describe('year range — Nyr', () => { it('parses [1yr] as the reference year', () => { const result = parseDateRangeFromTitle('[1yr] Company goal', REF_YEAR) expect(result).not.toBeNull() expect(result!.start.getFullYear()).toBe(REF_YEAR) expect(result!.start.getMonth()).toBe(0) expect(result!.start.getDate()).toBe(1) expect(result!.end.getFullYear()).toBe(REF_YEAR) }) it('parses [3yr] spanning three years from the reference year', () => { const result = parseDateRangeFromTitle('[3yr] Three-year plan', REF_YEAR) expect(result).not.toBeNull() expect(result!.start.getFullYear()).toBe(REF_YEAR) expect(result!.end.getFullYear()).toBe(REF_YEAR + 2) }) it('is case-insensitive — [2YR] works', () => { const result = parseDateRangeFromTitle('[2YR] Two-year plan', REF_YEAR) expect(result).not.toBeNull() expect(result!.end.getFullYear()).toBe(REF_YEAR + 1) }) }) describe('half-year — H1 / H2', () => { it('parses [H1] as Jan 1 – Jun 30', () => { const result = parseDateRangeFromTitle('[H1] First half', REF_YEAR) expect(result).not.toBeNull() expect(result!.start).toEqual(new Date(REF_YEAR, 0, 1)) expect(result!.end).toEqual(new Date(REF_YEAR, 5, 30)) }) it('parses [H2] as Jul 1 – Dec 31', () => { const result = parseDateRangeFromTitle('[H2] Second half', REF_YEAR) expect(result).not.toBeNull() expect(result!.start).toEqual(new Date(REF_YEAR, 6, 1)) expect(result!.end).toEqual(new Date(REF_YEAR, 11, 31)) }) }) describe('quarter — Q1–Q4', () => { it('parses [Q1] as Jan–Mar', () => { const result = parseDateRangeFromTitle('[Q1] First quarter', REF_YEAR) expect(result).not.toBeNull() expect(result!.start.getMonth()).toBe(0) // Jan expect(result!.end.getMonth()).toBe(2) // Mar }) it('parses [Q2] as Apr–Jun', () => { const result = parseDateRangeFromTitle('[Q2] Q2 goals', REF_YEAR) expect(result).not.toBeNull() expect(result!.start.getMonth()).toBe(3) expect(result!.end.getMonth()).toBe(5) }) it('parses [Q3] as Jul–Sep', () => { const result = parseDateRangeFromTitle('[Q3] Q3 goals', REF_YEAR) expect(result).not.toBeNull() expect(result!.start.getMonth()).toBe(6) expect(result!.end.getMonth()).toBe(8) }) it('parses [Q4] as Oct–Dec', () => { const result = parseDateRangeFromTitle('[Q4] Q4 goals', REF_YEAR) expect(result).not.toBeNull() expect(result!.start.getMonth()).toBe(9) expect(result!.end.getMonth()).toBe(11) }) }) describe('month tags', () => { it('parses [Jan] as January', () => { const result = parseDateRangeFromTitle('[Jan] January sprint', REF_YEAR) expect(result).not.toBeNull() expect(result!.start.getMonth()).toBe(0) expect(result!.end.getMonth()).toBe(0) }) it('parses [Jun] as June', () => { const result = parseDateRangeFromTitle('[Jun] Mid-year review', REF_YEAR) expect(result).not.toBeNull() expect(result!.start.getMonth()).toBe(5) expect(result!.end.getMonth()).toBe(5) }) it('parses [Dec] as December', () => { const result = parseDateRangeFromTitle('[Dec] Year-end', REF_YEAR) expect(result).not.toBeNull() expect(result!.start.getMonth()).toBe(11) expect(result!.end.getMonth()).toBe(11) }) it('month start date is the 1st', () => { const result = parseDateRangeFromTitle('[Mar] Sprint', REF_YEAR) expect(result!.start.getDate()).toBe(1) }) }) describe('week tags — Wn', () => { it('parses [W1] as the first week of the year', () => { const result = parseDateRangeFromTitle('[W1] Week 1', REF_YEAR) expect(result).not.toBeNull() expect(result!.start.getFullYear()).toBe(REF_YEAR) // W1 should be 7 days const diff = result!.end.getTime() - result!.start.getTime() expect(diff).toBe(6 * 24 * 60 * 60 * 1000) }) it('parses [W10] as the 10th week', () => { const result = parseDateRangeFromTitle('[W10] Sprint', REF_YEAR) expect(result).not.toBeNull() // W10 starts 63 days into the year (0-indexed: (10-1)*7 = 63) const expectedStart = new Date(REF_YEAR, 0, 1 + 63) expect(result!.start.toDateString()).toBe(expectedStart.toDateString()) }) }) describe('unknown tag', () => { it('returns null for an unrecognised bracket tag', () => { expect(parseDateRangeFromTitle('[FOO] Something', REF_YEAR)).toBeNull() }) }) }) // --------------------------------------------------------------------------- describe('getHierarchyLevel', () => { it('returns 0 for plain titles with no bracket tag', () => { expect(getHierarchyLevel('Plain milestone')).toBe(0) expect(getHierarchyLevel('')).toBe(0) }) it('returns highest level (100+) for year tags', () => { expect(getHierarchyLevel('[1yr] Company OKR')).toBe(101) expect(getHierarchyLevel('[3yr] Long-term')).toBe(103) }) it('returns 50 for H1 / H2 tags', () => { expect(getHierarchyLevel('[H1] First half')).toBe(50) expect(getHierarchyLevel('[H2] Second half')).toBe(50) }) it('returns 40 for quarter tags', () => { expect(getHierarchyLevel('[Q1] Quarter one')).toBe(40) expect(getHierarchyLevel('[Q4] Quarter four')).toBe(40) }) it('returns 30 for month tags', () => { expect(getHierarchyLevel('[Jan] January')).toBe(30) expect(getHierarchyLevel('[Dec] December')).toBe(30) }) it('returns 20 for week tags', () => { expect(getHierarchyLevel('[W1] Week 1')).toBe(20) expect(getHierarchyLevel('[W52] Week 52')).toBe(20) }) it('returns 10 for unrecognised bracket tags', () => { expect(getHierarchyLevel('[FOO] Something')).toBe(10) }) it('enforces ordering: year > half > quarter > month > week > custom', () => { const levels = [ getHierarchyLevel('[1yr] Goal'), getHierarchyLevel('[H1] Goal'), getHierarchyLevel('[Q2] Goal'), getHierarchyLevel('[Mar] Goal'), getHierarchyLevel('[W5] Goal'), getHierarchyLevel('[FOO] Goal'), getHierarchyLevel('No tag'), ] // Each subsequent level should be strictly less than the previous for (let i = 1; i < levels.length; i++) { expect(levels[i]).toBeLessThan(levels[i - 1]) } }) }) // --------------------------------------------------------------------------- // progress.ts // --------------------------------------------------------------------------- describe('calculateExpectedProgress', () => { it('returns 0 when current date equals start date', () => { const start = new Date('2025-01-01') const end = new Date('2025-12-31') expect(calculateExpectedProgress(start, end, start)).toBe(0) }) it('returns 0 when current date is before start date', () => { const start = new Date('2025-06-01') const end = new Date('2025-12-31') const before = new Date('2025-01-01') expect(calculateExpectedProgress(start, end, before)).toBe(0) }) it('returns 100 when current date equals end date', () => { const start = new Date('2025-01-01') const end = new Date('2025-12-31') expect(calculateExpectedProgress(start, end, end)).toBe(100) }) it('returns 100 when current date is after end date', () => { const start = new Date('2025-01-01') const end = new Date('2025-06-01') const after = new Date('2025-12-31') expect(calculateExpectedProgress(start, end, after)).toBe(100) }) it('returns ~50 when current date is halfway through', () => { const start = new Date('2025-01-01') const end = new Date('2025-01-11') // 10 days const mid = new Date('2025-01-06') // 5 days elapsed const result = calculateExpectedProgress(start, end, mid) // 5/10 = 50% expect(result).toBe(50) }) it('returns a value between 0 and 100 exclusive for a mid-point', () => { const start = new Date('2025-01-01') const end = new Date('2025-12-31') const mid = new Date('2025-07-01') const result = calculateExpectedProgress(start, end, mid) expect(result).toBeGreaterThan(0) expect(result).toBeLessThan(100) }) it('rounds to an integer', () => { const start = new Date('2025-01-01') const end = new Date('2025-01-04') // 3 days const current = new Date('2025-01-02') // 1 day elapsed → 33.33% const result = calculateExpectedProgress(start, end, current) expect(Number.isInteger(result)).toBe(true) expect(result).toBe(33) }) }) // --------------------------------------------------------------------------- describe('calculateHealthStatus', () => { it('returns "blocked" when hasBlockers is true, regardless of progress', () => { const result = calculateHealthStatus(80, 30, true) expect(result.status).toBe('blocked') expect(result.reason).toBeDefined() }) it('returns "not_started" when progress is 0 and expected > 0', () => { const result = calculateHealthStatus(0, 25) expect(result.status).toBe('not_started') expect(result.reason).toBeDefined() }) it('returns "on_track" when progress is 0 and expected is also 0', () => { const result = calculateHealthStatus(0, 0) expect(result.status).toBe('on_track') }) it('returns "at_risk" when progress is more than 10 points behind expected', () => { const result = calculateHealthStatus(30, 50) // 20 points behind expect(result.status).toBe('at_risk') expect(result.reason).toBeDefined() }) it('returns "on_track" when actual progress equals expected', () => { const result = calculateHealthStatus(60, 60) expect(result.status).toBe('on_track') }) it('returns "on_track" when actual progress is ahead of expected', () => { const result = calculateHealthStatus(70, 50) expect(result.status).toBe('on_track') }) it('returns "on_track" when actual is exactly 10 points behind (boundary)', () => { // diff = 40 - 50 = -10, which is NOT < -10, so on_track const result = calculateHealthStatus(40, 50) expect(result.status).toBe('on_track') }) it('returns "at_risk" when actual is 11 points behind (just past boundary)', () => { const result = calculateHealthStatus(39, 50) expect(result.status).toBe('at_risk') }) it('blocked takes precedence over not_started', () => { const result = calculateHealthStatus(0, 50, true) expect(result.status).toBe('blocked') }) }) // --------------------------------------------------------------------------- describe('getHealthColor', () => { it('returns green for on_track', () => { expect(getHealthColor('on_track')).toBe('#22c55e') }) it('returns yellow for at_risk', () => { expect(getHealthColor('at_risk')).toBe('#eab308') }) it('returns red for blocked', () => { expect(getHealthColor('blocked')).toBe('#ef4444') }) it('returns gray for not_started', () => { expect(getHealthColor('not_started')).toBe('#9ca3af') }) it('returns a non-empty CSS color string for every valid status', () => { const statuses = ['on_track', 'at_risk', 'blocked', 'not_started'] as const for (const s of statuses) { const color = getHealthColor(s) expect(color).toMatch(/^#[0-9a-f]{6}$/i) } }) })