Skip to content

Commit cdead2a

Browse files
committed
Add 'lifetime' analytics range support
1 parent fe38ff4 commit cdead2a

File tree

7 files changed

+105
-84
lines changed

7 files changed

+105
-84
lines changed

apps/web/app/(org)/dashboard/analytics/components/AnalyticsDashboard.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ const RANGE_OPTIONS: { value: AnalyticsRange; label: string }[] = [
1515
{ value: "24h", label: "Last 24 hours" },
1616
{ value: "7d", label: "Last 7 days" },
1717
{ value: "30d", label: "Last 30 days" },
18+
{ value: "lifetime", label: "Lifetime" },
1819
];
1920

2021
const formatNumber = (value: number) =>

apps/web/app/(org)/dashboard/analytics/components/Header.tsx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,10 +41,16 @@ const DATE_RANGE_OPTIONS = [
4141
{ value: "30d", label: "Last 30 days" },
4242
{ value: "wtd", label: "Week to date" },
4343
{ value: "mtd", label: "Month to date" },
44+
{ value: "lifetime", label: "Lifetime" },
4445
] as const;
4546

4647
const mapToBackendRange = (value: string): AnalyticsRange => {
47-
if (value === "24h" || value === "7d" || value === "30d") {
48+
if (
49+
value === "24h" ||
50+
value === "7d" ||
51+
value === "30d" ||
52+
value === "lifetime"
53+
) {
4854
return value as AnalyticsRange;
4955
}
5056
if (value === "today" || value === "yesterday") {
@@ -81,6 +87,7 @@ const getDisplayValue = (
8187
return lastUISelection;
8288
}
8389
}
90+
if (backendValue === "lifetime") return "lifetime";
8491
if (backendValue === "24h") return "24h";
8592
if (backendValue === "7d") return "7d";
8693
if (backendValue === "30d") return "30d";
@@ -119,6 +126,8 @@ export default function Header({
119126
setLastUISelection("7d");
120127
} else if (value === "30d") {
121128
setLastUISelection("30d");
129+
} else if (value === "lifetime") {
130+
setLastUISelection("lifetime");
122131
}
123132
}
124133
}, [value, lastUISelection]);

apps/web/app/(org)/dashboard/analytics/data.ts

