feat: implement category history widget and loading state for category history page

This commit is contained in:
Felipe Coutinho
2025-11-28 13:42:21 +00:00
parent 302521ce14
commit cf5a0b7745
12 changed files with 808 additions and 34 deletions

View File

@@ -0,0 +1,201 @@
import { db } from "@/lib/db";
import { categorias, lancamentos, pagadores } from "@/db/schema";
import { and, eq, inArray, isNull, or, sql } from "drizzle-orm";
import { toNumber } from "@/lib/dashboard/common";
import { addMonths, format } from "date-fns";
import { ptBR } from "date-fns/locale";
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/lib/accounts/constants";
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
export type CategoryOption = {
id: string;
name: string;
icon: string | null;
type: "receita" | "despesa";
};
export type CategoryHistoryItem = {
id: string;
name: string;
icon: string | null;
color: string;
data: Record<string, number>;
};
export type CategoryHistoryData = {
months: string[]; // ["NOV", "DEZ", "JAN", ...]
categories: CategoryHistoryItem[];
chartData: Array<{
month: string;
[categoryName: string]: number | string;
}>;
allCategories: CategoryOption[];
};
const CHART_COLORS = [
"#ef4444", // red-500
"#3b82f6", // blue-500
"#10b981", // emerald-500
"#f59e0b", // amber-500
"#8b5cf6", // violet-500
];
export async function fetchAllCategories(
userId: string
): Promise<CategoryOption[]> {
const result = await db
.select({
id: categorias.id,
name: categorias.name,
icon: categorias.icon,
type: categorias.type,
})
.from(categorias)
.where(eq(categorias.userId, userId))
.orderBy(categorias.type, categorias.name);
return result as CategoryOption[];
}
/**
* Fetches category expense/income history for all categories with transactions
* Widget will allow user to select up to 5 to display
*/
export async function fetchCategoryHistory(
userId: string,
currentPeriod: string
): Promise<CategoryHistoryData> {
// Generate last 6 months including current
const periods: string[] = [];
const monthLabels: string[] = [];
const [year, month] = currentPeriod.split("-").map(Number);
const currentDate = new Date(year, month - 1, 1);
for (let i = 8; i >= 0; i--) {
const date = addMonths(currentDate, -i);
const period = format(date, "yyyy-MM");
const label = format(date, "MMM", { locale: ptBR }).toUpperCase();
periods.push(period);
monthLabels.push(label);
}
// Fetch all categories for the selector
const allCategories = await fetchAllCategories(userId);
// Fetch monthly data for ALL categories with transactions
const monthlyDataQuery = await db
.select({
categoryId: categorias.id,
categoryName: categorias.name,
categoryIcon: categorias.icon,
period: lancamentos.period,
totalAmount: sql<string>`SUM(ABS(${lancamentos.amount}))`.as(
"total_amount"
),
})
.from(lancamentos)
.innerJoin(categorias, eq(lancamentos.categoriaId, categorias.id))
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.where(
and(
eq(lancamentos.userId, userId),
eq(categorias.userId, userId),
inArray(lancamentos.period, periods),
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
or(
isNull(lancamentos.note),
sql`${
lancamentos.note
} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`
)
)
)
.groupBy(
categorias.id,
categorias.name,
categorias.icon,
lancamentos.period
);
if (monthlyDataQuery.length === 0) {
return {
months: monthLabels,
categories: [],
chartData: monthLabels.map((month) => ({ month })),
allCategories,
};
}
// Get unique categories from query results
const uniqueCategories = Array.from(
new Map(
monthlyDataQuery.map((row) => [
row.categoryId,
{
id: row.categoryId,
name: row.categoryName,
icon: row.categoryIcon,
},
])
).values()
);
// Transform data into chart-ready format
const categoriesMap = new Map<
string,
{
id: string;
name: string;
icon: string | null;
color: string;
data: Record<string, number>;
}
>();
// Initialize ALL categories with transactions with all months set to 0
uniqueCategories.forEach((cat, index) => {
const monthData: Record<string, number> = {};
periods.forEach((period, periodIndex) => {
monthData[monthLabels[periodIndex]] = 0;
});
categoriesMap.set(cat.id, {
id: cat.id,
name: cat.name,
icon: cat.icon,
color: CHART_COLORS[index % CHART_COLORS.length],
data: monthData,
});
});
// Fill in actual values from monthly data
monthlyDataQuery.forEach((row) => {
const category = categoriesMap.get(row.categoryId);
if (category) {
const periodIndex = periods.indexOf(row.period);
if (periodIndex !== -1) {
const monthLabel = monthLabels[periodIndex];
category.data[monthLabel] = toNumber(row.totalAmount);
}
}
});
// Convert to chart data format
const chartData = monthLabels.map((month) => {
const dataPoint: Record<string, number | string> = { month };
categoriesMap.forEach((category) => {
dataPoint[category.name] = category.data[month];
});
return dataPoint;
});
return {
months: monthLabels,
categories: Array.from(categoriesMap.values()),
chartData,
allCategories,
};
}

