diff --git a/components/dashboard/income-expense-graph-tooltip.tsx b/components/dashboard/income-expense-graph-tooltip.tsx
new file mode 100644
index 0000000..2f47efa
--- /dev/null
+++ b/components/dashboard/income-expense-graph-tooltip.tsx
@@ -0,0 +1,104 @@
+import { formatCurrency, formatPeriodLabel } from "@/lib/utils"
+import { DetailedTimeSeriesData } from "@/models/stats"
+
+interface ChartTooltipProps {
+ data: DetailedTimeSeriesData | null
+ defaultCurrency: string
+ position: { x: number; y: number }
+ visible: boolean
+ containerWidth?: number
+}
+
+export function IncomeExpenceGraphTooltip({ data, defaultCurrency, position, visible }: ChartTooltipProps) {
+ if (!visible || !data) {
+ return null
+ }
+
+ const incomeCategories = data.categories.filter((cat) => cat.income > 0)
+ const expenseCategories = data.categories.filter((cat) => cat.expenses > 0)
+
+ // Calculate positioning - show to right if space available, otherwise to left
+ const tooltipWidth = 320 // estimated max width
+ const spaceToRight = window.innerWidth - position.x
+ const spaceToLeft = position.x
+ const showToRight = spaceToRight >= tooltipWidth + 20 // 20px margin
+
+ const horizontalOffset = showToRight ? 15 : -15 // distance from cursor
+ const horizontalTransform = showToRight ? "0%" : "-100%"
+
+ return (
+
+ {/* Header */}
+
+
{formatPeriodLabel(data.period, data.date)}
+
+ {data.totalTransactions} transaction{data.totalTransactions !== 1 ? "s" : ""}
+
+
+
+ {/* Totals */}
+
+ {data.income > 0 && (
+
+ Total Income:
+ {formatCurrency(data.income, defaultCurrency)}
+
+ )}
+ {data.expenses > 0 && (
+
+ Total Expenses:
+ {formatCurrency(data.expenses, defaultCurrency)}
+
+ )}
+
+
+ {/* Income Categories */}
+ {incomeCategories.length > 0 && (
+
+
Income by Category
+
+ {incomeCategories.map((category) => (
+
+
+
+ {formatCurrency(category.income, defaultCurrency)}
+
+
+ ))}
+
+
+ )}
+
+ {/* Expense Categories */}
+ {expenseCategories.length > 0 && (
+
+
Expenses by Category
+
+ {expenseCategories.map((category) => (
+
+
+
+ {formatCurrency(category.expenses, defaultCurrency)}
+
+
+ ))}
+
+
+ )}
+
+ )
+}
diff --git a/components/dashboard/income-expense-graph.tsx b/components/dashboard/income-expense-graph.tsx
new file mode 100644
index 0000000..6859ffc
--- /dev/null
+++ b/components/dashboard/income-expense-graph.tsx
@@ -0,0 +1,191 @@
+"use client"
+
+import { formatCurrency, formatPeriodLabel } from "@/lib/utils"
+import { DetailedTimeSeriesData } from "@/models/stats"
+import { addDays, endOfMonth, format, startOfMonth } from "date-fns"
+import { useRouter } from "next/navigation"
+import { useEffect, useRef, useState } from "react"
+import { IncomeExpenceGraphTooltip } from "./income-expense-graph-tooltip"
+
+interface IncomeExpenseGraphProps {
+ data: DetailedTimeSeriesData[]
+ defaultCurrency: string
+}
+
+export function IncomeExpenseGraph({ data, defaultCurrency }: IncomeExpenseGraphProps) {
+ const router = useRouter()
+ const scrollContainerRef = useRef(null)
+ const [tooltip, setTooltip] = useState<{
+ data: DetailedTimeSeriesData | null
+ position: { x: number; y: number }
+ visible: boolean
+ }>({
+ data: null,
+ position: { x: 0, y: 0 },
+ visible: false,
+ })
+
+ // Auto-scroll to the right to show latest data
+ useEffect(() => {
+ if (scrollContainerRef.current) {
+ scrollContainerRef.current.scrollLeft = scrollContainerRef.current.scrollWidth
+ }
+ }, [data])
+
+ const handleBarHover = (item: DetailedTimeSeriesData, event: React.MouseEvent) => {
+ const rect = event.currentTarget.getBoundingClientRect()
+ const containerRect = scrollContainerRef.current?.getBoundingClientRect()
+
+ setTooltip({
+ data: item,
+ position: {
+ x: rect.left + rect.width / 2,
+ y: containerRect ? containerRect.top + containerRect.height / 2 : rect.top,
+ },
+ visible: true,
+ })
+ }
+
+ const handleBarLeave = () => {
+ setTooltip((prev) => ({ ...prev, visible: false }))
+ }
+
+ const handleBarClick = (item: DetailedTimeSeriesData, type: "income" | "expense") => {
+ // Calculate date range for the period
+ const isDailyPeriod = item.period.includes("-") && item.period.split("-").length === 3
+
+ let dateFrom: string
+ let dateTo: string
+
+ if (isDailyPeriod) {
+ // Daily period: use the exact date, add 1 day to dateTo
+ const date = new Date(item.period)
+ dateFrom = item.period // YYYY-MM-DD format
+ dateTo = format(addDays(date, 1), "yyyy-MM-dd")
+ } else {
+ // Monthly period: use first and last day of the month, add 1 day to dateTo
+ const [year, month] = item.period.split("-")
+ const monthDate = new Date(parseInt(year), parseInt(month) - 1, 1)
+
+ dateFrom = format(startOfMonth(monthDate), "yyyy-MM-dd")
+ dateTo = format(addDays(endOfMonth(monthDate), 1), "yyyy-MM-dd")
+ }
+
+ // Build URL parameters
+ const params = new URLSearchParams({
+ type,
+ dateFrom,
+ dateTo,
+ })
+
+ // Navigate to transactions page with filters
+ router.push(`/transactions?${params.toString()}`)
+ }
+
+ if (!data.length) {
+ return (
+
+ No data available for the selected period
+
+ )
+ }
+
+ const maxIncome = Math.max(...data.map((d) => d.income))
+ const maxExpense = Math.max(...data.map((d) => d.expenses))
+ const maxValue = Math.max(maxIncome, maxExpense)
+
+ if (maxValue === 0) {
+ return (
+
+ No transactions found for the selected period
+
+ )
+ }
+
+ return (
+
+ {/* Chart container with horizontal scroll */}
+
+
+ {/* Income section (top half) */}
+
+ {data.map((item, index) => {
+ const incomeHeight = maxValue > 0 ? (item.income / maxValue) * 100 : 0
+
+ return (
+
handleBarHover(item, e)}
+ onMouseLeave={handleBarLeave}
+ onClick={() => item.income > 0 && handleBarClick(item, "income")}
+ >
+ {/* Period label above income bars */}
+
+ {formatPeriodLabel(item.period, item.date)}
+
+
+ {item.income > 0 && (
+ <>
+ {/* Income amount label */}
+
+ {formatCurrency(item.income, defaultCurrency)}
+
+ {/* Income bar growing upward from bottom */}
+
+ >
+ )}
+
+ )
+ })}
+
+
+ {/* X-axis line (center) */}
+
+
+ {/* Expense section (bottom half) */}
+
+ {data.map((item, index) => {
+ const expenseHeight = maxValue > 0 ? (item.expenses / maxValue) * 100 : 0
+
+ return (
+
handleBarHover(item, e)}
+ onMouseLeave={handleBarLeave}
+ onClick={() => item.expenses > 0 && handleBarClick(item, "expense")}
+ >
+ {item.expenses > 0 && (
+ <>
+ {/* Expense bar growing downward from top */}
+
+ {/* Expense amount label */}
+
+ {formatCurrency(item.expenses, defaultCurrency)}
+
+ >
+ )}
+
+ )
+ })}
+
+
+
+
+ {/* Tooltip */}
+
+
+ )
+}
diff --git a/components/dashboard/stats-widget.tsx b/components/dashboard/stats-widget.tsx
index 08c17b1..67f42c3 100644
--- a/components/dashboard/stats-widget.tsx
+++ b/components/dashboard/stats-widget.tsx
@@ -1,10 +1,12 @@
import { FiltersWidget } from "@/components/dashboard/filters-widget"
+import { IncomeExpenseGraph } from "@/components/dashboard/income-expense-graph"
import { ProjectsWidget } from "@/components/dashboard/projects-widget"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { getCurrentUser } from "@/lib/auth"
import { formatCurrency } from "@/lib/utils"
import { getProjects } from "@/models/projects"
-import { getDashboardStats, getProjectStats } from "@/models/stats"
+import { getSettings } from "@/models/settings"
+import { getDashboardStats, getDetailedTimeSeriesStats, getProjectStats } from "@/models/stats"
import { TransactionFilters } from "@/models/transactions"
import { ArrowDown, ArrowUp, BicepsFlexed } from "lucide-react"
import Link from "next/link"
@@ -12,7 +14,11 @@ import Link from "next/link"
export async function StatsWidget({ filters }: { filters: TransactionFilters }) {
const user = await getCurrentUser()
const projects = await getProjects(user.id)
+ const settings = await getSettings(user.id)
+ const defaultCurrency = settings.default_currency || "EUR"
+
const stats = await getDashboardStats(user.id, filters)
+ const statsTimeSeries = await getDetailedTimeSeriesStats(user.id, filters, defaultCurrency)
const statsPerProject = Object.fromEntries(
await Promise.all(
projects.map((project) => getProjectStats(user.id, project.code, filters).then((stats) => [project.code, stats]))
@@ -27,6 +33,8 @@ export async function StatsWidget({ filters }: { filters: TransactionFilters })
+ {statsTimeSeries.length > 0 && }
+
diff --git a/components/forms/date-range-picker.tsx b/components/forms/date-range-picker.tsx
index 7b1f868..6e2f585 100644
--- a/components/forms/date-range-picker.tsx
+++ b/components/forms/date-range-picker.tsx
@@ -2,7 +2,7 @@
import { format, startOfMonth, startOfQuarter, subMonths, subWeeks } from "date-fns"
import { CalendarIcon } from "lucide-react"
-import { useState, useEffect } from "react"
+import { useEffect, useState } from "react"
import { DateRange } from "react-day-picker"
import { Button } from "@/components/ui/button"
diff --git a/lib/utils.ts b/lib/utils.ts
index 3210919..108f817 100644
--- a/lib/utils.ts
+++ b/lib/utils.ts
@@ -116,3 +116,21 @@ export function generateUUID(): string {
return v.toString(16)
})
}
+
+export function formatPeriodLabel(period: string, date: Date): string {
+ if (period.includes("-") && period.split("-").length === 3) {
+ // Daily format: show day/month/year
+ return date.toLocaleDateString("en-US", {
+ weekday: "short",
+ month: "short",
+ day: "numeric",
+ year: "numeric",
+ })
+ } else {
+ // Monthly format: show month/year
+ return date.toLocaleDateString("en-US", {
+ month: "long",
+ year: "numeric",
+ })
+ }
+}
diff --git a/models/stats.ts b/models/stats.ts
index 7a42245..37eebdc 100644
--- a/models/stats.ts
+++ b/models/stats.ts
@@ -79,3 +79,245 @@ export const getProjectStats = cache(async (userId: string, projectId: string, f
invoicesProcessed,
}
})
+
+export type TimeSeriesData = {
+ period: string
+ income: number
+ expenses: number
+ date: Date
+}
+
+export type CategoryBreakdown = {
+ code: string
+ name: string
+ color: string
+ income: number
+ expenses: number
+ transactionCount: number
+}
+
+export type DetailedTimeSeriesData = {
+ period: string
+ income: number
+ expenses: number
+ date: Date
+ categories: CategoryBreakdown[]
+ totalTransactions: number
+}
+
+export const getTimeSeriesStats = cache(
+ async (
+ userId: string,
+ filters: TransactionFilters = {},
+ defaultCurrency: string = "EUR"
+ ): Promise => {
+ const where: Prisma.TransactionWhereInput = { userId }
+
+ if (filters.dateFrom || filters.dateTo) {
+ where.issuedAt = {
+ gte: filters.dateFrom ? new Date(filters.dateFrom) : undefined,
+ lte: filters.dateTo ? new Date(filters.dateTo) : undefined,
+ }
+ }
+
+ if (filters.categoryCode) {
+ where.categoryCode = filters.categoryCode
+ }
+
+ if (filters.projectCode) {
+ where.projectCode = filters.projectCode
+ }
+
+ if (filters.type) {
+ where.type = filters.type
+ }
+
+ const transactions = await prisma.transaction.findMany({
+ where,
+ orderBy: { issuedAt: "asc" },
+ })
+
+ if (transactions.length === 0) {
+ return []
+ }
+
+ // Determine if we should group by day or month
+ const dateFrom = filters.dateFrom ? new Date(filters.dateFrom) : new Date(transactions[0].issuedAt!)
+ const dateTo = filters.dateTo ? new Date(filters.dateTo) : new Date(transactions[transactions.length - 1].issuedAt!)
+ const daysDiff = Math.ceil((dateTo.getTime() - dateFrom.getTime()) / (1000 * 60 * 60 * 24))
+ const groupByDay = daysDiff <= 50
+
+ // Group transactions by time period
+ const grouped = transactions.reduce(
+ (acc, transaction) => {
+ if (!transaction.issuedAt) return acc
+
+ const date = new Date(transaction.issuedAt)
+ const period = groupByDay
+ ? date.toISOString().split("T")[0] // YYYY-MM-DD
+ : `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}` // YYYY-MM
+
+ if (!acc[period]) {
+ acc[period] = { period, income: 0, expenses: 0, date }
+ }
+
+ // Get amount in default currency
+ const amount =
+ transaction.convertedCurrencyCode?.toUpperCase() === defaultCurrency.toUpperCase()
+ ? transaction.convertedTotal || 0
+ : transaction.currencyCode?.toUpperCase() === defaultCurrency.toUpperCase()
+ ? transaction.total || 0
+ : 0 // Skip transactions not in default currency for simplicity
+
+ if (transaction.type === "income") {
+ acc[period].income += amount
+ } else if (transaction.type === "expense") {
+ acc[period].expenses += amount
+ }
+
+ return acc
+ },
+ {} as Record
+ )
+
+ return Object.values(grouped).sort((a, b) => a.date.getTime() - b.date.getTime())
+ }
+)
+
+export const getDetailedTimeSeriesStats = cache(
+ async (
+ userId: string,
+ filters: TransactionFilters = {},
+ defaultCurrency: string = "EUR"
+ ): Promise => {
+ const where: Prisma.TransactionWhereInput = { userId }
+
+ if (filters.dateFrom || filters.dateTo) {
+ where.issuedAt = {
+ gte: filters.dateFrom ? new Date(filters.dateFrom) : undefined,
+ lte: filters.dateTo ? new Date(filters.dateTo) : undefined,
+ }
+ }
+
+ if (filters.categoryCode) {
+ where.categoryCode = filters.categoryCode
+ }
+
+ if (filters.projectCode) {
+ where.projectCode = filters.projectCode
+ }
+
+ if (filters.type) {
+ where.type = filters.type
+ }
+
+ const [transactions, categories] = await Promise.all([
+ prisma.transaction.findMany({
+ where,
+ include: {
+ category: true,
+ },
+ orderBy: { issuedAt: "asc" },
+ }),
+ prisma.category.findMany({
+ where: { userId },
+ orderBy: { name: "asc" },
+ }),
+ ])
+
+ if (transactions.length === 0) {
+ return []
+ }
+
+ // Determine if we should group by day or month
+ const dateFrom = filters.dateFrom ? new Date(filters.dateFrom) : new Date(transactions[0].issuedAt!)
+ const dateTo = filters.dateTo ? new Date(filters.dateTo) : new Date(transactions[transactions.length - 1].issuedAt!)
+ const daysDiff = Math.ceil((dateTo.getTime() - dateFrom.getTime()) / (1000 * 60 * 60 * 24))
+ const groupByDay = daysDiff <= 50
+
+ // Create category lookup
+ const categoryLookup = new Map(categories.map((cat) => [cat.code, cat]))
+
+ // Group transactions by time period
+ const grouped = transactions.reduce(
+ (acc, transaction) => {
+ if (!transaction.issuedAt) return acc
+
+ const date = new Date(transaction.issuedAt)
+ const period = groupByDay
+ ? date.toISOString().split("T")[0] // YYYY-MM-DD
+ : `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}` // YYYY-MM
+
+ if (!acc[period]) {
+ acc[period] = {
+ period,
+ income: 0,
+ expenses: 0,
+ date,
+ categories: new Map(),
+ totalTransactions: 0,
+ }
+ }
+
+ // Get amount in default currency
+ const amount =
+ transaction.convertedCurrencyCode?.toUpperCase() === defaultCurrency.toUpperCase()
+ ? transaction.convertedTotal || 0
+ : transaction.currencyCode?.toUpperCase() === defaultCurrency.toUpperCase()
+ ? transaction.total || 0
+ : 0 // Skip transactions not in default currency for simplicity
+
+ const categoryCode = transaction.categoryCode || "other"
+ const category = categoryLookup.get(categoryCode) || {
+ code: "other",
+ name: "Other",
+ color: "#6b7280",
+ }
+
+ // Initialize category if not exists
+ if (!acc[period].categories.has(categoryCode)) {
+ acc[period].categories.set(categoryCode, {
+ code: category.code,
+ name: category.name,
+ color: category.color || "#6b7280",
+ income: 0,
+ expenses: 0,
+ transactionCount: 0,
+ })
+ }
+
+ const categoryData = acc[period].categories.get(categoryCode)!
+ categoryData.transactionCount++
+ acc[period].totalTransactions++
+
+ if (transaction.type === "income") {
+ acc[period].income += amount
+ categoryData.income += amount
+ } else if (transaction.type === "expense") {
+ acc[period].expenses += amount
+ categoryData.expenses += amount
+ }
+
+ return acc
+ },
+ {} as Record<
+ string,
+ {
+ period: string
+ income: number
+ expenses: number
+ date: Date
+ categories: Map
+ totalTransactions: number
+ }
+ >
+ )
+
+ return Object.values(grouped)
+ .map((item) => ({
+ ...item,
+ categories: Array.from(item.categories.values()).filter((cat) => cat.income > 0 || cat.expenses > 0),
+ }))
+ .sort((a, b) => a.date.getTime() - b.date.getTime())
+ }
+)