Lines changed: 74 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ import type {
1515
type VideoRow = typeof videos.$inferSelect;
1616
type OrgId = VideoRow["orgId"];
1717
type VideoId = VideoRow["id"];
18+
type SpaceVideoRow = typeof spaceVideos.$inferSelect;
19+
type SpaceOrOrgId = SpaceVideoRow["spaceId"];
1820

1921
type CountSeriesRow = { bucket: string; count: number };
2022
type ViewSeriesRow = { bucket: string; views: number };
@@ -31,15 +33,19 @@ type TinybirdAnalyticsData = {
3133
topCapsRaw: TopCapRow[];
3234
};
3335

34-
const RANGE_CONFIG: Record<
35-
AnalyticsRange,
36+
type RollingAnalyticsRange = Exclude<AnalyticsRange, "lifetime">;
37+
38+
const ROLLING_RANGE_CONFIG: Record<
39+
RollingAnalyticsRange,
3640
{ hours: number; bucket: "hour" | "day" }
3741
> = {
3842
"24h": { hours: 24, bucket: "hour" },
3943
"7d": { hours: 7 * 24, bucket: "day" },
4044
"30d": { hours: 30 * 24, bucket: "day" },
4145
};
4246

47+
const LIFETIME_FALLBACK_DAYS = 30;
48+
4349
const escapeLiteral = (value: string) => value.replace(/'/g, "''");
4450
const toDateString = (date: Date) => date.toISOString().slice(0, 10);
4551
const toDateTimeString = (date: Date) =>
@@ -80,6 +86,47 @@ const buildBuckets = (from: Date, to: Date, bucket: "hour" | "day") => {
8086
return buckets;
8187
};
8288

89+
const getLifetimeRangeStart = async (
90+
orgId: OrgId,
91+
videoIds?: VideoId[],
92+
): Promise<Date | undefined> => {
93+
const whereClause = videoIds && videoIds.length > 0
94+
? and(eq(videos.orgId, orgId), inArray(videos.id, videoIds))
95+
: eq(videos.orgId, orgId);
96+
97+
const rows = await db()
98+
.select({ minCreatedAt: sql<Date>`MIN(${videos.createdAt})` })
99+
.from(videos)
100+
.where(whereClause)
101+
.limit(1);
102+
103+
const candidate = rows[0]?.minCreatedAt;
104+
if (!candidate) return undefined;
105+
return candidate instanceof Date ? candidate : new Date(candidate);
106+
};
107+
108+
const resolveRangeBounds = async (
109+
range: AnalyticsRange,
110+
orgId: OrgId,
111+
videoIds?: VideoId[],
112+
): Promise<{ from: Date; to: Date; bucket: "hour" | "day" }> => {
113+
const to = new Date();
114+
115+
if (range === "lifetime") {
116+
const lifetimeStart = await getLifetimeRangeStart(orgId, videoIds);
117+
const fallbackFrom = new Date(
118+
to.getTime() - LIFETIME_FALLBACK_DAYS * 24 * 60 * 60 * 1000,
119+
);
120+
const from =
121+
lifetimeStart && lifetimeStart < to ? lifetimeStart : fallbackFrom;
122+
return { from, to, bucket: "day" };
123+
}
124+
125+
const config = ROLLING_RANGE_CONFIG[range];
126+
const from = new Date(to.getTime() - config.hours * 60 * 60 * 1000);
127+
return { from, to, bucket: config.bucket };
128+
};
129+
83130
const fallbackIfEmpty = <Row>(
84131
primary: Effect.Effect<Row[], never, never>,
85132
fallback?: Effect.Effect<Row[], never, never>,
@@ -101,27 +148,21 @@ const withTinybirdFallback = <Row>(
101148
return Effect.succeed<{ data: Row[] }>({ data: [] as Row[] });
102149
}),
103150
Effect.map((res) => {
104-
console.log("tinybird raw response", JSON.stringify(res, null, 2));
105-
const response = res as { data: unknown[] };
151+
const response = res as { data?: unknown[] };
106152
const data = response.data ?? [];
107-
console.log("tinybird data array", JSON.stringify(data, null, 2));
108-
const filtered = data.filter((item): item is Row => {
109-
const isObject = typeof item === "object" && item !== null;
110-
if (!isObject) {
111-
console.log("filtered out non-object item", typeof item, item);
112-
}
113-
return isObject;
114-
}) as Row[];
115-
console.log("tinybird filtered rows", JSON.stringify(filtered, null, 2));
116-
return filtered;
153+
return data.filter((item): item is Row =>
154+
typeof item === "object" && item !== null,
155+
) as Row[];
117156
}),
118157
);
119158

120-
const getSpaceVideoIds = async (spaceId: string): Promise<VideoId[]> => {
159+
const getSpaceVideoIds = async (
160+
spaceId: SpaceOrOrgId,
161+
): Promise<VideoId[]> => {
121162
const rows = await db()
122163
.select({ videoId: spaceVideos.videoId })
123164
.from(spaceVideos)
124-
.where(eq(spaceVideos.spaceId, spaceId as any));
165+
.where(eq(spaceVideos.spaceId, spaceId));
125166
return rows.map((row) => row.videoId);
126167
};
127168

@@ -131,16 +172,21 @@ export const getOrgAnalyticsData = async (
131172
spaceId?: string,
132173
capId?: string,
133174
): Promise<OrgAnalyticsResponse> => {
134-
const rangeConfig = RANGE_CONFIG[range];
135-
const to = new Date();
136-
const from = new Date(to.getTime() - rangeConfig.hours * 60 * 60 * 1000);
137-
const buckets = buildBuckets(from, to, rangeConfig.bucket);
138175
const typedOrgId = orgId as OrgId;
139176

140-
const spaceVideoIds = spaceId ? await getSpaceVideoIds(spaceId) : undefined;
177+
const spaceVideoIds = spaceId
178+
? await getSpaceVideoIds(spaceId as SpaceOrOrgId)
179+
: undefined;
141180
const capVideoIds = capId ? [capId as VideoId] : undefined;
142181
const videoIds = capVideoIds || spaceVideoIds;
143182

183+
const { from, to, bucket } = await resolveRangeBounds(
184+
range,
185+
typedOrgId,
186+
videoIds,
187+
);
188+
const buckets = buildBuckets(from, to, bucket);
189+
144190
if (
145191
(spaceId && spaceVideoIds && spaceVideoIds.length === 0) ||
146192
(capId && !capVideoIds)
@@ -177,48 +223,37 @@ export const getOrgAnalyticsData = async (
177223
}
178224

179225
const [capsSeries, commentSeries, reactionSeries] = await Promise.all([
180-
queryVideoSeries(typedOrgId, from, to, rangeConfig.bucket, videoIds),
226+
queryVideoSeries(typedOrgId, from, to, bucket, videoIds),
181227
queryCommentsSeries(
182228
typedOrgId,
183229
from,
184230
to,
185231
"text",
186-
rangeConfig.bucket,
232+
bucket,
187233
videoIds,
188234
),
189235
queryCommentsSeries(
190236
typedOrgId,
191237
from,
192238
to,
193239
"emoji",
194-
rangeConfig.bucket,
240+
bucket,
195241
videoIds,
196242
),
197243
]);
198244

199-
const tinybirdData = await runPromise(
200-
Effect.gen(function* () {
201-
const tinybird = yield* Tinybird;
202-
console.log("getOrgAnalyticsData - orgId:", orgId, "range:", range);
203-
console.log(
204-
"getOrgAnalyticsData - from:",
205-
from.toISOString(),
206-
"to:",
207-
to.toISOString(),
208-
);
245+
const tinybirdData = await runPromise(
246+
Effect.gen(function* () {
247+
const tinybird = yield* Tinybird;
209248

210249
const viewSeries = yield* queryViewSeries(
211250
tinybird,
212251
typedOrgId,
213252
from,
214253
to,
215-
rangeConfig.bucket,
254+
bucket,
216255
videoIds,
217256
);
218-
console.log(
219-
"getOrgAnalyticsData - viewSeries:",
220-
JSON.stringify(viewSeries, null, 2),
221-
);
222257

223258
const countries = yield* queryCountries(
224259
tinybird,
@@ -227,10 +262,6 @@ export const getOrgAnalyticsData = async (
227262
to,
228263
videoIds,
229264
);
230-
console.log(
231-
"getOrgAnalyticsData - countries:",
232-
JSON.stringify(countries, null, 2),
233-
);
234265

235266
const cities = yield* queryCities(
236267
tinybird,
@@ -239,10 +270,6 @@ export const getOrgAnalyticsData = async (
239270
to,
240271
videoIds,
241272
);
242-
console.log(
243-
"getOrgAnalyticsData - cities:",
244-
JSON.stringify(cities, null, 2),
245-
);
246273

247274
const browsers = yield* queryBrowsers(
248275
tinybird,
@@ -251,10 +278,6 @@ export const getOrgAnalyticsData = async (
251278
to,
252279
videoIds,
253280
);
254-
console.log(
255-
"getOrgAnalyticsData - browsers:",
256-
JSON.stringify(browsers, null, 2),
257-
);
258281

259282
const devices = yield* queryDevices(
260283
tinybird,
@@ -263,10 +286,6 @@ export const getOrgAnalyticsData = async (
263286
to,
264287
videoIds,
265288
);
266-
console.log(
267-
"getOrgAnalyticsData - devices:",
268-
JSON.stringify(devices, null, 2),
269-
);
270289

271290
const operatingSystems = yield* queryOperatingSystems(
272291
tinybird,
@@ -275,18 +294,10 @@ export const getOrgAnalyticsData = async (
275294
to,
276295
videoIds,
277296
);
278-
console.log(
279-
"getOrgAnalyticsData - operatingSystems:",
280-
JSON.stringify(operatingSystems, null, 2),
281-
);
282297

283298
const topCapsRaw = capId
284299
? []
285300
: yield* queryTopCaps(tinybird, typedOrgId, from, to, videoIds);
286-
console.log(
287-
"getOrgAnalyticsData - topCapsRaw:",
288-
JSON.stringify(topCapsRaw, null, 2),
289-
);
290301

291302
return {
292303
viewSeries,

apps/web/app/(org)/dashboard/analytics/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
export type AnalyticsRange = "24h" | "7d" | "30d";
1+
export type AnalyticsRange = "24h" | "7d" | "30d" | "lifetime";
22

33
export interface BreakdownRow {
44
name: string;

apps/web/app/api/analytics/track/route.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,8 +81,7 @@ export async function POST(request: NextRequest) {
8181
return;
8282
}
8383

84-
// @ts-expect-error - Tinybird service can be yielded directly
85-
const tinybird = yield* Tinybird;
84+
const tinybird = yield* Tinybird;
8685
yield* tinybird.appendEvents([
8786
{
8887
timestamp: timestamp.toISOString(),

apps/web/app/api/dashboard/analytics/route.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import type { NextRequest } from "next/server";
44
import { getOrgAnalyticsData } from "@/app/(org)/dashboard/analytics/data";
55
import type { AnalyticsRange } from "@/app/(org)/dashboard/analytics/types";
66

7-
const RANGE_VALUES: AnalyticsRange[] = ["24h", "7d", "30d"];
7+
const RANGE_VALUES: AnalyticsRange[] = ["24h", "7d", "30d", "lifetime"];
88

99
export const dynamic = "force-dynamic";
1010

0 commit comments

Comments
 (0)