Unify the dashboard experience and retire the multi-theme engine so onboarding and day-to-day invoicing feel consistent and easier to maintain.

Shared layout, tabs, and sidebar timer; user onboarding and registration polish; settings danger zone and data export; chart and tRPC perf fixes; migrations for onboarding and dropped appearance columns.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-26 03:08:22 -04:00
co-authored by Cursor
parent 6ec26a4a0d
commit c53f2e6c4d
79 changed files with 2871 additions and 3576 deletions
+228 -139
View File
@@ -1,155 +1,244 @@
import { and, desc, eq } from "drizzle-orm";
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
import { invoices, clients } from "~/server/db/schema";
import { and, desc, eq, isNotNull, lte } from "drizzle-orm";
import { getEffectiveInvoiceStatus } from "~/lib/invoice-status";
import { clients, invoices } from "~/server/db/schema";
import type { StoredInvoiceStatus } from "~/types/invoice";
type LiteInvoice = {
id: string;
totalAmount: number;
status: string;
dueDate: Date;
issueDate: Date;
};
function buildRevenueMonthKeys(now: Date, count: number) {
const keys: string[] = [];
for (let i = count - 1; i >= 0; i--) {
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
keys.push(
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`,
);
}
return keys;
}
function aggregateDashboardMetrics(userInvoices: LiteInvoice[], now: Date) {
const currentMonthStart = new Date(now.getFullYear(), now.getMonth(), 1);
const lastMonthStart = new Date(now.getFullYear(), now.getMonth() - 1, 1);
let totalRevenue = 0;
let pendingAmount = 0;
let overdueCount = 0;
let currentMonthRevenue = 0;
let lastMonthRevenue = 0;
const revenueByMonth = Object.fromEntries(
buildRevenueMonthKeys(now, 6).map((key) => [key, 0]),
) as Record<string, number>;
const statusTotals: Record<
string,
{ status: string; count: number; value: number }
> = {};
const monthlyTotals: Record<
string,
{
month: string;
totalInvoices: number;
paidInvoices: number;
pendingInvoices: number;
overdueInvoices: number;
draftInvoices: number;
}
> = {};
for (const inv of userInvoices) {
const effectiveStatus = getEffectiveInvoiceStatus(
inv.status as StoredInvoiceStatus,
inv.dueDate,
);
const amount = inv.totalAmount;
const issueDate = new Date(inv.issueDate);
if (effectiveStatus === "paid") {
totalRevenue += amount;
if (issueDate >= currentMonthStart) {
currentMonthRevenue += amount;
} else if (
issueDate >= lastMonthStart &&
issueDate < currentMonthStart
) {
lastMonthRevenue += amount;
}
const revenueKey = `${issueDate.getFullYear()}-${String(issueDate.getMonth() + 1).padStart(2, "0")}`;
const monthRevenue = revenueByMonth[revenueKey];
if (monthRevenue !== undefined) {
revenueByMonth[revenueKey] = monthRevenue + amount;
}
} else if (effectiveStatus === "sent" || effectiveStatus === "overdue") {
pendingAmount += amount;
}
if (effectiveStatus === "overdue") {
overdueCount++;
}
statusTotals[effectiveStatus] ??= {
status: effectiveStatus,
count: 0,
value: 0,
};
statusTotals[effectiveStatus].count += 1;
statusTotals[effectiveStatus].value += amount;
const monthKey = `${issueDate.getFullYear()}-${String(issueDate.getMonth() + 1).padStart(2, "0")}`;
monthlyTotals[monthKey] ??= {
month: monthKey,
totalInvoices: 0,
paidInvoices: 0,
pendingInvoices: 0,
overdueInvoices: 0,
draftInvoices: 0,
};
monthlyTotals[monthKey].totalInvoices += 1;
switch (effectiveStatus) {
case "paid":
monthlyTotals[monthKey].paidInvoices += 1;
break;
case "sent":
monthlyTotals[monthKey].pendingInvoices += 1;
break;
case "overdue":
monthlyTotals[monthKey].overdueInvoices += 1;
break;
case "draft":
monthlyTotals[monthKey].draftInvoices += 1;
break;
}
}
const revenueChartData = Object.entries(revenueByMonth)
.map(([month, revenue]) => ({
month,
revenue,
monthLabel: new Date(month + "-01").toLocaleDateString("en-US", {
month: "short",
year: "2-digit",
}),
}))
.sort((a, b) => a.month.localeCompare(b.month));
const statusChartData = Object.values(statusTotals).map((item) => ({
...item,
name: item.status.charAt(0).toUpperCase() + item.status.slice(1),
}));
const monthlyMetricsChartData = Object.values(monthlyTotals)
.sort((a, b) => a.month.localeCompare(b.month))
.slice(-6)
.map((item) => ({
...item,
monthLabel: new Date(item.month + "-01").toLocaleDateString("en-US", {
month: "short",
year: "2-digit",
}),
}));
return {
totalRevenue,
pendingAmount,
overdueCount,
revenueChange:
lastMonthRevenue > 0
? ((currentMonthRevenue - lastMonthRevenue) / lastMonthRevenue) * 100
: 0,
revenueChartData,
statusChartData,
monthlyMetricsChartData,
};
}
export const dashboardRouter = createTRPCRouter({
getStats: protectedProcedure.query(async ({ ctx }) => {
const userId = ctx.session.user.id;
const now = new Date();
const currentMonthStart = new Date(now.getFullYear(), now.getMonth(), 1);
const lastMonthStart = new Date(now.getFullYear(), now.getMonth() - 1, 1);
// 1. Fetch all invoices for the user to calculate stats
// Note: For very large datasets, we should use separate count/sum queries,
// but for typical usage, fetching fields is fine and allows flexible JS calculation
// where SQL complexity might be high (e.g. dynamic status).
// However, let's try to be efficient with SQL where possible.
const userInvoices = await ctx.db.query.invoices.findMany({
where: eq(invoices.createdById, userId),
columns: {
id: true,
totalAmount: true,
status: true,
dueDate: true,
issueDate: true,
},
});
const userClientsCount = await ctx.db.$count(
clients,
eq(clients.createdById, userId),
);
// Helper to check status
const getStatus = (inv: (typeof userInvoices)[0]) => {
if (inv.status === "paid") return "paid";
if (inv.status === "draft") return "draft";
if (new Date(inv.dueDate) < now && inv.status !== "paid")
return "overdue";
return "sent";
};
// Calculate Stats
let totalRevenue = 0;
let pendingAmount = 0;
let overdueCount = 0;
let currentMonthRevenue = 0;
let lastMonthRevenue = 0;
for (const inv of userInvoices) {
const status = getStatus(inv);
const amount = inv.totalAmount;
const issueDate = new Date(inv.issueDate);
if (status === "paid") {
totalRevenue += amount;
if (issueDate >= currentMonthStart) {
currentMonthRevenue += amount;
} else if (
issueDate >= lastMonthStart &&
issueDate < currentMonthStart
) {
lastMonthRevenue += amount;
}
} else if (status === "sent" || status === "overdue") {
pendingAmount += amount;
}
if (status === "overdue") {
overdueCount++;
}
}
// Revenue Trend (Last 6 months)
const revenueByMonth: Record<string, number> = {};
for (let i = 0; i < 6; i++) {
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
revenueByMonth[key] = 0;
}
for (const inv of userInvoices) {
if (getStatus(inv) === "paid") {
const d = new Date(inv.issueDate);
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
if (revenueByMonth[key] !== undefined) {
revenueByMonth[key] += inv.totalAmount;
}
}
}
const revenueChartData = Object.entries(revenueByMonth)
.map(([month, revenue]) => ({
month,
revenue,
monthLabel: new Date(month + "-01").toLocaleDateString("en-US", {
month: "short",
year: "2-digit",
}),
}))
.sort((a, b) => a.month.localeCompare(b.month));
// Recent Activity
const recentInvoices = await ctx.db.query.invoices.findMany({
where: eq(invoices.createdById, userId),
orderBy: [
desc(invoices.issueDate),
desc(invoices.dueDate),
desc(invoices.invoiceNumber),
],
limit: 5,
with: {
client: {
columns: { name: true },
const [
userInvoices,
userClientsCount,
recentInvoices,
currentDraft,
] = await Promise.all([
ctx.db.query.invoices.findMany({
where: eq(invoices.createdById, userId),
columns: {
id: true,
totalAmount: true,
status: true,
dueDate: true,
issueDate: true,
},
},
});
}),
ctx.db.$count(clients, eq(clients.createdById, userId)),
ctx.db.query.invoices.findMany({
where: eq(invoices.createdById, userId),
orderBy: [
desc(invoices.issueDate),
desc(invoices.dueDate),
desc(invoices.invoiceNumber),
],
limit: 5,
with: {
client: {
columns: { name: true },
},
},
}),
ctx.db.query.invoices.findFirst({
where: and(
eq(invoices.createdById, userId),
eq(invoices.status, "draft"),
),
orderBy: [
desc(invoices.issueDate),
desc(invoices.dueDate),
desc(invoices.invoiceNumber),
],
columns: {
id: true,
invoiceNumber: true,
totalAmount: true,
},
with: {
client: { columns: { name: true } },
items: { columns: { hours: true } },
},
}),
]);
const sendReminderDue = await ctx.db.query.invoices.findMany({
where: and(
eq(invoices.createdById, userId),
eq(invoices.status, "draft"),
isNotNull(invoices.sendReminderAt),
lte(invoices.sendReminderAt, now),
),
columns: {
id: true,
invoiceNumber: true,
invoicePrefix: true,
sendReminderAt: true,
},
with: {
client: { columns: { name: true } },
},
orderBy: [desc(invoices.sendReminderAt)],
limit: 10,
});
const metrics = aggregateDashboardMetrics(userInvoices, now);
return {
totalRevenue,
pendingAmount,
overdueCount,
...metrics,
totalClients: userClientsCount,
revenueChange:
lastMonthRevenue > 0
? ((currentMonthRevenue - lastMonthRevenue) / lastMonthRevenue) * 100
: 0,
revenueChartData,
recentInvoices,
sendReminderDue,
currentDraft: currentDraft
? {
id: currentDraft.id,
invoiceNumber: currentDraft.invoiceNumber,
totalAmount: currentDraft.totalAmount,
client: currentDraft.client,
totalHours: currentDraft.items.reduce(
(sum, item) => sum + item.hours,
0,
),
}
: null,
};
}),
});
+4 -2
View File
@@ -4,6 +4,8 @@ import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
import { invoices, platformSettings } from "~/server/db/schema";
import { eq } from "drizzle-orm";
import { env } from "~/env";
import { NOREPLY_EMAIL } from "~/lib/app-email";
import { getAppUrl } from "~/lib/app-url";
import { generateInvoicePDFBlob } from "~/lib/pdf-export";
import { generateInvoiceEmailTemplate } from "~/lib/email-templates";
@@ -153,7 +155,7 @@ export const emailRouter = createTRPCRouter({
customMessage,
userName,
userEmail,
baseUrl: process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000",
baseUrl: getAppUrl(),
});
// Determine Resend instance and email configuration to use
@@ -177,7 +179,7 @@ export const emailRouter = createTRPCRouter({
fromEmail = `noreply@${env.RESEND_DOMAIN}`;
} else if (env.RESEND_API_KEY) {
resendInstance = new Resend(env.RESEND_API_KEY);
fromEmail = invoice.business?.email ?? "noreply@example.com";
fromEmail = invoice.business?.email ?? NOREPLY_EMAIL;
} else {
throw new Error(
"Email delivery is not configured. Add a Resend API key globally or on this business.",
+2 -1
View File
@@ -12,6 +12,7 @@ import { TRPCError } from "@trpc/server";
import { generateInvoicePDFBlob } from "~/lib/pdf-export";
import { Resend } from "resend";
import { env } from "~/env";
import { NOREPLY_EMAIL } from "~/lib/app-email";
import { generateReminderEmailTemplate } from "~/lib/email-templates/reminder-email";
import type { db } from "~/server/db";
@@ -844,7 +845,7 @@ export const invoicesRouter = createTRPCRouter({
fromEmail = `noreply@${env.RESEND_DOMAIN}`;
} else if (env.RESEND_API_KEY) {
resendInstance = new Resend(env.RESEND_API_KEY);
fromEmail = invoice.business?.email ?? "noreply@example.com";
fromEmail = invoice.business?.email ?? NOREPLY_EMAIL;
} else {
throw new TRPCError({
code: "BAD_REQUEST",
File diff suppressed because it is too large Load Diff