diff --git a/docs/src/content/docs/commands/dashboard.md b/docs/src/content/docs/commands/dashboard.md index 3e8e3b0ab..51ce630e0 100644 --- a/docs/src/content/docs/commands/dashboard.md +++ b/docs/src/content/docs/commands/dashboard.md @@ -43,7 +43,7 @@ View a dashboard | `-w, --web` | Open in browser | | `-f, --fresh` | Bypass cache, re-detect projects, and fetch fresh data | | `-r, --refresh ` | Auto-refresh interval in seconds (default: 60, min: 10) | -| `-t, --period ` | Time period override (e.g., "24h", "7d", "14d") | +| `-t, --period ` | Time range: "7d", "2026-03-07..2026-04-07", ">=2026-03-07" | ### `sentry dashboard create ` diff --git a/docs/src/content/docs/commands/event.md b/docs/src/content/docs/commands/event.md index 7aa12fe83..34d1e0465 100644 --- a/docs/src/content/docs/commands/event.md +++ b/docs/src/content/docs/commands/event.md @@ -42,7 +42,7 @@ List events for an issue | `-n, --limit ` | Number of events (1-1000) (default: "25") | | `-q, --query ` | Search query (Sentry search syntax) | | `--full` | Include full event body (stacktraces) | -| `-t, --period ` | Time period (e.g., "1h", "24h", "7d", "30d") (default: "7d") | +| `-t, --period ` | Time range: "7d", "2026-03-07..2026-04-07", ">=2026-03-07" (default: "7d") | | `-f, --fresh` | Bypass cache, re-detect projects, and fetch fresh data | | `-c, --cursor ` | Navigate pages: "next", "prev", "first" (or raw cursor string) | diff --git a/docs/src/content/docs/commands/issue.md b/docs/src/content/docs/commands/issue.md index ac0d06bdc..7b5fb9d32 100644 --- a/docs/src/content/docs/commands/issue.md +++ b/docs/src/content/docs/commands/issue.md @@ -24,7 +24,7 @@ List issues in a project | `-q, --query ` | Search query (Sentry search syntax) | | `-n, --limit ` | Maximum number of issues to list (default: "25") | | `-s, --sort ` | Sort by: date, new, freq, user (default: "date") | -| `-t, --period ` | Time period for issue activity (e.g. 24h, 14d, 90d) (default: "90d") | +| `-t, --period ` | Time range: "7d", "2026-03-07..2026-04-07", ">=2026-03-07" (default: "90d") | | `-c, --cursor ` | Pagination cursor (use "next" for next page, "prev" for previous) | | `--compact` | Single-line rows for compact output (auto-detects if omitted) | | `-f, --fresh` | Bypass cache, re-detect projects, and fetch fresh data | @@ -46,7 +46,7 @@ List events for a specific issue | `-n, --limit ` | Number of events (1-1000) (default: "25") | | `-q, --query ` | Search query (Sentry search syntax) | | `--full` | Include full event body (stacktraces) | -| `-t, --period ` | Time period (e.g., "1h", "24h", "7d", "30d") (default: "7d") | +| `-t, --period ` | Time range: "7d", "2026-03-07..2026-04-07", ">=2026-03-07" (default: "7d") | | `-f, --fresh` | Bypass cache, re-detect projects, and fetch fresh data | | `-c, --cursor ` | Navigate pages: "next", "prev", "first" (or raw cursor string) | diff --git a/docs/src/content/docs/commands/log.md b/docs/src/content/docs/commands/log.md index 7b7b651d2..dcbd6b470 100644 --- a/docs/src/content/docs/commands/log.md +++ b/docs/src/content/docs/commands/log.md @@ -24,7 +24,7 @@ List logs from a project | `-n, --limit ` | Number of log entries (1-1000) (default: "100") | | `-q, --query ` | Filter query (Sentry search syntax) | | `-f, --follow ` | Stream logs (optionally specify poll interval in seconds) | -| `-t, --period ` | Time period (e.g., "30d", "14d", "24h"). Default: 30d (project mode), 14d (trace mode) | +| `-t, --period ` | Time range: "7d", "2026-03-07..2026-04-07", ">=2026-03-07" | | `-s, --sort ` | Sort order: "newest" (default) or "oldest" (default: "newest") | | `--fresh` | Bypass cache, re-detect projects, and fetch fresh data | diff --git a/docs/src/content/docs/commands/span.md b/docs/src/content/docs/commands/span.md index f86025d0e..053367ccb 100644 --- a/docs/src/content/docs/commands/span.md +++ b/docs/src/content/docs/commands/span.md @@ -24,7 +24,7 @@ List spans in a project or trace | `-n, --limit ` | Number of spans (<=1000) (default: "25") | | `-q, --query ` | Filter spans (e.g., "op:db", "duration:>100ms", "project:backend") | | `-s, --sort ` | Sort order: date, duration (default: "date") | -| `-t, --period ` | Time period (e.g., "1h", "24h", "7d", "30d") (default: "7d") | +| `-t, --period ` | Time range: "7d", "2026-03-07..2026-04-07", ">=2026-03-07" (default: "7d") | | `-f, --fresh` | Bypass cache, re-detect projects, and fetch fresh data | | `-c, --cursor ` | Navigate pages: "next", "prev", "first" (or raw cursor string) | diff --git a/docs/src/content/docs/commands/trace.md b/docs/src/content/docs/commands/trace.md index 7c3e40b4c..b5d8c1e07 100644 --- a/docs/src/content/docs/commands/trace.md +++ b/docs/src/content/docs/commands/trace.md @@ -24,7 +24,7 @@ List recent traces in a project | `-n, --limit ` | Number of traces (1-1000) (default: "25") | | `-q, --query ` | Search query (Sentry search syntax) | | `-s, --sort ` | Sort by: date, duration (default: "date") | -| `-t, --period ` | Time period (e.g., "1h", "24h", "7d", "30d") (default: "7d") | +| `-t, --period ` | Time range: "7d", "2026-03-07..2026-04-07", ">=2026-03-07" (default: "7d") | | `-f, --fresh` | Bypass cache, re-detect projects, and fetch fresh data | | `-c, --cursor ` | Navigate pages: "next", "prev", "first" (or raw cursor string) | @@ -61,7 +61,7 @@ View logs associated with a trace | Option | Description | |--------|-------------| | `-w, --web` | Open trace in browser | -| `-t, --period ` | Time period to search (e.g., "14d", "7d", "24h"). Default: 14d (default: "14d") | +| `-t, --period ` | Time range: "7d", "2026-03-07..2026-04-07", ">=2026-03-07" (default: "14d") | | `-n, --limit ` | Number of log entries (<=1000) (default: "100") | | `-q, --query ` | Additional filter query (Sentry search syntax) | | `-s, --sort ` | Sort order: "newest" (default) or "oldest" (default: "newest") | diff --git a/plugins/sentry-cli/skills/sentry-cli/references/dashboard.md b/plugins/sentry-cli/skills/sentry-cli/references/dashboard.md index 9f50c68fe..e1f837354 100644 --- a/plugins/sentry-cli/skills/sentry-cli/references/dashboard.md +++ b/plugins/sentry-cli/skills/sentry-cli/references/dashboard.md @@ -42,7 +42,7 @@ View a dashboard - `-w, --web - Open in browser` - `-f, --fresh - Bypass cache, re-detect projects, and fetch fresh data` - `-r, --refresh - Auto-refresh interval in seconds (default: 60, min: 10)` -- `-t, --period - Time period override (e.g., "24h", "7d", "14d")` +- `-t, --period - Time range: "7d", "2026-03-07..2026-04-07", ">=2026-03-07"` **Examples:** diff --git a/plugins/sentry-cli/skills/sentry-cli/references/event.md b/plugins/sentry-cli/skills/sentry-cli/references/event.md index c556503f9..573eb2e1d 100644 --- a/plugins/sentry-cli/skills/sentry-cli/references/event.md +++ b/plugins/sentry-cli/skills/sentry-cli/references/event.md @@ -28,7 +28,7 @@ List events for an issue - `-n, --limit - Number of events (1-1000) - (default: "25")` - `-q, --query - Search query (Sentry search syntax)` - `--full - Include full event body (stacktraces)` -- `-t, --period - Time period (e.g., "1h", "24h", "7d", "30d") - (default: "7d")` +- `-t, --period - Time range: "7d", "2026-03-07..2026-04-07", ">=2026-03-07" - (default: "7d")` - `-f, --fresh - Bypass cache, re-detect projects, and fetch fresh data` - `-c, --cursor - Navigate pages: "next", "prev", "first" (or raw cursor string)` diff --git a/plugins/sentry-cli/skills/sentry-cli/references/issue.md b/plugins/sentry-cli/skills/sentry-cli/references/issue.md index 7421a766c..197f9da95 100644 --- a/plugins/sentry-cli/skills/sentry-cli/references/issue.md +++ b/plugins/sentry-cli/skills/sentry-cli/references/issue.md @@ -19,7 +19,7 @@ List issues in a project - `-q, --query - Search query (Sentry search syntax)` - `-n, --limit - Maximum number of issues to list - (default: "25")` - `-s, --sort - Sort by: date, new, freq, user - (default: "date")` -- `-t, --period - Time period for issue activity (e.g. 24h, 14d, 90d) - (default: "90d")` +- `-t, --period - Time range: "7d", "2026-03-07..2026-04-07", ">=2026-03-07" - (default: "90d")` - `-c, --cursor - Pagination cursor (use "next" for next page, "prev" for previous)` - `--compact - Single-line rows for compact output (auto-detects if omitted)` - `-f, --fresh - Bypass cache, re-detect projects, and fetch fresh data` @@ -78,7 +78,7 @@ List events for a specific issue - `-n, --limit - Number of events (1-1000) - (default: "25")` - `-q, --query - Search query (Sentry search syntax)` - `--full - Include full event body (stacktraces)` -- `-t, --period - Time period (e.g., "1h", "24h", "7d", "30d") - (default: "7d")` +- `-t, --period - Time range: "7d", "2026-03-07..2026-04-07", ">=2026-03-07" - (default: "7d")` - `-f, --fresh - Bypass cache, re-detect projects, and fetch fresh data` - `-c, --cursor - Navigate pages: "next", "prev", "first" (or raw cursor string)` diff --git a/plugins/sentry-cli/skills/sentry-cli/references/log.md b/plugins/sentry-cli/skills/sentry-cli/references/log.md index 0d930a5a3..a75fd06e0 100644 --- a/plugins/sentry-cli/skills/sentry-cli/references/log.md +++ b/plugins/sentry-cli/skills/sentry-cli/references/log.md @@ -19,7 +19,7 @@ List logs from a project - `-n, --limit - Number of log entries (1-1000) - (default: "100")` - `-q, --query - Filter query (Sentry search syntax)` - `-f, --follow - Stream logs (optionally specify poll interval in seconds)` -- `-t, --period - Time period (e.g., "30d", "14d", "24h"). Default: 30d (project mode), 14d (trace mode)` +- `-t, --period - Time range: "7d", "2026-03-07..2026-04-07", ">=2026-03-07"` - `-s, --sort - Sort order: "newest" (default) or "oldest" - (default: "newest")` - `--fresh - Bypass cache, re-detect projects, and fetch fresh data` diff --git a/plugins/sentry-cli/skills/sentry-cli/references/span.md b/plugins/sentry-cli/skills/sentry-cli/references/span.md index 72818ea8d..0bba2e955 100644 --- a/plugins/sentry-cli/skills/sentry-cli/references/span.md +++ b/plugins/sentry-cli/skills/sentry-cli/references/span.md @@ -19,7 +19,7 @@ List spans in a project or trace - `-n, --limit - Number of spans (<=1000) - (default: "25")` - `-q, --query - Filter spans (e.g., "op:db", "duration:>100ms", "project:backend")` - `-s, --sort - Sort order: date, duration - (default: "date")` -- `-t, --period - Time period (e.g., "1h", "24h", "7d", "30d") - (default: "7d")` +- `-t, --period - Time range: "7d", "2026-03-07..2026-04-07", ">=2026-03-07" - (default: "7d")` - `-f, --fresh - Bypass cache, re-detect projects, and fetch fresh data` - `-c, --cursor - Navigate pages: "next", "prev", "first" (or raw cursor string)` diff --git a/plugins/sentry-cli/skills/sentry-cli/references/trace.md b/plugins/sentry-cli/skills/sentry-cli/references/trace.md index c840cad77..dbbd6e8aa 100644 --- a/plugins/sentry-cli/skills/sentry-cli/references/trace.md +++ b/plugins/sentry-cli/skills/sentry-cli/references/trace.md @@ -19,7 +19,7 @@ List recent traces in a project - `-n, --limit - Number of traces (1-1000) - (default: "25")` - `-q, --query - Search query (Sentry search syntax)` - `-s, --sort - Sort by: date, duration - (default: "date")` -- `-t, --period - Time period (e.g., "1h", "24h", "7d", "30d") - (default: "7d")` +- `-t, --period - Time range: "7d", "2026-03-07..2026-04-07", ">=2026-03-07" - (default: "7d")` - `-f, --fresh - Bypass cache, re-detect projects, and fetch fresh data` - `-c, --cursor - Navigate pages: "next", "prev", "first" (or raw cursor string)` @@ -78,7 +78,7 @@ View logs associated with a trace **Flags:** - `-w, --web - Open trace in browser` -- `-t, --period - Time period to search (e.g., "14d", "7d", "24h"). Default: 14d - (default: "14d")` +- `-t, --period - Time range: "7d", "2026-03-07..2026-04-07", ">=2026-03-07" - (default: "14d")` - `-n, --limit - Number of log entries (<=1000) - (default: "100")` - `-q, --query - Additional filter query (Sentry search syntax)` - `-s, --sort - Sort order: "newest" (default) or "oldest" - (default: "newest")` diff --git a/src/commands/dashboard/view.ts b/src/commands/dashboard/view.ts index 26b02da69..a55c17a07 100644 --- a/src/commands/dashboard/view.ts +++ b/src/commands/dashboard/view.ts @@ -22,6 +22,11 @@ import { logger } from "../../lib/logger.js"; import { withProgress } from "../../lib/polling.js"; import { resolveOrgRegion } from "../../lib/region.js"; import { buildDashboardUrl } from "../../lib/sentry-urls.js"; +import { + PERIOD_BRIEF, + parsePeriod, + timeRangeToSeconds, +} from "../../lib/time-range.js"; import type { DashboardWidget, WidgetDataResult, @@ -175,7 +180,7 @@ export const viewCommand = buildCommand({ period: { kind: "parsed", parse: String, - brief: 'Time period override (e.g., "24h", "7d", "14d")', + brief: PERIOD_BRIEF, optional: true, }, }, @@ -213,7 +218,14 @@ export const viewCommand = buildCommand({ ); const regionUrl = await resolveOrgRegion(orgSlug); - const period = flags.period ?? dashboard.period ?? "24h"; + const effectivePeriod = flags.period ?? dashboard.period ?? "24h"; + const timeRange = parsePeriod(effectivePeriod); + const periodSeconds = timeRangeToSeconds(timeRange); + // WidgetQueryOptions uses `period` (not `statsPeriod`) for the relative field + const widgetTimeOpts = + timeRange.type === "relative" + ? { period: timeRange.period } + : { start: timeRange.start, end: timeRange.end }; const widgets = dashboard.widgets ?? []; if (flags.refresh !== undefined) { @@ -244,12 +256,12 @@ export const viewCommand = buildCommand({ regionUrl, orgSlug, dashboard, - { period } + { ...widgetTimeOpts, periodSeconds } ); // Build output data before clearing so clear→render is instantaneous const viewData = buildViewData(dashboard, widgetData, widgets, { - period, + period: effectivePeriod, url, }); @@ -271,11 +283,18 @@ export const viewCommand = buildCommand({ // ── Single fetch mode ── const widgetData = await withProgress( { message: "Querying widget data...", json: flags.json }, - () => queryAllWidgets(regionUrl, orgSlug, dashboard, { period }) + () => + queryAllWidgets(regionUrl, orgSlug, dashboard, { + ...widgetTimeOpts, + periodSeconds, + }) ); yield new CommandOutput( - buildViewData(dashboard, widgetData, widgets, { period, url }) + buildViewData(dashboard, widgetData, widgets, { + period: effectivePeriod, + url, + }) ); return { hint: `Dashboard: ${url}` }; }, diff --git a/src/commands/event/list.ts b/src/commands/event/list.ts index e7e4645e4..196a79a31 100644 --- a/src/commands/event/list.ts +++ b/src/commands/event/list.ts @@ -19,6 +19,11 @@ import { ContextError } from "../../lib/errors.js"; import { CommandOutput } from "../../lib/formatters/output.js"; import { buildListCommand, paginationHint } from "../../lib/list-command.js"; import { withProgress } from "../../lib/polling.js"; +import { + parsePeriod, + serializeTimeRange, + timeRangeToApiParams, +} from "../../lib/time-range.js"; import { IssueEventSchema } from "../../types/index.js"; import { buildCommandHint, @@ -109,6 +114,7 @@ export const listCommand = buildListCommand("event", { } const { cwd } = this; + const timeRange = parsePeriod(flags.period); // Resolve issue using shared resolution logic (supports @latest, short IDs, etc.) const { org, issue } = await resolveIssue({ @@ -130,7 +136,7 @@ export const listCommand = buildListCommand("event", { const contextKey = buildPaginationContextKey( "event-list", `${org}/${issue.id}`, - { q: flags.query, period: flags.period } + { q: flags.query, period: serializeTimeRange(timeRange) } ); const { cursor, direction } = resolveCursor( flags.cursor, @@ -149,7 +155,7 @@ export const listCommand = buildListCommand("event", { query: flags.query, full: flags.full, cursor, - statsPeriod: flags.period, + ...timeRangeToApiParams(timeRange), }) ); diff --git a/src/commands/issue/events.ts b/src/commands/issue/events.ts index 6504f0189..6fef91eda 100644 --- a/src/commands/issue/events.ts +++ b/src/commands/issue/events.ts @@ -16,6 +16,11 @@ import { ContextError } from "../../lib/errors.js"; import { CommandOutput } from "../../lib/formatters/output.js"; import { buildListCommand, paginationHint } from "../../lib/list-command.js"; import { withProgress } from "../../lib/polling.js"; +import { + parsePeriod, + serializeTimeRange, + timeRangeToApiParams, +} from "../../lib/time-range.js"; import { IssueEventSchema } from "../../types/index.js"; import { appendEventsFlags, @@ -92,6 +97,7 @@ export const eventsCommand = buildListCommand("issue", { }, async *func(this: SentryContext, flags: EventsFlags, issueArg: string) { const { cwd } = this; + const timeRange = parsePeriod(flags.period); // Resolve issue using shared resolution logic (supports @latest, short IDs, etc.) const { org, issue } = await resolveIssue({ @@ -112,7 +118,7 @@ export const eventsCommand = buildListCommand("issue", { const contextKey = buildPaginationContextKey( "issue-events", `${org}/${issue.id}`, - { q: flags.query, period: flags.period } + { q: flags.query, period: serializeTimeRange(timeRange) } ); const { cursor, direction } = resolveCursor( flags.cursor, @@ -131,7 +137,7 @@ export const eventsCommand = buildListCommand("issue", { query: flags.query, full: flags.full, cursor, - statsPeriod: flags.period, + ...timeRangeToApiParams(timeRange), }) ); diff --git a/src/commands/issue/list.ts b/src/commands/issue/list.ts index acd974a48..3e97c2db2 100644 --- a/src/commands/issue/list.ts +++ b/src/commands/issue/list.ts @@ -80,6 +80,13 @@ import { } from "../../lib/resolve-target.js"; import { getApiBaseUrl } from "../../lib/sentry-client.js"; import { setOrgProjectContext } from "../../lib/telemetry.js"; +import { + PERIOD_BRIEF, + parsePeriod, + serializeTimeRange, + type TimeRange, + timeRangeToApiParams, +} from "../../lib/time-range.js"; import { type ProjectAliasEntry, type SentryIssue, @@ -542,6 +549,10 @@ async function fetchIssuesForTarget( limit: number; sort: SortValue; statsPeriod?: string; + /** Absolute start datetime (ISO-8601). Mutually exclusive with statsPeriod. */ + start?: string; + /** Absolute end datetime (ISO-8601). Mutually exclusive with statsPeriod. */ + end?: string; /** Resume from this cursor (Phase 2 redistribution or next-page resume). */ startCursor?: string; onPage?: (fetched: number, limit: number) => void; @@ -559,6 +570,8 @@ async function fetchIssuesForTarget( ...options, projectId: target.projectId, groupStatsPeriod: options.groupStatsPeriod, + start: options.start, + end: options.end, } ); return { target, issues, hasMore: !!nextCursor, nextCursor }; @@ -627,6 +640,10 @@ type BudgetFetchOptions = { limit: number; sort: SortValue; statsPeriod?: string; + /** Absolute start datetime (ISO-8601). Mutually exclusive with statsPeriod. */ + start?: string; + /** Absolute end datetime (ISO-8601). Mutually exclusive with statsPeriod. */ + end?: string; /** Per-target cursors from a previous page (compound cursor resume). */ startCursors?: Map; /** Pre-computed collapse fields for API performance. @see {@link buildListApiOptions} */ @@ -804,7 +821,8 @@ function decodeCompoundCursor(raw: string): (string | null)[] { */ function buildMultiTargetContextKey( targets: ResolvedTarget[], - flags: Pick + flags: Pick, + timeRange: TimeRange ): string { const host = getApiBaseUrl(); const targetFingerprint = targets @@ -814,7 +832,7 @@ function buildMultiTargetContextKey( const escapedQuery = flags.query ? escapeContextKeyValue(flags.query) : undefined; - const escapedPeriod = escapeContextKeyValue(flags.period ?? "90d"); + const escapedPeriod = escapeContextKeyValue(serializeTimeRange(timeRange)); const escapedSort = escapeContextKeyValue(flags.sort); return ( `host:${host}|type:multi:${targetFingerprint}` + @@ -855,11 +873,16 @@ function prevPageHint(org: string, flags: ListFlags): string { */ async function fetchOrgAllIssues( org: string, - flags: Pick, - cursor: string | undefined, - onPage?: (fetched: number, limit: number) => void + flags: Pick, + timeRange: TimeRange, + options: { + cursor?: string; + onPage?: (fetched: number, limit: number) => void; + } ): Promise { const apiOpts = buildListApiOptions(flags.json); + const timeParams = timeRangeToApiParams(timeRange); + const { cursor, onPage } = options; // When resuming with --cursor, fetch a single page so the cursor chain stays intact. if (cursor) { @@ -869,7 +892,7 @@ async function fetchOrgAllIssues( cursor, perPage, sort: flags.sort, - statsPeriod: flags.period, + ...timeParams, groupStatsPeriod: apiOpts.groupStatsPeriod, collapse: apiOpts.collapse, }); @@ -881,7 +904,7 @@ async function fetchOrgAllIssues( query: flags.query, limit: flags.limit, sort: flags.sort, - statsPeriod: flags.period, + ...timeParams, groupStatsPeriod: apiOpts.groupStatsPeriod, collapse: apiOpts.collapse, onPage, @@ -893,6 +916,7 @@ async function fetchOrgAllIssues( type OrgAllIssuesOptions = { org: string; flags: ListFlags; + timeRange: TimeRange; }; /** @@ -905,11 +929,11 @@ type OrgAllIssuesOptions = { async function handleOrgAllIssues( options: OrgAllIssuesOptions ): Promise { - const { org, flags } = options; + const { org, flags, timeRange } = options; // Encode sort + query in context key so cursors from different searches don't collide. const contextKey = buildPaginationContextKey("org", org, { sort: flags.sort, - period: flags.period ?? "90d", + period: serializeTimeRange(timeRange), q: flags.query, }); const { cursor, direction } = resolveCursor( @@ -926,11 +950,13 @@ async function handleOrgAllIssues( json: flags.json, }, (setMessage) => - fetchOrgAllIssues(org, flags, cursor, (fetched, limit) => - setMessage( - `Fetching issues, ${fetched} and counting (up to ${limit})...` - ) - ) + fetchOrgAllIssues(org, flags, timeRange, { + cursor, + onPage: (fetched, limit) => + setMessage( + `Fetching issues, ${fetched} and counting (up to ${limit})...` + ), + }) ); } catch (error) { throw enrichIssueListError(error, flags); @@ -1000,6 +1026,7 @@ type ResolvedTargetsOptions = { parsed: ReturnType; flags: ListFlags; cwd: string; + timeRange: TimeRange; }; /** Default --period value (used to detect user-implicit vs explicit). */ @@ -1133,7 +1160,7 @@ function build403Detail(originalDetail: string | undefined): string { async function handleResolvedTargets( options: ResolvedTargetsOptions ): Promise { - const { parsed, flags, cwd } = options; + const { parsed, flags, cwd, timeRange } = options; const { targets, footer, skippedSelfHosted, detectedDsns } = await resolveTargetsFromParsedArg(parsed, cwd); @@ -1152,7 +1179,7 @@ async function handleResolvedTargets( // Build a compound cursor context key that encodes the full target set + // search parameters so a cursor from one search is never reused for another. - const contextKey = buildMultiTargetContextKey(targets, flags); + const contextKey = buildMultiTargetContextKey(targets, flags, timeRange); // Resolve per-target start cursors from the stored compound cursor (--cursor resume). // Sorted target keys must match the order used in buildMultiTargetContextKey. @@ -1202,7 +1229,7 @@ async function handleResolvedTargets( query: flags.query, limit: flags.limit, sort: flags.sort, - statsPeriod: flags.period, + ...timeRangeToApiParams(timeRange), startCursors, collapse: apiOpts.collapse, groupStatsPeriod: apiOpts.groupStatsPeriod, @@ -1547,7 +1574,7 @@ export const listCommand = buildListCommand("issue", { period: { kind: "parsed", parse: String, - brief: "Time period for issue activity (e.g. 24h, 14d, 90d)", + brief: PERIOD_BRIEF, default: "90d", }, cursor: { @@ -1589,11 +1616,14 @@ export const listCommand = buildListCommand("issue", { ); } + const timeRange = parsePeriod(flags.period ?? DEFAULT_PERIOD); + // biome-ignore lint/suspicious/noExplicitAny: shared handler accepts any mode variant const resolveAndHandle: ModeHandler = (ctx) => handleResolvedTargets({ ...ctx, flags, + timeRange, }); const result = (await dispatchOrgScopedList({ @@ -1616,6 +1646,7 @@ export const listCommand = buildListCommand("issue", { handleOrgAllIssues({ org: ctx.parsed.org, flags, + timeRange, }), }, })) as IssueListResult; diff --git a/src/commands/log/list.ts b/src/commands/log/list.ts index a909ba128..e55c3856e 100644 --- a/src/commands/log/list.ts +++ b/src/commands/log/list.ts @@ -44,6 +44,12 @@ import { import { logger } from "../../lib/logger.js"; import { withProgress } from "../../lib/polling.js"; import { resolveOrgProjectFromArg } from "../../lib/resolve-target.js"; +import { + PERIOD_BRIEF, + parsePeriod, + type TimeRange, + timeRangeToApiParams, +} from "../../lib/time-range.js"; import { parseDualModeArgs, resolveTraceOrg, @@ -171,18 +177,26 @@ const DEFAULT_PROJECT_PERIOD = "30d"; async function executeSingleFetch( org: string, project: string, - flags: ListFlags + flags: ListFlags, + timeRange: TimeRange ): Promise { - const period = flags.period ?? DEFAULT_PROJECT_PERIOD; const logs = await listLogs(org, project, { query: flags.query, limit: flags.limit, - statsPeriod: period, + ...timeRangeToApiParams(timeRange), sort: flags.sort, }); + const periodLabel = + timeRange.type === "relative" + ? `in the last ${timeRange.period}` + : "in the specified range"; + if (logs.length === 0) { - return { result: { logs: [], hasMore: false }, hint: "No logs found." }; + return { + result: { logs: [], hasMore: false }, + hint: `No logs found ${periodLabel}.`, + }; } const hasMore = logs.length >= flags.limit; @@ -436,24 +450,26 @@ async function* yieldTraceFollowItems( async function executeTraceSingleFetch( org: string, traceId: string, - flags: ListFlags + flags: ListFlags, + timeRange: TimeRange ): Promise { - // Use the explicit period if set, otherwise default to 14d for trace mode. - // The flag is optional (no default) so undefined means "not explicitly set". - const period = flags.period ?? DEFAULT_TRACE_PERIOD; - const logs = await listTraceLogs(org, traceId, { query: flags.query, limit: flags.limit, - statsPeriod: period, + ...timeRangeToApiParams(timeRange), sort: flags.sort, }); + const periodLabel = + timeRange.type === "relative" + ? `in the last ${timeRange.period}` + : "in the specified range"; + if (logs.length === 0) { return { result: { logs: [], traceId, hasMore: false }, hint: - `No logs found for trace ${traceId} in the last ${period}.\n\n` + + `No logs found for trace ${traceId} ${periodLabel}.\n\n` + "Try 'sentry trace logs' for more options (e.g., --period 30d).", }; } @@ -645,8 +661,7 @@ export const listCommand = buildListCommand( period: { kind: "parsed", parse: String, - brief: - 'Time period (e.g., "30d", "14d", "24h"). Default: 30d (project mode), 14d (trace mode)', + brief: PERIOD_BRIEF, optional: true, }, sort: { @@ -676,6 +691,24 @@ export const listCommand = buildListCommand( const parsed = parseLogListArgs(args); + // Resolve mode-dependent default period, then parse the time range + const effectivePeriod = + flags.period ?? + (parsed.mode === "trace" + ? DEFAULT_TRACE_PERIOD + : DEFAULT_PROJECT_PERIOD); + const timeRange = parsePeriod(effectivePeriod); + + // Follow mode streams live events via short polling intervals — + // absolute date ranges are silently ignored, so reject them entirely. + if (flags.follow && timeRange.type === "absolute") { + throw new ValidationError( + "--follow cannot be used with an absolute date range. " + + "Use a relative duration (e.g., --period 1h) or omit --period.", + "period" + ); + } + if (parsed.mode === "trace") { // Trace mode: use the org-scoped trace-logs endpoint. warnIfNormalized(parsed.parsed, "log.list"); @@ -739,7 +772,7 @@ export const listCommand = buildListCommand( message: `Fetching logs (up to ${flags.limit})...`, json: flags.json, }, - () => executeTraceSingleFetch(org, traceId, flags) + () => executeTraceSingleFetch(org, traceId, flags, timeRange) ); yield new CommandOutput(result); return { hint }; @@ -783,7 +816,7 @@ export const listCommand = buildListCommand( message: `Fetching logs (up to ${flags.limit})...`, json: flags.json, }, - () => executeSingleFetch(org, project, flags) + () => executeSingleFetch(org, project, flags, timeRange) ); yield new CommandOutput(result); return { hint }; diff --git a/src/commands/span/list.ts b/src/commands/span/list.ts index 09bda9c14..41713796b 100644 --- a/src/commands/span/list.ts +++ b/src/commands/span/list.ts @@ -40,6 +40,12 @@ import { } from "../../lib/list-command.js"; import { withProgress } from "../../lib/polling.js"; import { resolveOrgProjectFromArg } from "../../lib/resolve-target.js"; +import { + parsePeriod, + serializeTimeRange, + type TimeRange, + timeRangeToApiParams, +} from "../../lib/time-range.js"; import { type ParsedTraceTarget, parseDualModeArgs, @@ -334,6 +340,8 @@ function jsonTransformSpanList(data: SpanListData, fields?: string[]): unknown { type ModeContext = { cwd: string; flags: ListFlags; + /** Parsed time range from the --period flag */ + timeRange: TimeRange; /** Extra API field names derived from --fields (undefined when none needed) */ extraApiFields?: string[]; }; @@ -348,7 +356,7 @@ async function handleTraceMode( parsed: ParsedTraceTarget, ctx: ModeContext ): Promise<{ output: SpanListData; hint?: string }> { - const { flags, cwd, extraApiFields } = ctx; + const { flags, cwd, extraApiFields, timeRange } = ctx; warnIfNormalized(parsed, "span.list"); const { traceId, org, project } = await resolveTraceOrgProject( parsed, @@ -364,7 +372,7 @@ async function handleTraceMode( const contextKey = buildPaginationContextKey( "span", `${org}/${project}/${traceId}`, - { sort: flags.sort, q: flags.query, period: flags.period } + { sort: flags.sort, q: flags.query, period: serializeTimeRange(timeRange) } ); const { cursor, direction } = resolveCursor( flags.cursor, @@ -380,7 +388,7 @@ async function handleTraceMode( sort: flags.sort, limit: flags.limit, cursor, - statsPeriod: flags.period, + ...timeRangeToApiParams(timeRange), extraFields: extraApiFields, }) ); @@ -434,7 +442,7 @@ async function handleProjectMode( target: string | undefined, ctx: ModeContext ): Promise<{ output: SpanListData; hint?: string }> { - const { flags, cwd, extraApiFields } = ctx; + const { flags, cwd, extraApiFields, timeRange } = ctx; const { org, project } = await resolveOrgProjectFromArg( target, cwd, @@ -445,7 +453,7 @@ async function handleProjectMode( const contextKey = buildPaginationContextKey( "span-search", `${org}/${project}`, - { sort: flags.sort, q: flags.query, period: flags.period } + { sort: flags.sort, q: flags.query, period: serializeTimeRange(timeRange) } ); const { cursor, direction } = resolveCursor( flags.cursor, @@ -461,7 +469,7 @@ async function handleProjectMode( sort: flags.sort, limit: flags.limit, cursor, - statsPeriod: flags.period, + ...timeRangeToApiParams(timeRange), extraFields: extraApiFields, }) ); @@ -588,9 +596,10 @@ export const listCommand = buildListCommand("span", { }, async *func(this: SentryContext, flags: ListFlags, ...args: string[]) { const { cwd } = this; + const timeRange = parsePeriod(flags.period); const parsed = parseSpanListArgs(args); const extraApiFields = extractExtraApiFields(flags.fields); - const modeCtx: ModeContext = { cwd, flags, extraApiFields }; + const modeCtx: ModeContext = { cwd, flags, timeRange, extraApiFields }; const { output, hint } = parsed.mode === "trace" diff --git a/src/commands/trace/list.ts b/src/commands/trace/list.ts index c2d2a9bff..4b6efe23a 100644 --- a/src/commands/trace/list.ts +++ b/src/commands/trace/list.ts @@ -28,6 +28,11 @@ import { } from "../../lib/list-command.js"; import { withProgress } from "../../lib/polling.js"; import { resolveOrgProjectFromArg } from "../../lib/resolve-target.js"; +import { + parsePeriod, + serializeTimeRange, + timeRangeToApiParams, +} from "../../lib/time-range.js"; import { type TransactionListItem, TransactionListItemSchema, @@ -251,6 +256,7 @@ export const listCommand = buildListCommand("trace", { }, async *func(this: SentryContext, flags: ListFlags, target?: string) { const { cwd } = this; + const timeRange = parsePeriod(flags.period); // Resolve org/project from positional arg, config, or DSN auto-detection const { org, project } = await resolveOrgProjectFromArg( @@ -262,7 +268,7 @@ export const listCommand = buildListCommand("trace", { const contextKey = buildPaginationContextKey("trace", `${org}/${project}`, { sort: flags.sort, q: flags.query, - period: flags.period, + period: serializeTimeRange(timeRange), }); const { cursor, direction } = resolveCursor( flags.cursor, @@ -281,7 +287,7 @@ export const listCommand = buildListCommand("trace", { limit: flags.limit, sort: flags.sort, cursor, - statsPeriod: flags.period, + ...timeRangeToApiParams(timeRange), }) ); diff --git a/src/commands/trace/logs.ts b/src/commands/trace/logs.ts index c0498affd..66d26ca82 100644 --- a/src/commands/trace/logs.ts +++ b/src/commands/trace/logs.ts @@ -20,6 +20,11 @@ import { } from "../../lib/list-command.js"; import { withProgress } from "../../lib/polling.js"; import { buildTraceUrl } from "../../lib/sentry-urls.js"; +import { + PERIOD_BRIEF, + parsePeriod, + timeRangeToApiParams, +} from "../../lib/time-range.js"; import { parseTraceTarget, resolveTraceOrg, @@ -128,7 +133,7 @@ export const logsCommand = buildCommand({ period: { kind: "parsed", parse: String, - brief: `Time period to search (e.g., "14d", "7d", "24h"). Default: ${DEFAULT_PERIOD}`, + brief: PERIOD_BRIEF, default: DEFAULT_PERIOD, }, limit: { @@ -163,6 +168,7 @@ export const logsCommand = buildCommand({ async *func(this: SentryContext, flags: LogsFlags, ...args: string[]) { applyFreshFlag(flags); const { cwd } = this; + const timeRange = parsePeriod(flags.period); // Parse and resolve org/trace-id const parsed = parseTraceTarget(args, USAGE_HINT); @@ -180,7 +186,7 @@ export const logsCommand = buildCommand({ }, () => listTraceLogs(org, traceId, { - statsPeriod: flags.period, + ...timeRangeToApiParams(timeRange), limit: flags.limit, query: flags.query, sort: flags.sort, diff --git a/src/lib/api/dashboards.ts b/src/lib/api/dashboards.ts index 06e721cfc..f75d19263 100644 --- a/src/lib/api/dashboards.ts +++ b/src/lib/api/dashboards.ts @@ -144,6 +144,12 @@ export async function updateDashboard( type WidgetQueryOptions = { /** Override the dashboard's time period (e.g., "24h", "7d") */ period?: string; + /** Absolute start datetime (ISO-8601). Mutually exclusive with period. */ + start?: string; + /** Absolute end datetime (ISO-8601). Mutually exclusive with period. */ + end?: string; + /** Pre-computed total seconds for interval computation (for absolute ranges). */ + periodSeconds?: number; /** Filter by environment(s) — from dashboard.environment */ environment?: string[]; /** Filter by project ID(s) — from dashboard.projects */ @@ -204,10 +210,12 @@ const VALID_INTERVALS = ["1m", "5m", "15m", "30m", "1h", "4h", "12h", "1d"]; * produces at least `chartWidth` data points, ensuring barWidth stays at 1. */ export function computeOptimalInterval( - statsPeriod: string, - widget: DashboardWidget + statsPeriod: string | undefined, + widget: DashboardWidget, + periodSeconds?: number ): string | undefined { - const totalSeconds = periodToSeconds(statsPeriod); + const totalSeconds = + periodSeconds ?? (statsPeriod ? periodToSeconds(statsPeriod) : undefined); if (!totalSeconds) { return widget.interval; } @@ -325,14 +333,30 @@ type WidgetQueryParams = { regionUrl: string; orgSlug: string; widget: DashboardWidget; - statsPeriod: string; + /** Relative period (e.g., "24h"). Omit when using absolute start/end. */ + statsPeriod?: string; + /** Absolute start datetime (ISO-8601). Mutually exclusive with statsPeriod. */ + start?: string; + /** Absolute end datetime (ISO-8601). Mutually exclusive with statsPeriod. */ + end?: string; + /** Pre-computed total seconds for interval computation (for absolute ranges). */ + periodSeconds?: number; options?: WidgetQueryOptions; }; async function queryWidgetTimeseries( params: WidgetQueryParams ): Promise { - const { regionUrl, orgSlug, widget, statsPeriod, options = {} } = params; + const { + regionUrl, + orgSlug, + widget, + statsPeriod, + start, + end, + periodSeconds, + options = {}, + } = params; const allSeries: TimeseriesResult["series"] = []; for (const query of widget.queries ?? []) { @@ -346,7 +370,9 @@ async function queryWidgetTimeseries( query: query.conditions || undefined, dataset: dataset ?? undefined, statsPeriod, - interval: computeOptimalInterval(statsPeriod, widget), + start, + end, + interval: computeOptimalInterval(statsPeriod, widget, periodSeconds), environment: options.environment, project: options.project?.map(String), }; @@ -391,7 +417,15 @@ async function queryWidgetTimeseries( async function queryWidgetTable( params: WidgetQueryParams ): Promise { - const { regionUrl, orgSlug, widget, statsPeriod, options = {} } = params; + const { + regionUrl, + orgSlug, + widget, + statsPeriod, + start, + end, + options = {}, + } = params; const query = widget.queries?.[0]; const fields = query?.fields ?? [ ...(query?.columns ?? []), @@ -408,6 +442,8 @@ async function queryWidgetTable( query: query?.conditions || undefined, dataset: dataset ?? undefined, statsPeriod, + start, + end, sort: query?.orderby || undefined, per_page: widget.limit ?? 10, environment: options.environment, @@ -543,7 +579,12 @@ export async function queryAllWidgets( options: WidgetQueryOptions = {} ): Promise> { const widgets = dashboard.widgets ?? []; - const statsPeriod = options.period ?? dashboard.period ?? "24h"; + // When absolute start/end are provided, skip relative statsPeriod — + // the API treats them as mutually exclusive. + const statsPeriod = + options.start || options.end + ? undefined + : (options.period ?? dashboard.period ?? "24h"); // Merge dashboard-level filters with caller overrides const mergedOptions: WidgetQueryOptions = { @@ -568,6 +609,9 @@ export async function queryAllWidgets( orgSlug, widget, statsPeriod, + start: options.start, + end: options.end, + periodSeconds: options.periodSeconds, options: mergedOptions, }) ) diff --git a/src/lib/api/events.ts b/src/lib/api/events.ts index f8172ec07..093b81f72 100644 --- a/src/lib/api/events.ts +++ b/src/lib/api/events.ts @@ -174,6 +174,10 @@ export type ListIssueEventsOptions = { cursor?: string; /** Relative time period (e.g., "7d", "24h"). Overrides start/end on the API. */ statsPeriod?: string; + /** Absolute start datetime (ISO-8601). Mutually exclusive with statsPeriod. */ + start?: string; + /** Absolute end datetime (ISO-8601). Mutually exclusive with statsPeriod. */ + end?: string; }; /** @@ -193,7 +197,7 @@ export async function listIssueEvents( issueId: string, options: ListIssueEventsOptions = {} ): Promise> { - const { limit = 25, query, full, cursor, statsPeriod } = options; + const { limit = 25, query, full, cursor, statsPeriod, start, end } = options; const config = await getOrgSdkConfig(orgSlug); @@ -213,6 +217,8 @@ export async function listIssueEvents( full, cursor: currentCursor, statsPeriod, + start, + end, }, }); diff --git a/src/lib/api/issues.ts b/src/lib/api/issues.ts index b52157e96..8b4d41356 100644 --- a/src/lib/api/issues.ts +++ b/src/lib/api/issues.ts @@ -118,6 +118,10 @@ export async function listIssuesPaginated( /** Fields to collapse (omit) from the response for performance. * @see {@link buildIssueListCollapse} */ collapse?: IssueCollapseField[]; + /** Absolute start datetime (ISO-8601). Mutually exclusive with statsPeriod. */ + start?: string; + /** Absolute end datetime (ISO-8601). Mutually exclusive with statsPeriod. */ + end?: string; } = {} ): Promise> { // When we have a numeric project ID, use the `project` query param (Array) @@ -144,6 +148,8 @@ export async function listIssuesPaginated( limit: options.perPage ?? 25, sort: options.sort, statsPeriod: options.statsPeriod, + start: options.start, + end: options.end, groupStatsPeriod: options.groupStatsPeriod, collapse: options.collapse, }, @@ -201,6 +207,10 @@ export async function listIssuesAllPages( /** Fields to collapse (omit) from the response for performance. * @see {@link buildIssueListCollapse} */ collapse?: IssueCollapseField[]; + /** Absolute start datetime (ISO-8601). Mutually exclusive with statsPeriod. */ + start?: string; + /** Absolute end datetime (ISO-8601). Mutually exclusive with statsPeriod. */ + end?: string; } ): Promise { if (options.limit < 1) { @@ -222,6 +232,8 @@ export async function listIssuesAllPages( perPage, sort: options.sort, statsPeriod: options.statsPeriod, + start: options.start, + end: options.end, projectId: options.projectId, groupStatsPeriod: options.groupStatsPeriod, collapse: options.collapse, diff --git a/src/lib/api/logs.ts b/src/lib/api/logs.ts index 74d5049de..0cc85ac9d 100644 --- a/src/lib/api/logs.ts +++ b/src/lib/api/logs.ts @@ -59,6 +59,10 @@ type ListLogsOptions = { sort?: LogSortDirection; /** Only return logs after this timestamp_precise value (for streaming) */ afterTimestamp?: number; + /** Absolute start datetime (ISO-8601). Mutually exclusive with statsPeriod. */ + start?: string; + /** Absolute end datetime (ISO-8601). Mutually exclusive with statsPeriod. */ + end?: string; }; /** @@ -97,7 +101,12 @@ export async function listLogs( project: isNumericProject ? [Number(projectSlug)] : undefined, query: fullQuery || undefined, per_page: options.limit || API_MAX_PER_PAGE, - statsPeriod: options.statsPeriod ?? "30d", + statsPeriod: + options.start || options.end + ? undefined + : (options.statsPeriod ?? "30d"), + start: options.start, + end: options.end, sort: toApiSort(options.sort), }, }); @@ -209,6 +218,10 @@ type ListTraceLogsOptions = { statsPeriod?: string; /** Sort direction: "newest" (default) or "oldest" */ sort?: LogSortDirection; + /** Absolute start datetime (ISO-8601). Mutually exclusive with statsPeriod. */ + start?: string; + /** Absolute end datetime (ISO-8601). Mutually exclusive with statsPeriod. */ + end?: string; }; /** @@ -240,7 +253,12 @@ export async function listTraceLogs( { params: { traceId, - statsPeriod: options.statsPeriod ?? "14d", + statsPeriod: + options.start || options.end + ? undefined + : (options.statsPeriod ?? "14d"), + start: options.start, + end: options.end, per_page: options.limit ?? API_MAX_PER_PAGE, query: options.query, sort: toApiSort(options.sort), diff --git a/src/lib/api/traces.ts b/src/lib/api/traces.ts index 9e9cf57e2..e3a7c2ed4 100644 --- a/src/lib/api/traces.ts +++ b/src/lib/api/traces.ts @@ -190,6 +190,10 @@ type ListTransactionsOptions = { statsPeriod?: string; /** Pagination cursor to resume from a previous page */ cursor?: string; + /** Absolute start datetime (ISO-8601). Mutually exclusive with statsPeriod. */ + start?: string; + /** Absolute end datetime (ISO-8601). Mutually exclusive with statsPeriod. */ + end?: string; }; /** @@ -231,7 +235,12 @@ export async function listTransactions( // omitting the parameter. query: fullQuery || undefined, per_page: options.limit || 10, - statsPeriod: options.statsPeriod ?? "7d", + statsPeriod: + options.start || options.end + ? undefined + : (options.statsPeriod ?? "7d"), + start: options.start, + end: options.end, sort: options.sort === "duration" ? "-transaction.duration" @@ -277,6 +286,10 @@ type ListSpansOptions = { cursor?: string; /** Additional field names to request from the API beyond SPAN_FIELDS */ extraFields?: string[]; + /** Absolute start datetime (ISO-8601). Mutually exclusive with statsPeriod. */ + start?: string; + /** Absolute end datetime (ISO-8601). Mutually exclusive with statsPeriod. */ + end?: string; }; /** @@ -313,7 +326,12 @@ export async function listSpans( project: isNumericProject ? projectSlug : undefined, query: fullQuery || undefined, per_page: options.limit || 10, - statsPeriod: options.statsPeriod ?? "7d", + statsPeriod: + options.start || options.end + ? undefined + : (options.statsPeriod ?? "7d"), + start: options.start, + end: options.end, sort: options.sort === "duration" ? "-span.duration" : "-timestamp", cursor: options.cursor, }, diff --git a/src/lib/list-command.ts b/src/lib/list-command.ts index 48d1cc80c..a620e9708 100644 --- a/src/lib/list-command.ts +++ b/src/lib/list-command.ts @@ -32,6 +32,7 @@ import { type OrgListConfig, } from "./org-list.js"; import { disableResponseCache } from "./response-cache.js"; +import { PERIOD_BRIEF } from "./time-range.js"; // --------------------------------------------------------------------------- // Level A: shared parameter / flag definitions @@ -305,7 +306,7 @@ export function buildListLimitFlag( export const LIST_PERIOD_FLAG = { kind: "parsed" as const, parse: String, - brief: 'Time period (e.g., "1h", "24h", "7d", "30d")', + brief: PERIOD_BRIEF, default: "7d", }; diff --git a/src/lib/time-range.ts b/src/lib/time-range.ts new file mode 100644 index 000000000..7b10c67d7 --- /dev/null +++ b/src/lib/time-range.ts @@ -0,0 +1,420 @@ +/** + * Time range parsing and conversion for the --period flag. + * + * Supports three syntaxes: + * 1. Relative durations: "7d", "24h", "1h", "30m", "2w" + * 2. Date ranges with ".." separator: "2024-01-01..2024-02-01", "2024-01-01..", "..2024-02-01" + * 3. Comparison operators (gh-compatible): ">2024-01-01", ">=2024-01-01", "<2024-02-01", "<=2024-02-01" + * + * Date-only inputs are normalized using the local machine timezone. + * Datetimes with explicit timezone offsets are passed through as-is. + */ + +import { ValidationError } from "./errors.js"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** Relative duration like "7d", "24h" */ +export type RelativeTimeRange = { + readonly type: "relative"; + /** Raw duration string passed through to the API's statsPeriod param */ + readonly period: string; +}; + +/** Absolute date range with optional open ends */ +export type AbsoluteTimeRange = { + readonly type: "absolute"; + /** ISO-8601 datetime string, or undefined for open-ended start */ + readonly start?: string; + /** ISO-8601 datetime string, or undefined for open-ended end */ + readonly end?: string; +}; + +/** Discriminated union for all period flag values */ +export type TimeRange = RelativeTimeRange | AbsoluteTimeRange; + +/** API parameters for time-scoped queries — statsPeriod and start/end are mutually exclusive */ +export type TimeRangeApiParams = { + statsPeriod?: string; + start?: string; + end?: string; +}; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +/** Seconds per unit for relative period computation */ +const UNIT_SECONDS: Record = { + s: 1, + m: 60, + h: 3600, + d: 86_400, + w: 604_800, +}; + +/** Valid unit suffixes for relative period strings, derived from UNIT_SECONDS */ +const PERIOD_UNITS = new Set(Object.keys(UNIT_SECONDS)); + +/** + * Dynamic example dates relative to "today" so examples never look stale. + * Computed once at module load. + */ +const EXAMPLE_START = (() => { + const d = new Date(); + d.setMonth(d.getMonth() - 1); + return d.toISOString().slice(0, 10); +})(); +const EXAMPLE_END = new Date().toISOString().slice(0, 10); + +/** Brief text for --period flag help, shared across commands */ +export const PERIOD_BRIEF = `Time range: "7d", "${EXAMPLE_START}..${EXAMPLE_END}", ">=${EXAMPLE_START}"`; + +/** + * Try to parse a relative period string (e.g., "7d") into its numeric value and unit. + * Returns null if the string isn't a valid relative period. + */ +function parseRelativeParts( + value: string +): { value: number; unit: string } | null { + if (value.length < 2) { + return null; + } + const unit = value.at(-1) as string; + if (!PERIOD_UNITS.has(unit)) { + return null; + } + const numStr = value.slice(0, -1); + const num = Number(numStr); + if (!Number.isInteger(num) || num < 0 || numStr.length === 0) { + return null; + } + return { value: num, unit }; +} + +// --------------------------------------------------------------------------- +// Date parsing +// --------------------------------------------------------------------------- + +/** + * Date normalization positions. + * + * - "start": inclusive start boundary (>= semantics). Date-only → local midnight. + * - "end": inclusive end boundary (<= semantics). Date-only → local end-of-day. + * - "after": exclusive start boundary (> semantics). Date-only → next day local midnight. + * - "before": exclusive end boundary (< semantics). Date-only → previous day local end-of-day. + */ +type DatePosition = "start" | "end" | "after" | "before"; + +/** + * Validate a date string with `new Date()` and return the parsed Date. + * @throws ValidationError on invalid input + */ +function validateDate(raw: string): Date { + const d = new Date(raw); + if (Number.isNaN(d.getTime())) { + throw new ValidationError( + `Invalid date: '${raw}'. Expected ISO-8601 format (e.g., ${EXAMPLE_START} or ${EXAMPLE_START}T12:00:00).`, + "period" + ); + } + return d; +} + +/** + * Parse and normalize a date string based on its position in a range expression. + * + * All outputs are UTC ISO-8601 strings (via `.toISOString()`). + * - Date-only inputs are interpreted as local time (JS treats `new Date("2024-01-15T00:00:00")` + * without "Z" as local), then converted to UTC. + * - Datetime inputs with explicit timezone are parsed natively by `new Date()`. + * - Datetime inputs without timezone are treated as local time by `new Date()`. + * + * @throws ValidationError on invalid date input + */ +export function parseDate(raw: string, position: DatePosition): string { + const trimmed = raw.trim(); + if (trimmed.length === 0) { + throw new ValidationError( + `Empty date value. Expected ISO-8601 format (e.g., ${EXAMPLE_START})`, + "period" + ); + } + + // Normalize space-separated datetime to ISO "T" separator (common user input) + const normalized = trimmed.includes(" ") + ? trimmed.replace(" ", "T") + : trimmed; + + // Date-only (no "T"): apply position-aware time boundaries in local time + if (!normalized.includes("T")) { + return normalizeDateOnly(normalized, position); + } + + // Datetime: validate and convert to UTC + return validateDate(normalized).toISOString(); +} + +/** + * Normalize a date-only string (YYYY-MM-DD) with position-aware boundaries. + * + * Constructs datetimes without "Z" so `new Date()` interprets them as local time, + * then converts to UTC via `.toISOString()`. + * + * Day arithmetic for "after"/"before" uses local Date methods (setDate/setHours) + * to avoid UTC/local mismatches in extreme timezones. + */ +function normalizeDateOnly(dateStr: string, position: DatePosition): string { + // Validate the date is parseable + validateDate(`${dateStr}T12:00:00`); + + if (position === "start") { + return new Date(`${dateStr}T00:00:00`).toISOString(); + } + if (position === "end") { + return new Date(`${dateStr}T23:59:59.999`).toISOString(); + } + if (position === "after") { + // Exclusive start (>): next day's local midnight. + // Use local Date methods throughout to avoid UTC/local day mismatch. + const d = new Date(`${dateStr}T00:00:00`); + d.setDate(d.getDate() + 1); + d.setHours(0, 0, 0, 0); + return d.toISOString(); + } + // position === "before": Exclusive end (<): previous day's local end-of-day. + const d = new Date(`${dateStr}T23:59:59.999`); + d.setDate(d.getDate() - 1); + d.setHours(23, 59, 59, 999); + return d.toISOString(); +} + +// --------------------------------------------------------------------------- +// Main parser +// --------------------------------------------------------------------------- + +/** + * Try to parse a comparison operator prefix (>=, >, <=, <). + * Returns the parsed TimeRange or null if the value doesn't start with an operator. + */ +function tryParseOperator(value: string): AbsoluteTimeRange | null { + const operators: Array<{ + prefix: string; + position: DatePosition; + field: "start" | "end"; + }> = [ + { prefix: ">=", position: "start", field: "start" }, + { prefix: ">", position: "after", field: "start" }, + { prefix: "<=", position: "end", field: "end" }, + { prefix: "<", position: "before", field: "end" }, + ]; + + for (const op of operators) { + if (!value.startsWith(op.prefix)) { + continue; + } + const dateStr = value.slice(op.prefix.length); + if (dateStr.length === 0) { + throw new ValidationError( + `Missing date after '${op.prefix}'. Expected e.g., '${op.prefix}${EXAMPLE_START}'.`, + "period" + ); + } + const parsed = parseDate(dateStr, op.position); + return { + type: "absolute", + [op.field]: parsed, + } as AbsoluteTimeRange; + } + + return null; +} + +/** + * Try to parse a ".." range expression. + * Returns the parsed TimeRange or null if the value doesn't contain "..". + */ +function tryParseRange(value: string): AbsoluteTimeRange | null { + const dotDotIdx = value.indexOf(".."); + if (dotDotIdx === -1) { + return null; + } + + const left = value.slice(0, dotDotIdx); + const right = value.slice(dotDotIdx + 2); + + if (left.length === 0 && right.length === 0) { + throw new ValidationError( + "Empty range '..'. Provide at least one date " + + `(e.g., '${EXAMPLE_START}..', '..${EXAMPLE_END}', '${EXAMPLE_START}..${EXAMPLE_END}').`, + "period" + ); + } + + const start = left.length > 0 ? parseDate(left, "start") : undefined; + const end = right.length > 0 ? parseDate(right, "end") : undefined; + + // Validate start < end when both are present + if (start && end) { + const startTime = new Date(start).getTime(); + const endTime = new Date(end).getTime(); + if (startTime > endTime) { + throw new ValidationError( + `Start date '${left}' is after end date '${right}'. ` + + "The start must be before the end.", + "period" + ); + } + } + + return { type: "absolute", start, end }; +} + +/** + * Try to parse a relative duration string (e.g., "7d", "24h"). + * Returns the parsed TimeRange or null if the value isn't a valid relative duration. + */ +function tryParseRelative(value: string): RelativeTimeRange | null { + const parts = parseRelativeParts(value); + if (!parts) { + return null; + } + if (parts.value === 0) { + throw new ValidationError( + `Invalid period '${value}': duration cannot be zero.`, + "period" + ); + } + return { type: "relative", period: value }; +} + +/** + * Parse a --period flag value into a TimeRange. + * + * Accepts three syntax families: + * 1. Comparison operators: ">2024-01-01", ">=2024-01-01", "<2024-02-01", "<=2024-02-01" + * 2. Range syntax: "2024-01-01..2024-02-01", "2024-01-01..", "..2024-02-01" + * 3. Relative durations: "7d", "24h", "1h", "30m", "2w" + * + * @throws ValidationError on invalid input + */ +export function parsePeriod(value: string): TimeRange { + const trimmed = value.trim(); + if (trimmed.length === 0) { + throw new ValidationError( + "Empty period value. Use a relative duration (e.g., '7d', '24h') " + + `or a date range (e.g., '${EXAMPLE_START}..${EXAMPLE_END}', '>=${EXAMPLE_START}').`, + "period" + ); + } + + return ( + tryParseOperator(trimmed) ?? + tryParseRange(trimmed) ?? + tryParseRelative(trimmed) ?? + throwInvalidPeriod(trimmed) + ); +} + +/** Throw a helpful error for unrecognized period values. */ +function throwInvalidPeriod(value: string): never { + throw new ValidationError( + `Invalid period '${value}'. Use a relative duration (e.g., '7d', '24h'), ` + + `a date range (e.g., '${EXAMPLE_START}..${EXAMPLE_END}'), ` + + `or a comparison operator (e.g., '>=${EXAMPLE_START}', '<${EXAMPLE_END}').`, + "period" + ); +} + +// --------------------------------------------------------------------------- +// Converters +// --------------------------------------------------------------------------- + +/** + * Convert a parsed TimeRange to Sentry API query parameters. + * + * - Relative → `{ statsPeriod: "7d" }` + * - Absolute → `{ start: "...", end: "..." }` (either may be omitted) + * + * `statsPeriod` and `start`/`end` are mutually exclusive in the Sentry API. + */ +export function timeRangeToApiParams(range: TimeRange): TimeRangeApiParams { + if (range.type === "relative") { + return { statsPeriod: range.period }; + } + const params: TimeRangeApiParams = {}; + if (range.start) { + params.start = range.start; + } + if (range.end) { + params.end = range.end; + } + return params; +} + +/** + * Serialize a TimeRange to a stable string for use in pagination context keys. + * + * All absolute dates are normalized to UTC (via `.toISOString()`) to ensure + * deterministic keys regardless of local timezone. Different user syntaxes that + * resolve to the same boundary produce the same key. + * + * - Relative: `"rel:7d"` + * - Absolute: `"abs:.."` + * - Open-ended: `"abs:.."` or `"abs:.."` + */ +export function serializeTimeRange(range: TimeRange): string { + if (range.type === "relative") { + return `rel:${range.period}`; + } + const startUtc = range.start ? new Date(range.start).toISOString() : ""; + const endUtc = range.end ? new Date(range.end).toISOString() : ""; + return `abs:${startUtc}..${endUtc}`; +} + +/** + * Compute the total duration in seconds for a TimeRange. + * + * - Relative: parses "7d" → 604800 + * - Absolute with both bounds: (end - start) in seconds + * - Open-ended: returns undefined (cannot compute) + * + * Used by dashboard interval computation to select optimal bucket sizes. + */ +export function timeRangeToSeconds(range: TimeRange): number | undefined { + if (range.type === "relative") { + return relativeToSeconds(range.period); + } + + if (range.start && range.end) { + return absoluteRangeToSeconds(range.start, range.end); + } + + // Open-ended range — cannot compute duration + return; +} + +/** Parse a relative period string into seconds. */ +function relativeToSeconds(period: string): number | undefined { + const parts = parseRelativeParts(period); + if (!parts) { + return; + } + const seconds = UNIT_SECONDS[parts.unit]; + return seconds ? parts.value * seconds : undefined; +} + +/** Compute seconds between two ISO-8601 datetime strings. */ +function absoluteRangeToSeconds( + start: string, + end: string +): number | undefined { + const startMs = new Date(start).getTime(); + const endMs = new Date(end).getTime(); + if (Number.isNaN(startMs) || Number.isNaN(endMs)) { + return; + } + return Math.max(0, (endMs - startMs) / 1000); +} diff --git a/test/commands/issue/list.test.ts b/test/commands/issue/list.test.ts index ff4cbf8b0..0b3516c7b 100644 --- a/test/commands/issue/list.test.ts +++ b/test/commands/issue/list.test.ts @@ -914,7 +914,7 @@ describe("issue list: compound cursor resume", () => { // Build the context key matching buildMultiTargetContextKey for a single target const fingerprint = "test-org/proj-a"; - const contextKey = `host:${host}|type:multi:${fingerprint}|sort:date|period:${escapeContextKeyValue("90d")}`; + const contextKey = `host:${host}|type:multi:${fingerprint}|sort:date|period:${escapeContextKeyValue("rel:90d")}`; // Set up pagination state so "last"/"next" resolves to "resume-cursor:0:0" advancePaginationState( PAGINATION_KEY, diff --git a/test/lib/time-range.property.test.ts b/test/lib/time-range.property.test.ts new file mode 100644 index 000000000..4ca777303 --- /dev/null +++ b/test/lib/time-range.property.test.ts @@ -0,0 +1,323 @@ +/** + * Property-based tests for the time-range module. + * + * Tests invariants that should hold for any valid input using fast-check. + */ + +import { describe, expect, test } from "bun:test"; +import { + array, + constantFrom, + date, + assert as fcAssert, + integer, + oneof, + property, +} from "fast-check"; +import { + parsePeriod, + serializeTimeRange, + timeRangeToApiParams, + timeRangeToSeconds, +} from "../../src/lib/time-range.js"; +import { DEFAULT_NUM_RUNS } from "../model-based/helpers.js"; + +// --------------------------------------------------------------------------- +// Arbitraries +// --------------------------------------------------------------------------- + +/** Generate a valid relative period string like "7d", "24h", "1m" */ +const relativePeriodArb = integer({ min: 1, max: 999 }).chain((n) => + constantFrom("m", "h", "d", "w").map((u) => `${n}${u}`) +); + +/** Generate an ISO date string (YYYY-MM-DD) in a sensible range */ +const isoDateArb = date({ + min: new Date("2020-01-02"), + max: new Date("2030-12-30"), + noInvalidDate: true, +}).map((d) => d.toISOString().slice(0, 10)); + +/** Generate a pair of sorted ISO date strings for valid ranges */ +const sortedDatePairArb = array(isoDateArb, { minLength: 2, maxLength: 2 }).map( + (dates) => { + const sorted = [...dates].sort(); + // Ensure they're different (no zero-length range issues) + if (sorted[0] === sorted[1]) { + const d = new Date(sorted[1]!); + d.setDate(d.getDate() + 1); + sorted[1] = d.toISOString().slice(0, 10); + } + return sorted as [string, string]; + } +); + +// --------------------------------------------------------------------------- +// Properties: parsePeriod — relative +// --------------------------------------------------------------------------- + +describe("property: parsePeriod relative", () => { + test("all valid relative periods parse as { type: relative }", () => { + fcAssert( + property(relativePeriodArb, (period) => { + const result = parsePeriod(period); + expect(result.type).toBe("relative"); + if (result.type === "relative") { + expect(result.period).toBe(period); + } + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); + +// --------------------------------------------------------------------------- +// Properties: parsePeriod — range syntax +// --------------------------------------------------------------------------- + +describe("property: parsePeriod range syntax", () => { + test("full range (date..date) parses as absolute with both bounds", () => { + fcAssert( + property(sortedDatePairArb, ([start, end]) => { + const result = parsePeriod(`${start}..${end}`); + expect(result.type).toBe("absolute"); + if (result.type === "absolute") { + expect(result.start).toBeDefined(); + expect(result.end).toBeDefined(); + } + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("open-ended start (date..) parses as absolute with start only", () => { + fcAssert( + property(isoDateArb, (d) => { + const result = parsePeriod(`${d}..`); + expect(result.type).toBe("absolute"); + if (result.type === "absolute") { + expect(result.start).toBeDefined(); + expect(result.end).toBeUndefined(); + } + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("open-ended end (..date) parses as absolute with end only", () => { + fcAssert( + property(isoDateArb, (d) => { + const result = parsePeriod(`..${d}`); + expect(result.type).toBe("absolute"); + if (result.type === "absolute") { + expect(result.start).toBeUndefined(); + expect(result.end).toBeDefined(); + } + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); + +// --------------------------------------------------------------------------- +// Properties: parsePeriod — comparison operators +// --------------------------------------------------------------------------- + +describe("property: parsePeriod operators", () => { + test(">= parses as absolute with start", () => { + fcAssert( + property(isoDateArb, (d) => { + const result = parsePeriod(`>=${d}`); + expect(result.type).toBe("absolute"); + if (result.type === "absolute") { + expect(result.start).toBeDefined(); + expect(result.end).toBeUndefined(); + } + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("> parses as absolute with start (next day for date-only)", () => { + fcAssert( + property(isoDateArb, (d) => { + const result = parsePeriod(`>${d}`); + expect(result.type).toBe("absolute"); + if (result.type === "absolute") { + expect(result.start).toBeDefined(); + expect(result.end).toBeUndefined(); + } + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("<= parses as absolute with end", () => { + fcAssert( + property(isoDateArb, (d) => { + const result = parsePeriod(`<=${d}`); + expect(result.type).toBe("absolute"); + if (result.type === "absolute") { + expect(result.start).toBeUndefined(); + expect(result.end).toBeDefined(); + } + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("< parses as absolute with end (prev day for date-only)", () => { + fcAssert( + property(isoDateArb, (d) => { + const result = parsePeriod(`<${d}`); + expect(result.type).toBe("absolute"); + if (result.type === "absolute") { + expect(result.start).toBeUndefined(); + expect(result.end).toBeDefined(); + } + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("> produces strictly later start than >= for date-only", () => { + fcAssert( + property(isoDateArb, (d) => { + const excl = parsePeriod(`>${d}`); + const incl = parsePeriod(`>=${d}`); + if (excl.type === "absolute" && incl.type === "absolute") { + const exclTime = new Date(excl.start!).getTime(); + const inclTime = new Date(incl.start!).getTime(); + expect(exclTime).toBeGreaterThan(inclTime); + } + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("< produces strictly earlier end than <= for date-only", () => { + fcAssert( + property(isoDateArb, (d) => { + const excl = parsePeriod(`<${d}`); + const incl = parsePeriod(`<=${d}`); + if (excl.type === "absolute" && incl.type === "absolute") { + const exclTime = new Date(excl.end!).getTime(); + const inclTime = new Date(incl.end!).getTime(); + expect(exclTime).toBeLessThan(inclTime); + } + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); + +// --------------------------------------------------------------------------- +// Properties: timeRangeToApiParams — mutual exclusivity +// --------------------------------------------------------------------------- + +describe("property: timeRangeToApiParams mutual exclusivity", () => { + const timeRangeArb = oneof( + relativePeriodArb.map((p) => parsePeriod(p)), + sortedDatePairArb.map(([s, e]) => parsePeriod(`${s}..${e}`)), + isoDateArb.map((d) => parsePeriod(`${d}..`)), + isoDateArb.map((d) => parsePeriod(`..${d}`)), + isoDateArb.map((d) => parsePeriod(`>=${d}`)), + isoDateArb.map((d) => parsePeriod(`<=${d}`)) + ); + + test("statsPeriod and start/end are never both set", () => { + fcAssert( + property(timeRangeArb, (range) => { + const params = timeRangeToApiParams(range); + if (params.statsPeriod) { + expect(params.start).toBeUndefined(); + expect(params.end).toBeUndefined(); + } else { + // At least one of start/end should be defined for absolute + expect(params.start !== undefined || params.end !== undefined).toBe( + true + ); + } + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); + +// --------------------------------------------------------------------------- +// Properties: serialization +// --------------------------------------------------------------------------- + +describe("property: serializeTimeRange", () => { + test("deterministic — same input produces same output", () => { + fcAssert( + property(relativePeriodArb, (period) => { + const range = parsePeriod(period); + expect(serializeTimeRange(range)).toBe(serializeTimeRange(range)); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test(">= and .. produce same serialization for date-only", () => { + fcAssert( + property(isoDateArb, (d) => { + const fromOp = parsePeriod(`>=${d}`); + const fromRange = parsePeriod(`${d}..`); + expect(serializeTimeRange(fromOp)).toBe(serializeTimeRange(fromRange)); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("<= and ..date produce same serialization for date-only", () => { + fcAssert( + property(isoDateArb, (d) => { + const fromOp = parsePeriod(`<=${d}`); + const fromRange = parsePeriod(`..${d}`); + expect(serializeTimeRange(fromOp)).toBe(serializeTimeRange(fromRange)); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); + +// --------------------------------------------------------------------------- +// Properties: timeRangeToSeconds +// --------------------------------------------------------------------------- + +describe("property: timeRangeToSeconds", () => { + test("relative durations produce positive seconds", () => { + fcAssert( + property(relativePeriodArb, (period) => { + const range = parsePeriod(period); + const seconds = timeRangeToSeconds(range); + expect(seconds).toBeDefined(); + expect(seconds).toBeGreaterThan(0); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("full absolute ranges produce non-negative seconds", () => { + fcAssert( + property(sortedDatePairArb, ([start, end]) => { + const range = parsePeriod(`${start}..${end}`); + const seconds = timeRangeToSeconds(range); + expect(seconds).toBeDefined(); + expect(seconds).toBeGreaterThanOrEqual(0); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("open-ended ranges return undefined", () => { + fcAssert( + property(isoDateArb, (d) => { + expect(timeRangeToSeconds(parsePeriod(`${d}..`))).toBeUndefined(); + expect(timeRangeToSeconds(parsePeriod(`..${d}`))).toBeUndefined(); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); diff --git a/test/lib/time-range.test.ts b/test/lib/time-range.test.ts new file mode 100644 index 000000000..b7c13a835 --- /dev/null +++ b/test/lib/time-range.test.ts @@ -0,0 +1,386 @@ +/** + * Unit tests for the time-range module. + * + * Core invariants (round-trips, operator equivalence, mutual exclusivity) + * are tested via property-based tests in time-range.property.test.ts. + * These tests focus on edge cases, validation errors, and specific + * normalization behavior. + */ + +import { describe, expect, test } from "bun:test"; +import { + parseDate, + parsePeriod, + serializeTimeRange, + timeRangeToApiParams, + timeRangeToSeconds, +} from "../../src/lib/time-range.js"; + +// --------------------------------------------------------------------------- +// parsePeriod — relative durations (backward compatibility) +// --------------------------------------------------------------------------- + +describe("parsePeriod: relative durations", () => { + const validPeriods = [ + "1m", + "30m", + "1h", + "24h", + "7d", + "14d", + "30d", + "90d", + "1w", + "2w", + ]; + + for (const period of validPeriods) { + test(`parses "${period}" as relative`, () => { + const result = parsePeriod(period); + expect(result).toEqual({ type: "relative", period }); + }); + } + + test("rejects zero duration", () => { + expect(() => parsePeriod("0d")).toThrow("cannot be zero"); + expect(() => parsePeriod("0h")).toThrow("cannot be zero"); + expect(() => parsePeriod("0m")).toThrow("cannot be zero"); + }); + + test("rejects invalid units", () => { + expect(() => parsePeriod("7x")).toThrow("Invalid period"); + expect(() => parsePeriod("24z")).toThrow("Invalid period"); + }); + + test("rejects empty string", () => { + expect(() => parsePeriod("")).toThrow("Empty period"); + expect(() => parsePeriod(" ")).toThrow("Empty period"); + }); + + test("rejects bare date without operator or range", () => { + expect(() => parsePeriod("2024-01-01")).toThrow("Invalid period"); + }); +}); + +// --------------------------------------------------------------------------- +// parsePeriod — ".." range syntax +// --------------------------------------------------------------------------- + +describe("parsePeriod: range syntax", () => { + test('parses full range "start..end"', () => { + const result = parsePeriod("2024-01-01..2024-02-01"); + expect(result.type).toBe("absolute"); + if (result.type === "absolute") { + expect(result.start).toContain("2024-01-01"); + expect(result.end).toContain("2024-02-01"); + } + }); + + test('parses open-ended start "date.."', () => { + const result = parsePeriod("2024-06-01.."); + expect(result.type).toBe("absolute"); + if (result.type === "absolute") { + expect(result.start).toContain("2024-06-01"); + expect(result.end).toBeUndefined(); + } + }); + + test('parses open-ended end "..date"', () => { + const result = parsePeriod("..2024-03-01"); + expect(result.type).toBe("absolute"); + if (result.type === "absolute") { + expect(result.start).toBeUndefined(); + expect(result.end).toContain("2024-03-01"); + } + }); + + test('rejects bare ".."', () => { + expect(() => parsePeriod("..")).toThrow("Empty range"); + }); + + test("rejects end before start", () => { + expect(() => parsePeriod("2024-06-01..2024-01-01")).toThrow( + "is after end date" + ); + }); + + test("rejects invalid dates in range", () => { + expect(() => parsePeriod("not-a-date..2024-01-01")).toThrow("Invalid"); + expect(() => parsePeriod("2024-01-01..not-a-date")).toThrow("Invalid"); + }); + + test('rejects mixed relative in range "7d..14d"', () => { + expect(() => parsePeriod("7d..14d")).toThrow("Invalid"); + }); +}); + +// --------------------------------------------------------------------------- +// parsePeriod — comparison operators +// --------------------------------------------------------------------------- + +describe("parsePeriod: comparison operators", () => { + test('">=date" returns absolute with start', () => { + const result = parsePeriod(">=2024-01-15"); + expect(result.type).toBe("absolute"); + if (result.type === "absolute") { + expect(result.start).toContain("2024-01-15T00:00:00"); + expect(result.end).toBeUndefined(); + } + }); + + test('">date" returns absolute with next-day start', () => { + const result = parsePeriod(">2024-01-15"); + expect(result.type).toBe("absolute"); + if (result.type === "absolute") { + expect(result.start).toContain("2024-01-16T00:00:00"); + expect(result.end).toBeUndefined(); + } + }); + + test('"<=date" returns absolute with end', () => { + const result = parsePeriod("<=2024-02-01"); + expect(result.type).toBe("absolute"); + if (result.type === "absolute") { + expect(result.start).toBeUndefined(); + expect(result.end).toContain("2024-02-01T23:59:59"); + } + }); + + test('" { + const result = parsePeriod("<2024-02-01"); + expect(result.type).toBe("absolute"); + if (result.type === "absolute") { + expect(result.start).toBeUndefined(); + expect(result.end).toContain("2024-01-31T23:59:59"); + } + }); + + test('"> exclusive" differs from ">= inclusive" for date-only', () => { + const exclusive = parsePeriod(">2024-01-15"); + const inclusive = parsePeriod(">=2024-01-15"); + if (exclusive.type === "absolute" && inclusive.type === "absolute") { + // Exclusive start should be strictly later than inclusive start + expect(new Date(exclusive.start!).getTime()).toBeGreaterThan( + new Date(inclusive.start!).getTime() + ); + } + }); + + test('"< exclusive" differs from "<= inclusive" for date-only', () => { + const exclusive = parsePeriod("<2024-02-01"); + const inclusive = parsePeriod("<=2024-02-01"); + if (exclusive.type === "absolute" && inclusive.type === "absolute") { + // Exclusive end should be strictly earlier than inclusive end + expect(new Date(exclusive.end!).getTime()).toBeLessThan( + new Date(inclusive.end!).getTime() + ); + } + }); + + test("rejects operator without date", () => { + expect(() => parsePeriod(">")).toThrow("Missing date"); + expect(() => parsePeriod(">=")).toThrow("Missing date"); + expect(() => parsePeriod("<")).toThrow("Missing date"); + expect(() => parsePeriod("<=")).toThrow("Missing date"); + }); + + test("datetime with operator passes through as-is (no day shift)", () => { + const result = parsePeriod(">2024-01-01T12:00:00Z"); + if (result.type === "absolute") { + // Datetime with TZ → no next-day shift, converted to ISO + expect(result.start).toBe("2024-01-01T12:00:00.000Z"); + } + }); +}); + +// --------------------------------------------------------------------------- +// parseDate — timezone handling +// --------------------------------------------------------------------------- + +describe("parseDate: output format", () => { + test("date-only outputs UTC ISO string via local interpretation", () => { + const result = parseDate("2024-06-15", "start"); + // Output is always .toISOString() format (UTC, ends with Z) + expect(result).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/); + expect(Number.isNaN(new Date(result).getTime())).toBe(false); + // Should represent local midnight of 2024-06-15 converted to UTC + const d = new Date("2024-06-15T00:00:00"); // local time + expect(result).toBe(d.toISOString()); + }); + + test("datetime with Z is normalized to ISO", () => { + const result = parseDate("2024-06-15T12:00:00Z", "start"); + expect(result).toBe("2024-06-15T12:00:00.000Z"); + }); + + test("datetime with +offset is converted to UTC", () => { + const result = parseDate("2024-06-15T12:00:00+05:30", "start"); + // +05:30 means 12:00 local = 06:30 UTC + expect(result).toBe("2024-06-15T06:30:00.000Z"); + }); + + test("datetime with -offset is converted to UTC", () => { + const result = parseDate("2024-06-15T12:00:00-08:00", "start"); + // -08:00 means 12:00 local = 20:00 UTC + expect(result).toBe("2024-06-15T20:00:00.000Z"); + }); + + test("datetime without TZ is treated as local time", () => { + const result = parseDate("2024-06-15T12:00:00", "start"); + // new Date() without Z treats as local time + const expected = new Date("2024-06-15T12:00:00").toISOString(); + expect(result).toBe(expected); + }); + + test("space separator is accepted as T alternative", () => { + const withSpace = parseDate("2024-06-15 12:00:00Z", "start"); + const withT = parseDate("2024-06-15T12:00:00Z", "start"); + expect(withSpace).toBe(withT); + }); + + test("rejects invalid date", () => { + expect(() => parseDate("not-a-date", "start")).toThrow("Invalid"); + }); + + test("rejects empty string", () => { + expect(() => parseDate("", "start")).toThrow("Empty date"); + }); +}); + +// --------------------------------------------------------------------------- +// timeRangeToApiParams +// --------------------------------------------------------------------------- + +describe("timeRangeToApiParams", () => { + test("relative → statsPeriod only", () => { + const params = timeRangeToApiParams({ type: "relative", period: "7d" }); + expect(params).toEqual({ statsPeriod: "7d" }); + expect(params.start).toBeUndefined(); + expect(params.end).toBeUndefined(); + }); + + test("absolute full range → start + end, no statsPeriod", () => { + const params = timeRangeToApiParams({ + type: "absolute", + start: "2024-01-01T00:00:00Z", + end: "2024-02-01T23:59:59Z", + }); + expect(params.statsPeriod).toBeUndefined(); + expect(params.start).toBe("2024-01-01T00:00:00Z"); + expect(params.end).toBe("2024-02-01T23:59:59Z"); + }); + + test("absolute start-only → start, no end", () => { + const params = timeRangeToApiParams({ + type: "absolute", + start: "2024-01-01T00:00:00Z", + }); + expect(params.start).toBe("2024-01-01T00:00:00Z"); + expect(params.end).toBeUndefined(); + expect(params.statsPeriod).toBeUndefined(); + }); + + test("absolute end-only → end, no start", () => { + const params = timeRangeToApiParams({ + type: "absolute", + end: "2024-02-01T23:59:59Z", + }); + expect(params.end).toBe("2024-02-01T23:59:59Z"); + expect(params.start).toBeUndefined(); + expect(params.statsPeriod).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// serializeTimeRange +// --------------------------------------------------------------------------- + +describe("serializeTimeRange", () => { + test("relative → 'rel:7d'", () => { + expect(serializeTimeRange({ type: "relative", period: "7d" })).toBe( + "rel:7d" + ); + }); + + test("absolute full range → 'abs:start..end' in UTC", () => { + const result = serializeTimeRange({ + type: "absolute", + start: "2024-01-01T00:00:00Z", + end: "2024-02-01T23:59:59Z", + }); + expect(result).toMatch(/^abs:.*\.\..*$/); + // UTC normalization + expect(result).toContain("2024-01-01T00:00:00.000Z"); + }); + + test("open-ended start → 'abs:start..'", () => { + const result = serializeTimeRange({ + type: "absolute", + start: "2024-06-01T00:00:00Z", + }); + expect(result).toMatch(/^abs:.*\.\.$/); + }); + + test("open-ended end → 'abs:..end'", () => { + const result = serializeTimeRange({ + type: "absolute", + end: "2024-03-01T23:59:59Z", + }); + expect(result).toMatch(/^abs:\.\..*$/); + }); + + test("operator equivalences serialize identically", () => { + // >=2024-01-01 and 2024-01-01.. should produce the same serialization + const fromOp = parsePeriod(">=2024-01-01"); + const fromRange = parsePeriod("2024-01-01.."); + expect(serializeTimeRange(fromOp)).toBe(serializeTimeRange(fromRange)); + }); +}); + +// --------------------------------------------------------------------------- +// timeRangeToSeconds +// --------------------------------------------------------------------------- + +describe("timeRangeToSeconds", () => { + test("relative 7d → 604800", () => { + expect(timeRangeToSeconds({ type: "relative", period: "7d" })).toBe( + 604_800 + ); + }); + + test("relative 24h → 86400", () => { + expect(timeRangeToSeconds({ type: "relative", period: "24h" })).toBe( + 86_400 + ); + }); + + test("relative 1w → 604800", () => { + expect(timeRangeToSeconds({ type: "relative", period: "1w" })).toBe( + 604_800 + ); + }); + + test("absolute 7-day range → 604800", () => { + const result = timeRangeToSeconds({ + type: "absolute", + start: "2024-01-01T00:00:00Z", + end: "2024-01-08T00:00:00Z", + }); + expect(result).toBe(604_800); + }); + + test("open-ended range → undefined", () => { + expect( + timeRangeToSeconds({ + type: "absolute", + start: "2024-01-01T00:00:00Z", + }) + ).toBeUndefined(); + expect( + timeRangeToSeconds({ + type: "absolute", + end: "2024-02-01T00:00:00Z", + }) + ).toBeUndefined(); + }); +});