-
Notifications
You must be signed in to change notification settings - Fork 1.1k
Stats api #461
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Stats api #461
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,124 @@ | ||
| import format from "date-fns/format"; | ||
| import { z } from "zod"; | ||
| import sumBy from "lodash/sumBy"; | ||
| import { zodPeriod } from "@inboxzero/tinybird"; | ||
| import prisma from "@/utils/prisma"; | ||
| import { Prisma } from "@prisma/client"; | ||
|
|
||
| export const statsByWeekParams = z.object({ | ||
| period: zodPeriod, | ||
| fromDate: z.coerce.number().nullish(), | ||
| toDate: z.coerce.number().nullish(), | ||
| }); | ||
| export type StatsByWeekParams = z.infer<typeof statsByWeekParams>; | ||
| export type StatsByWeekResponse = Awaited<ReturnType<typeof getStatsByPeriod>>; | ||
|
|
||
| export async function getStatsByPeriod( | ||
| options: StatsByWeekParams & { | ||
| emailAccountId: string; | ||
| }, | ||
| ) { | ||
| // Get all stats in a single query | ||
| const stats = await getEmailStatsByPeriod(options); | ||
|
|
||
| // Transform stats to match the expected format | ||
| const formattedStats = stats.map((stat) => { | ||
| const startOfPeriodFormatted = format(stat.startOfPeriod, "LLL dd, y"); | ||
|
|
||
| return { | ||
| startOfPeriod: startOfPeriodFormatted, | ||
| All: Number(stat.totalCount), | ||
| Sent: Number(stat.sentCount), | ||
| Read: Number(stat.readCount), | ||
| Unread: Number(stat.unread), | ||
| Unarchived: Number(stat.inboxCount), | ||
| Archived: Number(stat.notInbox), | ||
| }; | ||
| }); | ||
|
|
||
| // Calculate totals | ||
| const totalAll = sumBy(stats, (stat) => Number(stat.totalCount)); | ||
| const totalInbox = sumBy(stats, (stat) => Number(stat.inboxCount)); | ||
| const totalRead = sumBy(stats, (stat) => Number(stat.readCount)); | ||
| const totalSent = sumBy(stats, (stat) => Number(stat.sentCount)); | ||
|
|
||
| return { | ||
| result: formattedStats, | ||
| allCount: totalAll, | ||
| inboxCount: totalInbox, | ||
| readCount: totalRead, | ||
| sentCount: totalSent, | ||
| }; | ||
| } | ||
|
|
||
| async function getEmailStatsByPeriod( | ||
| options: StatsByWeekParams & { emailAccountId: string }, | ||
| ) { | ||
| const { period, fromDate, toDate, emailAccountId } = options; | ||
|
|
||
| // Build date conditions without starting with AND | ||
| const dateConditions: Prisma.Sql[] = []; | ||
| if (fromDate) { | ||
| dateConditions.push(Prisma.sql`date >= ${new Date(fromDate)}`); | ||
| } | ||
| if (toDate) { | ||
| dateConditions.push(Prisma.sql`date <= ${new Date(toDate)}`); | ||
| } | ||
|
|
||
| const dateFormat = | ||
| period === "day" | ||
| ? "YYYY-MM-DD" | ||
| : period === "week" | ||
| ? "YYYY-WW" | ||
| : period === "month" | ||
| ? "YYYY-MM" | ||
| : "YYYY"; | ||
|
|
||
| // Using raw query with properly typed parameters | ||
| type StatsResult = { | ||
| period_group: string; | ||
| startOfPeriod: Date; | ||
| totalCount: bigint; | ||
| inboxCount: bigint; | ||
| readCount: bigint; | ||
| sentCount: bigint; | ||
| unread: bigint; | ||
| notInbox: bigint; | ||
| }; | ||
|
|
||
| // Create WHERE clause properly | ||
| const whereClause = Prisma.sql`WHERE "emailAccountId" = ${emailAccountId}`; | ||
| const dateClause = | ||
| dateConditions.length > 0 | ||
| ? Prisma.sql` AND ${Prisma.join(dateConditions, " AND ")}` | ||
| : Prisma.sql``; | ||
|
|
||
| // Convert period and dateFormat to string literals in PostgreSQL | ||
| return prisma.$queryRaw<StatsResult[]>` | ||
| WITH stats AS ( | ||
| SELECT | ||
| TO_CHAR(date, ${Prisma.raw(`'${dateFormat}'`)}) AS period_group, | ||
| DATE_TRUNC(${Prisma.raw(`'${period}'`)}, date) AS start_of_period, | ||
| COUNT(*) AS total_count, | ||
| SUM(CASE WHEN inbox = true THEN 1 ELSE 0 END) AS inbox_count, | ||
| SUM(CASE WHEN inbox = false THEN 1 ELSE 0 END) AS not_inbox, | ||
| SUM(CASE WHEN read = true THEN 1 ELSE 0 END) AS read_count, | ||
| SUM(CASE WHEN read = false THEN 1 ELSE 0 END) AS unread, | ||
| SUM(CASE WHEN sent = true THEN 1 ELSE 0 END) AS sent_count | ||
| FROM "EmailMessage" | ||
| ${whereClause}${dateClause} | ||
| GROUP BY period_group, start_of_period | ||
| ORDER BY start_of_period | ||
| ) | ||
| SELECT | ||
| period_group, | ||
| start_of_period AS "startOfPeriod", | ||
| total_count AS "totalCount", | ||
| inbox_count AS "inboxCount", | ||
| not_inbox AS "notInbox", | ||
| read_count AS "readCount", | ||
| unread, | ||
| sent_count AS "sentCount" | ||
| FROM stats | ||
| `; | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,76 @@ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { z } from "zod"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import type { gmail_v1 } from "@googleapis/gmail"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { dateToSeconds } from "@/utils/date"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { getMessages } from "@/utils/gmail/message"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| export const statsByDayQuery = z.object({ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| type: z.enum(["inbox", "sent", "archived"]), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| export type StatsByDayQuery = z.infer<typeof statsByDayQuery>; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| export type StatsByDayResponse = Awaited< | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ReturnType<typeof getPastSevenDayStats> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| >; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const DAYS = 7; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| export async function getPastSevenDayStats( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| options: { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| emailAccountId: string; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| gmail: gmail_v1.Gmail; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } & StatsByDayQuery, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const today = new Date(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const sevenDaysAgo = new Date( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| today.getFullYear(), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| today.getMonth(), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| today.getDate() - (DAYS - 1), // include today in stats | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // const cachedStats = await getAllStats({ email }) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const lastSevenDaysCountsArray = await Promise.all( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Array.from({ length: DAYS }, (_, i) => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const date = new Date(sevenDaysAgo); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| date.setDate(date.getDate() + i); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return date; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }).map(async (date) => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const dateString = `${date.getDate()}/${date.getMonth() + 1}`; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fix date format inconsistency The date formatting here uses Apply this fix to use consistent date formatting: - const dateString = `${date.getDate()}/${date.getMonth() + 1}`;
+ const dateString = date.toISOString().split('T')[0]; // YYYY-MM-DD format📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // let count = cachedStats?.[dateString] | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| let count: number | undefined = undefined; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (typeof count !== "number") { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const query = getQuery(options.type, date); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const messages = await getMessages(options.gmail, { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| query, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| maxResults: 500, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+44
to
+47
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🛠️ Refactor suggestion Make maxResults configurable and add error handling The hardcoded +const DEFAULT_MAX_RESULTS = 500;
+
export async function getPastSevenDayStats(
options: {
emailAccountId: string;
gmail: gmail_v1.Gmail;
+ maxResults?: number;
} & StatsByDayQuery,
) {
// ... existing code ...
- const messages = await getMessages(options.gmail, {
- query,
- maxResults: 500,
- });
+ try {
+ const messages = await getMessages(options.gmail, {
+ query,
+ maxResults: options.maxResults ?? DEFAULT_MAX_RESULTS,
+ });
+ count = messages.messages?.length || 0;
+ } catch (error) {
+ console.error(`Failed to fetch messages for ${dateString}:`, error);
+ count = 0; // or throw error based on requirements
+ }📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| count = messages.messages?.length || 0; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| date: dateString, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Emails: count, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return lastSevenDaysCountsArray; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| function getQuery(type: StatsByDayQuery["type"], date: Date) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const startOfDayInSeconds = dateToSeconds(date); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const endOfDayInSeconds = startOfDayInSeconds + 86400; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const dateRange = `after:${startOfDayInSeconds} before:${endOfDayInSeconds}`; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| switch (type) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| case "inbox": | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return `in:inbox ${dateRange}`; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| case "sent": | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return `in:sent ${dateRange}`; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| case "archived": | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return `-in:inbox -in:sent ${dateRange}`; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion
Consider timezone handling for date calculations
The date calculation logic doesn't account for timezone differences, which could lead to incorrect day boundaries for users in different timezones.
Consider using a timezone-aware date library or explicitly handling the user's timezone:
🤖 Prompt for AI Agents