Skip to content

feat(events): add summary subcommand; switch date parser to local TZ (FT-1922)#35

Merged
joalves merged 19 commits into
mainfrom
feat/FT-1922/events-summary
May 12, 2026
Merged

feat(events): add summary subcommand; switch date parser to local TZ (FT-1922)#35
joalves merged 19 commits into
mainfrom
feat/FT-1922/events-summary

Conversation

@joalves
Copy link
Copy Markdown
Collaborator

@joalves joalves commented May 12, 2026

Summary

Add abs events summary for per-team, per-period (day/week/month) event rollups, and switch the shared date parser to local-time semantics across the CLI.

JIRA: FT-1922 · bumps version to 1.5.0

New: abs events summary

Wraps GET /v1/events/summary. Defaults: --period week --group-by total --visualization table --event-type all.

abs events summary --from month-start                                  # current month, weekly buckets, totals
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)
abs events summary --from 30d --group-by team -o json                  # rolled-up JSON, not raw payload

Flags: --from, --to, --event-type all|goal|exposure, --group-by team|total, --period day|week|month, --cumulative, --visualization table|bar, --transpose.

Behavior notes:

  • 100-day cap and from > to validation client-side before the API call.
  • Week labels render as ISO YYYY-Www (e.g. 2026-W19); month as YYYY-MM; day as YYYY-MM-DD.
  • Unowned team (id = -1) renders as Unowned.
  • --raw passes the API payload through untouched; -o json|yaml emits the rolled-up data (the same data the table view shows), including team names alongside ids inside each row's teams map.
  • --transpose swaps rows and columns in both the table view and the JSON/YAML shape (teams become the outer dimension; a periods[] array gives ordering and per-period totals).

Breaking-but-not-really: date parser now defaults to local TZ

parseDateFlag is shared across experiments, events, activity-feed, etc. Changes:

  • 2026-05-01local midnight (was UTC midnight).
  • today → 00:00 local today (was current instant).
  • yesterday → 00:00 local yesterday (was now - 24h).
  • New calendar keywords: month-start, last-month-start, last-month-end, year-start, last-year-start, last-year-end — all local.
  • ISO datetimes with Z and epoch ms unchanged.
  • For deterministic UTC boundaries in automation: TZ=UTC abs ….

Marked SemVer-major (feat!:) in the commit body but bumping to 1.5.0 since the package has no external consumers yet.

Other changes

  • Added getEventsSummary to APIClient.
  • New core module src/core/events/summary.ts: getEventsSummary, rollUpEvents (UTC-safe ISO weeks, DST-aware bucket math), aggregateByTeam, applyCumulative.
  • README updates: events examples, date-format table now includes a Calendar row, prose paragraph explains local-TZ defaults + TZ=UTC escape hatch.

Test plan

  • bun test:run — 2358 passed / 4 skipped
  • bun typecheck clean
  • bun lint clean
  • bun format --check clean
  • abs events summary --help shows all 7 flags with correct defaults
  • Visual sanity-check of table/bar/JSON output with synthetic data (ISO weeks correct including year-boundary, January rollover, 53-week years)
  • Smoke test against a live profile (reviewer or merger to verify): abs events summary --from 30d --visualization bar

Summary by CodeRabbit

Release Notes

  • New Features

    • Added events summary subcommand to display aggregated event analytics by team and time period
    • Introduced calendar keywords for date filtering (e.g., month/year start/end)
    • Added grouping options (--group-by, --period, --cumulative, --transpose) for flexible data analysis
    • Support for JSON/YAML output formats in summary reports
  • Documentation

    • Enhanced README with improved date-format documentation and calendar keyword examples
  • Chores

    • Version bumped to 1.5.0

Review Change Stack

joalves added 19 commits May 12, 2026 13:30
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.
…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.
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 12, 2026

Walkthrough

