diff --git a/app/lib/__tests__/aggregator.test.ts b/app/lib/__tests__/aggregator.test.ts new file mode 100644 index 0000000..148c0f8 --- /dev/null +++ b/app/lib/__tests__/aggregator.test.ts @@ -0,0 +1,111 @@ +import { describe, it, expect } from "vitest" +import { processActivities, getDaySummary } from "../aggregator" + +function makeActivity(source: string, hour: number, type = "event", extra: Record = {}) { + const timestamp = new Date(`2026-03-15T${String(hour).padStart(2, "0")}:30:00Z`) + return { source, type, timestamp, ...extra } +} + +function makeSpanningActivity(source: string, startHour: number, endHour: number) { + const timestamp = new Date(`2026-03-15T${String(startHour).padStart(2, "0")}:00:00Z`) + const endTime = new Date(`2026-03-15T${String(endHour).padStart(2, "0")}:00:00Z`) + return { source, type: "event", timestamp, endTime } +} + +describe("processActivities", () => { + it("buckets a single activity into the correct hour", () => { + const activities = [makeActivity("calendar", 9)] + const result = processActivities(activities, 6, 23, "UTC") + expect(result[9].primaries).toHaveLength(1) + expect(result[9].primaries[0].source).toBe("calendar") + }) + + it("drops activities outside work hours", () => { + const activities = [makeActivity("calendar", 5), makeActivity("calendar", 23)] + const result = processActivities(activities, 6, 23, "UTC") + // Neither hour 5 nor hour 23 should have activities + expect(result[6]?.primaries).toHaveLength(0) + expect(result[22]?.primaries).toHaveLength(0) + }) + + it("separates calendar (primaries) from other sources (communications)", () => { + const activities = [ + makeActivity("calendar", 10), + makeActivity("slack", 10), + makeActivity("gmail", 10), + ] + const result = processActivities(activities, 6, 23, "UTC") + expect(result[10].primaries).toHaveLength(1) + expect(result[10].communications).toHaveLength(2) + }) + + it("spans calendar events across multiple hours", () => { + const activities = [makeSpanningActivity("calendar", 9, 11)] + const result = processActivities(activities, 6, 23, "UTC") + expect(result[9].primaries).toHaveLength(1) + expect(result[10].primaries).toHaveLength(1) + // The original event starts at 9, so hour 10 should be a spanning entry + expect(result[10].primaries[0].isSpanning).toBe(true) + }) + + it("creates empty buckets for all work hours", () => { + const result = processActivities([], 6, 23, "UTC") + for (let h = 6; h < 23; h++) { + expect(result[h]).toBeDefined() + expect(result[h].primaries).toHaveLength(0) + expect(result[h].communications).toHaveLength(0) + } + }) + + it("deduplicates emails by normalized subject", () => { + const activities = [ + makeActivity("gmail", 10, "email", { subject: "Re: Project update", from: "alice@example.com" }), + makeActivity("gmail", 10, "email", { subject: "Sv: Project update", from: "bob@example.com" }), + ] + const result = processActivities(activities, 6, 23, "UTC") + // Both emails normalize to "project update", so only one should remain + expect(result[10].communications).toHaveLength(1) + }) + + it("filters calendar notification emails", () => { + const activities = [ + makeActivity("gmail", 10, "email", { subject: "Meeting invite", from: "calendar-notification@google.com" }), + makeActivity("gmail", 10, "email", { subject: "Real email", from: "colleague@example.com" }), + ] + const result = processActivities(activities, 6, 23, "UTC") + expect(result[10].communications).toHaveLength(1) + expect(result[10].communications[0].subject).toBe("Real email") + }) +}) + +describe("getDaySummary", () => { + it("counts activities by source", () => { + const activities = [ + makeActivity("calendar", 9), + makeActivity("calendar", 10), + makeActivity("slack", 9), + makeActivity("gmail", 9, "email", { subject: "Hello", from: "user@example.com" }), + makeActivity("docs", 11), + makeActivity("trello", 11), + makeActivity("github", 12), + makeActivity("jira", 12), + ] + const hourly = processActivities(activities, 6, 23, "UTC") + const summary = getDaySummary(hourly) + expect(summary.totalMeetings).toBe(2) + expect(summary.totalSlackMessages).toBe(1) + expect(summary.totalEmails).toBe(1) + expect(summary.totalDocEdits).toBe(1) + expect(summary.totalTrelloActivities).toBe(1) + expect(summary.totalGitHubActivities).toBe(1) + expect(summary.totalJiraActivities).toBe(1) + }) + + it("returns zero counts for an empty day", () => { + const hourly = processActivities([], 6, 23, "UTC") + const summary = getDaySummary(hourly) + expect(summary.totalMeetings).toBe(0) + expect(summary.totalSlackMessages).toBe(0) + expect(summary.totalEmails).toBe(0) + }) +}) diff --git a/app/lib/ai/__tests__/parse.test.ts b/app/lib/ai/__tests__/parse.test.ts new file mode 100644 index 0000000..b3ef08e --- /dev/null +++ b/app/lib/ai/__tests__/parse.test.ts @@ -0,0 +1,104 @@ +import { describe, it, expect } from "vitest" +import { parseSuggestions } from "../parse" + +describe("parseSuggestions", () => { + it("parses a valid JSON array", () => { + const input = JSON.stringify([ + { + projectId: "123", + activityTypeId: "456", + hours: 2, + description: "Worked on feature X", + confidence: "high", + sourceActivities: [ + { source: "calendar", title: "Planning meeting", timestamp: "2026-03-15T09:00:00Z" }, + ], + }, + ]) + const result = parseSuggestions(input) + expect(result).toHaveLength(1) + expect(result[0].projectId).toBe("123") + expect(result[0].activityTypeId).toBe("456") + expect(result[0].hours).toBe(2) + expect(result[0].description).toBe("Worked on feature X") + expect(result[0].confidence).toBe("high") + expect(result[0].status).toBe("pending") + expect(result[0].sourceActivities).toHaveLength(1) + }) + + it("extracts JSON array from markdown-wrapped response", () => { + const input = "Here are the suggestions:\n```json\n" + JSON.stringify([ + { projectId: "1", activityTypeId: "2", hours: 1, description: "Test" }, + ]) + "\n```" + const result = parseSuggestions(input) + expect(result).toHaveLength(1) + expect(result[0].projectId).toBe("1") + }) + + it("rounds hours to nearest 0.5, minimum 0.5", () => { + const input = JSON.stringify([ + { projectId: "1", activityTypeId: "2", hours: 0.3, description: "Short task" }, + { projectId: "1", activityTypeId: "2", hours: 1.7, description: "Medium task" }, + { projectId: "1", activityTypeId: "2", hours: 0.1, description: "Tiny task" }, + ]) + const result = parseSuggestions(input) + expect(result[0].hours).toBe(0.5) // 0.3 rounds to 0.5 (minimum) + expect(result[1].hours).toBe(1.5) // 1.7 rounds to 1.5 + expect(result[2].hours).toBe(0.5) // 0.1 rounds to 0.5 (minimum) + }) + + it("defaults confidence to medium for invalid values", () => { + const input = JSON.stringify([ + { projectId: "1", activityTypeId: "2", hours: 1, description: "Test", confidence: "invalid" }, + { projectId: "1", activityTypeId: "2", hours: 1, description: "Test" }, + ]) + const result = parseSuggestions(input) + expect(result[0].confidence).toBe("medium") + expect(result[1].confidence).toBe("medium") + }) + + it("handles truncated JSON response by salvaging complete objects", () => { + const complete = { projectId: "1", activityTypeId: "2", hours: 1, description: "First" } + const input = "[" + JSON.stringify(complete) + ",{\"projectId\":\"2\",\"activ" + const result = parseSuggestions(input) + expect(result).toHaveLength(1) + expect(result[0].projectId).toBe("1") + }) + + it("throws on completely unparseable input", () => { + expect(() => parseSuggestions("not json at all")).toThrow() + }) + + it("assigns unique IDs to each suggestion", () => { + const input = JSON.stringify([ + { projectId: "1", activityTypeId: "2", hours: 1, description: "A" }, + { projectId: "1", activityTypeId: "2", hours: 1, description: "B" }, + ]) + const result = parseSuggestions(input) + expect(result[0].id).toBeTruthy() + expect(result[1].id).toBeTruthy() + expect(result[0].id).not.toBe(result[1].id) + }) + + it("parses internalNote when present", () => { + const input = JSON.stringify([ + { + projectId: "1", + activityTypeId: "2", + hours: 1, + description: "Client-facing text", + internalNote: "Technical context here", + }, + ]) + const result = parseSuggestions(input) + expect(result[0].internalNote).toBe("Technical context here") + }) + + it("handles empty sourceActivities gracefully", () => { + const input = JSON.stringify([ + { projectId: "1", activityTypeId: "2", hours: 1, description: "Test", sourceActivities: null }, + ]) + const result = parseSuggestions(input) + expect(result[0].sourceActivities).toEqual([]) + }) +}) diff --git a/app/lib/ai/__tests__/preprocess.test.ts b/app/lib/ai/__tests__/preprocess.test.ts new file mode 100644 index 0000000..cb7b9c5 --- /dev/null +++ b/app/lib/ai/__tests__/preprocess.test.ts @@ -0,0 +1,119 @@ +import { describe, it, expect } from "vitest" +import { preprocessActivities } from "../preprocess" + +function makeHourData(primaries: any[] = [], communications: any[] = []) { + return { primaries, communications } +} + +function makeCalendarEvent(hour: number, durationMinutes: number, title = "Meeting") { + const timestamp = `2026-03-15T${String(hour).padStart(2, "0")}:00:00Z` + const endTime = new Date(new Date(timestamp).getTime() + durationMinutes * 60000).toISOString() + return { source: "calendar", type: "event", title, timestamp, endTime } +} + +function makeSlackMessage(hour: number, channel: string, isDm = false) { + return { + source: "slack", + type: "message", + channel, + isDm, + timestamp: `2026-03-15T${String(hour).padStart(2, "0")}:15:00Z`, + } +} + +function makeGmailActivity(hour: number, subject: string, from = "user@example.com") { + return { + source: "gmail", + type: "email", + subject, + from, + timestamp: `2026-03-15T${String(hour).padStart(2, "0")}:20:00Z`, + } +} + +describe("preprocessActivities", () => { + it("flattens activities from hour buckets into a sorted list", () => { + const hours = { + "9": makeHourData([makeCalendarEvent(9, 60)], [makeSlackMessage(9, "general")]), + "10": makeHourData([], [makeSlackMessage(10, "dev")]), + } + const result = preprocessActivities(hours) + expect(result.activities).toHaveLength(3) + // Should be sorted by timestamp + expect(result.activities[0].source).toBe("calendar") + expect(result.activities[1].source).toBe("slack") + expect(result.activities[2].source).toBe("slack") + }) + + it("calculates calendar minutes from events with duration", () => { + const hours = { + "9": makeHourData([makeCalendarEvent(9, 60)]), + "10": makeHourData([makeCalendarEvent(10, 30)]), + } + const result = preprocessActivities(hours) + expect(result.calendarMinutes).toBe(90) + }) + + it("skips spanning entries to avoid double-counting", () => { + const event = makeCalendarEvent(9, 120) + const hours = { + "9": makeHourData([{ ...event, isSpanning: false, spanStart: true }]), + "10": makeHourData([{ ...event, isSpanning: true }]), + } + const result = preprocessActivities(hours) + // Only the non-spanning entry should be counted + expect(result.activities).toHaveLength(1) + }) + + it("filters out calendar invite emails", () => { + const hours = { + "9": makeHourData([], [ + makeGmailActivity(9, "Meeting invite from John"), + makeGmailActivity(9, "Real email about project"), + ]), + } + const result = preprocessActivities(hours) + expect(result.activities).toHaveLength(1) + expect(result.activities[0].title).toContain("Real email") + }) + + it("generates correct titles for different sources", () => { + const hours = { + "9": makeHourData( + [makeCalendarEvent(9, 30, "Sprint Planning")], + [ + makeSlackMessage(9, "general"), + makeSlackMessage(9, "Alice", true), + { source: "docs", type: "Edited", title: "Design Doc", timestamp: "2026-03-15T09:30:00Z" }, + { source: "github", repoName: "my-repo", title: "Fix bug #123", timestamp: "2026-03-15T09:35:00Z" }, + { source: "jira", issueKey: "PROJ-42", issueSummary: "Implement login", timestamp: "2026-03-15T09:40:00Z" }, + ] + ), + } + const result = preprocessActivities(hours) + const titles = result.activities.map((a) => a.title) + expect(titles).toContain("Sprint Planning") + expect(titles).toContain("#general") + expect(titles).toContain("DM: Alice") + expect(titles).toContain("Edited: Design Doc") + expect(titles.find((t) => t.includes("my-repo"))).toBeTruthy() + expect(titles.find((t) => t.includes("PROJ-42"))).toBeTruthy() + }) + + it("deduplicates activities by source+timestamp+title key", () => { + const slack = makeSlackMessage(9, "general") + const hours = { + "9": makeHourData([], [slack, slack]), + } + const result = preprocessActivities(hours) + expect(result.activities).toHaveLength(1) + }) + + it("returns zero values for empty input", () => { + const result = preprocessActivities({}) + expect(result.activities).toHaveLength(0) + expect(result.calendarMinutes).toBe(0) + expect(result.gapMinutes).toBe(0) + expect(result.totalActiveMinutes).toBe(0) + }) +}) diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..241bfac --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from "vitest/config" +import path from "path" + +export default defineConfig({ + resolve: { + alias: { + "@": path.resolve(__dirname, "."), + }, + }, + test: { + environment: "node", + }, +})