View File

@@ -41,6 +41,26 @@ const MONTH_NAMES = [
// DATE CREATION & MANIPULATION
// ============================================================================
/**
* Safely parses a date string (YYYY-MM-DD) as a local date
*
* IMPORTANT: new Date("2025-11-25") treats the date as UTC midnight,
* which in Brazil (UTC-3) becomes 2025-11-26 03:00 local time!
*
* This function always interprets the date string in the local timezone.
*
* @param dateString - Date string in YYYY-MM-DD format
* @returns Date object in local timezone
*/
export function parseLocalDateString(dateString: string): Date {
const [year, month, day] = dateString.split("-");
return new Date(
Number.parseInt(year ?? "0", 10),
Number.parseInt(month ?? "1", 10) - 1,
Number.parseInt(day ?? "1", 10)
);
}
/**
* Gets today's date in UTC
* @returns Date object set to today at midnight UTC
@@ -110,7 +130,7 @@ export function getTodayDateString(): string {
* @returns Date object for today
*/
export function getTodayDate(): Date {
return new Date(getTodayDateString());
return parseLocalDateString(getTodayDateString());
}
/**
@@ -119,12 +139,12 @@ export function getTodayDate(): Date {
*/
export function getTodayInfo(): { date: Date; period: string } {
const now = new Date();
const year = now.getUTCFullYear();
const month = now.getUTCMonth();
const day = now.getUTCDate();
const year = now.getFullYear();
const month = now.getMonth();
const day = now.getDate();
return {
date: new Date(Date.UTC(year, month, day)),
date: new Date(year, month, day),
period: `${year}-${String(month + 1).padStart(2, "0")}`,
};
}
@@ -162,12 +182,7 @@ export function addMonthsToDate(value: Date, offset: number): Date {
* formatDate("2024-11-14") // "qui 14 nov"
*/
export function formatDate(value: string): string {
const [year, month, day] = value.split("-");
const parsed = new Date(
Number.parseInt(year ?? "0", 10),
Number.parseInt(month ?? "1", 10) - 1,
Number.parseInt(day ?? "1", 10)
);
const parsed = parseLocalDateString(value);
return new Intl.DateTimeFormat("pt-BR", {
weekday: "short",

View File

@@ -334,10 +334,24 @@ export function formatMonthLabel(period: string): string {
* derivePeriodFromDate() // current period
*/
export function derivePeriodFromDate(value?: string | null): string {
const date = value ? new Date(value) : new Date();
if (!value) {
return getCurrentPeriod();
}
// Parse date string as local date to avoid timezone issues
// IMPORTANT: new Date("2025-11-25") treats the date as UTC midnight,
// which in Brazil (UTC-3) becomes 2025-11-26 03:00 local time!
const [year, month, day] = value.split("-");
const date = new Date(
Number.parseInt(year ?? "0", 10),
Number.parseInt(month ?? "1", 10) - 1,
Number.parseInt(day ?? "1", 10)
);
if (Number.isNaN(date.getTime())) {
return getCurrentPeriod();
}
return formatPeriod(date.getFullYear(), date.getMonth() + 1);
}