This pull request introduces a comprehensive events summary feature for the A/B Smartly CLI. It adds a new events summary subcommand that aggregates event data (exposures and goals) by team and configurable time period (day, week, or month), with support for cumulative totals and multiple output formats. The implementation includes enhanced date-flag parsing with calendar keywords (such as month-start and last-month-end), core domain logic for event bucketing and aggregation, API client integration, and a full-featured CLI command with table/bar visualisations and JSON/YAML output modes. Documentation and version number have been updated accordingly.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐰 Hops with delight at the summary sight,
Events now grouped by team and by night,
Calendar keywords guide the way true,
Aggregate, bucketing, cumulative too!
Tables and bars make the data take flight.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the two main changes: adding a new events summary subcommand and switching the date parser to local timezone semantics.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/FT-1922/events-summary

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/commands/events/summary.ts`:
- Around line 264-268: The bar visualization forces a minimum length of 1 which
makes zero totals appear non-zero; update the computation of barLen in the block
that calls pickRowTotal so that when count === 0 barLen becomes 0, otherwise
keep Math.max(1, Math.round((count / max) * BAR_WIDTH)) to preserve visibility
for small non-zero counts; ensure the subsequent bar, colored and lines.push
logic (references: pickRowTotal, BAR_WIDTH, options.noColor) handles an empty
bar string correctly so zeros render without a block.

In `@src/lib/utils/date-parser.ts`:
- Around line 101-110: The thrown Error message for invalid dates (the throw new
Error block referencing dateStr) omits "now" from the list of accepted relative
inputs; update the error string to include "now" among the Relative examples so
the guidance matches supported inputs (i.e., add "now" alongside "yesterday,
today" in the message emitted when parse fails).
- Around line 85-99: The iso date code path using isoDateMatch currently
constructs a Date which can silently normalize invalid calendar dates (e.g.,
2024-02-31); after creating the Date from y, mo, d (variable t and the Date
instance), validate by comparing date.getFullYear() === y, date.getMonth() ===
mo and date.getDate() === d and throw the existing Error if any component
differs. Likewise, for the ISO datetime branch (the
/^\d{4}-\d{2}-\d{2}T[\d:.-]+Z$/ test), extract the numeric components from the
string (year, month, day, hour, minute, second, millisecond as present),
construct a Date and then verify each corresponding
date.getUTCFullYear()/getUTCMonth()/getUTCDate()/getUTCHours()/getUTCMinutes()/getUTCSeconds()/getUTCMilliseconds()
matches the parsed values; if any mismatch occurs, throw the same Invalid date
error instead of accepting the normalized value.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 67acf30a-7718-4e12-ba45-f5f7bb79841c

📥 Commits

Reviewing files that changed from the base of the PR and between a3b8c7a and 5e960ea.

📒 Files selected for processing (11)
  • README.md
  • package.json
  • src/api-client/api-client.ts
  • src/commands/events/events.test.ts
  • src/commands/events/index.ts
  • src/commands/events/summary.test.ts
  • src/commands/events/summary.ts
  • src/core/events/summary.test.ts
  • src/core/events/summary.ts
  • src/lib/utils/date-parser.test.ts
  • src/lib/utils/date-parser.ts

Comment on lines +264 to +268
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()}`);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Zero totals are rendered as non-zero bars in bar visualisation.

At Line 265, forcing a minimum bar length of 1 makes 0 look non-zero in total mode. This misrepresents the data.

