No contributor activity recorded for this day.
;
+ }
+
+ // Group data by hour
+ const activityByHour: { [hour: number]: ContributorActivityHour[] } = {};
+ activityData.forEach(activity => {
+ if (!activityByHour[activity.hour]) {
+ activityByHour[activity.hour] = [];
+ }
+ // Ensure no duplicate logins within the same hour slot on the timeline display
+ if (!activityByHour[activity.hour].find(a => a.login === activity.login)) {
+ activityByHour[activity.hour].push(activity);
+ }
+ });
+
+ const hours = Array.from({ length: 24 }, (_, i) => i); // 0 to 23
+
+ return (
+
@@ -142,6 +145,7 @@ export default async function IntervalSummaryPage({ params }: PageProps) {
+ {/* New component instance */}
diff --git a/src/app/[interval]/[[...date]]/queries.test.ts b/src/app/[interval]/[[...date]]/queries.test.ts
new file mode 100644
index 000000000..b6b545557
--- /dev/null
+++ b/src/app/[interval]/[[...date]]/queries.test.ts
@@ -0,0 +1,166 @@
+import { getTimelineActivityData } from "./queries";
+import { db } from "@/lib/data/db"; // To be mocked
+import { IntervalType, TimelineActivityData, ContributorActivityHour } from "@/lib/data/types";
+import { UTCDate } from "@date-fns/utc";
+
+// Mock the db module
+jest.mock("@/lib/data/db", () => ({
+ db: {
+ execute: jest.fn(),
+ },
+}));
+
+// Create a typed mock variable for db.execute
+const mockDbExecute = db.execute as jest.Mock;
+
+// Helper to create ISO strings for specific hours on a given date
+const createISOTimestamp = (date: string, hour: number, minute: number = 0, second: number = 0): string => {
+ const d = new UTCDate(date);
+ d.setUTCHours(hour, minute, second, 0);
+ return d.toISOString();
+};
+
+describe("getTimelineActivityData", () => {
+ const targetDate = "2023-10-26";
+ const intervalType: IntervalType = "day";
+
+ beforeEach(() => {
+ // Clear any previous mock usage and implementations
+ mockDbExecute.mockClear();
+ });
+
+ test("should return an empty array if there is no activity", async () => {
+ mockDbExecute.mockResolvedValueOnce([]); // Simulate empty result from DB
+ const result = await getTimelineActivityData(targetDate, intervalType);
+ expect(result).toEqual([]);
+ expect(mockDbExecute).toHaveBeenCalledTimes(1);
+ });
+
+ test("should return single activity for single contributor, single hour", async () => {
+ const mockActivity = [
+ { login: "userA", avatarUrl: "urlA", activityTime: createISOTimestamp(targetDate, 10) },
+ ];
+ mockDbExecute.mockResolvedValueOnce(mockActivity);
+ const result = await getTimelineActivityData(targetDate, intervalType);
+ expect(result).toHaveLength(1);
+ expect(result[0]).toEqual({ login: "userA", avatarUrl: "urlA", hour: 10 });
+ });
+
+ test("should aggregate multiple activities for a single contributor in the same hour", async () => {
+ const mockActivities = [
+ { login: "userA", avatarUrl: "urlA", activityTime: createISOTimestamp(targetDate, 10, 5) },
+ { login: "userA", avatarUrl: "urlA", activityTime: createISOTimestamp(targetDate, 10, 30) },
+ ];
+ mockDbExecute.mockResolvedValueOnce(mockActivities);
+ const result = await getTimelineActivityData(targetDate, intervalType);
+ expect(result).toHaveLength(1);
+ expect(result[0]).toEqual({ login: "userA", avatarUrl: "urlA", hour: 10 });
+ });
+
+ test("should return multiple activities for a single contributor in different hours", async () => {
+ const mockActivities = [
+ { login: "userA", avatarUrl: "urlA", activityTime: createISOTimestamp(targetDate, 10) },
+ { login: "userA", avatarUrl: "urlA", activityTime: createISOTimestamp(targetDate, 12) },
+ ];
+ mockDbExecute.mockResolvedValueOnce(mockActivities);
+ const result = await getTimelineActivityData(targetDate, intervalType);
+ expect(result).toHaveLength(2);
+ expect(result).toContainEqual({ login: "userA", avatarUrl: "urlA", hour: 10 });
+ expect(result).toContainEqual({ login: "userA", avatarUrl: "urlA", hour: 12 });
+ });
+
+ test("should return activities for multiple contributors in the same hour", async () => {
+ const mockActivities = [
+ { login: "userA", avatarUrl: "urlA", activityTime: createISOTimestamp(targetDate, 14) },
+ { login: "userB", avatarUrl: "urlB", activityTime: createISOTimestamp(targetDate, 14) },
+ ];
+ mockDbExecute.mockResolvedValueOnce(mockActivities);
+ const result = await getTimelineActivityData(targetDate, intervalType);
+ expect(result).toHaveLength(2);
+ expect(result).toContainEqual({ login: "userA", avatarUrl: "urlA", hour: 14 });
+ expect(result).toContainEqual({ login: "userB", avatarUrl: "urlB", hour: 14 });
+ });
+
+ test("should handle multiple contributors and multiple hours correctly", async () => {
+ const mockActivities = [
+ { login: "userA", avatarUrl: "urlA", activityTime: createISOTimestamp(targetDate, 9) },
+ { login: "userB", avatarUrl: "urlB", activityTime: createISOTimestamp(targetDate, 9) },
+ { login: "userA", avatarUrl: "urlA", activityTime: createISOTimestamp(targetDate, 15) },
+ { login: "userC", avatarUrl: "urlC", activityTime: createISOTimestamp(targetDate, 20) },
+ ];
+ mockDbExecute.mockResolvedValueOnce(mockActivities);
+ const result = await getTimelineActivityData(targetDate, intervalType);
+ expect(result).toHaveLength(4); // userA (9), userB (9), userA (15), userC (20)
+ expect(result).toContainEqual({ login: "userA", avatarUrl: "urlA", hour: 9 });
+ expect(result).toContainEqual({ login: "userB", avatarUrl: "urlB", hour: 9 });
+ expect(result).toContainEqual({ login: "userA", avatarUrl: "urlA", hour: 15 });
+ expect(result).toContainEqual({ login: "userC", avatarUrl: "urlC", hour: 20 });
+ });
+
+ test("should handle missing avatarUrl (null from DB)", async () => {
+ const mockActivity = [
+ { login: "userD", avatarUrl: null, activityTime: createISOTimestamp(targetDate, 10) },
+ ];
+ mockDbExecute.mockResolvedValueOnce(mockActivity);
+ const result = await getTimelineActivityData(targetDate, intervalType);
+ expect(result).toHaveLength(1);
+ expect(result[0]).toEqual({ login: "userD", avatarUrl: undefined, hour: 10 });
+ });
+
+ test("should correctly determine date range for query", async () => {
+ mockDbExecute.mockResolvedValueOnce([]);
+ await getTimelineActivityData(targetDate, intervalType);
+
+ expect(mockDbExecute).toHaveBeenCalledTimes(1);
+ const executedSql = mockDbExecute.mock.calls[0][0];
+
+ // Check that the date parameters are correctly formatted ISO strings
+ // For targetDate = "2023-10-26"
+ // startDate should be "2023-10-26T00:00:00.000Z"
+ // endDate should be "2023-10-27T00:00:00.000Z"
+
+ // Drizzle's sql object structure is complex. We access parameters through `executedSql.getSQL()` and `executedSql.params`
+ // However, `db.execute` receives the prepared statement object directly.
+ // The actual SQL string and parameters are embedded in the `sql` tagged template literal object.
+ // Accessing these for assertion is tricky without knowing Drizzle's internal structure for `sql.execute`.
+ // The mock receives an object that has properties like `sql` (the string) and `params`.
+ // For `sql` tagged template from `drizzle-orm`, the parameters are part of the `values` array on the sql object.
+
+ const sqlQueryObject = mockDbExecute.mock.calls[0][0];
+ expect(sqlQueryObject.values).toBeInstanceOf(Array);
+ expect(sqlQueryObject.values).toContain("2023-10-26T00:00:00.000Z"); // Start of targetDate
+ expect(sqlQueryObject.values).toContain("2023-10-27T00:00:00.000Z"); // Start of next day
+ });
+
+ test("should handle various activity types (simulated by diverse data)", async () => {
+ // This test relies on the fact that the UNION ALL query in the implementation
+ // normalizes data. We just provide mixed data that could come from any table.
+ const mockActivities = [
+ { login: "userCommit", avatarUrl: "urlC", activityTime: createISOTimestamp(targetDate, 8, 10) }, // Simulates a commit
+ { login: "userPR", avatarUrl: "urlPR", activityTime: createISOTimestamp(targetDate, 11, 20) }, // Simulates a PR
+ { login: "userIssue", avatarUrl: "urlI", activityTime: createISOTimestamp(targetDate, 11, 25) }, // Simulates an Issue (same hour as PR)
+ { login: "userReview", avatarUrl: "urlRV", activityTime: createISOTimestamp(targetDate, 16, 0) },// Simulates a Review
+ { login: "userComment", avatarUrl: "urlCM", activityTime: createISOTimestamp(targetDate, 17, 5) },// Simulates a Comment
+ ];
+ mockDbExecute.mockResolvedValueOnce(mockActivities);
+ const result = await getTimelineActivityData(targetDate, intervalType);
+ expect(result).toHaveLength(5);
+ expect(result).toContainEqual({ login: "userCommit", avatarUrl: "urlC", hour: 8 });
+ expect(result).toContainEqual({ login: "userPR", avatarUrl: "urlPR", hour: 11 });
+ expect(result).toContainEqual({ login: "userIssue", avatarUrl: "urlI", hour: 11 });
+ expect(result).toContainEqual({ login: "userReview", avatarUrl: "urlRV", hour: 16 });
+ expect(result).toContainEqual({ login: "userComment", avatarUrl: "urlCM", hour: 17 });
+ });
+
+ test("should log a warning if intervalType is not 'day' but still proceed", async () => {
+ const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
+ mockDbExecute.mockResolvedValueOnce([]);
+ await getTimelineActivityData(targetDate, "week");
+ expect(consoleWarnSpy).toHaveBeenCalledWith(
+ "getTimelineActivityData called with intervalType 'week', but currently only supports 'day'. Proceeding as 'day'."
+ );
+ expect(mockDbExecute).toHaveBeenCalledTimes(1); // Should still execute
+ consoleWarnSpy.mockRestore();
+ });
+
+});
diff --git a/src/app/[interval]/[[...date]]/queries.ts b/src/app/[interval]/[[...date]]/queries.ts
index 58e1d2bc1..3108b64ab 100644
--- a/src/app/[interval]/[[...date]]/queries.ts
+++ b/src/app/[interval]/[[...date]]/queries.ts
@@ -17,19 +17,121 @@ import { rawPullRequests } from "@/lib/data/schema";
import { UTCDate } from "@date-fns/utc";
import fs from "fs/promises";
import { getRepoFilePath } from "@/lib/fsHelpers";
+import {
+ TimelineActivityData,
+ ContributorActivityHour,
+} from "@/lib/data/types";
+import * as schema from "@/lib/data/schema";
+import { sql } from "drizzle-orm";
+import { addDays } from "date-fns"; // For calculating next day
export async function getLatestAvailableDate() {
const date = await db
.select({
- max: rawPullRequests.updatedAt,
+ max: schema.rawPullRequests.updatedAt,
})
- .from(rawPullRequests)
- .orderBy(desc(rawPullRequests.updatedAt))
+ .from(schema.rawPullRequests)
+ .orderBy(desc(schema.rawPullRequests.updatedAt))
.limit(1);
return toDateString(date[0].max);
}
+export async function getTimelineActivityData(
+ targetDate: string, // Expects YYYY-MM-DD
+ intervalType: IntervalType, // 'day', 'week', 'month' - though current use case is 'day'
+): Promise
{
+ if (intervalType !== "day") {
+ // For now, this function is specifically designed for daily activity
+ console.warn(
+ `getTimelineActivityData called with intervalType '${intervalType}', but currently only supports 'day'. Proceeding as 'day'.`,
+ );
+ }
+
+ const startDate = new UTCDate(targetDate); // Sets time to 00:00:00.000Z for that date
+ const nextDayDate = addDays(startDate, 1); // Start of the next day
+
+ const startOfDayISO = startDate.toISOString();
+ const startOfNextDayISO = nextDayDate.toISOString();
+
+ // Define the raw SQL query using UNION ALL
+ // Ensure all selected columns are aliased consistently (login, avatarUrl, activityTime)
+ // Also, ensure that users.avatarUrl can be null and handle it.
+ const queryString = sql`
+ SELECT users.username as login, users.avatar_url as avatarUrl, raw_commits.committed_date as activityTime
+ FROM ${schema.rawCommits} raw_commits
+ JOIN ${schema.users} users ON raw_commits.author = users.username
+ WHERE raw_commits.committed_date >= ${startOfDayISO} AND raw_commits.committed_date < ${startOfNextDayISO}
+ UNION ALL
+ SELECT users.username as login, users.avatar_url as avatarUrl, raw_pull_requests.created_at as activityTime
+ FROM ${schema.rawPullRequests} raw_pull_requests
+ JOIN ${schema.users} users ON raw_pull_requests.author = users.username
+ WHERE raw_pull_requests.created_at >= ${startOfDayISO} AND raw_pull_requests.created_at < ${startOfNextDayISO}
+ UNION ALL
+ SELECT users.username as login, users.avatar_url as avatarUrl, raw_issues.created_at as activityTime
+ FROM ${schema.rawIssues} raw_issues
+ JOIN ${schema.users} users ON raw_issues.author = users.username
+ WHERE raw_issues.created_at >= ${startOfDayISO} AND raw_issues.created_at < ${startOfNextDayISO}
+ UNION ALL
+ SELECT users.username as login, users.avatar_url as avatarUrl, pr_reviews.created_at as activityTime
+ FROM ${schema.prReviews} pr_reviews
+ JOIN ${schema.users} users ON pr_reviews.author = users.username
+ WHERE pr_reviews.created_at >= ${startOfDayISO} AND pr_reviews.created_at < ${startOfNextDayISO}
+ UNION ALL
+ SELECT users.username as login, users.avatar_url as avatarUrl, pr_comments.created_at as activityTime
+ FROM ${schema.prComments} pr_comments
+ JOIN ${schema.users} users ON pr_comments.author = users.username
+ WHERE pr_comments.created_at >= ${startOfDayISO} AND pr_comments.created_at < ${startOfNextDayISO}
+ UNION ALL
+ SELECT users.username as login, users.avatar_url as avatarUrl, issue_comments.created_at as activityTime
+ FROM ${schema.issueComments} issue_comments
+ JOIN ${schema.users} users ON issue_comments.author = users.username
+ WHERE issue_comments.created_at >= ${startOfDayISO} AND issue_comments.created_at < ${startOfNextDayISO};
+ `;
+
+ type RawActivityResult = {
+ login: string;
+ avatarUrl: string | null;
+ activityTime: string; // ISO date string
+ };
+
+ // Execute the raw query. db.execute should be used for raw SQL.
+ // The result type from db.execute is an array of objects, but the exact shape depends on the driver.
+ // For bun:sqlite, it should be `unknown[]` or `Record[]`.
+ // We cast it to RawActivityResult[] for easier processing.
+ const results = (await db.execute(queryString)) as RawActivityResult[];
+
+ if (!results || results.length === 0) {
+ return [];
+ }
+
+ const processedActivities = new Map();
+
+ for (const row of results) {
+ if (!row.login || !row.activityTime) { // Basic validation
+ console.warn("Skipping row with missing login or activityTime:", row);
+ continue;
+ }
+ try {
+ const activityDate = new UTCDate(row.activityTime);
+ const hour = activityDate.getUTCHours();
+ const key = `${row.login}-${hour}`;
+
+ if (!processedActivities.has(key)) {
+ processedActivities.set(key, {
+ login: row.login,
+ avatarUrl: row.avatarUrl ?? undefined, // Ensure it's string | undefined
+ hour: hour,
+ });
+ }
+ } catch (e) {
+ console.error("Error processing activity row:", row, e);
+ }
+ }
+
+ return Array.from(processedActivities.values());
+}
+
/**
* Parse date string based on interval type format
* @param dateStr - Date string to parse
diff --git a/src/lib/data/types.ts b/src/lib/data/types.ts
index 4dc6df93e..f86d5779a 100644
--- a/src/lib/data/types.ts
+++ b/src/lib/data/types.ts
@@ -168,6 +168,14 @@ export const RawIssueSchema = z.object({
export type GithubUser = z.infer;
+export interface ContributorActivityHour {
+ login: string;
+ avatarUrl?: string;
+ hour: number;
+}
+
+export type TimelineActivityData = ContributorActivityHour[];
+
export interface DateRange {
startDate?: string;
endDate?: string;