summaryrefslogtreecommitdiffhomepage
path: root/packages/throughput-store/src/period.ts
blob: d8225f85e4281340535d4306a39043ae5d2b7ebd (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
/**
 * Pure period math for throughput aggregation.
 *
 * All boundaries are computed in the SERVER'S LOCAL timezone (a single-operator
 * self-hosted assumption):
 *   - day:   `YYYY-MM-DD` → [local midnight, next local midnight)
 *   - week:  `YYYY-MM-DD` → the ISO week (Mon–Sun) CONTAINING that date
 *   - month: `YYYY-MM`    → [first of month, first of next month)
 *
 * A resolved period yields the half-open `[start, end)` epoch-ms range plus the
 * list of local `YYYY-MM-DD` day keys it spans (used to address the per-day
 * sample buckets in storage).
 */

export type Period = "day" | "week" | "month";

export interface ResolvedPeriod {
	readonly ok: true;
	/** Inclusive start, epoch-ms (local midnight). */
	readonly start: number;
	/** Exclusive end, epoch-ms (local midnight). */
	readonly end: number;
	/** Local `YYYY-MM-DD` day keys this period spans, in order. */
	readonly dayKeys: readonly string[];
	/** The normalized input date string. */
	readonly date: string;
}

export interface PeriodError {
	readonly ok: false;
	readonly error: string;
}

function pad2(n: number): string {
	return n < 10 ? `0${n}` : String(n);
}

/** Local `YYYY-MM-DD` key for an epoch-ms timestamp. */
export function dayKeyOf(ts: number): string {
	const d = new Date(ts);
	return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
}

/** Local `YYYY-MM-DD` key for a local calendar date. */
function dayKey(year: number, monthIndex: number, day: number): string {
	const d = new Date(year, monthIndex, day);
	return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
}

function parseYmd(date: string): { y: number; m: number; d: number } | null {
	if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) return null;
	const [y, m, d] = date.split("-").map(Number) as [number, number, number];
	if (m < 1 || m > 12 || d < 1 || d > 31) return null;
	// Reject impossible dates (e.g. 2026-02-30) by round-tripping through Date.
	const probe = new Date(y, m - 1, d);
	if (probe.getFullYear() !== y || probe.getMonth() !== m - 1 || probe.getDate() !== d) {
		return null;
	}
	return { y, m, d };
}

function parseYm(date: string): { y: number; m: number } | null {
	if (!/^\d{4}-\d{2}$/.test(date)) return null;
	const [y, m] = date.split("-").map(Number) as [number, number];
	if (m < 1 || m > 12) return null;
	return { y, m };
}

/**
 * Resolve a `(period, date)` pair into a concrete `[start, end)` range + the day
 * keys it covers. Returns a `PeriodError` for malformed input.
 */
export function resolvePeriod(period: Period, date: string): ResolvedPeriod | PeriodError {
	if (period === "day") {
		const p = parseYmd(date);
		if (!p) return { ok: false, error: `invalid day date "${date}" (expected YYYY-MM-DD)` };
		const start = new Date(p.y, p.m - 1, p.d).getTime();
		const end = new Date(p.y, p.m - 1, p.d + 1).getTime();
		return { ok: true, start, end, dayKeys: [dayKey(p.y, p.m - 1, p.d)], date };
	}

	if (period === "week") {
		const p = parseYmd(date);
		if (!p) return { ok: false, error: `invalid week date "${date}" (expected YYYY-MM-DD)` };
		// ISO week: Monday-based. JS getDay() is 0=Sun..6=Sat.
		const base = new Date(p.y, p.m - 1, p.d);
		const offset = (base.getDay() + 6) % 7; // days since Monday
		const monStart = new Date(p.y, p.m - 1, p.d - offset);
		const start = monStart.getTime();
		const end = new Date(
			monStart.getFullYear(),
			monStart.getMonth(),
			monStart.getDate() + 7,
		).getTime();
		const dayKeys: string[] = [];
		for (let i = 0; i < 7; i++) {
			dayKeys.push(dayKey(monStart.getFullYear(), monStart.getMonth(), monStart.getDate() + i));
		}
		return { ok: true, start, end, dayKeys, date };
	}

	// month
	const p = parseYm(date);
	if (!p) return { ok: false, error: `invalid month date "${date}" (expected YYYY-MM)` };
	const start = new Date(p.y, p.m - 1, 1).getTime();
	const end = new Date(p.y, p.m, 1).getTime();
	const lastDay = new Date(p.y, p.m, 0).getDate();
	const dayKeys: string[] = [];
	for (let d = 1; d <= lastDay; d++) {
		dayKeys.push(dayKey(p.y, p.m - 1, d));
	}
	return { ok: true, start, end, dayKeys, date };
}