From 4278d67e227bbadaafe1d007ede011bcb06ad891 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Tue, 3 Jun 2025 01:19:28 +0000 Subject: [PATCH] feat: Add contributor activity timeline to daily summary This commit introduces a new timeline visualization on the daily summary page, displaying contributor activity throughout the day. Key changes: 1. **Data Structures (`src/lib/data/types.ts`):** * Added `ContributorActivityHour` interface and `TimelineActivityData` type to represent hourly activity. 2. **Data Fetching (`src/app/[interval]/[[...date]]/queries.ts`):** * Implemented `getTimelineActivityData` query to fetch commits, PRs, issues, reviews, and comments, aggregated by contributor and hour for a given day. * The query joins with the `users` table to include login and avatar information. 3. **Timeline Component (`src/app/[interval]/[[...date]]/components/ContributorActivityTimeline.tsx`):** * Created a new React component to render the timeline. * Displays 24 hour slots with contributor avatars shown under the hour of their activity. * Uses existing `Avatar` and `Tooltip` UI components. * Handles cases with no activity and provides avatar fallbacks. 4. **Integration (`src/app/[interval]/[[...date]]/page.tsx`):** * The `ContributorActivityTimeline` is now included on the daily summary page, positioned above the "Code Changes" section. * Data is fetched using the new query and passed to the component. 5. **Testing:** * Added comprehensive unit tests for `getTimelineActivityData` in `queries.test.ts`, mocking database responses and covering various scenarios (no activity, single/multiple users, aggregation, different activity types). * Added unit tests for `ContributorActivityTimeline` in `ContributorActivityTimeline.test.tsx` using React Testing Library, covering rendering logic, empty states, avatar display, and fallbacks. This new timeline provides a clear visual overview of when contributors are active during a specific day, enhancing the insights available on the daily summary page. --- .../ContributorActivityTimeline.test.tsx | 185 ++++++++++++++++++ .../ContributorActivityTimeline.tsx | 92 +++++++++ src/app/[interval]/[[...date]]/page.tsx | 4 + .../[interval]/[[...date]]/queries.test.ts | 166 ++++++++++++++++ src/app/[interval]/[[...date]]/queries.ts | 108 +++++++++- src/lib/data/types.ts | 8 + 6 files changed, 560 insertions(+), 3 deletions(-) create mode 100644 src/app/[interval]/[[...date]]/components/ContributorActivityTimeline.test.tsx create mode 100644 src/app/[interval]/[[...date]]/components/ContributorActivityTimeline.tsx create mode 100644 src/app/[interval]/[[...date]]/queries.test.ts diff --git a/src/app/[interval]/[[...date]]/components/ContributorActivityTimeline.test.tsx b/src/app/[interval]/[[...date]]/components/ContributorActivityTimeline.test.tsx new file mode 100644 index 000000000..d26325776 --- /dev/null +++ b/src/app/[interval]/[[...date]]/components/ContributorActivityTimeline.test.tsx @@ -0,0 +1,185 @@ +import React from 'react'; +import { render, screen, within, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import ContributorActivityTimeline from './ContributorActivityTimeline'; +import { TimelineActivityData, ContributorActivityHour } from '@/lib/data/types'; + +// Mock the Tooltip components from antd or similar library if they cause issues +// For this example, assuming a simple TooltipProvider mock is enough or not needed +// if the UI library's Tooltip works well in JSDOM or is simple enough. +// If using shadcn/ui tooltips, they often need TooltipProvider. +jest.mock('@/components/ui/tooltip', () => ({ + Tooltip: ({ children }: { children: React.ReactNode }) =>
{children}
, + TooltipTrigger: ({ children }: { children: React.ReactNode }) =>
{children}
, + TooltipContent: ({ children }: { children: React.ReactNode }) =>
{children}
, + TooltipProvider: ({ children }: { children: React.ReactNode }) =>
{children}
, +})); + + +describe('ContributorActivityTimeline', () => { + const generateMockActivity = (login: string, hour: number, avatarUrl?: string): ContributorActivityHour => ({ + login, + hour, + avatarUrl: avatarUrl === undefined ? `/${login.toLowerCase()}.png` : avatarUrl, // Default avatar if not specified + }); + + test('renders "no activity" message when activityData is empty', () => { + render(); + expect(screen.getByText(/No contributor activity recorded for this day/i)).toBeInTheDocument(); + + // Check that no hour-specific elements like avatars are rendered + // queryByRole is used because getByRole would throw an error if not found, which is what we want to avoid here. + const avatars = screen.queryAllByRole('img'); + expect(avatars.length).toBe(0); + + // Check if hour labels are still rendered (they should be, but empty) + for (let i = 0; i < 24; i++) { + const hourLabel = String(i).padStart(2, '0'); + expect(screen.getByText(hourLabel)).toBeInTheDocument(); + } + }); + + test('renders activity for a single contributor in a single hour', () => { + const mockData: TimelineActivityData = [generateMockActivity('userA', 10, 'userA.png')]; + render(); + + const hourSlotLabel = screen.getByText('10'); + expect(hourSlotLabel).toBeInTheDocument(); + + // Find the parent div of the hour label, then the avatars container within it + const hourSlotDiv = hourSlotLabel.closest('.hour-slot'); + expect(hourSlotDiv).toBeInTheDocument(); + + if (hourSlotDiv) { + const avatarImg = within(hourSlotDiv).getByRole('img', { name: /userA/i }); + expect(avatarImg).toBeInTheDocument(); + expect(avatarImg).toHaveAttribute('src', 'userA.png'); + + // Check for tooltip content (mocked) + // This simplified mock check might need adjustment based on actual Tooltip behavior + const tooltipTrigger = within(hourSlotDiv).getByTestId('tooltip-trigger'); + expect(within(tooltipTrigger).getByRole('img', {name: /userA/i})).toBeInTheDocument(); + // The mocked TooltipContent would exist if our mock structure was more complex. + // For now, we assume the presence of the trigger implies the tooltip structure. + } + }); + + test('renders activities for multiple contributors in the same hour', () => { + const mockData: TimelineActivityData = [ + generateMockActivity('userA', 14, 'userA.png'), + generateMockActivity('userB', 14, 'userB.png'), + ]; + render(); + + const hourSlotLabel = screen.getByText('14'); + const hourSlotDiv = hourSlotLabel.closest('.hour-slot'); + expect(hourSlotDiv).toBeInTheDocument(); + + if (hourSlotDiv) { + const avatarA = within(hourSlotDiv).getByRole('img', { name: /userA/i }); + expect(avatarA).toBeInTheDocument(); + expect(avatarA).toHaveAttribute('src', 'userA.png'); + + const avatarB = within(hourSlotDiv).getByRole('img', { name: /userB/i }); + expect(avatarB).toBeInTheDocument(); + expect(avatarB).toHaveAttribute('src', 'userB.png'); + } + }); + + test('renders activities spanning multiple hours', () => { + const mockData: TimelineActivityData = [ + generateMockActivity('userA', 9, 'userA.png'), + generateMockActivity('userB', 17, 'userB.png'), + ]; + render(); + + const hourSlot9 = screen.getByText('09').closest('.hour-slot'); + expect(hourSlot9).toBeInTheDocument(); + if (hourSlot9) { + expect(within(hourSlot9).getByRole('img', { name: /userA/i })).toBeInTheDocument(); + } + + const hourSlot17 = screen.getByText('17').closest('.hour-slot'); + expect(hourSlot17).toBeInTheDocument(); + if (hourSlot17) { + expect(within(hourSlot17).getByRole('img', { name: /userB/i })).toBeInTheDocument(); + } + }); + + test('renders avatar fallback when avatarUrl is null or undefined', () => { + const mockData: TimelineActivityData = [generateMockActivity('userX', 12, null as any)]; // Pass null for avatarUrl + render(); + + const hourSlot12 = screen.getByText('12').closest('.hour-slot'); + expect(hourSlot12).toBeInTheDocument(); + + if (hourSlot12) { + // Check for fallback text (e.g., initials "UX") + // The AvatarFallback in the component uses login.substring(0, 2).toUpperCase() + const fallback = within(hourSlot12).getByText('UX'); + expect(fallback).toBeInTheDocument(); + + // Ensure no 'img' role is found if fallback is active for this specific avatar + const imagesInSlot = within(hourSlot12).queryAllByRole('img'); + // This assertion depends on how AvatarImage and AvatarFallback interact. + // If AvatarImage is still rendered but hidden, this might need adjustment. + // Typically, if src is invalid/null, only fallback is visible. + // Let's assume the image src would be empty or invalid, and thus not rendered as a meaningful image. + // A more robust test might involve checking that the specific AvatarImage for 'userX' is not present or has no src. + let imgFound = false; + imagesInSlot.forEach(img => { + if(img.getAttribute('alt') === 'userX') imgFound = true; + }); + expect(imgFound).toBe(false); // No direct image for userX if fallback is shown + } + }); + + test('renders avatar fallback when avatarUrl is an empty string', () => { + const mockData: TimelineActivityData = [generateMockActivity('userY', 13, '')]; // Pass empty string for avatarUrl + render(); + + const hourSlot13 = screen.getByText('13').closest('.hour-slot'); + expect(hourSlot13).toBeInTheDocument(); + + if (hourSlot13) { + const fallback = within(hourSlot13).getByText('UY'); + expect(fallback).toBeInTheDocument(); + let imgFound = false; + const imagesInSlot = within(hourSlot13).queryAllByRole('img'); + imagesInSlot.forEach(img => { + if(img.getAttribute('alt') === 'userY') imgFound = true; + }); + expect(imgFound).toBe(false); + } + }); + + + test('renders all 24 hour labels', () => { + render(); // Data doesn't matter for this test + for (let i = 0; i < 24; i++) { + const hourLabel = String(i).padStart(2, '0'); + expect(screen.getByText(hourLabel)).toBeInTheDocument(); + } + }); + + test('does not duplicate users within the same hour slot in display', () => { + // The component internally de-duplicates based on login per hour for display. + // This test ensures that if the input `activityData` (which should already be unique per user-hour from query) + // somehow had duplicates, the display would still be correct. + const mockData: TimelineActivityData = [ + generateMockActivity('userA', 10, 'userA.png'), + generateMockActivity('userA', 10, 'userA.png'), // Duplicate entry + ]; + render(); + + const hourSlotLabel = screen.getByText('10'); + const hourSlotDiv = hourSlotLabel.closest('.hour-slot'); + expect(hourSlotDiv).toBeInTheDocument(); + + if (hourSlotDiv) { + const avatars = within(hourSlotDiv).getAllByRole('img', { name: /userA/i }); + expect(avatars).toHaveLength(1); // Should only render one avatar for userA in this slot + } + }); + +}); diff --git a/src/app/[interval]/[[...date]]/components/ContributorActivityTimeline.tsx b/src/app/[interval]/[[...date]]/components/ContributorActivityTimeline.tsx new file mode 100644 index 000000000..4a8940286 --- /dev/null +++ b/src/app/[interval]/[[...date]]/components/ContributorActivityTimeline.tsx @@ -0,0 +1,92 @@ +import React from 'react'; +import { TimelineActivityData, ContributorActivityHour } from '@/lib/data/types'; +import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'; // Assuming path is correct +import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from '@/components/ui/tooltip'; // Assuming path is correct + +interface ContributorActivityTimelineProps { + activityData: TimelineActivityData; +} + +const ContributorActivityTimeline: React.FC = ({ activityData }) => { + if (!activityData || activityData.length === 0) { + return

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 ( + +
+
+ {hours.map(hour => ( +
+
+ {String(hour).padStart(2, '0')} +
+
+ {activityByHour[hour]?.map(activity => ( + + + + + + {activity.login.substring(0, 2).toUpperCase()} + + + + +

{activity.login}

+
+
+ ))} +
+
+ ))} +
+
+
+ ); +}; + +export default ContributorActivityTimeline; diff --git a/src/app/[interval]/[[...date]]/page.tsx b/src/app/[interval]/[[...date]]/page.tsx index ae51a81a4..d64b7f7f4 100644 --- a/src/app/[interval]/[[...date]]/page.tsx +++ b/src/app/[interval]/[[...date]]/page.tsx @@ -3,6 +3,7 @@ import { getLatestAvailableDate, getIntervalSummaryContent, parseIntervalDate, + getTimelineActivityData, // New import } from "./queries"; import { notFound } from "next/navigation"; import pipelineConfig from "@/../config/pipeline.config"; @@ -19,6 +20,7 @@ import { DateNavigation } from "./components/DateNavigation"; import { SummaryContent } from "./components/SummaryContent"; import { StatCardsDisplay } from "./components/StatCardsDisplay"; import { CodeChangesDisplay } from "./components/CodeChangesDisplay"; +import { ContributorActivityTimeline } from "./components/ContributorActivityTimeline"; // New import import { LlmCopyButton } from "@/components/ui/llm-copy-button"; import { IntervalSelector } from "./components/IntervalSelector"; @@ -122,6 +124,7 @@ export default async function IntervalSummaryPage({ params }: PageProps) { targetDate, intervalType, ); + const timelineData = await getTimelineActivityData(targetDate, intervalType); // New data fetching 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;