Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
a0bfd54
feat(date-parser): add calendar boundary keywords (FT-1922)
joalves May 12, 2026
4c598d4
feat(api-client): add getEventsSummary method (FT-1922)
joalves May 12, 2026
69ed402
feat(events): add core getEventsSummary wrapper with 100-day cap (FT-…
joalves May 12, 2026
f3a5335
feat(events): add rollUpEvents for day/week/month bucketing (FT-1922)
joalves May 12, 2026
73b7d5e
feat(events): add aggregateByTeam with event-type filter (FT-1922)
joalves May 12, 2026
0edf8b2
feat(events): add applyCumulative running-totals pass (FT-1922)
joalves May 12, 2026
4d8f272
feat(events): add table and bar formatters for events summary (FT-1922)
joalves May 12, 2026
f8b4c41
feat(events): wire up summary subcommand (FT-1922)
joalves May 12, 2026
81aea2d
docs: document events summary and calendar date keywords (FT-1922)
joalves May 12, 2026
df5378f
feat(events): default --group-by to total for events summary (FT-1922)
joalves May 12, 2026
cde33f6
docs: surface calendar keywords in date-formats table and examples (F…
joalves May 12, 2026
13890d3
feat(date-parser)!: parse dates in local timezone by default (FT-1922)
joalves May 12, 2026
35ee90b
feat(events): render week period as ISO YYYY-Www and fix local-tz for…
joalves May 12, 2026
5cc1997
feat(events): add --transpose to swap rows and columns in summary tab…
joalves May 12, 2026
8a816e7
fix(events): -o json/yaml output the rolled-up data; bucket in local …
joalves May 12, 2026
1a9d6ea
feat(events): include team name in each row's per-team JSON entry (FT…
joalves May 12, 2026
97991c2
feat(events): make --transpose also flip JSON/YAML output shape (FT-1…
joalves May 12, 2026
00224a3
chore(release): bump cli to 1.5.0 (FT-1922)
joalves May 12, 2026
5e960ea
style: prettier-format events summary files (FT-1922)
joalves May 12, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
8 changes: 8 additions & 0 deletions src/api-client/api-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2346,6 +2346,14 @@ export class APIClient {
return response.data;
}

async getEventsSummary(params: { from?: number; to?: number }): Promise<unknown> {
const queryParams: Record<string, string | number | boolean | undefined> = {};
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;
Expand Down
79 changes: 79 additions & 0 deletions src/commands/events/events.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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<string, unknown>;
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<string, unknown>;
// 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<string, { name: string }> }>;
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<string, unknown>;
expect(arg.transposed).toBe(true);
expect(arg).toHaveProperty('periods');
const rows = arg.rows as Array<{
team_id: number;
name: string;
periods: Record<string, unknown>;
}>;
// 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');
});
});
2 changes: 2 additions & 0 deletions src/commands/events/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)];
Expand Down Expand Up @@ -214,3 +215,4 @@ eventsCommand.addCommand(unitDataCommand);
eventsCommand.addCommand(deleteUnitDataCommand);
eventsCommand.addCommand(jsonValuesCommand);
eventsCommand.addCommand(jsonLayoutsCommand);
eventsCommand.addCommand(summaryCommand);
159 changes: 159 additions & 0 deletions src/commands/events/summary.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading
Loading