Skip to content

Conversation

@elie222
Copy link
Owner

@elie222 elie222 commented Nov 10, 2025

Summary by CodeRabbit

  • New Features
    • Added email response-time analytics API: median, average, percent answered within one hour, response rate, distribution buckets, weekly trend, and prior-period comparison.
  • Tests
    • Added comprehensive test suite validating response-time calculations, distribution bucketing, trend and summary metrics, and edge-case behaviors.

✏️ Tip: You can customize this high-level summary in your review settings.

@vercel
Copy link

vercel bot commented Nov 10, 2025

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Preview Updated (UTC)
inbox-zero Error Error Nov 22, 2025 11:39pm

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Nov 10, 2025

Note

Other AI code review bot(s) detected

CodeRabbit has detected other AI code review bot(s) in this pull request and will avoid duplicating their findings in the review comments. This may lead to a less comprehensive review.

Walkthrough

Adds a new Next.js GET API route at apps/web/app/api/user/stats/response-time/route.ts that computes email response-time analytics (per-thread response times, summary, distribution, weekly trend, and previous-period comparison) and a comprehensive test suite validating the calculations.

Changes

Cohort / File(s) Summary
Response-time stats endpoint
apps/web/app/api/user/stats/response-time/route.ts
New GET route and exported helpers: parses fromDate/toDate, fetches sent messages, loads thread messages, computes per-thread responseTimeMinutes, builds summary (median, average, responseRate, within1Hour), distribution (time buckets), weekly trend (medians/counts), previous-period comparison, and exposes getResponseTimeStats, ResponseTimeParams, and ResponseTimeResponse.
Tests for response-time logic
apps/web/app/api/user/stats/response-time/route.test.ts
New test suite covering calculateResponseTimes, summary/distribution/trend calculations, multi-step thread scenarios, fallback cases (missing SENT label), bucket boundaries and counts; uses mocked EmailProvider and Logger.

Sequence Diagram(s)

sequenceDiagram
    participant Client as Client
    participant Handler as GET /response-time
    participant Provider as EmailProvider
    participant Logger as Logger
    participant Aggregator as Aggregation Logic

    Client->>Handler: GET /api/user/stats/response-time?fromDate=&toDate=
    Handler->>Handler: validate query params
    Handler->>Provider: fetchSentMessages(fromDate,toDate)
    
    rect rgb(220,240,230)
    Note over Handler,Provider: iterate sent threads
    Handler->>Provider: getThreadMessages(threadId)
    alt thread loaded
        Handler->>Handler: sort messages by internalDate
        Handler->>Handler: detect RECEIVED -> SENT pairs
        Handler->>Aggregator: emit ResponseTimeEntry (minutes)
    else error loading thread
        Provider-->>Logger: log error and continue
    end
    end

    rect rgb(240,240,255)
    Handler->>Aggregator: calculateSummaryStats(entries)
    Handler->>Aggregator: calculateDistribution(entries)
    Handler->>Aggregator: calculateTrend(entries)
    end

    rect rgb(255,240,240)
    Note over Handler: if fromDate/toDate present
    Handler->>Handler: compute previous period stats (recursive/fetch)
    end

    Handler->>Client: JSON { summary, distribution, trend }
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

  • Pay extra attention to:
    • Pairing logic in calculateResponseTimes (edge cases and ordering).
    • Previous-period window calculation and off-by-one/date-range handling.
    • Distribution bucket boundaries and rounding rules.
    • Tests covering multi-step thread sequences and fallback behavior.

Possibly related PRs

  • Pass logger from req #934 — related to passing a per-request logger and withEmailProvider handler signature changes that affect the new route's middleware/handler contract.

Suggested reviewers

  • anakarentorosserrano-star

Poem

🐇 I hop through threads both near and far,
Counting minutes between reply and star,
Buckets and medians, weekly trend in sight,
I nibble on timestamps and store answers right.
Tests pass — I twitch my nose with delight!

Pre-merge checks and finishing touches

❌ Failed checks (1 inconclusive)
Check name Status Explanation Resolution
Title check ❓ Inconclusive The title 'WIP: response time tracking' is vague and uses the WIP prefix indicating work-in-progress status, which doesn't clearly describe the specific change being implemented. Replace with a more descriptive title that explains the actual change, such as 'Add response-time analytics API endpoint' or 'Implement email response time tracking feature'.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/response-time-analytics

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

3 issues found across 1 file

Prompt for AI agents (all 3 issues)

Understand the root cause of the following 3 issues and fix them.


<file name="apps/web/app/api/user/stats/response-time/route.ts">

<violation number="1" location="apps/web/app/api/user/stats/response-time/route.ts:106">
The received filter compares against the recipient address, so the customer&#39;s reply is excluded and your own outbound message is treated as inbound; switch to the sender address so we keep actual customer responses.</violation>

