# Files changed · PR \#107 · flambo/atlantis

[View on GitCafe](https://git.cafe/flambo/atlantis/pull/107/diffs)

Repository: [flambo/atlantis](https://git.cafe/flambo/atlantis)

Visibility: public

[feat: add 10-minute granularity](https://git.cafe/flambo/atlantis/pull/107.md)

Version: 0

Head commit: 478218db51cfbc59d7d0fc4f6353d6ab2d156689

Base commit: 54105be7b9a1714f6553b3306292cad7876de2ff

Comparison base: 54105be7b9a1714f6553b3306292cad7876de2ff

Showing up to 20 entries from this revision. If the pull request changes, reopen the first page before continuing.

## apps/web/src/lib/components/charts/BreakdownChart.svelte

Status: modified; +4; −2

```diff
@@ -68,13 +68,15 @@
 		'1d': 'date',
 		'1h': 'hour',
 		'30m': '30min',
+		'10m': '10min',
 		'5m': '5min'
 	};
 
 	const GROUP_BY_TRANSITIONS: Record<GroupByOption, GroupByOption | null> = {
 		date: 'hour',
+		hour: '10min',
-		hour: '30min',
 		'30min': '5min',
+		'10min': '5min',
 		'5min': null
 	};
 
@@ -665,7 +667,7 @@
 			const rangeStart = new Date(clickedDate.getTime() - 3 * 24 * 60 * 60 * 1000);
 			const rangeEnd = new Date(clickedDate.getTime() + 4 * 24 * 60 * 60 * 1000);
 			emitDrilldown(nextGroupBy, rangeStart, rangeEnd);
+		} else if (groupBy === '30min' || groupBy === '10min') {
-		} else if (groupBy === '30min') {
 			const rangeEnd = new Date(clickedDate.getTime() + 24 * 60 * 60 * 1000);
 			emitDrilldown(nextGroupBy, clickedDate, rangeEnd);
 		}
```

## apps/web/src/lib/components/charts/ChartContainer.svelte

Status: modified; +4; −4

```diff
@@ -178,7 +178,7 @@
 			return hours === 0 ? `${weekday} ${month}/${day}` : '';
 		}
 
-		if (currentGroupBy === '30min') {
+		if (currentGroupBy === '30min' || currentGroupBy === '10min') {
 			if (minutes === 0 && (hours === 0 || hours === 12)) {
 				return `${weekday} ${month}/${day} ${hours.toString().padStart(2, '0')}:00`;
 			}
@@ -212,7 +212,7 @@
 		if (currentGroupBy === 'hour') {
 			return hours === 0;
 		}
-		if (currentGroupBy === '30min') {
+		if (currentGroupBy === '30min' || currentGroupBy === '10min') {
 			return minutes === 0 && (hours === 0 || hours === 12);
 		}
 		if (currentGroupBy === '5min') {
@@ -368,8 +368,8 @@
 			const endOfWeek = new Date(clickedDate.getTime() + 4 * 24 * 60 * 60 * 1000);
 			const startDateStr = formatDateAsPSTDateString(startOfWeek);
 			const endDateStr = formatDateAsPSTDateString(endOfWeek);
-			onDrillDown?.('30min', startDateStr, endDateStr);
-		} else if (groupBy === '30min') {
+			onDrillDown?.('10min', startDateStr, endDateStr);
+		} else if (groupBy === '30min' || groupBy === '10min') {
 			const endDate = new Date(clickedDate.getTime() + 24 * 60 * 60 * 1000);
 			const startDateStr = formatDateAsPSTDateString(clickedDate);
 			const endDateStr = formatDateAsPSTDateString(endDate);
```

## apps/web/src/lib/components/charts/FlowCharacteristicsChart.svelte

Status: modified; +1; −0

```diff
@@ -25,6 +25,7 @@
 		date: '1d',
 		hour: '1h',
 		'30min': '30m',
+		'10min': '10m',
 		'5min': '5m'
 	};
 	const IP_FAMILY_OPTIONS: Array<{ value: NetflowIpFamily; label: string }> = [
```

## apps/web/src/lib/components/charts/MetricLinePanel.svelte

Status: modified; +1; −1

```diff
@@ -210,7 +210,7 @@
 			);
 		} else if (props.groupBy === 'hour') {
 			props.onDrillDown(
-				'30min',
+				'10min',
 				formatDateAsPSTDateString(new Date(clickedDate.getTime() - 3 * 24 * 60 * 60 * 1000)),
 				formatDateAsPSTDateString(new Date(clickedDate.getTime() + 4 * 24 * 60 * 60 * 1000))
 			);
```

## apps/web/src/lib/components/charts/PortCardinalityChart.svelte

Status: modified; +1; −0

```diff
@@ -32,6 +32,7 @@
 		date: '1d',
 		hour: '1h',
 		'30min': '30m',
+		'10min': '10m',
 		'5min': '5m'
 	};
 	const PORT_COLORS: Record<`${PortSide}-${PortRange}`, string> = {
```

## apps/web/src/lib/components/charts/chart-utils.ts

Status: modified; +14; −6

```diff
@@ -253,6 +253,7 @@
 			case 'hour':
 				return `${year}-${month}-${day} ${hours}:00`;
 			case '30min':
+			case '10min':
 			case '5min':
 				return `${year}-${month}-${day} ${hours}:${minutes}`;
 			default:
@@ -294,6 +295,8 @@
 			return 'Hour';
 		case '30min':
 			return '30 Minutes';
+		case '10min':
+			return '10 Minutes';
 		case '5min':
 			return '5 Minutes';
 		default:
@@ -359,6 +362,7 @@
 	if (groupBy === 'date') return 24 * 60 * 60 * 1000;
 	if (groupBy === 'hour') return 60 * 60 * 1000;
 	if (groupBy === '30min') return 30 * 60 * 1000;
+	if (groupBy === '10min') return 10 * 60 * 1000;
 	return 5 * 60 * 1000;
 }
 
@@ -366,7 +370,8 @@
 	date: 0,
 	hour: 1,
 	'30min': 2,
-	'5min': 3
+	'10min': 3,
+	'5min': 4
 };
 
 function parseDateInputToUtcMs(value: string): number | null {
@@ -393,20 +398,23 @@
 
 /**
  * Choose granularity from selected range duration.
+ * Click drilldown windows land on the same choices:
- * Thresholds are calibrated from existing click drilldown windows:
- * 1 day -> 5min, 7 days -> 30min, 31 days -> hour.
+ * 1 day -> 5min, 7 days -> 10min, 31 days -> hour.
+ * Cutoffs: 5min up to 4 days, 10min up to 8 days, 30min up to 19 days,
+ * hour up to 62 days, then date.
  */
 export function chooseAdaptiveGranularity(rangeMs: number): GroupByOption {
 	const oneDay = 24 * 60 * 60 * 1000;
 	const sevenDays = 7 * oneDay;
 	const thirtyOneDays = 31 * oneDay;
 
-	// Midpoints between known-good drilldown windows.
 	const fiveMinCutoff = (oneDay + sevenDays) / 2; // 4 days
+	const tenMinCutoff = fiveMinCutoff * 2;
 	const thirtyMinCutoff = (sevenDays + thirtyOneDays) / 2; // 19 days
 	const hourCutoff = thirtyOneDays * 2; // 62 days
 
 	if (rangeMs <= fiveMinCutoff) return '5min';
+	if (rangeMs <= tenMinCutoff) return '10min';
 	if (rangeMs <= thirtyMinCutoff) return '30min';
 	if (rangeMs <= hourCutoff) return 'hour';
 	return 'date';
@@ -481,7 +489,7 @@
 		return hours === 0 ? `${weekday} ${month}/${day}` : '';
 	}
 
+	if (groupBy === '30min' || groupBy === '10min') {
-	if (groupBy === '30min') {
 		if (minutes === 0 && (hours === 0 || hours === 12)) {
 			return `${weekday} ${month}/${day} ${hours.toString().padStart(2, '0')}:00`;
 		}
@@ -516,7 +524,7 @@
 	if (groupBy === 'hour') {
 		return hours === 0;
 	}
-	if (groupBy === '30min') {
+	if (groupBy === '30min' || groupBy === '10min') {
 		return minutes === 0 && (hours === 0 || hours === 12);
 	}
 	if (groupBy === '5min') {
```

## apps/web/src/lib/components/charts/flow-characteristics-data.svelte.ts

Status: modified; +1; −0

```diff
@@ -46,6 +46,7 @@
 	date: '1d',
 	hour: '1h',
 	'30min': '30m',
+	'10min': '10m',
 	'5min': '5m'
 };
 
```

## apps/web/src/lib/components/charts/ip-time-axis.ts

Status: modified; +2; −2

```diff
@@ -36,7 +36,7 @@
 		return hours === 0 ? `${weekday} ${pst.month}/${pst.day}` : '';
 	}
 
-	if (granularity === '30m') {
+	if (granularity === '30m' || granularity === '10m') {
 		if (minutes === 0 && (hours === 0 || hours === 12)) {
 			return `${weekday} ${pst.month}/${pst.day} ${hours.toString().padStart(2, '0')}:00`;
 		}
@@ -67,7 +67,7 @@
 	if (granularity === '1h') {
 		return hours === 0;
 	}
-	if (granularity === '30m') {
+	if (granularity === '30m' || granularity === '10m') {
 		return minutes === 0 && (hours === 0 || hours === 12);
 	}
 	if (granularity === '5m') {
```

## apps/web/src/lib/components/filters/PrimaryFilters.svelte

Status: modified; +1; −0

```diff
@@ -18,6 +18,7 @@
 		{ value: 'date', label: 'Day' },
 		{ value: 'hour', label: 'Hour' },
 		{ value: '30min', label: '30 min' },
+		{ value: '10min', label: '10 min' },
 		{ value: '5min', label: '5 min' }
 	];
 
```

## apps/web/src/lib/components/netflow/DatasetDashboardPage.svelte

Status: modified; +1; −0

```diff
@@ -137,6 +137,7 @@
 		date: '1d',
 		hour: '1h',
 		'30min': '30m',
+		'10min': '10m',
 		'5min': '5m'
 	};
 
```

## apps/web/src/lib/components/netflow/types.ts

Status: modified; +1; −1

```diff
@@ -12,7 +12,7 @@
 	[key: string]: boolean;
 }
 
-export type GroupByOption = 'date' | 'hour' | '30min' | '5min';
+export type GroupByOption = 'date' | 'hour' | '30min' | '10min' | '5min';
 
 export type ChartTypeOption = 'stacked' | 'line';
 
```

## apps/web/src/lib/schemas.ts

Status: modified; +1; −1

```diff
@@ -7,7 +7,7 @@
 	return z.object({
 		startDate: z.iso.date().default(defaultStartDate),
 		endDate: z.iso.date().default(new Date().toJSON().slice(0, 10)),
-		groupBy: z.enum(['date', 'hour', '30min', '5min']).default('date'),
+		groupBy: z.enum(['date', 'hour', '30min', '10min', '5min']).default('date'),
 		srcVisibility: z.enum(FLOW_VISIBILITIES).default('all'),
 		dstVisibility: z.enum(FLOW_VISIBILITIES).default('all')
 	});
```

## apps/web/src/lib/server/db/coverage.ts

Status: modified; +1; −0

```diff
@@ -10,6 +10,7 @@
 
 const GRANULARITY_SECONDS: Record<IpGranularity, number> = {
 	'5m': 5 * 60,
+	'10m': 10 * 60,
 	'30m': 30 * 60,
 	'1h': 60 * 60,
 	'1d': 24 * 60 * 60
```

## apps/web/src/lib/server/db/local-schema.ts

Status: modified; +6; −6

```diff
@@ -30,7 +30,7 @@
 
 	CREATE TABLE IF NOT EXISTS bucket_coverage (
 		source_id TEXT NOT NULL,
-		granularity TEXT NOT NULL CHECK(granularity IN ('5m', '30m', '1h', '1d')),
+		granularity TEXT NOT NULL CHECK(granularity IN ('5m', '10m', '30m', '1h', '1d')),
 		bucket_start INTEGER NOT NULL,
 		bucket_end INTEGER NOT NULL CHECK(bucket_end > bucket_start),
 		coverage_state TEXT NOT NULL CHECK(coverage_state IN ('complete', 'partial', 'unknown')),
@@ -49,7 +49,7 @@
 
 	CREATE TABLE IF NOT EXISTS traffic_stats (
 		source_id TEXT NOT NULL,
-		granularity TEXT NOT NULL CHECK(granularity IN ('5m', '30m', '1h', '1d')),
+		granularity TEXT NOT NULL CHECK(granularity IN ('5m', '10m', '30m', '1h', '1d')),
 		bucket_start INTEGER NOT NULL,
 		bucket_end INTEGER NOT NULL,
 		ip_version INTEGER NOT NULL CHECK(ip_version IN (4, 6)),
@@ -85,7 +85,7 @@
 
 	CREATE TABLE IF NOT EXISTS protocol_stats (
 		source_id TEXT NOT NULL,
+		granularity TEXT NOT NULL CHECK(granularity IN ('5m', '10m', '30m', '1h', '1d')),
-		granularity TEXT NOT NULL CHECK(granularity IN ('5m', '30m', '1h', '1d')),
 		bucket_start INTEGER NOT NULL,
 		bucket_end INTEGER NOT NULL,
 		ip_version INTEGER NOT NULL CHECK(ip_version IN (4, 6)),
@@ -99,7 +99,7 @@
 
 	CREATE TABLE IF NOT EXISTS address_count_stats (
 		source_id TEXT NOT NULL,
-		granularity TEXT NOT NULL CHECK(granularity IN ('5m', '30m', '1h', '1d')),
+		granularity TEXT NOT NULL CHECK(granularity IN ('5m', '10m', '30m', '1h', '1d')),
 		bucket_start INTEGER NOT NULL,
 		bucket_end INTEGER NOT NULL,
 		ip_version INTEGER NOT NULL CHECK(ip_version IN (4, 6)),
@@ -113,7 +113,7 @@
 
 	CREATE TABLE IF NOT EXISTS address_structure_stats (
 		source_id TEXT NOT NULL,
-		granularity TEXT NOT NULL CHECK(granularity IN ('5m', '30m', '1h', '1d')),
+		granularity TEXT NOT NULL CHECK(granularity IN ('5m', '10m', '30m', '1h', '1d')),
 		bucket_start INTEGER NOT NULL,
 		bucket_end INTEGER NOT NULL,
 		ip_version INTEGER NOT NULL CHECK(ip_version IN (4, 6)),
@@ -132,7 +132,7 @@
 
 	CREATE TABLE IF NOT EXISTS port_count_stats (
 		source_id TEXT NOT NULL,
+		granularity TEXT NOT NULL CHECK(granularity IN ('5m', '10m', '30m', '1h', '1d')),
-		granularity TEXT NOT NULL CHECK(granularity IN ('5m', '30m', '1h', '1d')),
 		bucket_start INTEGER NOT NULL,
 		bucket_end INTEGER NOT NULL,
 		ip_version INTEGER NOT NULL CHECK(ip_version IN (4, 6)),
```

## apps/web/src/lib/server/db/schema.ts

Status: modified; +6; −6

```diff
@@ -64,7 +64,7 @@
 	'bucket_coverage',
 	{
 		sourceId: text('source_id').notNull(),
-		granularity: text('granularity', { enum: ['5m', '30m', '1h', '1d'] }).notNull(),
+		granularity: text('granularity', { enum: ['5m', '10m', '30m', '1h', '1d'] }).notNull(),
 		bucketStart: integer('bucket_start').notNull(),
 		bucketEnd: integer('bucket_end').notNull(),
 		coverageState: text('coverage_state', {
@@ -127,7 +127,7 @@
 	'traffic_stats',
 	{
 		sourceId: text('source_id').notNull(),
-		granularity: text('granularity', { enum: ['5m', '30m', '1h', '1d'] }).notNull(),
+		granularity: text('granularity', { enum: ['5m', '10m', '30m', '1h', '1d'] }).notNull(),
 		bucketStart: integer('bucket_start').notNull(),
 		bucketEnd: integer('bucket_end').notNull(),
 		ipVersion: integer('ip_version').notNull(),
@@ -170,7 +170,7 @@
 	'protocol_stats',
 	{
 		sourceId: text('source_id').notNull(),
+		granularity: text('granularity', { enum: ['5m', '10m', '30m', '1h', '1d'] }).notNull(),
-		granularity: text('granularity', { enum: ['5m', '30m', '1h', '1d'] }).notNull(),
 		bucketStart: integer('bucket_start').notNull(),
 		bucketEnd: integer('bucket_end').notNull(),
 		ipVersion: integer('ip_version').notNull(),
@@ -206,7 +206,7 @@
 	'address_count_stats',
 	{
 		sourceId: text('source_id').notNull(),
-		granularity: text('granularity', { enum: ['5m', '30m', '1h', '1d'] }).notNull(),
+		granularity: text('granularity', { enum: ['5m', '10m', '30m', '1h', '1d'] }).notNull(),
 		bucketStart: integer('bucket_start').notNull(),
 		bucketEnd: integer('bucket_end').notNull(),
 		ipVersion: integer('ip_version').notNull(),
@@ -252,7 +252,7 @@
 	'port_count_stats',
 	{
 		sourceId: text('source_id').notNull(),
-		granularity: text('granularity', { enum: ['5m', '30m', '1h', '1d'] }).notNull(),
+		granularity: text('granularity', { enum: ['5m', '10m', '30m', '1h', '1d'] }).notNull(),
 		bucketStart: integer('bucket_start').notNull(),
 		bucketEnd: integer('bucket_end').notNull(),
 		ipVersion: integer('ip_version').notNull(),
@@ -291,7 +291,7 @@
 	'address_structure_stats',
 	{
 		sourceId: text('source_id').notNull(),
+		granularity: text('granularity', { enum: ['5m', '10m', '30m', '1h', '1d'] }).notNull(),
-		granularity: text('granularity', { enum: ['5m', '30m', '1h', '1d'] }).notNull(),
 		bucketStart: integer('bucket_start').notNull(),
 		bucketEnd: integer('bucket_end').notNull(),
 		ipVersion: integer('ip_version').notNull(),
```

## apps/web/src/lib/server/netflow-v3.ts

Status: modified; +1; −10

```diff
@@ -283,19 +283,10 @@
 	if (groupBy === 'date') return '1d';
 	if (groupBy === 'hour') return '1h';
 	if (groupBy === '30min') return '30m';
+	if (groupBy === '10min') return '10m';
 	return FIVE_MINUTE_GRANULARITY;
 }
 
-export function getBucketStartQuery(columnName: string, groupBy: string): string {
-	const granularity = groupByToGranularity(groupBy);
-	if (granularity === '5m') {
-		return columnName;
-	}
-
-	const bucketSize = granularity === '30m' ? 1800 : granularity === '1h' ? 3600 : 86400;
-	return `(CAST(strftime('%s', datetime(${columnName}, 'unixepoch', 'localtime', 'start of day', 'utc', printf('+%d seconds', ((CAST(strftime('%s', datetime(${columnName}, 'unixepoch', 'localtime')) AS integer) - CAST(strftime('%s', datetime(${columnName}, 'unixepoch', 'localtime', 'start of day')) AS integer)) / ${bucketSize}) * ${bucketSize}))) AS integer))`;
-}
-
 export function normalizeStructurePoints(
 	points: RawStructureFunctionPoint[]
 ): StructureFunctionPoint[] {
```

## apps/web/src/lib/types/types.ts

Status: modified; +1; −1

```diff
@@ -232,7 +232,7 @@
 	routers: NetflowFileDetailsRouter[];
 }
 
-export const IP_GRANULARITIES = ['5m', '30m', '1h', '1d'] as const;
+export const IP_GRANULARITIES = ['5m', '10m', '30m', '1h', '1d'] as const;
 
 export type IpGranularity = (typeof IP_GRANULARITIES)[number];
 export const FLOW_VISIBILITIES = ['all', 'literal', 'anonymized'] as const;
```

## apps/web/src/routes/api/netflow/coverage/\+server.ts

Status: modified; +1; −0

```diff
@@ -17,6 +17,7 @@
 
 const GRANULARITY_SECONDS: Record<Exclude<IpGranularity, '1d'>, number> = {
 	'5m': 5 * 60,
+	'10m': 10 * 60,
 	'30m': 30 * 60,
 	'1h': 60 * 60
 };
```

## apps/web/tests/lib/chart-utils.test.ts

Status: modified; +10; −1

```diff
@@ -22,11 +22,20 @@
 	});
 
 	it('disables 5 minute granularity once the range exceeds the adaptive cutoff', () => {
-		expect(getMaxAllowedGranularityForDateRange('2026-03-01', '2026-03-05')).toBe('30min');
+		expect(getMaxAllowedGranularityForDateRange('2026-03-01', '2026-03-05')).toBe('10min');
 		expect(isGranularityAllowedForDateRange('5min', '2026-03-01', '2026-03-05')).toBe(false);
+		expect(isGranularityAllowedForDateRange('10min', '2026-03-01', '2026-03-05')).toBe(true);
 		expect(isGranularityAllowedForDateRange('30min', '2026-03-01', '2026-03-05')).toBe(true);
 	});
 
+	it('allows 10 minute granularity up to eight days', () => {
+		expect(getMaxAllowedGranularityForDateRange('2026-03-01', '2026-03-08')).toBe('10min');
+		expect(isGranularityAllowedForDateRange('10min', '2026-03-01', '2026-03-08')).toBe(true);
+		expect(getMaxAllowedGranularityForDateRange('2026-03-01', '2026-03-09')).toBe('30min');
+		expect(isGranularityAllowedForDateRange('10min', '2026-03-01', '2026-03-09')).toBe(false);
+		expect(clampGroupByToDateRange('10min', '2026-03-01', '2026-03-09')).toBe('30min');
+	});
+
 	it('clamps an invalid selection to the finest allowed granularity', () => {
 		expect(clampGroupByToDateRange('5min', '2026-03-01', '2026-03-25')).toBe('hour');
 		expect(clampGroupByToDateRange('30min', '2026-03-01', '2026-06-15')).toBe('date');
```

## apps/web/tests/lib/schemas.test.ts

Status: added; +16; −0

```diff
@@ -1,0 +1,16 @@
+import { describe, expect, it } from 'vitest';
+import { createDateRangeSearchSchema } from '../../src/lib/schemas';
+
+describe('date range search params', () => {
+	const schema = createDateRangeSearchSchema('2026-03-01');
+
+	it('accepts every dashboard grouping including 10 minutes', () => {
+		for (const groupBy of ['date', 'hour', '30min', '10min', '5min']) {
+			expect(schema.parse({ groupBy }).groupBy).toBe(groupBy);
+		}
+	});
+
+	it('rejects stored granularity spellings as groupings', () => {
+		expect(schema.safeParse({ groupBy: '10m' }).success).toBe(false);
+	});
+});
```

[Next page](https://git.cafe/flambo/atlantis/pull/107/diffs?after=ImFwcHMvd2ViL3Rlc3RzL2xpYi9zY2hlbWFzLnRlc3QudHMi&expectedVersion=0&format=markdown)
