diff --git a/README.md b/README.md index 7a0e2fc..dbb11bd 100644 --- a/README.md +++ b/README.md @@ -759,12 +759,15 @@ 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 | `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. +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` - `abs experiments metrics results --from`, `--to` @@ -773,6 +776,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 @@ -782,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 @@ -1194,6 +1200,11 @@ 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, totals (default) +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/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", 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; diff --git a/src/commands/events/events.test.ts b/src/commands/events/events.test.ts index 403f963..da17fd1 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,76 @@ 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(); + // --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(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. + const rows = arg.rows as Array<{ teams: Record }>; + 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/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.test.ts b/src/commands/events/summary.test.ts new file mode 100644 index 0000000..ee50137 --- /dev/null +++ b/src/commands/events/summary.test.ts @@ -0,0 +1,159 @@ +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) => new Date(y, m, d).getTime(); + +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-W19/); + 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/); + }); + + 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', () => { + 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-W19/); + 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..d6b7ed9 --- /dev/null +++ b/src/commands/events/summary.ts @@ -0,0 +1,356 @@ +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, + Period, + 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'; + eventType: EventTypeFilter; + noColor: boolean; + transpose?: 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.getFullYear(); + const m = String(d.getMonth() + 1).padStart(2, '0'); + if (period === 'month') return `${y}-${m}`; + 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}`; +} + +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; +} + +function serializeAggregated( + rows: AggregatedRow[], + teams: SummaryTeam[], + 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: teamRows, + periods, + }; +} + +export function formatSummaryTable( + rows: AggregatedRow[], + 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); + } + 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(); +} + +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[], + options: FormatOptions +): string { + const BAR_WIDTH = 40; + 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[] = []; + 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'); +} + +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('total') + ) + .addOption( + new Option('--period

', 'client-side rollup bucket') + .choices(['day', 'week', 'month']) + .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' + ) + .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 }); + + // --raw bypasses all aggregation and returns the API payload as-is. + if (globalOptions.raw) { + 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, + 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) + : formatSummaryTable(finalRows, teams, formatOpts); + console.log(out); + }) + ); diff --git a/src/core/events/summary.test.ts b/src/core/events/summary.test.ts new file mode 100644 index 0000000..57b9fe2 --- /dev/null +++ b/src/core/events/summary.test.ts @@ -0,0 +1,227 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { + getEventsSummary, + rollUpEvents, + aggregateByTeam, + applyCumulative, + type AggregatedRow, +} 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(); + }); +}); + +describe('rollUpEvents', () => { + 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 = [ + { 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)]); + }); +}); + +describe('aggregateByTeam', () => { + 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 = [ + { 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([]); + }); +}); + +describe('applyCumulative', () => { + const day = (y: number, m: number, d: number) => new Date(y, m, d).getTime(); + + 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 new file mode 100644 index 0000000..b9be4dd --- /dev/null +++ b/src/core/events/summary.ts @@ -0,0 +1,158 @@ +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 }; +} + +export type Period = 'day' | 'week' | 'month'; + +function bucketStart(date: number, period: Period): number { + const d = new Date(date); + if (period === 'day') { + return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime(); + } + if (period === 'month') { + return new Date(d.getFullYear(), d.getMonth(), 1).getTime(); + } + // 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 new Date(d.getFullYear(), d.getMonth(), d.getDate() - daysSinceMonday).getTime(); +} + +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); +} + +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); +} + +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, + }; + }); +} diff --git a/src/lib/utils/date-parser.test.ts b/src/lib/utils/date-parser.test.ts index 8d77ee7..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,9 +100,62 @@ 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 (safe for any reasonable TZ + // to still report May 12 locally). + 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 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 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(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, 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 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 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(new Date(2026, 0, 1).getTime() - 1); + }); + + it('is case-insensitive', () => { + 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 9c28adf..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, @@ -29,11 +23,38 @@ 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(); + const year = now.getFullYear(); + const month = now.getMonth(); + + switch (lower) { + case 'month-start': + return new Date(year, month, 1).getTime(); + case 'last-month-start': + return new Date(year, month - 1, 1).getTime(); + case 'last-month-end': + return new Date(year, month, 1).getTime() - 1; + case 'year-start': + return new Date(year, 0, 1).getTime(); + case 'last-year-start': + return new Date(year - 1, 0, 1).getTime(); + case 'last-year-end': + return new Date(year, 0, 1).getTime() - 1; + default: + return 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); @@ -55,29 +76,39 @@ 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; - 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` + - ` - 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 {