From a0bfd54ab73e93f0e1b9d8a4f5eedf4980d578ea Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Tue, 12 May 2026 13:30:31 +0100 Subject: [PATCH 01/19] feat(date-parser): add calendar boundary keywords (FT-1922) --- src/lib/utils/date-parser.test.ts | 49 +++++++++++++++++++++++++++++++ src/lib/utils/date-parser.ts | 28 ++++++++++++++++++ 2 files changed, 77 insertions(+) diff --git a/src/lib/utils/date-parser.test.ts b/src/lib/utils/date-parser.test.ts index 8d77ee7..c7322a2 100644 --- a/src/lib/utils/date-parser.test.ts +++ b/src/lib/utils/date-parser.test.ts @@ -98,6 +98,55 @@ describe('parseDateFlag', () => { }); }); + describe('calendar keywords', () => { + // 2026-05-12T14:23:45Z — a Tuesday mid-month + const NOW = Date.UTC(2026, 4, 12, 14, 23, 45); + + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(NOW); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('parses "month-start" as 00:00 UTC on the 1st of the current month', () => { + expect(parseDateFlag('month-start')).toBe(Date.UTC(2026, 4, 1)); + }); + + it('parses "last-month-start" as 00:00 UTC on the 1st of the previous month', () => { + expect(parseDateFlag('last-month-start')).toBe(Date.UTC(2026, 3, 1)); + }); + + it('parses "last-month-end" as 1ms before the start of the current month', () => { + expect(parseDateFlag('last-month-end')).toBe(Date.UTC(2026, 4, 1) - 1); + }); + + it('handles January correctly for last-month-start (rolls to December of previous year)', () => { + vi.setSystemTime(Date.UTC(2026, 0, 15)); + expect(parseDateFlag('last-month-start')).toBe(Date.UTC(2025, 11, 1)); + expect(parseDateFlag('last-month-end')).toBe(Date.UTC(2026, 0, 1) - 1); + }); + + it('parses "year-start" as 00:00 UTC on Jan 1 of the current year', () => { + expect(parseDateFlag('year-start')).toBe(Date.UTC(2026, 0, 1)); + }); + + it('parses "last-year-start" as 00:00 UTC on Jan 1 of the previous year', () => { + expect(parseDateFlag('last-year-start')).toBe(Date.UTC(2025, 0, 1)); + }); + + it('parses "last-year-end" as 1ms before the start of the current year', () => { + expect(parseDateFlag('last-year-end')).toBe(Date.UTC(2026, 0, 1) - 1); + }); + + it('is case-insensitive', () => { + expect(parseDateFlag('Month-Start')).toBe(Date.UTC(2026, 4, 1)); + expect(parseDateFlag('YEAR-START')).toBe(Date.UTC(2026, 0, 1)); + }); + }); + describe('error handling', () => { it('should throw for invalid date format', () => { expect(() => parseDateFlag('invalid-date')).toThrow(/Invalid date format/); diff --git a/src/lib/utils/date-parser.ts b/src/lib/utils/date-parser.ts index 9c28adf..bdcf0e8 100644 --- a/src/lib/utils/date-parser.ts +++ b/src/lib/utils/date-parser.ts @@ -29,6 +29,30 @@ const UNIT_MS: Record = { years: 365 * 24 * 60 * 60 * 1000, }; +function parseCalendarKeyword(input: string): number | null { + const lower = input.trim().toLowerCase(); + const now = new Date(Date.now()); + const year = now.getUTCFullYear(); + const month = now.getUTCMonth(); + + switch (lower) { + case 'month-start': + return Date.UTC(year, month, 1); + case 'last-month-start': + return Date.UTC(year, month - 1, 1); + case 'last-month-end': + return Date.UTC(year, month, 1) - 1; + case 'year-start': + return Date.UTC(year, 0, 1); + case 'last-year-start': + return Date.UTC(year - 1, 0, 1); + case 'last-year-end': + return Date.UTC(year, 0, 1) - 1; + default: + return null; + } +} + function parseRelativeDate(input: string): number | null { const lower = input.trim().toLowerCase(); @@ -55,6 +79,9 @@ export function parseDateFlag(dateStr: string): number { return asNumber; } + const calendar = parseCalendarKeyword(dateStr); + if (calendar !== null) return calendar; + const relative = parseRelativeDate(dateStr); if (relative !== null) return relative; @@ -64,6 +91,7 @@ export function parseDateFlag(dateStr: string): number { `Invalid date format: "${dateStr}"\n` + `Expected formats:\n` + ` - Relative: 7d, 2w, 30d ago, yesterday, today\n` + + ` - Calendar: month-start, last-month-start, last-month-end, year-start, last-year-start, last-year-end\n` + ` - ISO 8601 date: 2024-01-01\n` + ` - ISO 8601 datetime: 2024-01-01T00:00:00Z\n` + ` - Milliseconds since epoch: 1704067200000\n` + From 4c598d49b0cd09579b4d9448e73079f99389ab60 Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Tue, 12 May 2026 13:33:45 +0100 Subject: [PATCH 02/19] feat(api-client): add getEventsSummary method (FT-1922) --- src/api-client/api-client.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/api-client/api-client.ts b/src/api-client/api-client.ts index f11281d..bd55e1e 100644 --- a/src/api-client/api-client.ts +++ b/src/api-client/api-client.ts @@ -2346,6 +2346,14 @@ export class APIClient { return response.data; } + async getEventsSummary(params: { from?: number; to?: number }): Promise { + const queryParams: Record = {}; + if (params.from !== undefined) queryParams.from = params.from; + if (params.to !== undefined) queryParams.to = params.to; + const response = await this.request('GET', '/events/summary', { params: queryParams }); + return response.data; + } + async getVelocityInsightsDetail(params: { from: number; to: number; From 69ed402c23808ed18fa505771a2978a30436e41f Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Tue, 12 May 2026 13:36:50 +0100 Subject: [PATCH 03/19] feat(events): add core getEventsSummary wrapper with 100-day cap (FT-1922) --- src/core/events/summary.test.ts | 41 ++++++++++++++++++++++++++ src/core/events/summary.ts | 51 +++++++++++++++++++++++++++++++++ 2 files changed, 92 insertions(+) create mode 100644 src/core/events/summary.test.ts create mode 100644 src/core/events/summary.ts diff --git a/src/core/events/summary.test.ts b/src/core/events/summary.test.ts new file mode 100644 index 0000000..07397e7 --- /dev/null +++ b/src/core/events/summary.test.ts @@ -0,0 +1,41 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { getEventsSummary } from './summary.js'; + +describe('getEventsSummary', () => { + const mockClient = { + getEventsSummary: vi.fn(), + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('passes from/to through to the client', async () => { + mockClient.getEventsSummary.mockResolvedValue({ events: [], teams: [] }); + const result = await getEventsSummary(mockClient as any, { from: 100, to: 200 }); + expect(mockClient.getEventsSummary).toHaveBeenCalledWith({ from: 100, to: 200 }); + expect(result.data).toEqual({ events: [], teams: [] }); + }); + + it('omits undefined from/to', async () => { + mockClient.getEventsSummary.mockResolvedValue({ events: [], teams: [] }); + await getEventsSummary(mockClient as any, {}); + expect(mockClient.getEventsSummary).toHaveBeenCalledWith({}); + }); + + it('rejects ranges greater than 100 days client-side', async () => { + const from = 0; + const to = 101 * 86_400_000; + await expect( + getEventsSummary(mockClient as any, { from, to }) + ).rejects.toThrow(/maximum is 100 days/i); + expect(mockClient.getEventsSummary).not.toHaveBeenCalled(); + }); + + it('rejects ranges where from > to', async () => { + await expect( + getEventsSummary(mockClient as any, { from: 200, to: 100 }) + ).rejects.toThrow(/`from` must be less than or equal to `to`/); + expect(mockClient.getEventsSummary).not.toHaveBeenCalled(); + }); +}); diff --git a/src/core/events/summary.ts b/src/core/events/summary.ts new file mode 100644 index 0000000..5cd17cd --- /dev/null +++ b/src/core/events/summary.ts @@ -0,0 +1,51 @@ +import type { APIClient } from '../../api-client/api-client.js'; +import type { CommandResult } from '../types.js'; + +export type EventType = 'exposure' | 'goal'; + +export interface SummaryEventRow { + date: number; + team_id: number; + count: number; + type: EventType; +} + +export interface SummaryTeam { + id: number; + name: string; + initials: string; + color: string; +} + +export interface EventsSummaryResponse { + events: SummaryEventRow[]; + teams: SummaryTeam[]; +} + +export interface GetEventsSummaryParams { + from?: number | undefined; + to?: number | undefined; +} + +const MAX_RANGE_MS = 100 * 86_400_000; + +export async function getEventsSummary( + client: Pick, + params: GetEventsSummaryParams +): Promise> { + if (params.from !== undefined && params.to !== undefined) { + if (params.from > params.to) { + throw new Error('`from` must be less than or equal to `to`'); + } + if (params.to - params.from > MAX_RANGE_MS) { + throw new Error('Date range too large - maximum is 100 days'); + } + } + + const body: { from?: number; to?: number } = {}; + if (params.from !== undefined) body.from = params.from; + if (params.to !== undefined) body.to = params.to; + + const data = (await client.getEventsSummary(body)) as EventsSummaryResponse; + return { data }; +} From f3a533504233be688d12f878df05c26f056b2d54 Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Tue, 12 May 2026 13:39:31 +0100 Subject: [PATCH 04/19] feat(events): add rollUpEvents for day/week/month bucketing (FT-1922) --- src/core/events/summary.test.ts | 65 ++++++++++++++++++++++++++++++++- src/core/events/summary.ts | 39 ++++++++++++++++++++ 2 files changed, 103 insertions(+), 1 deletion(-) diff --git a/src/core/events/summary.test.ts b/src/core/events/summary.test.ts index 07397e7..cf3fc0f 100644 --- a/src/core/events/summary.test.ts +++ b/src/core/events/summary.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { getEventsSummary } from './summary.js'; +import { getEventsSummary, rollUpEvents } from './summary.js'; describe('getEventsSummary', () => { const mockClient = { @@ -39,3 +39,66 @@ describe('getEventsSummary', () => { expect(mockClient.getEventsSummary).not.toHaveBeenCalled(); }); }); + +describe('rollUpEvents', () => { + const day = (y: number, m: number, d: number) => Date.UTC(y, m, d); + + it('passes through daily rows unchanged when period=day', () => { + const events = [ + { date: day(2026, 4, 10), team_id: 1, count: 5, type: 'exposure' as const }, + { date: day(2026, 4, 11), team_id: 1, count: 7, type: 'exposure' as const }, + ]; + expect(rollUpEvents(events, 'day')).toEqual(events); + }); + + it('buckets days into ISO weeks (Monday start) when period=week', () => { + // 2026-05-04 is a Monday. 2026-05-10 is the following Sunday. + const events = [ + { date: day(2026, 4, 4), team_id: 1, count: 1, type: 'exposure' as const }, + { date: day(2026, 4, 7), team_id: 1, count: 2, type: 'exposure' as const }, + { date: day(2026, 4, 10), team_id: 1, count: 3, type: 'exposure' as const }, + { date: day(2026, 4, 11), team_id: 1, count: 4, type: 'exposure' as const }, // next week + ]; + const result = rollUpEvents(events, 'week'); + expect(result).toEqual([ + { date: day(2026, 4, 4), team_id: 1, count: 6, type: 'exposure' }, + { date: day(2026, 4, 11), team_id: 1, count: 4, type: 'exposure' }, + ]); + }); + + it('buckets into calendar months when period=month', () => { + const events = [ + { date: day(2026, 3, 28), team_id: 1, count: 1, type: 'exposure' as const }, + { date: day(2026, 4, 1), team_id: 1, count: 2, type: 'exposure' as const }, + { date: day(2026, 4, 31), team_id: 1, count: 3, type: 'exposure' as const }, + ]; + const result = rollUpEvents(events, 'month'); + expect(result).toEqual([ + { date: day(2026, 3, 1), team_id: 1, count: 1, type: 'exposure' }, + { date: day(2026, 4, 1), team_id: 1, count: 5, type: 'exposure' }, + ]); + }); + + it('keeps team_id and type separate when bucketing', () => { + const events = [ + { date: day(2026, 4, 4), team_id: 1, count: 1, type: 'exposure' as const }, + { date: day(2026, 4, 5), team_id: 2, count: 2, type: 'exposure' as const }, + { date: day(2026, 4, 6), team_id: 1, count: 3, type: 'goal' as const }, + { date: day(2026, 4, 7), team_id: 1, count: 4, type: 'exposure' as const }, + ]; + const result = rollUpEvents(events, 'week'); + expect(result).toContainEqual({ date: day(2026, 4, 4), team_id: 1, count: 5, type: 'exposure' }); + expect(result).toContainEqual({ date: day(2026, 4, 4), team_id: 2, count: 2, type: 'exposure' }); + expect(result).toContainEqual({ date: day(2026, 4, 4), team_id: 1, count: 3, type: 'goal' }); + expect(result).toHaveLength(3); + }); + + it('sorts output by date ascending', () => { + const events = [ + { date: day(2026, 4, 20), team_id: 1, count: 1, type: 'exposure' as const }, + { date: day(2026, 4, 4), team_id: 1, count: 2, type: 'exposure' as const }, + ]; + const result = rollUpEvents(events, 'week'); + expect(result.map((r) => r.date)).toEqual([day(2026, 4, 4), day(2026, 4, 18)]); + }); +}); diff --git a/src/core/events/summary.ts b/src/core/events/summary.ts index 5cd17cd..a6f69ec 100644 --- a/src/core/events/summary.ts +++ b/src/core/events/summary.ts @@ -49,3 +49,42 @@ export async function getEventsSummary( const data = (await client.getEventsSummary(body)) as EventsSummaryResponse; return { data }; } + +export type Period = 'day' | 'week' | 'month'; + +function bucketStart(date: number, period: Period): number { + if (period === 'day') { + return Date.UTC( + new Date(date).getUTCFullYear(), + new Date(date).getUTCMonth(), + new Date(date).getUTCDate() + ); + } + if (period === 'month') { + const d = new Date(date); + return Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1); + } + // week: ISO week, Monday start + const d = new Date(date); + const dayOfWeek = d.getUTCDay(); // 0 = Sun … 6 = Sat + const daysSinceMonday = (dayOfWeek + 6) % 7; // Mon=0, Tue=1 … Sun=6 + return Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate() - daysSinceMonday); +} + +export function rollUpEvents(events: SummaryEventRow[], period: Period): SummaryEventRow[] { + if (period === 'day') { + return [...events].sort((a, b) => a.date - b.date); + } + const buckets = new Map(); + for (const ev of events) { + const bucket = bucketStart(ev.date, period); + const key = `${bucket}|${ev.team_id}|${ev.type}`; + const existing = buckets.get(key); + if (existing) { + existing.count += ev.count; + } else { + buckets.set(key, { date: bucket, team_id: ev.team_id, count: ev.count, type: ev.type }); + } + } + return Array.from(buckets.values()).sort((a, b) => a.date - b.date); +} From 73b7d5ee87c27e38f9916b2a8ce5c5496e48e52c Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Tue, 12 May 2026 13:42:11 +0100 Subject: [PATCH 05/19] feat(events): add aggregateByTeam with event-type filter (FT-1922) --- src/core/events/summary.test.ts | 51 ++++++++++++++++++++++++++++++++- src/core/events/summary.ts | 40 ++++++++++++++++++++++++++ 2 files changed, 90 insertions(+), 1 deletion(-) diff --git a/src/core/events/summary.test.ts b/src/core/events/summary.test.ts index cf3fc0f..c406ba5 100644 --- a/src/core/events/summary.test.ts +++ b/src/core/events/summary.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { getEventsSummary, rollUpEvents } from './summary.js'; +import { getEventsSummary, rollUpEvents, aggregateByTeam } from './summary.js'; describe('getEventsSummary', () => { const mockClient = { @@ -102,3 +102,52 @@ describe('rollUpEvents', () => { expect(result.map((r) => r.date)).toEqual([day(2026, 4, 4), day(2026, 4, 18)]); }); }); + +describe('aggregateByTeam', () => { + const day = (y: number, m: number, d: number) => Date.UTC(y, m, d); + + it('groups rows by date and team_id and computes totals', () => { + const events = [ + { date: day(2026, 4, 4), team_id: 1, count: 10, type: 'exposure' as const }, + { date: day(2026, 4, 4), team_id: 1, count: 3, type: 'goal' as const }, + { date: day(2026, 4, 4), team_id: 2, count: 7, type: 'exposure' as const }, + { date: day(2026, 4, 11), team_id: 1, count: 5, type: 'exposure' as const }, + ]; + const result = aggregateByTeam(events, { eventType: 'all' }); + expect(result).toHaveLength(2); + const week1 = result[0]!; + expect(week1.date).toBe(day(2026, 4, 4)); + expect(week1.teams.get(1)).toEqual({ goal: 3, exposure: 10, total: 13 }); + expect(week1.teams.get(2)).toEqual({ goal: 0, exposure: 7, total: 7 }); + expect(week1.totalExposure).toBe(17); + expect(week1.totalGoal).toBe(3); + expect(week1.total).toBe(20); + }); + + it('filters to exposures only when eventType=exposure', () => { + const events = [ + { date: day(2026, 4, 4), team_id: 1, count: 10, type: 'exposure' as const }, + { date: day(2026, 4, 4), team_id: 1, count: 3, type: 'goal' as const }, + ]; + const result = aggregateByTeam(events, { eventType: 'exposure' }); + expect(result[0]!.totalGoal).toBe(0); + expect(result[0]!.totalExposure).toBe(10); + expect(result[0]!.total).toBe(10); + expect(result[0]!.teams.get(1)).toEqual({ goal: 0, exposure: 10, total: 10 }); + }); + + it('filters to goals only when eventType=goal', () => { + const events = [ + { date: day(2026, 4, 4), team_id: 1, count: 10, type: 'exposure' as const }, + { date: day(2026, 4, 4), team_id: 1, count: 3, type: 'goal' as const }, + ]; + const result = aggregateByTeam(events, { eventType: 'goal' }); + expect(result[0]!.totalGoal).toBe(3); + expect(result[0]!.totalExposure).toBe(0); + expect(result[0]!.total).toBe(3); + }); + + it('returns empty array for no events', () => { + expect(aggregateByTeam([], { eventType: 'all' })).toEqual([]); + }); +}); diff --git a/src/core/events/summary.ts b/src/core/events/summary.ts index a6f69ec..89edd41 100644 --- a/src/core/events/summary.ts +++ b/src/core/events/summary.ts @@ -88,3 +88,43 @@ export function rollUpEvents(events: SummaryEventRow[], period: Period): Summary } return Array.from(buckets.values()).sort((a, b) => a.date - b.date); } + +export type EventTypeFilter = 'all' | 'goal' | 'exposure'; + +export interface AggregatedRow { + date: number; + teams: Map; + totalGoal: number; + totalExposure: number; + total: number; +} + +export function aggregateByTeam( + events: SummaryEventRow[], + options: { eventType: EventTypeFilter } +): AggregatedRow[] { + const byDate = new Map(); + for (const ev of events) { + if (options.eventType !== 'all' && ev.type !== options.eventType) continue; + let row = byDate.get(ev.date); + if (!row) { + row = { date: ev.date, teams: new Map(), totalGoal: 0, totalExposure: 0, total: 0 }; + byDate.set(ev.date, row); + } + let team = row.teams.get(ev.team_id); + if (!team) { + team = { goal: 0, exposure: 0, total: 0 }; + row.teams.set(ev.team_id, team); + } + if (ev.type === 'goal') { + team.goal += ev.count; + row.totalGoal += ev.count; + } else { + team.exposure += ev.count; + row.totalExposure += ev.count; + } + team.total += ev.count; + row.total += ev.count; + } + return Array.from(byDate.values()).sort((a, b) => a.date - b.date); +} From 0edf8b2c023de2c897b0871f5ac70139660e010f Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Tue, 12 May 2026 13:44:57 +0100 Subject: [PATCH 06/19] feat(events): add applyCumulative running-totals pass (FT-1922) --- src/core/events/summary.test.ts | 56 ++++++++++++++++++++++++++++++++- src/core/events/summary.ts | 33 +++++++++++++++++++ 2 files changed, 88 insertions(+), 1 deletion(-) diff --git a/src/core/events/summary.test.ts b/src/core/events/summary.test.ts index c406ba5..c2603f2 100644 --- a/src/core/events/summary.test.ts +++ b/src/core/events/summary.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { getEventsSummary, rollUpEvents, aggregateByTeam } from './summary.js'; +import { getEventsSummary, rollUpEvents, aggregateByTeam, applyCumulative, type AggregatedRow } from './summary.js'; describe('getEventsSummary', () => { const mockClient = { @@ -151,3 +151,57 @@ describe('aggregateByTeam', () => { expect(aggregateByTeam([], { eventType: 'all' })).toEqual([]); }); }); + +describe('applyCumulative', () => { + const day = (y: number, m: number, d: number) => Date.UTC(y, m, d); + + it('produces running totals over rows', () => { + const rows: AggregatedRow[] = [ + { + date: day(2026, 4, 4), + teams: new Map([[1, { goal: 1, exposure: 10, total: 11 }]]), + totalGoal: 1, + totalExposure: 10, + total: 11, + }, + { + date: day(2026, 4, 11), + teams: new Map([[1, { goal: 2, exposure: 5, total: 7 }]]), + totalGoal: 2, + totalExposure: 5, + total: 7, + }, + ]; + const out = applyCumulative(rows); + expect(out[0]!.total).toBe(11); + expect(out[1]!.total).toBe(18); + expect(out[0]!.totalExposure).toBe(10); + expect(out[1]!.totalExposure).toBe(15); + expect(out[1]!.teams.get(1)).toEqual({ goal: 3, exposure: 15, total: 18 }); + }); + + it('carries forward teams that appear later', () => { + const rows: AggregatedRow[] = [ + { + date: day(2026, 4, 4), + teams: new Map([[1, { goal: 0, exposure: 5, total: 5 }]]), + totalGoal: 0, totalExposure: 5, total: 5, + }, + { + date: day(2026, 4, 11), + teams: new Map([ + [1, { goal: 0, exposure: 3, total: 3 }], + [2, { goal: 0, exposure: 7, total: 7 }], + ]), + totalGoal: 0, totalExposure: 10, total: 10, + }, + ]; + const out = applyCumulative(rows); + expect(out[1]!.teams.get(1)).toEqual({ goal: 0, exposure: 8, total: 8 }); + expect(out[1]!.teams.get(2)).toEqual({ goal: 0, exposure: 7, total: 7 }); + }); + + it('returns empty array for no rows', () => { + expect(applyCumulative([])).toEqual([]); + }); +}); diff --git a/src/core/events/summary.ts b/src/core/events/summary.ts index 89edd41..f8ba6fd 100644 --- a/src/core/events/summary.ts +++ b/src/core/events/summary.ts @@ -128,3 +128,36 @@ export function aggregateByTeam( } return Array.from(byDate.values()).sort((a, b) => a.date - b.date); } + +export function applyCumulative(rows: AggregatedRow[]): AggregatedRow[] { + let cumGoal = 0; + let cumExposure = 0; + let cumTotal = 0; + const cumByTeam = new Map(); + return rows.map((row) => { + cumGoal += row.totalGoal; + cumExposure += row.totalExposure; + cumTotal += row.total; + const newTeams = new Map(); + // Union of team ids seen so far in current row + all previously seen. + const allTeamIds = new Set([...cumByTeam.keys(), ...row.teams.keys()]); + for (const id of allTeamIds) { + const prev = cumByTeam.get(id) ?? { goal: 0, exposure: 0, total: 0 }; + const cur = row.teams.get(id) ?? { goal: 0, exposure: 0, total: 0 }; + const merged = { + goal: prev.goal + cur.goal, + exposure: prev.exposure + cur.exposure, + total: prev.total + cur.total, + }; + cumByTeam.set(id, merged); + newTeams.set(id, merged); + } + return { + date: row.date, + teams: newTeams, + totalGoal: cumGoal, + totalExposure: cumExposure, + total: cumTotal, + }; + }); +} From 4d8f2720f7ce74f6313eb390674bbab02d2b3f85 Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Tue, 12 May 2026 13:50:15 +0100 Subject: [PATCH 07/19] feat(events): add table and bar formatters for events summary (FT-1922) --- src/commands/events/summary.test.ts | 118 ++++++++++++++++++++++++++++ src/commands/events/summary.ts | 113 ++++++++++++++++++++++++++ 2 files changed, 231 insertions(+) create mode 100644 src/commands/events/summary.test.ts create mode 100644 src/commands/events/summary.ts diff --git a/src/commands/events/summary.test.ts b/src/commands/events/summary.test.ts new file mode 100644 index 0000000..0db965d --- /dev/null +++ b/src/commands/events/summary.test.ts @@ -0,0 +1,118 @@ +import { describe, it, expect } from 'vitest'; +import { formatSummaryTable, formatSummaryBars } from './summary.js'; +import type { AggregatedRow, SummaryTeam } from '../../core/events/summary.js'; + +const day = (y: number, m: number, d: number) => Date.UTC(y, m, d); + +const teams: SummaryTeam[] = [ + { id: 1, name: 'Growth', initials: 'GR', color: 'blue' }, + { id: 2, name: 'Platform', initials: 'PL', color: 'green' }, +]; + +const rows: AggregatedRow[] = [ + { + date: day(2026, 4, 4), + teams: new Map([ + [1, { goal: 1, exposure: 10, total: 11 }], + [2, { goal: 0, exposure: 5, total: 5 }], + ]), + totalGoal: 1, + totalExposure: 15, + total: 16, + }, + { + date: day(2026, 4, 11), + teams: new Map([[1, { goal: 2, exposure: 8, total: 10 }]]), + totalGoal: 2, + totalExposure: 8, + total: 10, + }, +]; + +describe('formatSummaryTable', () => { + it('renders team columns when groupBy=team', () => { + const out = formatSummaryTable(rows, teams, { + period: 'week', + groupBy: 'team', + eventType: 'all', + noColor: true, + }); + expect(out).toMatch(/Growth/); + expect(out).toMatch(/Platform/); + expect(out).toMatch(/2026-05-04/); + expect(out).toMatch(/Total/); + }); + + it('renders only total column when groupBy=total', () => { + const out = formatSummaryTable(rows, teams, { + period: 'week', + groupBy: 'total', + eventType: 'all', + noColor: true, + }); + expect(out).not.toMatch(/Growth/); + expect(out).toMatch(/Total/); + expect(out).toMatch(/16/); + expect(out).toMatch(/10/); + }); + + it('formats period as YYYY-MM when period=month', () => { + const monthRows: AggregatedRow[] = [ + { ...rows[0]!, date: day(2026, 4, 1) }, + ]; + const out = formatSummaryTable(monthRows, teams, { + period: 'month', + groupBy: 'team', + eventType: 'all', + noColor: true, + }); + expect(out).toMatch(/2026-05/); + expect(out).not.toMatch(/2026-05-01/); + }); + + it('renders Unowned for team_id=-1', () => { + const unowned: SummaryTeam = { id: -1, name: 'Unowned', initials: 'UN', color: 'gray' }; + const r: AggregatedRow[] = [ + { + date: day(2026, 4, 4), + teams: new Map([[-1, { goal: 0, exposure: 4, total: 4 }]]), + totalGoal: 0, totalExposure: 4, total: 4, + }, + ]; + const out = formatSummaryTable(r, [unowned], { + period: 'week', + groupBy: 'team', + eventType: 'all', + noColor: true, + }); + expect(out).toMatch(/Unowned/); + }); +}); + +describe('formatSummaryBars', () => { + it('renders one line per (period, team) with a bar character', () => { + const out = formatSummaryBars(rows, teams, { + period: 'week', + groupBy: 'team', + eventType: 'all', + noColor: true, + }); + const lines = out.split('\n').filter((l) => l.length > 0); + expect(lines.length).toBeGreaterThanOrEqual(3); // 2 teams in week1, 1 in week2 + expect(out).toMatch(/2026-05-04/); + expect(out).toMatch(/Growth/); + expect(out).toMatch(/Platform/); + expect(out).toMatch(/█/); + }); + + it('renders one line per period (totals) when groupBy=total', () => { + const out = formatSummaryBars(rows, teams, { + period: 'week', + groupBy: 'total', + eventType: 'all', + noColor: true, + }); + const lines = out.split('\n').filter((l) => l.length > 0); + expect(lines).toHaveLength(2); + }); +}); diff --git a/src/commands/events/summary.ts b/src/commands/events/summary.ts new file mode 100644 index 0000000..8d20538 --- /dev/null +++ b/src/commands/events/summary.ts @@ -0,0 +1,113 @@ +import chalk from 'chalk'; +import Table from 'cli-table3'; +import type { + AggregatedRow, + EventTypeFilter, + Period, + SummaryTeam, +} from '../../core/events/summary.js'; + +export interface FormatOptions { + period: Period; + groupBy: 'team' | 'total'; + eventType: EventTypeFilter; + noColor: boolean; +} + +function formatPeriodCell(date: number, period: Period): string { + const d = new Date(date); + const y = d.getUTCFullYear(); + const m = String(d.getUTCMonth() + 1).padStart(2, '0'); + if (period === 'month') return `${y}-${m}`; + const day = String(d.getUTCDate()).padStart(2, '0'); + return `${y}-${m}-${day}`; +} + +function pickCount( + row: { goal: number; exposure: number; total: number } | undefined, + eventType: EventTypeFilter +): number { + if (!row) return 0; + if (eventType === 'goal') return row.goal; + if (eventType === 'exposure') return row.exposure; + return row.total; +} + +function pickRowTotal(row: AggregatedRow, eventType: EventTypeFilter): number { + if (eventType === 'goal') return row.totalGoal; + if (eventType === 'exposure') return row.totalExposure; + return row.total; +} + +export function formatSummaryTable( + rows: AggregatedRow[], + teams: SummaryTeam[], + options: FormatOptions +): string { + const head: string[] = [options.period === 'month' ? 'Month' : options.period === 'week' ? 'Week' : 'Date']; + if (options.groupBy === 'team') { + for (const t of teams) head.push(t.name); + } + head.push('Total'); + + const table = new Table({ + head: options.noColor ? head : head.map((h) => chalk.cyan(h)), + style: { head: [], border: options.noColor ? [] : ['gray'] }, + colAligns: ['left', ...head.slice(1).map(() => 'right' as const)], + }); + + for (const row of rows) { + const cells: (string | number)[] = [formatPeriodCell(row.date, options.period)]; + if (options.groupBy === 'team') { + for (const t of teams) { + cells.push(pickCount(row.teams.get(t.id), options.eventType).toLocaleString()); + } + } + cells.push(pickRowTotal(row, options.eventType).toLocaleString()); + table.push(cells); + } + return table.toString(); +} + +export function formatSummaryBars( + rows: AggregatedRow[], + teams: SummaryTeam[], + options: FormatOptions +): string { + const BAR_WIDTH = 40; + const periodWidth = options.period === 'month' ? 7 : 10; + const teamWidth = Math.max(...teams.map((t) => t.name.length), 'Total'.length); + + const values: number[] = []; + for (const row of rows) { + if (options.groupBy === 'team') { + for (const t of teams) values.push(pickCount(row.teams.get(t.id), options.eventType)); + } else { + values.push(pickRowTotal(row, options.eventType)); + } + } + const max = Math.max(1, ...values); + + const lines: string[] = []; + for (const row of rows) { + const period = formatPeriodCell(row.date, options.period).padEnd(periodWidth); + if (options.groupBy === 'team') { + for (const t of teams) { + const count = pickCount(row.teams.get(t.id), options.eventType); + if (count === 0) continue; + const barLen = Math.max(1, Math.round((count / max) * BAR_WIDTH)); + const bar = '█'.repeat(barLen); + const teamLabel = t.name.padEnd(teamWidth); + const colored = options.noColor ? bar : chalk.cyan(bar); + lines.push(`${period} ${teamLabel} ${colored} ${count.toLocaleString()}`); + } + } else { + const count = pickRowTotal(row, options.eventType); + const barLen = Math.max(1, Math.round((count / max) * BAR_WIDTH)); + const bar = '█'.repeat(barLen); + const colored = options.noColor ? bar : chalk.cyan(bar); + lines.push(`${period} ${'Total'.padEnd(teamWidth)} ${colored} ${count.toLocaleString()}`); + } + } + return lines.join('\n'); +} From f8b4c418c6bcc923bbc022edc8a1ee7ec2a8b04d Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Tue, 12 May 2026 13:57:25 +0100 Subject: [PATCH 08/19] feat(events): wire up summary subcommand (FT-1922) --- src/commands/events/events.test.ts | 32 ++++++++++++ src/commands/events/index.ts | 2 + src/commands/events/summary.ts | 84 ++++++++++++++++++++++++++++++ 3 files changed, 118 insertions(+) diff --git a/src/commands/events/events.test.ts b/src/commands/events/events.test.ts index 403f963..7be72f0 100644 --- a/src/commands/events/events.test.ts +++ b/src/commands/events/events.test.ts @@ -35,6 +35,13 @@ describe('events command', () => { getEventJsonLayouts: vi .fn() .mockResolvedValue({ layouts: [{ path: 'variant', type: 'string' }] }), + getEventsSummary: vi.fn().mockResolvedValue({ + events: [ + { date: Date.UTC(2026, 4, 4), team_id: 1, count: 100, type: 'exposure' }, + { date: Date.UTC(2026, 4, 4), team_id: 1, count: 5, type: 'goal' }, + ], + teams: [{ id: 1, name: 'Growth', initials: 'GR', color: 'blue' }], + }), }; beforeEach(() => { @@ -192,4 +199,29 @@ describe('events command', () => { expect.stringContaining('Invalid unit_type_id') ); }); + + it('should run events summary with default args', async () => { + await eventsCommand.parseAsync(['node', 'test', 'summary']); + expect(mockClient.getEventsSummary).toHaveBeenCalledWith({}); + expect(consoleSpy).toHaveBeenCalled(); + }); + + it('should pass from/to to events summary', async () => { + await eventsCommand.parseAsync([ + 'node', 'test', 'summary', '--from', '1000', '--to', '2000', + ]); + expect(mockClient.getEventsSummary).toHaveBeenCalledWith({ from: 1000, to: 2000 }); + }); + + it('should reject invalid --period', async () => { + await expect( + eventsCommand.parseAsync(['node', 'test', 'summary', '--period', 'hour']) + ).rejects.toThrow(); + }); + + it('should print raw payload when --raw is set', async () => { + vi.mocked(getGlobalOptions).mockReturnValueOnce({ output: 'table', raw: true } as any); + await eventsCommand.parseAsync(['node', 'test', 'summary']); + expect(printFormatted).toHaveBeenCalled(); + }); }); diff --git a/src/commands/events/index.ts b/src/commands/events/index.ts index 8237042..28bc25a 100644 --- a/src/commands/events/index.ts +++ b/src/commands/events/index.ts @@ -17,6 +17,7 @@ import { getEventJsonLayouts as coreGetEventJsonLayouts, parseUnits, } from '../../core/events/events.js'; +import { summaryCommand } from './summary.js'; function parseNumberArray(value: string, previous: number[]): number[] { return [...previous, Number(value)]; @@ -214,3 +215,4 @@ eventsCommand.addCommand(unitDataCommand); eventsCommand.addCommand(deleteUnitDataCommand); eventsCommand.addCommand(jsonValuesCommand); eventsCommand.addCommand(jsonLayoutsCommand); +eventsCommand.addCommand(summaryCommand); diff --git a/src/commands/events/summary.ts b/src/commands/events/summary.ts index 8d20538..b5f8e7f 100644 --- a/src/commands/events/summary.ts +++ b/src/commands/events/summary.ts @@ -1,5 +1,19 @@ import chalk from 'chalk'; +import { Command, Option } from 'commander'; import Table from 'cli-table3'; +import { + getAPIClientFromOptions, + getGlobalOptions, + printFormatted, + withErrorHandling, +} from '../../lib/utils/api-helper.js'; +import { parseDateFlagOrUndefined } from '../../lib/utils/date-parser.js'; +import { + aggregateByTeam, + applyCumulative, + getEventsSummary as coreGetEventsSummary, + rollUpEvents, +} from '../../core/events/summary.js'; import type { AggregatedRow, EventTypeFilter, @@ -7,6 +21,8 @@ import type { SummaryTeam, } from '../../core/events/summary.js'; +const UNOWNED_TEAM: SummaryTeam = { id: -1, name: 'Unowned', initials: 'UN', color: 'gray' }; + export interface FormatOptions { period: Period; groupBy: 'team' | 'total'; @@ -111,3 +127,71 @@ export function formatSummaryBars( } return lines.join('\n'); } + +export const summaryCommand = new Command('summary') + .description('Summary of exposures and goal events grouped by team and period') + .option('--from ', 'start time (e.g. 7d, month-start, 2026-05-01, epoch ms)') + .option('--to ', 'end time (e.g. now, last-month-end, 2026-05-31, epoch ms)') + .addOption( + new Option('--event-type ', 'event type to include') + .choices(['all', 'goal', 'exposure']) + .default('all') + ) + .addOption( + new Option('--group-by ', 'grouping for the output') + .choices(['team', 'total']) + .default('team') + ) + .addOption( + new Option('--period

', 'client-side rollup bucket') + .choices(['day', 'week', 'month']) + .default('week') + ) + .option('--cumulative', 'show running totals across periods') + .addOption( + new Option('--visualization ', 'output style') + .choices(['table', 'bar']) + .default('table') + ) + .action( + withErrorHandling(async (options) => { + const globalOptions = getGlobalOptions(summaryCommand); + const client = await getAPIClientFromOptions(globalOptions); + + const from = parseDateFlagOrUndefined(options.from); + const to = parseDateFlagOrUndefined(options.to); + + const result = await coreGetEventsSummary(client, { from, to }); + + if (globalOptions.raw || globalOptions.output === 'json' || globalOptions.output === 'yaml') { + printFormatted(result.data, globalOptions); + return; + } + + const period = options.period as Period; + const eventType = options.eventType as EventTypeFilter; + const groupBy = options.groupBy as 'team' | 'total'; + const visualization = options.visualization as 'table' | 'bar'; + + const teamsWithUnowned: SummaryTeam[] = [...result.data.teams, UNOWNED_TEAM]; + const usedTeamIds = new Set(result.data.events.map((e) => e.team_id)); + const teams = teamsWithUnowned.filter((t) => usedTeamIds.has(t.id)); + + const rolled = rollUpEvents(result.data.events, period); + const aggregated = aggregateByTeam(rolled, { eventType }); + const finalRows = options.cumulative ? applyCumulative(aggregated) : aggregated; + + const formatOpts = { + period, + groupBy, + eventType, + noColor: globalOptions.noColor ?? false, + }; + + const out = + visualization === 'bar' + ? formatSummaryBars(finalRows, teams, formatOpts) + : formatSummaryTable(finalRows, teams, formatOpts); + console.log(out); + }) + ); From 81aea2d5db4fe5798217fb3cee9c482ca70120e6 Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Tue, 12 May 2026 14:01:53 +0100 Subject: [PATCH 09/19] docs: document events summary and calendar date keywords (FT-1922) --- README.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/README.md b/README.md index 7a0e2fc..b5d5f26 100644 --- a/README.md +++ b/README.md @@ -765,6 +765,8 @@ All `--from`, `--to`, `--since`, `--created-after`, `--started-before`, and othe Relative units: `m` (minutes), `h` (hours), `d` (days), `w` (weeks), `mo` (months), `y` (years). Case-insensitive. +Calendar keywords (UTC): `month-start`, `last-month-start`, `last-month-end`, `year-start`, `last-year-start`, `last-year-end`. + Commands using date formats: - `abs experiments list --created-after`, `--created-before`, `--started-after`, `--started-before`, `--stopped-after`, `--stopped-before` - `abs experiments metrics results --from`, `--to` @@ -773,6 +775,7 @@ Commands using date formats: - `abs events history --from`, `--to` - `abs events json-values --from`, `--to` - `abs events json-layouts --from`, `--to` +- `abs events summary --from`, `--to` ```bash abs experiments list --created-after 7d # last 7 days @@ -1194,6 +1197,10 @@ abs events unit-data 1:user123 2:device456 abs events delete-unit-data 1:user123 abs events json-values --event-type exposure --path "variant" --experiment-id 123 abs events json-layouts --source unit_attribute --phase after_enrichment +abs events summary --from month-start # this month, weekly buckets, per team (default) +abs events summary --from last-month-start --to last-month-end --period month +abs events summary --from 30d --group-by total --cumulative +abs events summary --from 7d --visualization bar ``` ### Insights From df5378f3ab3cc431ad6ec51adc41351df7ce8f8c Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Tue, 12 May 2026 14:42:29 +0100 Subject: [PATCH 10/19] feat(events): default --group-by to total for events summary (FT-1922) --- README.md | 4 ++-- src/commands/events/summary.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index b5d5f26..ac907ce 100644 --- a/README.md +++ b/README.md @@ -1197,9 +1197,9 @@ abs events unit-data 1:user123 2:device456 abs events delete-unit-data 1:user123 abs events json-values --event-type exposure --path "variant" --experiment-id 123 abs events json-layouts --source unit_attribute --phase after_enrichment -abs events summary --from month-start # this month, weekly buckets, per team (default) +abs events summary --from month-start # this month, weekly buckets, totals (default) abs events summary --from last-month-start --to last-month-end --period month -abs events summary --from 30d --group-by total --cumulative +abs events summary --from 30d --group-by team --cumulative abs events summary --from 7d --visualization bar ``` diff --git a/src/commands/events/summary.ts b/src/commands/events/summary.ts index b5f8e7f..23e9fe7 100644 --- a/src/commands/events/summary.ts +++ b/src/commands/events/summary.ts @@ -140,7 +140,7 @@ export const summaryCommand = new Command('summary') .addOption( new Option('--group-by ', 'grouping for the output') .choices(['team', 'total']) - .default('team') + .default('total') ) .addOption( new Option('--period

', 'client-side rollup bucket') From cde33f6bce6514875f7c477848095a2d01ffe20e Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Tue, 12 May 2026 14:43:20 +0100 Subject: [PATCH 11/19] docs: surface calendar keywords in date-formats table and examples (FT-1922) --- README.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index ac907ce..e86c629 100644 --- a/README.md +++ b/README.md @@ -759,13 +759,14 @@ All `--from`, `--to`, `--since`, `--created-after`, `--started-before`, and othe | Relative (short) | `7d`, `2w`, `1mo`, `24h`, `30m`, `1y` | | Relative (with ago) | `7d ago`, `2 weeks ago`, `3 months ago` | | Keywords | `today`, `yesterday`, `now` | +| Calendar (UTC) | `month-start`, `last-month-start`, `last-month-end`, `year-start`, `last-year-start`, `last-year-end` | | ISO 8601 date | `2024-01-01` | | ISO 8601 datetime | `2024-01-01T00:00:00Z` | | Epoch milliseconds | `1704067200000` | Relative units: `m` (minutes), `h` (hours), `d` (days), `w` (weeks), `mo` (months), `y` (years). Case-insensitive. -Calendar keywords (UTC): `month-start`, `last-month-start`, `last-month-end`, `year-start`, `last-year-start`, `last-year-end`. +Calendar keywords resolve to UTC boundaries: `month-start` is 00:00 UTC on the 1st of the current month; `last-month-end` is 1ms before the start of the current month. `year-start` / `last-year-end` work the same way on calendar years. Commands using date formats: - `abs experiments list --created-after`, `--created-before`, `--started-after`, `--started-before`, `--stopped-after`, `--stopped-before` @@ -785,6 +786,8 @@ abs experiments list --created-after 2024-01-01 # since Jan 1 2024 abs experiments metrics results 123 --from 7d --to now abs activity-feed list --since 1h abs events list --from 2w --to yesterday +abs events summary --from month-start # current calendar month +abs events summary --from last-month-start --to last-month-end # exactly last calendar month ``` #### Valid reasons for stop and restart From 13890d329f4e052587f315e31626a2fd0acfc3b0 Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Tue, 12 May 2026 14:58:44 +0100 Subject: [PATCH 12/19] feat(date-parser)!: parse dates in local timezone by default (FT-1922) BREAKING CHANGE: Date inputs now resolve to local timezone instead of UTC for ISO plain dates (`2024-01-01`), `today`, `yesterday`, and the calendar keywords (`month-start`, etc.). Explicit ISO datetimes with `Z` and epoch ms are unchanged. Set `TZ=UTC` to restore UTC behavior for automation that relied on it. - `2024-01-01` is now local midnight on Jan 1 (was UTC midnight). - `today` is now 00:00 local today (was the current instant). - `yesterday` is now 00:00 local yesterday (was the current instant minus 24h). - Calendar keywords were previously UTC; now local. They were introduced earlier in this branch and never shipped, but flipping here keeps the parser internally consistent. --- README.md | 4 +- src/lib/utils/date-parser.test.ts | 62 ++++++++++++++++----------- src/lib/utils/date-parser.ts | 71 ++++++++++++++++--------------- 3 files changed, 76 insertions(+), 61 deletions(-) diff --git a/README.md b/README.md index e86c629..87f99f4 100644 --- a/README.md +++ b/README.md @@ -759,14 +759,14 @@ All `--from`, `--to`, `--since`, `--created-after`, `--started-before`, and othe | Relative (short) | `7d`, `2w`, `1mo`, `24h`, `30m`, `1y` | | Relative (with ago) | `7d ago`, `2 weeks ago`, `3 months ago` | | Keywords | `today`, `yesterday`, `now` | -| Calendar (UTC) | `month-start`, `last-month-start`, `last-month-end`, `year-start`, `last-year-start`, `last-year-end` | +| Calendar | `month-start`, `last-month-start`, `last-month-end`, `year-start`, `last-year-start`, `last-year-end` | | ISO 8601 date | `2024-01-01` | | ISO 8601 datetime | `2024-01-01T00:00:00Z` | | Epoch milliseconds | `1704067200000` | Relative units: `m` (minutes), `h` (hours), `d` (days), `w` (weeks), `mo` (months), `y` (years). Case-insensitive. -Calendar keywords resolve to UTC boundaries: `month-start` is 00:00 UTC on the 1st of the current month; `last-month-end` is 1ms before the start of the current month. `year-start` / `last-year-end` work the same way on calendar years. +Date inputs default to **your local timezone**. `today` and `yesterday` are calendar-aligned (00:00 local); `2024-01-01` is parsed as local midnight; calendar keywords resolve to local boundaries (`month-start` is 00:00 on the 1st of the current month in your local timezone; `last-month-end` is 1ms before the start of the current month). Explicit ISO datetimes with `Z` (e.g. `2024-01-01T00:00:00Z`) and epoch ms are taken as-is. For deterministic UTC boundaries in automation, run with `TZ=UTC`. Commands using date formats: - `abs experiments list --created-after`, `--created-before`, `--started-after`, `--started-before`, `--stopped-after`, `--stopped-before` diff --git a/src/lib/utils/date-parser.test.ts b/src/lib/utils/date-parser.test.ts index c7322a2..f0929fa 100644 --- a/src/lib/utils/date-parser.test.ts +++ b/src/lib/utils/date-parser.test.ts @@ -12,9 +12,9 @@ describe('parseDateFlag', () => { expect(result).toBe(1704067200000); }); - it('should parse simple date (UTC midnight)', () => { + it('should parse simple date as local midnight', () => { const result = parseDateFlag('2024-01-01'); - expect(result).toBe(1704067200000); + expect(result).toBe(new Date(2024, 0, 1).getTime()); }); it('should accept ISO date with milliseconds', () => { @@ -28,7 +28,9 @@ describe('parseDateFlag', () => { }); describe('relative dates', () => { - const NOW = 1711000000000; + // 2024-03-21T08:26:40Z — mid-day UTC, so the local calendar day is + // 2024-03-21 in any reasonable TZ. + const NOW = 1711010000000; beforeEach(() => { vi.useFakeTimers(); @@ -39,16 +41,22 @@ describe('parseDateFlag', () => { vi.useRealTimers(); }); - it('should parse "today" as current time', () => { - expect(parseDateFlag('today')).toBe(NOW); + it('should parse "today" as local midnight today', () => { + const n = new Date(NOW); + expect(parseDateFlag('today')).toBe( + new Date(n.getFullYear(), n.getMonth(), n.getDate()).getTime() + ); }); it('should parse "now" as current time', () => { expect(parseDateFlag('now')).toBe(NOW); }); - it('should parse "yesterday" as 24h ago', () => { - expect(parseDateFlag('yesterday')).toBe(NOW - 24 * 60 * 60 * 1000); + it('should parse "yesterday" as local midnight yesterday', () => { + const n = new Date(NOW); + expect(parseDateFlag('yesterday')).toBe( + new Date(n.getFullYear(), n.getMonth(), n.getDate() - 1).getTime() + ); }); it('should parse "7d" as 7 days ago', () => { @@ -92,14 +100,18 @@ describe('parseDateFlag', () => { }); it('should be case-insensitive', () => { + const n = new Date(NOW); + const todayStart = new Date(n.getFullYear(), n.getMonth(), n.getDate()).getTime(); + const yesterdayStart = new Date(n.getFullYear(), n.getMonth(), n.getDate() - 1).getTime(); expect(parseDateFlag('7D')).toBe(NOW - 7 * 24 * 60 * 60 * 1000); - expect(parseDateFlag('Yesterday')).toBe(NOW - 24 * 60 * 60 * 1000); - expect(parseDateFlag('TODAY')).toBe(NOW); + expect(parseDateFlag('Yesterday')).toBe(yesterdayStart); + expect(parseDateFlag('TODAY')).toBe(todayStart); }); }); describe('calendar keywords', () => { - // 2026-05-12T14:23:45Z — a Tuesday mid-month + // 2026-05-12T14:23:45Z — a Tuesday mid-month (safe for any reasonable TZ + // to still report May 12 locally). const NOW = Date.UTC(2026, 4, 12, 14, 23, 45); beforeEach(() => { @@ -111,39 +123,39 @@ describe('parseDateFlag', () => { vi.useRealTimers(); }); - it('parses "month-start" as 00:00 UTC on the 1st of the current month', () => { - expect(parseDateFlag('month-start')).toBe(Date.UTC(2026, 4, 1)); + it('parses "month-start" as 00:00 local on the 1st of the current month', () => { + expect(parseDateFlag('month-start')).toBe(new Date(2026, 4, 1).getTime()); }); - it('parses "last-month-start" as 00:00 UTC on the 1st of the previous month', () => { - expect(parseDateFlag('last-month-start')).toBe(Date.UTC(2026, 3, 1)); + it('parses "last-month-start" as 00:00 local on the 1st of the previous month', () => { + expect(parseDateFlag('last-month-start')).toBe(new Date(2026, 3, 1).getTime()); }); it('parses "last-month-end" as 1ms before the start of the current month', () => { - expect(parseDateFlag('last-month-end')).toBe(Date.UTC(2026, 4, 1) - 1); + expect(parseDateFlag('last-month-end')).toBe(new Date(2026, 4, 1).getTime() - 1); }); it('handles January correctly for last-month-start (rolls to December of previous year)', () => { - vi.setSystemTime(Date.UTC(2026, 0, 15)); - expect(parseDateFlag('last-month-start')).toBe(Date.UTC(2025, 11, 1)); - expect(parseDateFlag('last-month-end')).toBe(Date.UTC(2026, 0, 1) - 1); + vi.setSystemTime(Date.UTC(2026, 0, 15, 12)); + expect(parseDateFlag('last-month-start')).toBe(new Date(2025, 11, 1).getTime()); + expect(parseDateFlag('last-month-end')).toBe(new Date(2026, 0, 1).getTime() - 1); }); - it('parses "year-start" as 00:00 UTC on Jan 1 of the current year', () => { - expect(parseDateFlag('year-start')).toBe(Date.UTC(2026, 0, 1)); + it('parses "year-start" as 00:00 local on Jan 1 of the current year', () => { + expect(parseDateFlag('year-start')).toBe(new Date(2026, 0, 1).getTime()); }); - it('parses "last-year-start" as 00:00 UTC on Jan 1 of the previous year', () => { - expect(parseDateFlag('last-year-start')).toBe(Date.UTC(2025, 0, 1)); + it('parses "last-year-start" as 00:00 local on Jan 1 of the previous year', () => { + expect(parseDateFlag('last-year-start')).toBe(new Date(2025, 0, 1).getTime()); }); it('parses "last-year-end" as 1ms before the start of the current year', () => { - expect(parseDateFlag('last-year-end')).toBe(Date.UTC(2026, 0, 1) - 1); + expect(parseDateFlag('last-year-end')).toBe(new Date(2026, 0, 1).getTime() - 1); }); it('is case-insensitive', () => { - expect(parseDateFlag('Month-Start')).toBe(Date.UTC(2026, 4, 1)); - expect(parseDateFlag('YEAR-START')).toBe(Date.UTC(2026, 0, 1)); + expect(parseDateFlag('Month-Start')).toBe(new Date(2026, 4, 1).getTime()); + expect(parseDateFlag('YEAR-START')).toBe(new Date(2026, 0, 1).getTime()); }); }); diff --git a/src/lib/utils/date-parser.ts b/src/lib/utils/date-parser.ts index bdcf0e8..49c944d 100644 --- a/src/lib/utils/date-parser.ts +++ b/src/lib/utils/date-parser.ts @@ -1,12 +1,6 @@ const RELATIVE_PATTERN = /^(\d+)\s*(m|min|minutes?|h|hours?|d|days?|w|weeks?|mo|months?|y|years?)\s*(ago)?$/i; -const RELATIVE_KEYWORDS: Record = { - now: 0, - today: 0, - yesterday: 24 * 60 * 60 * 1000, -}; - const UNIT_MS: Record = { m: 60 * 1000, min: 60 * 1000, @@ -31,23 +25,23 @@ const UNIT_MS: Record = { function parseCalendarKeyword(input: string): number | null { const lower = input.trim().toLowerCase(); - const now = new Date(Date.now()); - const year = now.getUTCFullYear(); - const month = now.getUTCMonth(); + const now = new Date(); + const year = now.getFullYear(); + const month = now.getMonth(); switch (lower) { case 'month-start': - return Date.UTC(year, month, 1); + return new Date(year, month, 1).getTime(); case 'last-month-start': - return Date.UTC(year, month - 1, 1); + return new Date(year, month - 1, 1).getTime(); case 'last-month-end': - return Date.UTC(year, month, 1) - 1; + return new Date(year, month, 1).getTime() - 1; case 'year-start': - return Date.UTC(year, 0, 1); + return new Date(year, 0, 1).getTime(); case 'last-year-start': - return Date.UTC(year - 1, 0, 1); + return new Date(year - 1, 0, 1).getTime(); case 'last-year-end': - return Date.UTC(year, 0, 1) - 1; + return new Date(year, 0, 1).getTime() - 1; default: return null; } @@ -56,8 +50,11 @@ function parseCalendarKeyword(input: string): number | null { function parseRelativeDate(input: string): number | null { const lower = input.trim().toLowerCase(); - if (lower in RELATIVE_KEYWORDS) { - return Date.now() - RELATIVE_KEYWORDS[lower]!; + if (lower === 'now') return Date.now(); + if (lower === 'today' || lower === 'yesterday') { + const now = new Date(); + const day = lower === 'today' ? now.getDate() : now.getDate() - 1; + return new Date(now.getFullYear(), now.getMonth(), day).getTime(); } const match = RELATIVE_PATTERN.exec(lower); @@ -85,27 +82,33 @@ export function parseDateFlag(dateStr: string): number { const relative = parseRelativeDate(dateStr); if (relative !== null) return relative; - const isoPattern = /^\d{4}-\d{2}-\d{2}(T[\d:.-]+Z)?$/; - if (!isoPattern.test(dateStr)) { - throw new Error( - `Invalid date format: "${dateStr}"\n` + - `Expected formats:\n` + - ` - Relative: 7d, 2w, 30d ago, yesterday, today\n` + - ` - Calendar: month-start, last-month-start, last-month-end, year-start, last-year-start, last-year-end\n` + - ` - ISO 8601 date: 2024-01-01\n` + - ` - ISO 8601 datetime: 2024-01-01T00:00:00Z\n` + - ` - Milliseconds since epoch: 1704067200000\n` + - `\n` + - `Relative units: m (minutes), h (hours), d (days), w (weeks), mo (months), y (years)` - ); + const isoDateMatch = /^(\d{4})-(\d{2})-(\d{2})$/.exec(dateStr); + if (isoDateMatch) { + const y = parseInt(isoDateMatch[1]!, 10); + const mo = parseInt(isoDateMatch[2]!, 10) - 1; + const d = parseInt(isoDateMatch[3]!, 10); + const t = new Date(y, mo, d).getTime(); + if (isNaN(t)) throw new Error(`Invalid date: "${dateStr}" could not be parsed`); + return t; } - const date = new Date(dateStr); - if (isNaN(date.getTime())) { - throw new Error(`Invalid date: "${dateStr}" could not be parsed`); + if (/^\d{4}-\d{2}-\d{2}T[\d:.-]+Z$/.test(dateStr)) { + const t = new Date(dateStr).getTime(); + if (isNaN(t)) throw new Error(`Invalid date: "${dateStr}" could not be parsed`); + return t; } - return date.getTime(); + throw new Error( + `Invalid date format: "${dateStr}"\n` + + `Expected formats:\n` + + ` - Relative: 7d, 2w, 30d ago, yesterday, today\n` + + ` - Calendar: month-start, last-month-start, last-month-end, year-start, last-year-start, last-year-end\n` + + ` - ISO 8601 date: 2024-01-01\n` + + ` - ISO 8601 datetime: 2024-01-01T00:00:00Z\n` + + ` - Milliseconds since epoch: 1704067200000\n` + + `\n` + + `Relative units: m (minutes), h (hours), d (days), w (weeks), mo (months), y (years)` + ); } export function parseDateFlagOrUndefined(dateStr?: string): number | undefined { From 35ee90b8ea3c2afbdd5963af7c233b7d10f93360 Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Tue, 12 May 2026 15:05:38 +0100 Subject: [PATCH 13/19] feat(events): render week period as ISO YYYY-Www and fix local-tz formatting (FT-1922) --- src/commands/events/summary.test.ts | 6 +++--- src/commands/events/summary.ts | 25 +++++++++++++++++++++---- 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/src/commands/events/summary.test.ts b/src/commands/events/summary.test.ts index 0db965d..bdd346f 100644 --- a/src/commands/events/summary.test.ts +++ b/src/commands/events/summary.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest'; import { formatSummaryTable, formatSummaryBars } from './summary.js'; import type { AggregatedRow, SummaryTeam } from '../../core/events/summary.js'; -const day = (y: number, m: number, d: number) => Date.UTC(y, m, d); +const day = (y: number, m: number, d: number) => new Date(y, m, d).getTime(); const teams: SummaryTeam[] = [ { id: 1, name: 'Growth', initials: 'GR', color: 'blue' }, @@ -39,7 +39,7 @@ describe('formatSummaryTable', () => { }); expect(out).toMatch(/Growth/); expect(out).toMatch(/Platform/); - expect(out).toMatch(/2026-05-04/); + expect(out).toMatch(/2026-W19/); expect(out).toMatch(/Total/); }); @@ -99,7 +99,7 @@ describe('formatSummaryBars', () => { }); const lines = out.split('\n').filter((l) => l.length > 0); expect(lines.length).toBeGreaterThanOrEqual(3); // 2 teams in week1, 1 in week2 - expect(out).toMatch(/2026-05-04/); + expect(out).toMatch(/2026-W19/); expect(out).toMatch(/Growth/); expect(out).toMatch(/Platform/); expect(out).toMatch(/█/); diff --git a/src/commands/events/summary.ts b/src/commands/events/summary.ts index 23e9fe7..fe67838 100644 --- a/src/commands/events/summary.ts +++ b/src/commands/events/summary.ts @@ -30,12 +30,28 @@ export interface FormatOptions { noColor: boolean; } +function isoWeek(date: Date): { year: number; week: number } { + // ISO 8601 week-numbering: week 1 is the week containing the year's first + // Thursday. The "ISO year" is the calendar year of that Thursday. + // Map local Y/M/D to a UTC date so the diff math is DST-safe. + const utc = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate())); + const dayOfWeek = (utc.getUTCDay() + 6) % 7; // Mon=0 … Sun=6 + utc.setUTCDate(utc.getUTCDate() + 3 - dayOfWeek); + const yearStart = Date.UTC(utc.getUTCFullYear(), 0, 1); + const week = Math.floor((utc.getTime() - yearStart) / 86_400_000 / 7) + 1; + return { year: utc.getUTCFullYear(), week }; +} + function formatPeriodCell(date: number, period: Period): string { const d = new Date(date); - const y = d.getUTCFullYear(); - const m = String(d.getUTCMonth() + 1).padStart(2, '0'); + const y = d.getFullYear(); + const m = String(d.getMonth() + 1).padStart(2, '0'); if (period === 'month') return `${y}-${m}`; - const day = String(d.getUTCDate()).padStart(2, '0'); + if (period === 'week') { + const { year, week } = isoWeek(d); + return `${year}-W${String(week).padStart(2, '0')}`; + } + const day = String(d.getDate()).padStart(2, '0'); return `${y}-${m}-${day}`; } @@ -91,7 +107,8 @@ export function formatSummaryBars( options: FormatOptions ): string { const BAR_WIDTH = 40; - const periodWidth = options.period === 'month' ? 7 : 10; + const periodWidth = + options.period === 'month' ? 7 : options.period === 'week' ? 8 : 10; const teamWidth = Math.max(...teams.map((t) => t.name.length), 'Total'.length); const values: number[] = []; From 5cc19971f6e6a0a4b36db03e5b41d987d604e01d Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Tue, 12 May 2026 15:19:42 +0100 Subject: [PATCH 14/19] feat(events): add --transpose to swap rows and columns in summary table (FT-1922) --- README.md | 1 + src/commands/events/summary.test.ts | 41 +++++++++++++++++++++++++ src/commands/events/summary.ts | 46 +++++++++++++++++++++++++++++ 3 files changed, 88 insertions(+) diff --git a/README.md b/README.md index 87f99f4..dbb11bd 100644 --- a/README.md +++ b/README.md @@ -1204,6 +1204,7 @@ abs events summary --from month-start # this month, weekl abs events summary --from last-month-start --to last-month-end --period month abs events summary --from 30d --group-by team --cumulative abs events summary --from 7d --visualization bar +abs events summary --from 30d --group-by team --transpose # teams as rows (handy with many teams) ``` ### Insights diff --git a/src/commands/events/summary.test.ts b/src/commands/events/summary.test.ts index bdd346f..b23b8e7 100644 --- a/src/commands/events/summary.test.ts +++ b/src/commands/events/summary.test.ts @@ -87,6 +87,47 @@ describe('formatSummaryTable', () => { }); expect(out).toMatch(/Unowned/); }); + + it('transposes layout when transpose=true: teams are rows, periods are columns', () => { + const out = formatSummaryTable(rows, teams, { + period: 'week', + groupBy: 'team', + eventType: 'all', + noColor: true, + transpose: true, + }); + // Header row contains 'Team' label and both period columns + expect(out).toMatch(/Team/); + expect(out).toMatch(/2026-W19/); + expect(out).toMatch(/2026-W20/); + // Team rows: Growth has 11 + 10 = 21 total; Platform has 5 + 0 = 5 + const growthRow = out.split('\n').find((l) => /Growth/.test(l)); + expect(growthRow).toMatch(/21/); + const platformRow = out.split('\n').find((l) => /Platform/.test(l)); + expect(platformRow).toMatch(/\b5\b/); + // Bottom totals row sums per period (16, 10) and grand total 26 + const totalRow = out.split('\n').find((l) => /Total/.test(l) && !/Team/.test(l)); + expect(totalRow).toMatch(/16/); + expect(totalRow).toMatch(/10/); + expect(totalRow).toMatch(/26/); + }); + + it('transpose is ignored when groupBy=total', () => { + const normal = formatSummaryTable(rows, teams, { + period: 'week', + groupBy: 'total', + eventType: 'all', + noColor: true, + }); + const transposed = formatSummaryTable(rows, teams, { + period: 'week', + groupBy: 'total', + eventType: 'all', + noColor: true, + transpose: true, + }); + expect(transposed).toBe(normal); + }); }); describe('formatSummaryBars', () => { diff --git a/src/commands/events/summary.ts b/src/commands/events/summary.ts index fe67838..cd6cfac 100644 --- a/src/commands/events/summary.ts +++ b/src/commands/events/summary.ts @@ -28,6 +28,7 @@ export interface FormatOptions { groupBy: 'team' | 'total'; eventType: EventTypeFilter; noColor: boolean; + transpose?: boolean; } function isoWeek(date: Date): { year: number; week: number } { @@ -76,6 +77,10 @@ export function formatSummaryTable( teams: SummaryTeam[], options: FormatOptions ): string { + if (options.transpose && options.groupBy === 'team') { + return formatSummaryTableTransposed(rows, teams, options); + } + const head: string[] = [options.period === 'month' ? 'Month' : options.period === 'week' ? 'Week' : 'Date']; if (options.groupBy === 'team') { for (const t of teams) head.push(t.name); @@ -101,6 +106,45 @@ export function formatSummaryTable( return table.toString(); } +function formatSummaryTableTransposed( + rows: AggregatedRow[], + teams: SummaryTeam[], + options: FormatOptions +): string { + const periodHeaders = rows.map((r) => formatPeriodCell(r.date, options.period)); + const head: string[] = ['Team', ...periodHeaders, 'Total']; + + const table = new Table({ + head: options.noColor ? head : head.map((h) => chalk.cyan(h)), + style: { head: [], border: options.noColor ? [] : ['gray'] }, + colAligns: ['left', ...head.slice(1).map(() => 'right' as const)], + }); + + for (const t of teams) { + const cells: (string | number)[] = [t.name]; + let teamSum = 0; + for (const row of rows) { + const c = pickCount(row.teams.get(t.id), options.eventType); + teamSum += c; + cells.push(c.toLocaleString()); + } + cells.push(teamSum.toLocaleString()); + table.push(cells); + } + + const totalsRow: (string | number)[] = ['Total']; + let grand = 0; + for (const row of rows) { + const t = pickRowTotal(row, options.eventType); + grand += t; + totalsRow.push(t.toLocaleString()); + } + totalsRow.push(grand.toLocaleString()); + table.push(totalsRow); + + return table.toString(); +} + export function formatSummaryBars( rows: AggregatedRow[], teams: SummaryTeam[], @@ -165,6 +209,7 @@ export const summaryCommand = new Command('summary') .default('week') ) .option('--cumulative', 'show running totals across periods') + .option('--transpose', 'swap table rows and columns (teams as rows; ignored with --group-by total)') .addOption( new Option('--visualization ', 'output style') .choices(['table', 'bar']) @@ -203,6 +248,7 @@ export const summaryCommand = new Command('summary') groupBy, eventType, noColor: globalOptions.noColor ?? false, + transpose: Boolean(options.transpose), }; const out = From 8a816e703a6fceeae0071538b416a94267b5198e Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Tue, 12 May 2026 15:29:57 +0100 Subject: [PATCH 15/19] fix(events): -o json/yaml output the rolled-up data; bucket in local TZ (FT-1922) Two related fixes: 1. -o json and -o yaml used to short-circuit to the raw API payload, identical to --raw. They now serialize the aggregated, period-rolled, optionally-cumulative data so structured consumers see the same rollup as the table view. --raw still bypasses everything for debugging and raw passthrough. 2. rollUpEvents bucketed timestamps in UTC while formatPeriodCell reads them in local TZ, causing the displayed week to drift by one in non-UTC environments. bucketStart now computes Monday-start weeks (and month/day starts) in local TZ, matching the formatter. --- src/commands/events/events.test.ts | 18 ++++++++++++++ src/commands/events/summary.ts | 38 +++++++++++++++++++++++++++++- src/core/events/summary.test.ts | 6 ++--- src/core/events/summary.ts | 17 +++++-------- 4 files changed, 64 insertions(+), 15 deletions(-) diff --git a/src/commands/events/events.test.ts b/src/commands/events/events.test.ts index 7be72f0..b3f2d78 100644 --- a/src/commands/events/events.test.ts +++ b/src/commands/events/events.test.ts @@ -223,5 +223,23 @@ describe('events command', () => { vi.mocked(getGlobalOptions).mockReturnValueOnce({ output: 'table', raw: true } as any); await eventsCommand.parseAsync(['node', 'test', 'summary']); expect(printFormatted).toHaveBeenCalled(); + // --raw passes the API payload through untouched. + const arg = vi.mocked(printFormatted).mock.calls[0]![0] as Record; + expect(arg).toHaveProperty('events'); + expect(arg).toHaveProperty('teams'); + expect(arg).not.toHaveProperty('rows'); + }); + + it('should print aggregated rollup when -o json is set', async () => { + vi.mocked(getGlobalOptions).mockReturnValueOnce({ output: 'json' } as any); + await eventsCommand.parseAsync(['node', 'test', 'summary']); + expect(printFormatted).toHaveBeenCalled(); + const arg = vi.mocked(printFormatted).mock.calls[0]![0] as Record; + // The serialized rollup contains the aggregated rows + metadata, + // not the raw API columns. + expect(arg).toHaveProperty('period'); + expect(arg).toHaveProperty('rows'); + expect(arg).toHaveProperty('teams'); + expect(Array.isArray(arg.rows)).toBe(true); }); }); diff --git a/src/commands/events/summary.ts b/src/commands/events/summary.ts index cd6cfac..3e3b5ae 100644 --- a/src/commands/events/summary.ts +++ b/src/commands/events/summary.ts @@ -72,6 +72,29 @@ function pickRowTotal(row: AggregatedRow, eventType: EventTypeFilter): number { return row.total; } +function serializeAggregated( + rows: AggregatedRow[], + teams: SummaryTeam[], + options: FormatOptions & { cumulative: boolean } +): unknown { + return { + period: options.period, + eventType: options.eventType, + cumulative: options.cumulative, + teams, + rows: rows.map((r) => ({ + date: r.date, + period: formatPeriodCell(r.date, options.period), + teams: Object.fromEntries( + Array.from(r.teams.entries()).map(([id, v]) => [String(id), v]) + ), + totalGoal: r.totalGoal, + totalExposure: r.totalExposure, + total: r.total, + })), + }; +} + export function formatSummaryTable( rows: AggregatedRow[], teams: SummaryTeam[], @@ -225,7 +248,8 @@ export const summaryCommand = new Command('summary') const result = await coreGetEventsSummary(client, { from, to }); - if (globalOptions.raw || globalOptions.output === 'json' || globalOptions.output === 'yaml') { + // --raw bypasses all aggregation and returns the API payload as-is. + if (globalOptions.raw) { printFormatted(result.data, globalOptions); return; } @@ -251,6 +275,18 @@ export const summaryCommand = new Command('summary') transpose: Boolean(options.transpose), }; + // Structured outputs render the aggregated/rolled-up data. + if (globalOptions.output === 'json' || globalOptions.output === 'yaml') { + printFormatted( + serializeAggregated(finalRows, teams, { + ...formatOpts, + cumulative: Boolean(options.cumulative), + }), + globalOptions + ); + return; + } + const out = visualization === 'bar' ? formatSummaryBars(finalRows, teams, formatOpts) diff --git a/src/core/events/summary.test.ts b/src/core/events/summary.test.ts index c2603f2..e540c35 100644 --- a/src/core/events/summary.test.ts +++ b/src/core/events/summary.test.ts @@ -41,7 +41,7 @@ describe('getEventsSummary', () => { }); describe('rollUpEvents', () => { - const day = (y: number, m: number, d: number) => Date.UTC(y, m, d); + const day = (y: number, m: number, d: number) => new Date(y, m, d).getTime(); it('passes through daily rows unchanged when period=day', () => { const events = [ @@ -104,7 +104,7 @@ describe('rollUpEvents', () => { }); describe('aggregateByTeam', () => { - const day = (y: number, m: number, d: number) => Date.UTC(y, m, d); + const day = (y: number, m: number, d: number) => new Date(y, m, d).getTime(); it('groups rows by date and team_id and computes totals', () => { const events = [ @@ -153,7 +153,7 @@ describe('aggregateByTeam', () => { }); describe('applyCumulative', () => { - const day = (y: number, m: number, d: number) => Date.UTC(y, m, d); + const day = (y: number, m: number, d: number) => new Date(y, m, d).getTime(); it('produces running totals over rows', () => { const rows: AggregatedRow[] = [ diff --git a/src/core/events/summary.ts b/src/core/events/summary.ts index f8ba6fd..b9be4dd 100644 --- a/src/core/events/summary.ts +++ b/src/core/events/summary.ts @@ -53,22 +53,17 @@ export async function getEventsSummary( export type Period = 'day' | 'week' | 'month'; function bucketStart(date: number, period: Period): number { + const d = new Date(date); if (period === 'day') { - return Date.UTC( - new Date(date).getUTCFullYear(), - new Date(date).getUTCMonth(), - new Date(date).getUTCDate() - ); + return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime(); } if (period === 'month') { - const d = new Date(date); - return Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1); + return new Date(d.getFullYear(), d.getMonth(), 1).getTime(); } - // week: ISO week, Monday start - const d = new Date(date); - const dayOfWeek = d.getUTCDay(); // 0 = Sun … 6 = Sat + // week: ISO week, Monday start, in local TZ + const dayOfWeek = d.getDay(); // 0 = Sun … 6 = Sat const daysSinceMonday = (dayOfWeek + 6) % 7; // Mon=0, Tue=1 … Sun=6 - return Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate() - daysSinceMonday); + return new Date(d.getFullYear(), d.getMonth(), d.getDate() - daysSinceMonday).getTime(); } export function rollUpEvents(events: SummaryEventRow[], period: Period): SummaryEventRow[] { From 1a9d6ea8407b326b362673b1554245f6fd856e94 Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Tue, 12 May 2026 15:34:54 +0100 Subject: [PATCH 16/19] feat(events): include team name in each row's per-team JSON entry (FT-1922) --- src/commands/events/events.test.ts | 5 +++++ src/commands/events/summary.ts | 6 +++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/commands/events/events.test.ts b/src/commands/events/events.test.ts index b3f2d78..3cf3083 100644 --- a/src/commands/events/events.test.ts +++ b/src/commands/events/events.test.ts @@ -241,5 +241,10 @@ describe('events command', () => { expect(arg).toHaveProperty('rows'); expect(arg).toHaveProperty('teams'); expect(Array.isArray(arg.rows)).toBe(true); + // Each row's team entries should carry the human-readable name + // alongside the counts so consumers don't need to cross-reference. + const rows = arg.rows as Array<{ teams: Record }>; + const firstRow = rows[0]!; + expect(firstRow.teams['1']).toMatchObject({ name: 'Growth' }); }); }); diff --git a/src/commands/events/summary.ts b/src/commands/events/summary.ts index 3e3b5ae..5d73369 100644 --- a/src/commands/events/summary.ts +++ b/src/commands/events/summary.ts @@ -77,6 +77,7 @@ function serializeAggregated( teams: SummaryTeam[], options: FormatOptions & { cumulative: boolean } ): unknown { + const teamById = new Map(teams.map((t) => [t.id, t])); return { period: options.period, eventType: options.eventType, @@ -86,7 +87,10 @@ function serializeAggregated( date: r.date, period: formatPeriodCell(r.date, options.period), teams: Object.fromEntries( - Array.from(r.teams.entries()).map(([id, v]) => [String(id), v]) + Array.from(r.teams.entries()).map(([id, v]) => [ + String(id), + { name: teamById.get(id)?.name ?? null, ...v }, + ]) ), totalGoal: r.totalGoal, totalExposure: r.totalExposure, From 97991c2f2cd763fc50acd82ee6c0fe6c6f30085b Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Tue, 12 May 2026 15:42:07 +0100 Subject: [PATCH 17/19] feat(events): make --transpose also flip JSON/YAML output shape (FT-1922) --- src/commands/events/events.test.ts | 15 ++++++ src/commands/events/summary.ts | 82 +++++++++++++++++++++++++----- 2 files changed, 83 insertions(+), 14 deletions(-) diff --git a/src/commands/events/events.test.ts b/src/commands/events/events.test.ts index 3cf3083..37662d3 100644 --- a/src/commands/events/events.test.ts +++ b/src/commands/events/events.test.ts @@ -240,6 +240,7 @@ describe('events command', () => { expect(arg).toHaveProperty('period'); expect(arg).toHaveProperty('rows'); expect(arg).toHaveProperty('teams'); + expect(arg.transposed).toBe(false); expect(Array.isArray(arg.rows)).toBe(true); // Each row's team entries should carry the human-readable name // alongside the counts so consumers don't need to cross-reference. @@ -247,4 +248,18 @@ describe('events command', () => { const firstRow = rows[0]!; expect(firstRow.teams['1']).toMatchObject({ name: 'Growth' }); }); + + it('should produce transposed JSON when --transpose -o json', async () => { + vi.mocked(getGlobalOptions).mockReturnValueOnce({ output: 'json' } as any); + await eventsCommand.parseAsync(['node', 'test', 'summary', '--transpose', '--group-by', 'team']); + const arg = vi.mocked(printFormatted).mock.calls[0]![0] as Record; + expect(arg.transposed).toBe(true); + expect(arg).toHaveProperty('periods'); + const rows = arg.rows as Array<{ team_id: number; name: string; periods: Record }>; + // Each row is now a team + expect(rows[0]).toHaveProperty('team_id'); + expect(rows[0]).toHaveProperty('name'); + expect(rows[0]).toHaveProperty('periods'); + expect(typeof rows[0]!.periods).toBe('object'); + }); }); diff --git a/src/commands/events/summary.ts b/src/commands/events/summary.ts index 5d73369..c4fdc28 100644 --- a/src/commands/events/summary.ts +++ b/src/commands/events/summary.ts @@ -78,24 +78,78 @@ function serializeAggregated( options: FormatOptions & { cumulative: boolean } ): unknown { const teamById = new Map(teams.map((t) => [t.id, t])); + const transpose = options.transpose && options.groupBy === 'team'; + + if (!transpose) { + return { + period: options.period, + eventType: options.eventType, + cumulative: options.cumulative, + transposed: false, + teams, + rows: rows.map((r) => ({ + date: r.date, + period: formatPeriodCell(r.date, options.period), + teams: Object.fromEntries( + Array.from(r.teams.entries()).map(([id, v]) => [ + String(id), + { name: teamById.get(id)?.name ?? null, ...v }, + ]) + ), + totalGoal: r.totalGoal, + totalExposure: r.totalExposure, + total: r.total, + })), + }; + } + + // Transposed: teams are the outer dimension. + const periods = rows.map((r) => ({ + date: r.date, + period: formatPeriodCell(r.date, options.period), + totalGoal: r.totalGoal, + totalExposure: r.totalExposure, + total: r.total, + })); + + const teamRows = teams.map((t) => { + const periodsObj: Record< + string, + { date: number; goal: number; exposure: number; total: number } + > = {}; + let goalSum = 0; + let exposureSum = 0; + let totalSum = 0; + for (const r of rows) { + const data = r.teams.get(t.id) ?? { goal: 0, exposure: 0, total: 0 }; + goalSum += data.goal; + exposureSum += data.exposure; + totalSum += data.total; + periodsObj[formatPeriodCell(r.date, options.period)] = { + date: r.date, + goal: data.goal, + exposure: data.exposure, + total: data.total, + }; + } + return { + team_id: t.id, + name: t.name, + periods: periodsObj, + totalGoal: goalSum, + totalExposure: exposureSum, + total: totalSum, + }; + }); + return { period: options.period, eventType: options.eventType, cumulative: options.cumulative, + transposed: true, teams, - rows: rows.map((r) => ({ - date: r.date, - period: formatPeriodCell(r.date, options.period), - teams: Object.fromEntries( - Array.from(r.teams.entries()).map(([id, v]) => [ - String(id), - { name: teamById.get(id)?.name ?? null, ...v }, - ]) - ), - totalGoal: r.totalGoal, - totalExposure: r.totalExposure, - total: r.total, - })), + rows: teamRows, + periods, }; } @@ -236,7 +290,7 @@ export const summaryCommand = new Command('summary') .default('week') ) .option('--cumulative', 'show running totals across periods') - .option('--transpose', 'swap table rows and columns (teams as rows; ignored with --group-by total)') + .option('--transpose', 'orient output by team (teams as rows / outer entities); ignored with --group-by total') .addOption( new Option('--visualization ', 'output style') .choices(['table', 'bar']) From 00224a3e90355c7066c0281d00a0f4757baf476f Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Tue, 12 May 2026 15:50:07 +0100 Subject: [PATCH 18/19] chore(release): bump cli to 1.5.0 (FT-1922) --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index c8aefc3..3ea1c3c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@absmartly/cli", - "version": "1.4.0", + "version": "1.5.0", "description": "ABSmartly CLI - A/B Testing and Feature Flags command-line tool for AI agents and humans", "type": "module", "main": "./dist/index.js", From 5e960eaa432550dcd5502d495cdeba58d0e5bf0d Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Tue, 12 May 2026 15:52:02 +0100 Subject: [PATCH 19/19] style: prettier-format events summary files (FT-1922) --- src/commands/events/events.test.ts | 19 +++++++++---- src/commands/events/summary.test.ts | 8 +++--- src/commands/events/summary.ts | 16 ++++++----- src/core/events/summary.test.ts | 42 +++++++++++++++++++++-------- 4 files changed, 58 insertions(+), 27 deletions(-) diff --git a/src/commands/events/events.test.ts b/src/commands/events/events.test.ts index 37662d3..da17fd1 100644 --- a/src/commands/events/events.test.ts +++ b/src/commands/events/events.test.ts @@ -207,9 +207,7 @@ describe('events command', () => { }); it('should pass from/to to events summary', async () => { - await eventsCommand.parseAsync([ - 'node', 'test', 'summary', '--from', '1000', '--to', '2000', - ]); + await eventsCommand.parseAsync(['node', 'test', 'summary', '--from', '1000', '--to', '2000']); expect(mockClient.getEventsSummary).toHaveBeenCalledWith({ from: 1000, to: 2000 }); }); @@ -251,11 +249,22 @@ describe('events command', () => { it('should produce transposed JSON when --transpose -o json', async () => { vi.mocked(getGlobalOptions).mockReturnValueOnce({ output: 'json' } as any); - await eventsCommand.parseAsync(['node', 'test', 'summary', '--transpose', '--group-by', 'team']); + await eventsCommand.parseAsync([ + 'node', + 'test', + 'summary', + '--transpose', + '--group-by', + 'team', + ]); const arg = vi.mocked(printFormatted).mock.calls[0]![0] as Record; expect(arg.transposed).toBe(true); expect(arg).toHaveProperty('periods'); - const rows = arg.rows as Array<{ team_id: number; name: string; periods: Record }>; + const rows = arg.rows as Array<{ + team_id: number; + name: string; + periods: Record; + }>; // Each row is now a team expect(rows[0]).toHaveProperty('team_id'); expect(rows[0]).toHaveProperty('name'); diff --git a/src/commands/events/summary.test.ts b/src/commands/events/summary.test.ts index b23b8e7..ee50137 100644 --- a/src/commands/events/summary.test.ts +++ b/src/commands/events/summary.test.ts @@ -57,9 +57,7 @@ describe('formatSummaryTable', () => { }); it('formats period as YYYY-MM when period=month', () => { - const monthRows: AggregatedRow[] = [ - { ...rows[0]!, date: day(2026, 4, 1) }, - ]; + const monthRows: AggregatedRow[] = [{ ...rows[0]!, date: day(2026, 4, 1) }]; const out = formatSummaryTable(monthRows, teams, { period: 'month', groupBy: 'team', @@ -76,7 +74,9 @@ describe('formatSummaryTable', () => { { date: day(2026, 4, 4), teams: new Map([[-1, { goal: 0, exposure: 4, total: 4 }]]), - totalGoal: 0, totalExposure: 4, total: 4, + totalGoal: 0, + totalExposure: 4, + total: 4, }, ]; const out = formatSummaryTable(r, [unowned], { diff --git a/src/commands/events/summary.ts b/src/commands/events/summary.ts index c4fdc28..d6b7ed9 100644 --- a/src/commands/events/summary.ts +++ b/src/commands/events/summary.ts @@ -162,7 +162,9 @@ export function formatSummaryTable( return formatSummaryTableTransposed(rows, teams, options); } - const head: string[] = [options.period === 'month' ? 'Month' : options.period === 'week' ? 'Week' : 'Date']; + const head: string[] = [ + options.period === 'month' ? 'Month' : options.period === 'week' ? 'Week' : 'Date', + ]; if (options.groupBy === 'team') { for (const t of teams) head.push(t.name); } @@ -232,8 +234,7 @@ export function formatSummaryBars( options: FormatOptions ): string { const BAR_WIDTH = 40; - const periodWidth = - options.period === 'month' ? 7 : options.period === 'week' ? 8 : 10; + const periodWidth = options.period === 'month' ? 7 : options.period === 'week' ? 8 : 10; const teamWidth = Math.max(...teams.map((t) => t.name.length), 'Total'.length); const values: number[] = []; @@ -290,11 +291,12 @@ export const summaryCommand = new Command('summary') .default('week') ) .option('--cumulative', 'show running totals across periods') - .option('--transpose', 'orient output by team (teams as rows / outer entities); ignored with --group-by total') + .option( + '--transpose', + 'orient output by team (teams as rows / outer entities); ignored with --group-by total' + ) .addOption( - new Option('--visualization ', 'output style') - .choices(['table', 'bar']) - .default('table') + new Option('--visualization ', 'output style').choices(['table', 'bar']).default('table') ) .action( withErrorHandling(async (options) => { diff --git a/src/core/events/summary.test.ts b/src/core/events/summary.test.ts index e540c35..57b9fe2 100644 --- a/src/core/events/summary.test.ts +++ b/src/core/events/summary.test.ts @@ -1,5 +1,11 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { getEventsSummary, rollUpEvents, aggregateByTeam, applyCumulative, type AggregatedRow } from './summary.js'; +import { + getEventsSummary, + rollUpEvents, + aggregateByTeam, + applyCumulative, + type AggregatedRow, +} from './summary.js'; describe('getEventsSummary', () => { const mockClient = { @@ -26,16 +32,16 @@ describe('getEventsSummary', () => { it('rejects ranges greater than 100 days client-side', async () => { const from = 0; const to = 101 * 86_400_000; - await expect( - getEventsSummary(mockClient as any, { from, to }) - ).rejects.toThrow(/maximum is 100 days/i); + await expect(getEventsSummary(mockClient as any, { from, to })).rejects.toThrow( + /maximum is 100 days/i + ); expect(mockClient.getEventsSummary).not.toHaveBeenCalled(); }); it('rejects ranges where from > to', async () => { - await expect( - getEventsSummary(mockClient as any, { from: 200, to: 100 }) - ).rejects.toThrow(/`from` must be less than or equal to `to`/); + await expect(getEventsSummary(mockClient as any, { from: 200, to: 100 })).rejects.toThrow( + /`from` must be less than or equal to `to`/ + ); expect(mockClient.getEventsSummary).not.toHaveBeenCalled(); }); }); @@ -87,8 +93,18 @@ describe('rollUpEvents', () => { { date: day(2026, 4, 7), team_id: 1, count: 4, type: 'exposure' as const }, ]; const result = rollUpEvents(events, 'week'); - expect(result).toContainEqual({ date: day(2026, 4, 4), team_id: 1, count: 5, type: 'exposure' }); - expect(result).toContainEqual({ date: day(2026, 4, 4), team_id: 2, count: 2, type: 'exposure' }); + expect(result).toContainEqual({ + date: day(2026, 4, 4), + team_id: 1, + count: 5, + type: 'exposure', + }); + expect(result).toContainEqual({ + date: day(2026, 4, 4), + team_id: 2, + count: 2, + type: 'exposure', + }); expect(result).toContainEqual({ date: day(2026, 4, 4), team_id: 1, count: 3, type: 'goal' }); expect(result).toHaveLength(3); }); @@ -185,7 +201,9 @@ describe('applyCumulative', () => { { date: day(2026, 4, 4), teams: new Map([[1, { goal: 0, exposure: 5, total: 5 }]]), - totalGoal: 0, totalExposure: 5, total: 5, + totalGoal: 0, + totalExposure: 5, + total: 5, }, { date: day(2026, 4, 11), @@ -193,7 +211,9 @@ describe('applyCumulative', () => { [1, { goal: 0, exposure: 3, total: 3 }], [2, { goal: 0, exposure: 7, total: 7 }], ]), - totalGoal: 0, totalExposure: 10, total: 10, + totalGoal: 0, + totalExposure: 10, + total: 10, }, ]; const out = applyCumulative(rows);