Suggested fix
-        const barLen = Math.max(1, Math.round((count / max) * BAR_WIDTH));
+        const barLen = count === 0 ? 0 : Math.max(1, Math.round((count / max) * BAR_WIDTH));
         const bar = '█'.repeat(barLen);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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()}`);
const count = pickRowTotal(row, options.eventType);
const barLen = count === 0 ? 0 : 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()}`);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/commands/events/summary.ts` around lines 264 - 268, The bar visualization
forces a minimum length of 1 which makes zero totals appear non-zero; update the
computation of barLen in the block that calls pickRowTotal so that when count
=== 0 barLen becomes 0, otherwise keep Math.max(1, Math.round((count / max) *
BAR_WIDTH)) to preserve visibility for small non-zero counts; ensure the
subsequent bar, colored and lines.push logic (references: pickRowTotal,
BAR_WIDTH, options.noColor) handles an empty bar string correctly so zeros
render without a block.

Comment on lines +85 to 99
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;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verifies JS Date normalisation behaviour for invalid calendar dates.
node - <<'NODE'
const d1 = new Date(2024, 1, 31); // Feb 31 (invalid)
console.log('new Date(2024,1,31) =>', d1.toISOString());

const s = '2024-02-31T00:00:00Z';
const d2 = new Date(s);
console.log(`${s} =>`, isNaN(d2.getTime()) ? 'Invalid Date' : d2.toISOString());
NODE

Repository: absmartly/cli-ts

Length of output: 157


🏁 Script executed:

find . -name "date-parser.ts" -type f

Repository: absmartly/cli-ts

Length of output: 91


🏁 Script executed:

if [ -f "src/lib/utils/date-parser.ts" ]; then
  wc -l "src/lib/utils/date-parser.ts"
fi

Repository: absmartly/cli-ts

Length of output: 93


🏁 Script executed:

if [ -f "src/lib/utils/date-parser.ts" ]; then
  sed -n '80,110p' "src/lib/utils/date-parser.ts"
fi

Repository: absmartly/cli-ts

Length of output: 1302


Invalid calendar dates are silently accepted and normalised instead of being rejected.

Lines 90 and 96 use JavaScript's Date constructor which normalises impossible dates rather than rejecting them. For example, 2024-02-31 is converted to 2024-03-02 without error. The isNaN() check cannot catch this because getTime() returns a valid timestamp for normalised dates.

To fix, validate that the parsed date components match the input:

Proposed fix
   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 parsed = new Date(y, mo, d);
+    if (
+      parsed.getFullYear() !== y ||
+      parsed.getMonth() !== mo ||
+      parsed.getDate() !== d
+    ) {
+      throw new Error(`Invalid date: "${dateStr}" could not be parsed`);
+    }
+    return parsed.getTime();
   }
 
   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;
+    const parsed = new Date(dateStr);
+    if (isNaN(parsed.getTime()) || parsed.toISOString() !== dateStr) {
+      throw new Error(`Invalid date: "${dateStr}" could not be parsed`);
+    }
+    return parsed.getTime();
   }
🧰 Tools
🪛 OpenGrep (1.20.0)

[ERROR] 85-85: Dynamic command passed to child_process.exec/execSync. Use child_process.execFile or spawn with an argument array instead.

(coderabbit.command-injection.exec-js)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/utils/date-parser.ts` around lines 85 - 99, The iso date code path
using isoDateMatch currently constructs a Date which can silently normalize
invalid calendar dates (e.g., 2024-02-31); after creating the Date from y, mo, d
(variable t and the Date instance), validate by comparing date.getFullYear() ===
y, date.getMonth() === mo and date.getDate() === d and throw the existing Error
if any component differs. Likewise, for the ISO datetime branch (the
/^\d{4}-\d{2}-\d{2}T[\d:.-]+Z$/ test), extract the numeric components from the
string (year, month, day, hour, minute, second, millisecond as present),
construct a Date and then verify each corresponding
date.getUTCFullYear()/getUTCMonth()/getUTCDate()/getUTCHours()/getUTCMinutes()/getUTCSeconds()/getUTCMilliseconds()
matches the parsed values; if any mismatch occurs, throw the same Invalid date
error instead of accepting the normalized value.

Comment on lines +101 to +110
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)`
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Error guidance should include now to match accepted inputs.

Line 104 lists relative examples but omits now, which is valid and supported.

💡 Proposed fix
-      `  - Relative: 7d, 2w, 30d ago, yesterday, today\n` +
+      `  - Relative: 7d, 2w, 30d ago, yesterday, today, now\n` +
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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)`
throw new Error(
`Invalid date format: "${dateStr}"\n` +
`Expected formats:\n` +
` - Relative: 7d, 2w, 30d ago, yesterday, today, now\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)`
)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/utils/date-parser.ts` around lines 101 - 110, The thrown Error
message for invalid dates (the throw new Error block referencing dateStr) omits
"now" from the list of accepted relative inputs; update the error string to
include "now" among the Relative examples so the guidance matches supported
inputs (i.e., add "now" alongside "yesterday, today" in the message emitted when
parse fails).

@joalves joalves added this pull request to the merge queue May 12, 2026
Merged via the queue into main with commit 7cc838c May 12, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant