Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 111 additions & 0 deletions app/lib/__tests__/aggregator.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, any> = {}) {
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)
})
})
104 changes: 104 additions & 0 deletions app/lib/ai/__tests__/parse.test.ts
Original file line number Diff line number Diff line change
@@ -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([])
})
})
119 changes: 119 additions & 0 deletions app/lib/ai/__tests__/preprocess.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
13 changes: 13 additions & 0 deletions vitest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { defineConfig } from "vitest/config"
import path from "path"

export default defineConfig({
resolve: {
alias: {
"@": path.resolve(__dirname, "."),
},
},
test: {
environment: "node",
},
})