<violation number="2" location="apps/web/app/api/user/stats/response-time/route.ts:118">
This filter also checks against the recipient address, so inbound replies are treated as your own outbound messages; use the sender address to capture actual user-sent mail.</violation>

<violation number="3" location="apps/web/app/api/user/stats/response-time/route.ts:255">
Guard the previous-period recursion when `differenceInDays` returns 0 (same-day range); otherwise this calls itself with identical parameters and recurses forever.</violation>
</file>

React with 👍 or 👎 to teach cubic. Mention @cubic-dev-ai to give feedback, ask questions, or re-run the review.

const previousFromDate = subDays(new Date(fromDate), currentPeriodDays);
const previousToDate = new Date(fromDate);

const previousPeriodStats = await getResponseTimeStats({
Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot Nov 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Guard the previous-period recursion when differenceInDays returns 0 (same-day range); otherwise this calls itself with identical parameters and recurses forever.

Prompt for AI agents
Address the following comment on apps/web/app/api/user/stats/response-time/route.ts at line 255:

<comment>Guard the previous-period recursion when `differenceInDays` returns 0 (same-day range); otherwise this calls itself with identical parameters and recurses forever.</comment>

<file context>
@@ -0,0 +1,309 @@
+    const previousFromDate = subDays(new Date(fromDate), currentPeriodDays);
+    const previousToDate = new Date(fromDate);
+
+    const previousPeriodStats = await getResponseTimeStats({
+      fromDate: previousFromDate.getTime(),
+      toDate: previousToDate.getTime(),
</file context>
Fix with Cubic

.filter(
(m) =>
m.internalDate &&
m.headers.from?.includes(sentMsg.headers.to || ""),
Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot Nov 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This filter also checks against the recipient address, so inbound replies are treated as your own outbound messages; use the sender address to capture actual user-sent mail.

Prompt for AI agents
Address the following comment on apps/web/app/api/user/stats/response-time/route.ts at line 118:

<comment>This filter also checks against the recipient address, so inbound replies are treated as your own outbound messages; use the sender address to capture actual user-sent mail.</comment>

<file context>
@@ -0,0 +1,309 @@
+        .filter(
+          (m) =&gt;
+            m.internalDate &amp;&amp;
+            m.headers.from?.includes(sentMsg.headers.to || &quot;&quot;),
+        )
+        .sort(
</file context>

✅ Addressed in 6b48d85

.filter(
(m) =>
m.internalDate &&
!m.headers.from?.includes(sentMsg.headers.to || ""),
Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot Nov 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The received filter compares against the recipient address, so the customer's reply is excluded and your own outbound message is treated as inbound; switch to the sender address so we keep actual customer responses.

Prompt for AI agents
Address the following comment on apps/web/app/api/user/stats/response-time/route.ts at line 106:

<comment>The received filter compares against the recipient address, so the customer&#39;s reply is excluded and your own outbound message is treated as inbound; switch to the sender address so we keep actual customer responses.</comment>

<file context>
@@ -0,0 +1,309 @@
+        .filter(
+          (m) =&gt;
+            m.internalDate &amp;&amp;
+            !m.headers.from?.includes(sentMsg.headers.to || &quot;&quot;),
+        )
+        .sort(
</file context>

✅ Addressed in 6b48d85

@elie222 elie222 changed the base branch from feat/outlook-sub-history to main November 22, 2025 23:08
@macroscopeapp
Copy link

macroscopeapp bot commented Nov 22, 2025

Add response time tracking and expose GET endpoint in apps/web/app/api/user/stats/response-time/route.ts returning summary (median, average, <=60m), distribution (<1h, 1–4h, 4–24h, 1–3d, 3–7d, >7d), and weekly trend

Implement GET handler that computes response-time stats, including per-thread pairing of received→sent messages, summary with previous-period comparison when fromDate/toDate are provided, fixed-threshold distribution, and weekly median trend; add Vitest coverage for calculators and pairing logic.

📍Where to Start

Start with the GET handler and getResponseTimeStats orchestration in route.ts.


Macroscope summarized 0456674.

})
.sort((a, b) => a.periodDate.getTime() - b.periodDate.getTime());

// Calculate previous period comparison
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unbounded recursion when computing the previous period. getResponseTimeStats calls itself with another prior window, which never terminates (and loops immediately when differenceInDays is 0). Consider adding a guard (e.g., skip when currentPeriodDays <= 0) and/or a depth cap/flag to disable the previous-period calculation in recursive calls.

🚀 Reply to ask Macroscope to explain or update this suggestion.

👍 Helpful? React to give us feedback.

// Fetch sent messages in the date range
const sentMessages = await emailProvider.getMessagesByFields({
type: "sent",
...(fromDate ? { after: new Date(fromDate) } : {}),
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using truthy checks on fromDate/toDate drops valid 0 (Unix epoch). Consider checking for null/undefined instead (e.g., fromDate != null, toDate != null) in the spread filters and the previous-period if so 0 is respected.

🚀 Reply to ask Macroscope to explain or update this suggestion.

👍 Helpful? React to give us feedback.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (2)
apps/web/app/api/user/stats/response-time/route.ts (2)

101-125: Inbound vs outbound filters are inverted (using recipient instead of sender).

receivedMessages and sentMessagesInThread both compare headers.from against sentMsg.headers.to, which is the recipient address of your original outbound email. This:

  • Treats your own outbound messages as “received”.
  • Excludes actual customer replies from receivedMessages.

You almost certainly want to distinguish based on the sender (your address) instead:

  • “Sent” = messages whose from matches the original sender (sentMsg.headers.from).
  • “Received” = messages whose from does not match that sender.

A minimal fix:

-      const receivedMessages = threadMessages
-        .filter(
-          (m) =>
-            m.internalDate &&
-            !m.headers.from?.includes(sentMsg.headers.to || ""),
-        )
+      const receivedMessages = threadMessages
+        .filter(
+          (m) =>
+            m.internalDate &&
+            !m.headers.from?.includes(sentMsg.headers.from || ""),
+        )
@@
-      const sentMessagesInThread = threadMessages
-        .filter(
-          (m) =>
-            m.internalDate &&
-            m.headers.from?.includes(sentMsg.headers.to || ""),
-        )
+      const sentMessagesInThread = threadMessages
+        .filter(
+          (m) =>
+            m.internalDate &&
+            m.headers.from?.includes(sentMsg.headers.from || ""),
+        )

This aligns with the previous Cubic comment and ensures response times are computed between actual customer messages and your replies.


245-273: Guard previous‑period recursion to avoid infinite loop for zero‑length ranges.

If differenceInDays returns 0 (same‑day or otherwise zero‑day range), the computed previous window is identical (fromDatefromDate), and getResponseTimeStats recursively calls itself with the same parameters indefinitely.

You should skip the previous‑period computation when the day span is ≤ 0:

   // Calculate previous period comparison
   let previousPeriodComparison = null;
   if (fromDate && toDate) {
     const currentPeriodDays = differenceInDays(
       new Date(toDate),
       new Date(fromDate),
     );
-    const previousFromDate = subDays(new Date(fromDate), currentPeriodDays);
-    const previousToDate = new Date(fromDate);
-
-    const previousPeriodStats = await getResponseTimeStats({
-      fromDate: previousFromDate.getTime(),
-      toDate: previousToDate.getTime(),
-      emailProvider,
-      logger,
-    });
-
-    if (previousPeriodStats.summary.medianResponseTime > 0) {
-      const percentChange =
-        ((medianResponseTime - previousPeriodStats.summary.medianResponseTime) /
-          previousPeriodStats.summary.medianResponseTime) *
-        100;
-
-      previousPeriodComparison = {
-        medianResponseTime: previousPeriodStats.summary.medianResponseTime,
-        percentChange: Math.round(percentChange),
-      };
-    }
+    if (currentPeriodDays > 0) {
+      const previousFromDate = subDays(new Date(fromDate), currentPeriodDays);
+      const previousToDate = new Date(fromDate);
+
+      const previousPeriodStats = await getResponseTimeStats({
+        fromDate: previousFromDate.getTime(),
+        toDate: previousToDate.getTime(),
+        emailProvider,
+        logger,
+      });
+
+      if (previousPeriodStats.summary.medianResponseTime > 0) {
+        const percentChange =
+          ((medianResponseTime -
+            previousPeriodStats.summary.medianResponseTime) /
+            previousPeriodStats.summary.medianResponseTime) *
+          100;
+
+        previousPeriodComparison = {
+          medianResponseTime: previousPeriodStats.summary.medianResponseTime,
+          percentChange: Math.round(percentChange),
+        };
+      }
+    }
   }
In date-fns v4.1.0, what does `differenceInDays` return when both arguments are the same date, and can it be zero for valid ranges?
🧹 Nitpick comments (3)
apps/web/app/api/user/stats/response-time/route.ts (3)

44-49: periodDate is typed as Date but serialized to JSON as a string.

The trend type currently exposes periodDate: Date, and ResponseTimeResponse re‑exports that return type, but NextResponse.json will serialize the Date to an ISO string:

trend: Array<{
  period: string;
  periodDate: Date;
  ...
}>
...
return {
  period: format(date, "LLL dd, y"),
  periodDate: date,      // becomes string over the wire
  ...
};

On the client, anything using ResponseTimeResponse will think periodDate is a Date instance but actually receive a string.

Consider normalizing this to a string (or number) in both type and implementation, e.g.:

-  trend: Array<{
-    period: string;
-    periodDate: Date;
-    medianResponseTime: number;
-    count: number;
-  }>;
+  trend: Array<{
+    period: string;
+    periodDate: string; // ISO string
+    medianResponseTime: number;
+    count: number;
+  }>;

and:

-      return {
-        period: format(date, "LLL dd, y"),
-        periodDate: date,
-        medianResponseTime: Math.round(median),
-        count: values.length,
-      };
+      return {
+        period: format(date, "LLL dd, y"),
+        periodDate: date.toISOString(),
+        medianResponseTime: Math.round(median),
+        count: values.length,
+      };

This keeps the API contract honest and avoids subtle client bugs.

Also applies to: 236-241, 278-288, 291-293


91-153: Sequential getThreadMessages calls may be slow for larger datasets.

The for...of loop awaits emailProvider.getThreadMessages one thread at a time. With up to 500 sent messages, this can mean hundreds of sequential network calls and a very slow endpoint.

Consider batching with a bounded concurrency pattern, for example:

  • Build an array of async tasks per unique threadId.
  • Use Promise.all with a simple pool/limit (e.g. p-limit or a hand‑rolled queue) to run, say, 5–10 in parallel while keeping rate limits in mind.

This should materially improve response times without changing the external behavior.


11-14: Handle query param validation errors with 400 instead of 500.

The current code at line 295 uses responseTimeParams.parse() without error handling. When invalid query params are passed (e.g., ?fromDate=abc), z.coerce.number() will throw a ZodError that bubbles up as a 500 error instead of the expected 400 client error.

Switch to safeParse() and return a 400 JSON error on validation failure:

const result = responseTimeParams.safeParse({
  fromDate: searchParams.get("fromDate"),
  toDate: searchParams.get("toDate"),
});

if (!result.success) {
  return NextResponse.json(
    { error: "Invalid query parameters", details: result.error.flatten() },
    { status: 400 }
  );
}

const params = result.data;

Also applies to: 295-309

📜 Review details

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 7c6c783 and 93896ee.

📒 Files selected for processing (1)
  • apps/web/app/api/user/stats/response-time/route.ts (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
apps/web/app/api/user/stats/response-time/route.ts (2)
apps/web/utils/logger.ts (1)
  • Logger (5-5)
apps/web/utils/middleware.ts (1)
  • withEmailProvider (401-414)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (5)
  • GitHub Check: cubic · AI code reviewer
  • GitHub Check: Jit Security
  • GitHub Check: test
  • GitHub Check: Review for correctness
  • GitHub Check: Analyze (javascript-typescript)

Comment on lines 275 to 283
// Calculate response rate (threads with reply vs total sent)
const responseRate = 100; // All threads in our dataset have replies by definition

return {
summary: {
medianResponseTime: Math.round(medianResponseTime),
averageResponseTime: Math.round(averageResponseTime),
responseRate: Math.round(responseRate),
within1Hour: Math.round(within1Hour),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

responseRate is hardcoded to 100%, which makes the metric misleading.

Right now:

// Calculate response rate (threads with reply vs total sent)
const responseRate = 100; // All threads in our dataset have replies by definition

But you only push to responseTimes when a valid “received → sent” pair exists; threads without a qualifying reply are effectively invisible. Exposing responseRate: 100 will always claim perfect performance, regardless of reality.

Options:

  • Compute a real rate, e.g. responseTimes.length / processedThreads.size * 100, or
  • Drop/rename this field until you track unanswered threads explicitly.

I’d recommend at least removing the hardcoded 100% to avoid shipping a clearly incorrect metric.

🤖 Prompt for AI Agents
In apps/web/app/api/user/stats/response-time/route.ts around lines 275-283 the
responseRate is hardcoded to 100 which is misleading; compute an actual
percentage instead by dividing the number of threads with valid response pairs
(responseTimes.length) by the total number of processed threads
(processedThreads.size or equivalent), multiply by 100 and round, and guard
against division by zero (return 0 if no processed threads); alternatively
remove/rename the field if you cannot accurately track unanswered threads yet.

Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

3 issues found across 1 file

Prompt for AI agents (all 3 issues)

Understand the root cause of the following 3 issues and fix them.


<file name="apps/web/app/api/user/stats/response-time/route.ts">

<violation number="1" location="apps/web/app/api/user/stats/response-time/route.ts:106">
Received/sent classification compares sender headers to the outgoing recipients, so the buckets are inverted and the reported response times track customer replies instead of the user’s responses.</violation>

<violation number="2" location="apps/web/app/api/user/stats/response-time/route.ts:248">
`differenceInDays` can return 0 for same‑day ranges, so the recursive previous‑period call runs with the exact same dates and never terminates, leading to infinite recursion for short queries.</violation>

<violation number="3" location="apps/web/app/api/user/stats/response-time/route.ts:276">
Hardcoding the responseRate to 100% always reports perfect performance even if some processed threads never receive a qualifying reply; derive the percentage from the ratio of threads with measured response times to total processed threads.</violation>
</file>

Reply to cubic to teach it or ask questions. Re-run a review with @cubic-dev-ai review this PR

// Calculate previous period comparison
let previousPeriodComparison = null;
if (fromDate && toDate) {
const currentPeriodDays = differenceInDays(
Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot Nov 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

differenceInDays can return 0 for same‑day ranges, so the recursive previous‑period call runs with the exact same dates and never terminates, leading to infinite recursion for short queries.

Prompt for AI agents
Address the following comment on apps/web/app/api/user/stats/response-time/route.ts at line 248:

<comment>`differenceInDays` can return 0 for same‑day ranges, so the recursive previous‑period call runs with the exact same dates and never terminates, leading to infinite recursion for short queries.</comment>

<file context>
@@ -0,0 +1,309 @@
+  // Calculate previous period comparison
+  let previousPeriodComparison = null;
+  if (fromDate &amp;&amp; toDate) {
+    const currentPeriodDays = differenceInDays(
+      new Date(toDate),
+      new Date(fromDate),
</file context>

✅ Addressed in 6b48d85

.filter(
(m) =>
m.internalDate &&
!m.headers.from?.includes(sentMsg.headers.to || ""),
Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot Nov 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Received/sent classification compares sender headers to the outgoing recipients, so the buckets are inverted and the reported response times track customer replies instead of the user’s responses.

Prompt for AI agents
Address the following comment on apps/web/app/api/user/stats/response-time/route.ts at line 106:

<comment>Received/sent classification compares sender headers to the outgoing recipients, so the buckets are inverted and the reported response times track customer replies instead of the user’s responses.</comment>

<file context>
@@ -0,0 +1,309 @@
+        .filter(
+          (m) =&gt;
+            m.internalDate &amp;&amp;
+            !m.headers.from?.includes(sentMsg.headers.to || &quot;&quot;),
+        )
+        .sort(
</file context>

✅ Addressed in 6b48d85

}

// Calculate response rate (threads with reply vs total sent)
const responseRate = 100; // All threads in our dataset have replies by definition
Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot Nov 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hardcoding the responseRate to 100% always reports perfect performance even if some processed threads never receive a qualifying reply; derive the percentage from the ratio of threads with measured response times to total processed threads.

Prompt for AI agents
Address the following comment on apps/web/app/api/user/stats/response-time/route.ts at line 276:

<comment>Hardcoding the responseRate to 100% always reports perfect performance even if some processed threads never receive a qualifying reply; derive the percentage from the ratio of threads with measured response times to total processed threads.</comment>

<file context>
@@ -0,0 +1,309 @@
+  }
+
+  // Calculate response rate (threads with reply vs total sent)
+  const responseRate = 100; // All threads in our dataset have replies by definition
+
+  return {
</file context>
Suggested change
const responseRate = 100; // All threads in our dataset have replies by definition
const responseRate = processedThreads.size === 0 ? 0 : (responseTimes.length / processedThreads.size) * 100;
Fix with Cubic

const responseTimes: ResponseTimeEntry[] = [];
const processedThreads = new Set<string>();

const sentLabelId = "SENT";
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sent/received detection is provider-specific. Hardcoding Gmail "SENT" and falling back to the first 100 sentMessages can misclassify messages (e.g., Microsoft), skewing response times. Consider using emailProvider.isSentMessage(message) for reliable, provider-aware detection.

-  const sentLabelId = "SENT";
@@
-        // Determine if message is sent or received
-        let isSent = false;
-        if (message.labelIds?.includes(sentLabelId)) {
-          isSent = true;
-        }
-
-        // If we still haven't matched, fallback to checking if this specific message is in our known sent list
-        // (Only efficient if sentMessages is small, but we capped it at 100)
-        if (!isSent) {
-          // Optimize: check id match
-          if (sentMessages.some((sm: any) => sm.id === message.id)) {
-            isSent = true;
-          }
-        }
+        // Determine if message is sent or received
+        const isSent = emailProvider.isSentMessage(message);

🚀 Reply to ask Macroscope to explain or update this suggestion.

👍 Helpful? React to give us feedback.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

♻️ Duplicate comments (4)
apps/web/app/api/user/stats/response-time/route.ts (4)

81-82: Guard against dropping valid epoch timestamp (0).

The truthy checks fromDate ? ... and toDate ? ... will omit the filter when the timestamp is 0 (Unix epoch). Use explicit nullish checks instead.

Apply this diff:

-    ...(fromDate ? { after: new Date(fromDate) } : {}),
-    ...(toDate ? { before: new Date(toDate) } : {}),
+    ...(fromDate != null ? { after: new Date(fromDate) } : {}),
+    ...(toDate != null ? { before: new Date(toDate) } : {}),

216-245: Prevent infinite recursion for same-day ranges.

When differenceInDays returns 0, the recursive call to getResponseTimeStats receives identical date parameters, causing infinite recursion.

Apply this diff to guard the recursion:

 async function calculatePreviousPeriodComparison(
   fromDate: number | null | undefined,
   toDate: number | null | undefined,
   currentMedian: number,
   emailProvider: EmailProvider,
   logger: Logger,
 ): Promise<SummaryStats["previousPeriodComparison"]> {
   if (!fromDate || !toDate) return null;
 
   const currentDays = differenceInDays(new Date(toDate), new Date(fromDate));
+  // Guard against infinite recursion for same-day or invalid ranges
+  if (currentDays <= 0) return null;
+
   const prevFrom = subDays(new Date(fromDate), currentDays);
   const prevTo = new Date(fromDate);

271-271: Compute actual response rate instead of hardcoding 100%.

Hardcoding responseRate: 100 is misleading. Threads without qualifying received→sent pairs are excluded from responseTimes, making the reported rate inaccurate.

The actual rate should reflect the ratio of threads with measured responses to total processed threads. However, processedThreads is local to calculateResponseTimes and not accessible here. Consider one of these approaches:

Option 1: Return processedThreads.size from calculateResponseTimes and pass it to calculateSummaryStats:

 export async function calculateResponseTimes(
   sentMessages: any[],
   emailProvider: EmailProvider,
   logger: Logger,
-): Promise<ResponseTimeEntry[]> {
+): Promise<{ responseTimes: ResponseTimeEntry[]; totalThreads: number }> {
   const responseTimes: ResponseTimeEntry[] = [];
   const processedThreads = new Set<string>();
   
   // ... existing logic ...
   
-  return responseTimes;
+  return { responseTimes, totalThreads: processedThreads.size };
 }

Then update calculateSummaryStats:

 export async function calculateSummaryStats(
   responseTimes: ResponseTimeEntry[],
+  totalThreads: number,
   fromDate: number | null | undefined,
   toDate: number | null | undefined,
   emailProvider: EmailProvider,
   logger: Logger,
 ): Promise<SummaryStats> {
   const values = responseTimes.map((r) => r.responseTimeMinutes);
   
   // ...
   
   return {
     medianResponseTime: Math.round(medianResponseTime),
     averageResponseTime: Math.round(averageResponseTime),
-    responseRate: 100,
+    responseRate: totalThreads === 0 ? 0 : Math.round((responseTimes.length / totalThreads) * 100),
     within1Hour: Math.round(within1Hour),
     previousPeriodComparison,
   };
 }

Option 2: Remove or rename the field until you can accurately track unanswered threads.


223-223: Use nullish check for consistency.

Similar to lines 81-82, the truthy check here will reject valid 0 timestamps.

Apply this diff:

-  if (!fromDate || !toDate) return null;
+  if (fromDate == null || toDate == null) return null;
🧹 Nitpick comments (3)
apps/web/app/api/user/stats/response-time/route.test.ts (3)

11-11: Remove unused import.

The import on line 11 is shadowed by line 20 and never used.

Apply this diff:

-import { getMockMessage } from "@/utils/test/helpers";
 import type { EmailProvider } from "@/utils/email/types";

197-219: Consider adding test coverage for edge cases.

The current test doesn't verify:

  • The responseRate field (currently hardcoded to 100%)
  • The previousPeriodComparison logic (requires non-null fromDate/toDate)
  • Edge cases like empty arrays or single-value arrays

Consider adding tests like:

it("should handle empty response times", async () => {
  const result = await calculateSummaryStats(
    [],
    null,
    null,
    mockProvider,
    mockLogger,
  );
  
  expect(result.averageResponseTime).toBe(0);
  expect(result.medianResponseTime).toBe(0);
  expect(result.within1Hour).toBe(0);
});

it("should calculate previous period comparison when dates provided", async () => {
  // Test with fromDate/toDate to verify previousPeriodComparison
  // Note: This would require mocking getResponseTimeStats
});

221-238: Consider testing bucket boundaries.

The current test verifies basic bucketing but doesn't test boundary values that might be off-by-one errors.

Consider adding a test for exact boundaries:

it("should handle bucket boundaries correctly", () => {
  const responseTimes = [
    { responseTimeMinutes: 59 },    // < 1h (edge)
    { responseTimeMinutes: 60 },    // 1-4h (boundary)
    { responseTimeMinutes: 239 },   // 1-4h (edge)
    { responseTimeMinutes: 240 },   // 4-24h (boundary)
    { responseTimeMinutes: 1440 },  // 1-3d (boundary)
  ] as any[];

  const result = calculateDistribution(responseTimes);
  
  expect(result.lessThan1Hour).toBe(1);
  expect(result.oneToFourHours).toBe(2);
  expect(result.fourTo24Hours).toBe(1);
  expect(result.oneToThreeDays).toBe(1);
});
📜 Review details

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 93896ee and 6b48d85.

📒 Files selected for processing (2)
  • apps/web/app/api/user/stats/response-time/route.test.ts (1 hunks)
  • apps/web/app/api/user/stats/response-time/route.ts (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (2)
apps/web/app/api/user/stats/response-time/route.test.ts (2)
apps/web/utils/logger.ts (1)
  • Logger (5-5)
apps/web/app/api/user/stats/response-time/route.ts (3)
  • calculateResponseTimes (119-193)
  • calculateSummaryStats (247-275)
  • calculateDistribution (277-289)
apps/web/app/api/user/stats/response-time/route.ts (2)
apps/web/utils/middleware.ts (1)
  • withEmailProvider (401-414)
apps/web/utils/logger.ts (1)
  • Logger (5-5)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (5)
  • GitHub Check: Static Code Analysis Js
  • GitHub Check: Jit Security
  • GitHub Check: cubic · AI code reviewer
  • GitHub Check: test
  • GitHub Check: Review for correctness

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

♻️ Duplicate comments (5)
apps/web/app/api/user/stats/response-time/route.ts (5)

122-131: Sent/received detection is Gmail-specific; prefer provider-aware helper

Classification currently relies on the hardcoded "SENT" label and a fallback membership in the initial sentMessages list. That’s fine for Gmail but will misclassify messages for other providers (e.g. Outlook) that don’t share the same label semantics.

If your EmailProvider exposes something like emailProvider.isSentMessage(message), prefer delegating the classification to that helper so each provider can encode its own rules. This also centralizes any future tweaks in one place.

Also applies to: 155-165


12-16: Invalid timestamps become NaN and can bubble into Date logic / 500s; tighten schema or treat as bad request

z.coerce.number().nullish() will happily coerce invalid query strings (e.g. ?fromDate=abc or empty) to NaN, which then flows into new Date(fromDate) / differenceInDays and can cause unpredictable behavior or 500s rather than a clean 400. Consider refining to finite numbers and/or using safeParse to return a 400 for bad params, e.g.:

const timestamp = z
  .preprocess((value) => {
    if (value == null) return undefined;
    const n = Number(value);
    return Number.isFinite(n) ? n : undefined;
  }, z.number().optional());

const responseTimeSchema = z.object({
  fromDate: timestamp,
  toDate: timestamp,
});

and handle safeParse failure in the handler as a 400 instead of throwing.

Also applies to: 52-57


83-86: Truthiness checks on fromDate/toDate drop valid 0 (Unix epoch) and conflate “absent” with “0”

Both the getMessagesByFields filters and calculatePreviousPeriodComparison use truthy checks (fromDate ? … / if (!fromDate || !toDate)) so a valid 0 timestamp is treated as missing, and null/undefined/0 are all folded together. If 0 is meant to be valid, prefer explicit nullish checks:

// Fetch
...(fromDate != null ? { after: new Date(fromDate) } : {}),
...(toDate != null ? { before: new Date(toDate) } : {}),

// Previous-period guard
if (fromDate == null || toDate == null) return null;

This also pairs better with a refined schema that rejects NaN.

Also applies to: 217-225


68-87: Previous-period calculation causes unbounded recursion (and same-range loops when day-diff ≤ 0)

calculateSummaryStats always calls calculatePreviousPeriodComparison, which calls getResponseTimeStats again with another window; that inner call again computes a previous period, and so on without a base case. When differenceInDays returns 0 (same-day range) you even recurse with identical dates. This will eventually stack overflow for any request with fromDate/toDate.

You need both (a) a guard on the day span and (b) a way to disable previous-period recursion for the recursive call, e.g.:

type GetResponseTimeStatsOptions = { includePreviousPeriod?: boolean };

async function getResponseTimeStats(
  { fromDate, toDate, emailProvider, logger }: ResponseTimeParams & { emailProvider: EmailProvider; logger: Logger },
  { includePreviousPeriod = true }: GetResponseTimeStatsOptions = {},
) {
  // ...
  const summary = await calculateSummaryStats(
    responseTimes,
    includePreviousPeriod ? fromDate : null,
    includePreviousPeriod ? toDate : null,
    emailProvider,
    logger,
  );
}

async function calculatePreviousPeriodComparison(/* … */) {
  if (fromDate == null || toDate == null) return null;

  const currentDays = differenceInDays(new Date(toDate), new Date(fromDate));
  if (!Number.isFinite(currentDays) || currentDays <= 0) return null;

  const prevStats = await getResponseTimeStats(
    {
      fromDate: subDays(new Date(fromDate), currentDays).getTime(),
      toDate: new Date(fromDate).getTime(),
      emailProvider,
      logger,
    },
    { includePreviousPeriod: false },
  );
  // ...
}

Without something along these lines, this endpoint is not safe to deploy.

Also applies to: 217-246, 261-267


248-276: responseRate hardcoded to 100% makes the metric incorrect / misleading

calculateSummaryStats always returns responseRate: 100, while getEmptyStats uses 0. Since responseTimes only includes threads with a valid received→sent pair, unanswered threads are invisible and this 100% figure does not reflect reality.

Either compute an actual rate (e.g. thread-level responses / total processed threads, possibly by returning processedThreads.size from calculateResponseTimes) or drop/rename the field until you have enough data to compute it meaningfully. At minimum, avoid returning a hardcoded 100%.

Also applies to: 333-341

🧹 Nitpick comments (3)
apps/web/app/api/user/stats/response-time/route.ts (3)

211-215: Align “within 1 hour” metric with < 1 hour bucket semantics

calculateWithin1Hour treats <= 60 minutes as “within 1 hour”, while calculateDistribution uses < 60 for the lessThan1Hour bucket. A 60‑minute response is counted as “within 1 hour” but ends up in the oneToFourHours bucket, which may be confusing when comparing metrics.

Either change the metric to < 60 to match the bucket, or rename the bucket to something like upTo1Hour and adjust the threshold, so names and thresholds stay consistent.

Also applies to: 278-299


81-87: Sampling only the first 100 sent messages can skew stats for heavy users

getMessagesByFields is capped at maxResults: 100, and calculateResponseTimes processes only threads corresponding to those messages. For users with high volume, this may bias summary/distribution/trend toward the most recent or arbitrary slice of activity.

If feasible, consider:

  • Paging through more messages for the selected window, or
  • Making the cap configurable / clearly documenting that this is a sampled view.

This is not a correctness bug but worth being explicit about.

Also applies to: 122-191


18-23: API response serializes Date fields to strings; ensure consumer types reflect that

ResponseTimeEntry.receivedDate/sentDate and TrendEntry.periodDate are typed as Date, but NextResponse.json will serialize them as ISO strings over the wire. If these interfaces (or a derived ResponseTimeResponse type) are reused on the client, the runtime shape will actually be { periodDate: string, receivedDate: string, … }.

Consider defining explicit API response types that use string for date fields (and keep Date for internal/server-only structs), or consistently parse them back to Date on the client while reflecting that in types.

Also applies to: 45-50, 304-331, 333-352

📜 Review details

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 6b48d85 and 0456674.

📒 Files selected for processing (1)
  • apps/web/app/api/user/stats/response-time/route.ts (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
apps/web/app/api/user/stats/response-time/route.ts (3)
apps/web/utils/middleware.ts (1)
  • withEmailProvider (401-414)
apps/web/utils/logger.ts (1)
  • Logger (5-5)
apps/web/utils/types.ts (1)
  • ParsedMessage (51-73)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: cubic · AI code reviewer

Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2 issues found across 2 files (reviewed changes from recent commits).

Prompt for AI agents (all 2 issues)

Understand the root cause of the following 2 issues and fix them.


<file name="apps/web/app/api/user/stats/response-time/route.ts">

<violation number="1" location="apps/web/app/api/user/stats/response-time/route.ts:167">
Response-time metrics ignore the requested date range: once a thread is selected, every historical reply in that thread is counted, so a 7‑day query still includes months-old responses.</violation>

<violation number="2" location="apps/web/app/api/user/stats/response-time/route.ts:272">
Response rate is always reported as 100%, because it is hard-coded instead of being derived from the share of sent conversations that received a reply.</violation>
</file>

Reply to cubic to teach it or ask questions. Re-run a review with @cubic-dev-ai review this PR

return {
medianResponseTime: Math.round(medianResponseTime),
averageResponseTime: Math.round(averageResponseTime),
responseRate: 100,
Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot Nov 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Response rate is always reported as 100%, because it is hard-coded instead of being derived from the share of sent conversations that received a reply.

Prompt for AI agents
Address the following comment on apps/web/app/api/user/stats/response-time/route.ts at line 272:

<comment>Response rate is always reported as 100%, because it is hard-coded instead of being derived from the share of sent conversations that received a reply.</comment>

<file context>
@@ -23,215 +74,251 @@ async function getResponseTimeStats({
+  return {
+    medianResponseTime: Math.round(medianResponseTime),
+    averageResponseTime: Math.round(averageResponseTime),
+    responseRate: 100,
+    within1Hour: Math.round(within1Hour),
+    previousPeriodComparison,
</file context>
Fix with Cubic

isSent = true;
}

if (isSent) {
Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot Nov 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Response-time metrics ignore the requested date range: once a thread is selected, every historical reply in that thread is counted, so a 7‑day query still includes months-old responses.

Prompt for AI agents
Address the following comment on apps/web/app/api/user/stats/response-time/route.ts at line 167:

<comment>Response-time metrics ignore the requested date range: once a thread is selected, every historical reply in that thread is counted, so a 7‑day query still includes months-old responses.</comment>

<file context>
@@ -23,215 +74,251 @@ async function getResponseTimeStats({
+          isSent = true;
+        }
+
+        if (isSent) {
+          // Message is SENT
+          if (lastReceivedDate) {
</file context>
Fix with Cubic

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants