feat: implement analytics dashboard page with custom Recharts visualizations and Supabase RPC integration

This commit is contained in:
Poyraz
2026-06-17 09:20:21 +03:00
parent 3634b3a227
commit 3504a02229
8 changed files with 463 additions and 145 deletions
+16 -40
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useState } from "react"; import { useRouter, usePathname, useSearchParams } from "next/navigation";
import { Card, CardContent } from "poyraz-ui/atoms"; import { Card, CardContent } from "poyraz-ui/atoms";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "poyraz-ui/molecules"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "poyraz-ui/molecules";
import { import {
@@ -10,9 +10,12 @@ import {
import { BarChart3, Filter } from "lucide-react"; import { BarChart3, Filter } from "lucide-react";
export type AnalyticsData = { export type AnalyticsData = {
tasks: { id: string; status: string; created_at: string; due_at?: string }[]; metrics: {
projects: { id: string; name: string }[]; projectIncomeData: { name: string; value: number }[];
finances: { id: string; type: string; amount: number; transaction_date: string; project_id?: string }[]; completedTasks: number;
activeTasks: number;
};
range: string;
}; };
type AnalyticsClientProps = { type AnalyticsClientProps = {
@@ -22,44 +25,17 @@ type AnalyticsClientProps = {
const COLORS = ["hsl(var(--primary))", "hsl(var(--destructive))", "#eab308", "#3b82f6", "#8b5cf6"]; const COLORS = ["hsl(var(--primary))", "hsl(var(--destructive))", "#eab308", "#3b82f6", "#8b5cf6"];
export function AnalyticsClient({ data }: AnalyticsClientProps) { export function AnalyticsClient({ data }: AnalyticsClientProps) {
const [dateRange, setDateRange] = useState("this_month"); const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const now = new Date(); const handleRangeChange = (newRange: string) => {
const params = new URLSearchParams(searchParams.toString());
const filterByDate = (dateStr: string | null) => { params.set("range", newRange);
if (!dateStr) return false; router.push(`${pathname}?${params.toString()}`);
const date = new Date(dateStr);
if (dateRange === "this_week") {
const firstDay = new Date(now.setDate(now.getDate() - now.getDay() + (now.getDay() === 0 ? -6 : 1)));
return date >= firstDay;
}
if (dateRange === "this_month") {
return date.getMonth() === now.getMonth() && date.getFullYear() === now.getFullYear();
}
if (dateRange === "this_year") {
return date.getFullYear() === now.getFullYear();
}
return true;
}; };
const filteredFinances = data.finances.filter(f => filterByDate(f.transaction_date)); const { projectIncomeData, completedTasks, activeTasks } = data.metrics;
const filteredTasks = data.tasks.filter(t => filterByDate(t.created_at || t.due_at || null));
// Project-based income
const projectIncomeMap = new Map();
filteredFinances.filter(f => f.type === "income" && f.project_id).forEach(f => {
const project = data.projects.find(p => p.id === f.project_id);
const name = project ? project.name : "Bilinmeyen";
if (!projectIncomeMap.has(name)) {
projectIncomeMap.set(name, { name, value: 0 });
}
projectIncomeMap.get(name).value += Number(f.amount);
});
const projectIncomeData = Array.from(projectIncomeMap.values());
// Task completion stats
const completedTasks = filteredTasks.filter(t => t.status === "done").length;
const activeTasks = filteredTasks.filter(t => t.status !== "done" && t.status !== "cancelled").length;
const taskStatusData = [ const taskStatusData = [
{ name: "Tamamlanan", value: completedTasks }, { name: "Tamamlanan", value: completedTasks },
@@ -85,7 +61,7 @@ export function AnalyticsClient({ data }: AnalyticsClientProps) {
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Select value={dateRange} onValueChange={setDateRange}> <Select value={data.range} onValueChange={handleRangeChange}>
<SelectTrigger className="w-[160px]"> <SelectTrigger className="w-[160px]">
<SelectValue placeholder="Tarih aralığı" /> <SelectValue placeholder="Tarih aralığı" />
</SelectTrigger> </SelectTrigger>
+38 -17
View File
@@ -6,7 +6,11 @@ export const metadata = {
title: "Analizler - Neta", title: "Analizler - Neta",
}; };
export default async function AnalyticsPage() { export default async function AnalyticsPage({
searchParams,
}: {
searchParams: { [key: string]: string | string[] | undefined };
}) {
const supabase = await createClient(); const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser(); const { data: { user } } = await supabase.auth.getUser();
@@ -14,24 +18,41 @@ export default async function AnalyticsPage() {
redirect("/login"); redirect("/login");
} }
// Fetch all analytics data in parallel with only needed columns const range = typeof searchParams.range === "string" ? searchParams.range : "this_month";
const [{ data: tasks }, { data: projects }, { data: finances }] =
await Promise.all([ const now = new Date();
supabase let startDate = new Date();
.from("tasks") let endDate = new Date(now.getFullYear(), now.getMonth() + 1, 0, 23, 59, 59);
.select("id, status, created_at, due_at"),
supabase if (range === "this_week") {
.from("projects") const tempNow = new Date();
.select("id, name"), const firstDay = new Date(tempNow.setDate(tempNow.getDate() - tempNow.getDay() + (tempNow.getDay() === 0 ? -6 : 1)));
supabase firstDay.setHours(0, 0, 0, 0);
.from("finance_transactions") startDate = firstDay;
.select("id, type, amount, transaction_date, project_id"), endDate = new Date(firstDay.getTime());
]); endDate.setDate(endDate.getDate() + 6);
endDate.setHours(23, 59, 59, 999);
} else if (range === "this_month") {
startDate = new Date(now.getFullYear(), now.getMonth(), 1, 0, 0, 0);
endDate = new Date(now.getFullYear(), now.getMonth() + 1, 0, 23, 59, 59);
} else if (range === "this_year") {
startDate = new Date(now.getFullYear(), 0, 1, 0, 0, 0);
endDate = new Date(now.getFullYear(), 11, 31, 23, 59, 59);
}
// Fetch metrics using RPC
const { data: metricsData } = await supabase.rpc('get_analytics_metrics', {
p_start_date: startDate.toISOString(),
p_end_date: endDate.toISOString()
});
const analyticsData = { const analyticsData = {
tasks: tasks || [], metrics: metricsData || {
projects: projects || [], projectIncomeData: [],
finances: finances || [], completedTasks: 0,
activeTasks: 0
},
range
}; };
return <AnalyticsClient data={analyticsData} />; return <AnalyticsClient data={analyticsData} />;
+29 -56
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useState } from "react"; import { useRouter, usePathname, useSearchParams } from "next/navigation";
import { PendingLink } from "@/components/ui/pending-link"; import { PendingLink } from "@/components/ui/pending-link";
import { Badge, Card, CardContent } from "poyraz-ui/atoms"; import { Badge, Card, CardContent } from "poyraz-ui/atoms";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "poyraz-ui/molecules"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "poyraz-ui/molecules";
@@ -8,11 +8,17 @@ import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxi
import { CheckCircle2, Wallet, FolderKanban, Activity, Users } from "lucide-react"; import { CheckCircle2, Wallet, FolderKanban, Activity, Users } from "lucide-react";
export type DashboardData = { export type DashboardData = {
tasks: { id: string; status: string; created_at: string; updated_at?: string; due_at?: string }[]; metrics: {
netProfit: number;
activeProjectsCount: number;
completedTasksCount: number;
avgMood: string;
financeTrend: { date: string; income: number; expense: number }[];
moodTrend: { date: string; mood: number; energy: number }[];
};
projects: { id: string; status: string; name: string; created_at: string }[]; projects: { id: string; status: string; name: string; created_at: string }[];
finances: { id: string; type: string; amount: number; transaction_date: string }[];
logs: { id: string; log_date: string; mood_score: number; energy_score: number }[];
clients: { id: string; name: string; company_name: string; created_at: string }[]; clients: { id: string; name: string; company_name: string; created_at: string }[];
range: string;
}; };
type DashboardClientProps = { type DashboardClientProps = {
@@ -20,62 +26,29 @@ type DashboardClientProps = {
}; };
export function DashboardClient({ data }: DashboardClientProps) { export function DashboardClient({ data }: DashboardClientProps) {
const [dateRange, setDateRange] = useState("this_month"); const router = useRouter();
const pathname = usePathname();
// Calculate real metrics from the `data` prop depending on `dateRange` const searchParams = useSearchParams();
const now = new Date();
// Helper to filter by date const handleRangeChange = (newRange: string) => {
const filterByDate = (dateStr: string | null) => { const params = new URLSearchParams(searchParams.toString());
if (!dateStr) return false; params.set("range", newRange);
const date = new Date(dateStr); router.push(`${pathname}?${params.toString()}`);
if (dateRange === "today") {
return date.toDateString() === now.toDateString();
}
if (dateRange === "this_week") {
const firstDay = new Date(now.setDate(now.getDate() - now.getDay() + (now.getDay() === 0 ? -6 : 1))); // Monday
return date >= firstDay;
}
if (dateRange === "this_month") {
return date.getMonth() === now.getMonth() && date.getFullYear() === now.getFullYear();
}
return true;
}; };
// KPIs const { netProfit, activeProjectsCount, completedTasksCount, avgMood, financeTrend, moodTrend } = data.metrics;
const filteredFinances = (data.finances || []).filter(f => filterByDate(f.transaction_date));
const income = filteredFinances.filter(f => f.type === "income").reduce((acc, curr) => acc + Number(curr.amount), 0);
const expense = filteredFinances.filter(f => f.type === "expense").reduce((acc, curr) => acc + Number(curr.amount), 0);
const netProfit = income - expense;
const activeProjectsCount = (data.projects || []).filter(p => p.status === "active").length; // Format dates for Recharts using local timezone
const incomeTrendData = (financeTrend || []).map(f => ({
const completedTasksCount = (data.tasks || []).filter(t => t.status === "done" && filterByDate(t.updated_at || t.created_at)).length; name: new Date(f.date).toLocaleDateString("tr-TR", { month: "short", day: "numeric" }),
income: f.income,
expense: f.expense
}));
const filteredLogs = (data.logs || []).filter(l => filterByDate(l.log_date)); const moodTrendData = (moodTrend || []).map(l => ({
const avgMood = filteredLogs.length > 0 date: new Date(l.date).toLocaleDateString("tr-TR", { month: "short", day: "numeric" }),
? (filteredLogs.reduce((acc, curr) => acc + curr.mood_score, 0) / filteredLogs.length).toFixed(1) mood: l.mood,
: "0.0"; energy: l.energy,
// Recharts Data Prep
// Group finances by date
const financeMap = new Map();
filteredFinances.forEach(f => {
const d = new Date(f.transaction_date).toLocaleDateString("tr-TR", { month: "short", day: "numeric" });
if (!financeMap.has(d)) {
financeMap.set(d, { name: d, income: 0, expense: 0 });
}
const entry = financeMap.get(d);
if (f.type === "income") entry.income += Number(f.amount);
if (f.type === "expense") entry.expense += Number(f.amount);
});
const incomeTrendData = Array.from(financeMap.values());
// Group logs by date
const moodTrendData = filteredLogs.map(l => ({
date: new Date(l.log_date).toLocaleDateString("tr-TR", { month: "short", day: "numeric" }),
mood: l.mood_score,
energy: l.energy_score,
})); }));
// Format currency // Format currency
@@ -107,7 +80,7 @@ export function DashboardClient({ data }: DashboardClientProps) {
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Select value={dateRange} onValueChange={setDateRange}> <Select value={data.range} onValueChange={handleRangeChange}>
<SelectTrigger className="w-[160px]"> <SelectTrigger className="w-[160px]">
<SelectValue placeholder="Tarih aralığı" /> <SelectValue placeholder="Tarih aralığı" />
</SelectTrigger> </SelectTrigger>
+47 -30
View File
@@ -6,7 +6,11 @@ export const metadata = {
title: "Dashboard - Neta", title: "Dashboard - Neta",
}; };
export default async function DashboardPage() { export default async function DashboardPage({
searchParams,
}: {
searchParams: { [key: string]: string | string[] | undefined };
}) {
const supabase = await createClient(); const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser(); const { data: { user } } = await supabase.auth.getUser();
@@ -14,41 +18,48 @@ export default async function DashboardPage() {
redirect("/login"); redirect("/login");
} }
// Get current date and date 30 days ago const range = typeof searchParams.range === "string" ? searchParams.range : "this_month";
const today = new Date();
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
const todayStr = today.toISOString(); const now = new Date();
const thirtyDaysAgoStr = thirtyDaysAgo.toISOString(); let startDate = new Date();
let endDate = new Date(now.getFullYear(), now.getMonth() + 1, 0, 23, 59, 59); // default to end of month
if (range === "today") {
startDate = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0);
endDate = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 23, 59, 59);
} else if (range === "this_week") {
// Reset `now` because setDate mutates
const tempNow = new Date();
const firstDay = new Date(tempNow.setDate(tempNow.getDate() - tempNow.getDay() + (tempNow.getDay() === 0 ? -6 : 1)));
firstDay.setHours(0, 0, 0, 0);
startDate = firstDay;
endDate = new Date(firstDay.getTime());
endDate.setDate(endDate.getDate() + 6);
endDate.setHours(23, 59, 59, 999);
} else if (range === "this_month") {
startDate = new Date(now.getFullYear(), now.getMonth(), 1, 0, 0, 0);
endDate = new Date(now.getFullYear(), now.getMonth() + 1, 0, 23, 59, 59);
} else if (range === "this_year") {
startDate = new Date(now.getFullYear(), 0, 1, 0, 0, 0);
endDate = new Date(now.getFullYear(), 11, 31, 23, 59, 59);
}
// Fetch all dashboard data in parallel with only needed columns // Fetch metrics using RPC
const { data: metricsData } = await supabase.rpc('get_dashboard_metrics', {
p_start_date: startDate.toISOString(),
p_end_date: endDate.toISOString()
});
// Fetch limited recent data
const [ const [
{ data: tasks },
{ data: projects }, { data: projects },
{ data: finances },
{ data: logs },
{ data: clients }, { data: clients },
] = await Promise.all([ ] = await Promise.all([
supabase
.from("tasks")
.select("id, status, created_at, updated_at, due_at")
.or(`due_at.gte.${thirtyDaysAgoStr},created_at.gte.${thirtyDaysAgoStr}`)
.order("due_at", { ascending: true }),
supabase supabase
.from("projects") .from("projects")
.select("id, status, name, created_at") .select("id, status, name, created_at")
.order("created_at", { ascending: false }), .order("created_at", { ascending: false })
supabase .limit(5),
.from("finance_transactions")
.select("id, type, amount, transaction_date")
.gte("transaction_date", thirtyDaysAgoStr)
.order("transaction_date", { ascending: true }),
supabase
.from("daily_logs")
.select("id, log_date, mood_score, energy_score")
.gte("log_date", thirtyDaysAgoStr)
.order("log_date", { ascending: true }),
supabase supabase
.from("clients") .from("clients")
.select("id, name, company_name, created_at") .select("id, name, company_name, created_at")
@@ -57,11 +68,17 @@ export default async function DashboardPage() {
]); ]);
const dashboardData = { const dashboardData = {
tasks: tasks || [], metrics: metricsData || {
netProfit: 0,
activeProjectsCount: 0,
completedTasksCount: 0,
avgMood: "0.0",
financeTrend: [],
moodTrend: []
},
projects: projects || [], projects: projects || [],
finances: finances || [],
logs: logs || [],
clients: clients || [], clients: clients || [],
range
}; };
return <DashboardClient data={dashboardData} />; return <DashboardClient data={dashboardData} />;
+3 -1
View File
@@ -24,7 +24,7 @@ Repo içinde `supabase/` ve `docs/database/` klasörleri tutulmaya devam eder. B
## Tek Seferlik Kurulum SQL'i ## Tek Seferlik Kurulum SQL'i
Yeni bir Supabase projesini Neta için hazırlamanın en kolay yolu `supabase/setup.sql` dosyasını çalıştırmak. Bu dosya `supabase/schema.sql` ve `supabase/migrations/0002..0011` arasındaki migration dosyalarının tek dosyada birleştirilmiş hâlidir. Yeni bir Supabase projesini Neta için hazırlamanın en kolay yolu `supabase/setup.sql` dosyasını çalıştırmak. Bu dosya `supabase/schema.sql` ve `supabase/migrations/0002..0012` arasındaki migration dosyalarının tek dosyada birleştirilmiş hâlidir.
Demo seed verisi bu dosyaya dahil değildir. Production kurulumda örnek veri istemediğim için seed ayrı tutulur. Demo seed verisi bu dosyaya dahil değildir. Production kurulumda örnek veri istemediğim için seed ayrı tutulur.
@@ -98,6 +98,8 @@ Uygulama şu function/RPC yapılarına ihtiyaç duyar:
- `is_first_admin_setup_available` - `is_first_admin_setup_available`
- `request_internal_auth_creation` - `request_internal_auth_creation`
- `match_documents` - `match_documents`
- `get_dashboard_metrics` — Dashboard KPI ve grafik verilerini sunucu tarafında hesaplar
- `get_analytics_metrics` — Analiz sayfası proje gelir dağılımı ve görev istatistiklerini hesaplar
- Yeni auth user için profile oluşturan trigger/function - Yeni auth user için profile oluşturan trigger/function
- Proje ilerlemesini görev durumuna göre güncelleyen trigger/function - Proje ilerlemesini görev durumuna göre güncelleyen trigger/function
@@ -0,0 +1,161 @@
-- 0012_add_analytics_rpcs.sql
-- RPCs for Dashboard and Analytics aggregations to optimize frontend payload size
CREATE OR REPLACE FUNCTION public.get_dashboard_metrics(
p_start_date TIMESTAMP WITH TIME ZONE,
p_end_date TIMESTAMP WITH TIME ZONE
)
RETURNS JSONB
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_user_id UUID := auth.uid();
v_income NUMERIC;
v_expense NUMERIC;
v_net_profit NUMERIC;
v_active_projects INT;
v_completed_tasks INT;
v_avg_mood NUMERIC;
v_finance_trend JSONB;
v_mood_trend JSONB;
BEGIN
IF v_user_id IS NULL THEN
RAISE EXCEPTION 'Not authenticated';
END IF;
-- 1. Net Profit
SELECT COALESCE(SUM(amount), 0) INTO v_income
FROM finance_transactions
WHERE user_id = v_user_id AND type = 'income'
AND transaction_date >= p_start_date::date AND transaction_date <= p_end_date::date;
SELECT COALESCE(SUM(amount), 0) INTO v_expense
FROM finance_transactions
WHERE user_id = v_user_id AND type = 'expense'
AND transaction_date >= p_start_date::date AND transaction_date <= p_end_date::date;
v_net_profit := v_income - v_expense;
-- 2. Active Projects
SELECT COUNT(*) INTO v_active_projects
FROM projects
WHERE user_id = v_user_id AND status = 'active';
-- 3. Completed Tasks
SELECT COUNT(*) INTO v_completed_tasks
FROM tasks
WHERE user_id = v_user_id AND status = 'done'
AND COALESCE(updated_at, created_at) >= p_start_date AND COALESCE(updated_at, created_at) <= p_end_date;
-- 4. Average Mood
SELECT COALESCE(ROUND(AVG(mood_score)::numeric, 1), 0) INTO v_avg_mood
FROM daily_logs
WHERE user_id = v_user_id
AND log_date >= p_start_date::date AND log_date <= p_end_date::date;
-- 5. Finance Trend (group by date)
SELECT COALESCE(jsonb_agg(
jsonb_build_object(
'date', t.t_date,
'income', t.inc,
'expense', t.exp
)
), '[]'::jsonb) INTO v_finance_trend
FROM (
SELECT
transaction_date AS t_date,
SUM(CASE WHEN type = 'income' THEN amount ELSE 0 END) AS inc,
SUM(CASE WHEN type = 'expense' THEN amount ELSE 0 END) AS exp
FROM finance_transactions
WHERE user_id = v_user_id
AND transaction_date >= p_start_date::date AND transaction_date <= p_end_date::date
GROUP BY transaction_date
ORDER BY transaction_date ASC
) t;
-- 6. Mood Trend
SELECT COALESCE(jsonb_agg(
jsonb_build_object(
'date', log_date,
'mood', mood_score,
'energy', energy_score
)
), '[]'::jsonb) INTO v_mood_trend
FROM (
SELECT log_date, mood_score, energy_score
FROM daily_logs
WHERE user_id = v_user_id
AND log_date >= p_start_date::date AND log_date <= p_end_date::date
ORDER BY log_date ASC
) m;
RETURN jsonb_build_object(
'netProfit', v_net_profit,
'activeProjectsCount', v_active_projects,
'completedTasksCount', v_completed_tasks,
'avgMood', v_avg_mood::text,
'financeTrend', v_finance_trend,
'moodTrend', v_mood_trend
);
END;
$$;
CREATE OR REPLACE FUNCTION public.get_analytics_metrics(
p_start_date TIMESTAMP WITH TIME ZONE,
p_end_date TIMESTAMP WITH TIME ZONE
)
RETURNS JSONB
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_user_id UUID := auth.uid();
v_project_income JSONB;
v_completed_tasks INT;
v_active_tasks INT;
BEGIN
IF v_user_id IS NULL THEN
RAISE EXCEPTION 'Not authenticated';
END IF;
-- 1. Project Income Data
SELECT COALESCE(jsonb_agg(
jsonb_build_object(
'name', t.p_name,
'value', t.total_amount
)
), '[]'::jsonb) INTO v_project_income
FROM (
SELECT
COALESCE(p.name, 'Bilinmeyen') AS p_name,
SUM(f.amount) AS total_amount
FROM finance_transactions f
LEFT JOIN projects p ON f.project_id = p.id
WHERE f.user_id = v_user_id AND f.type = 'income'
AND f.transaction_date >= p_start_date::date AND f.transaction_date <= p_end_date::date
GROUP BY p.name
ORDER BY total_amount DESC
) t;
-- 2. Task completion stats
SELECT COUNT(*) INTO v_completed_tasks
FROM tasks
WHERE user_id = v_user_id AND status = 'done'
AND COALESCE(due_at, created_at) >= p_start_date AND COALESCE(due_at, created_at) <= p_end_date;
SELECT COUNT(*) INTO v_active_tasks
FROM tasks
WHERE user_id = v_user_id AND status != 'done' AND status != 'cancelled'
AND COALESCE(due_at, created_at) >= p_start_date AND COALESCE(due_at, created_at) <= p_end_date;
RETURN jsonb_build_object(
'projectIncomeData', v_project_income,
'completedTasks', v_completed_tasks,
'activeTasks', v_active_tasks
);
END;
$$;
+169 -1
View File
@@ -1,6 +1,6 @@
-- Neta one-shot Supabase setup SQL -- Neta one-shot Supabase setup SQL
-- Run this once in a fresh Supabase project after Auth and Storage schemas are available. -- Run this once in a fresh Supabase project after Auth and Storage schemas are available.
-- This file is generated from supabase/schema.sql and supabase/migrations/0002..0011. -- This file is generated from supabase/schema.sql and supabase/migrations/0002..0012.
-- Demo seed data is intentionally not included. -- Demo seed data is intentionally not included.
@@ -1560,4 +1560,172 @@ create policy "Users can delete their own project assets." on storage.objects
) )
); );
-- -----------------------------------------------------------------------------
-- Source: supabase/migrations/0012_add_analytics_rpcs.sql
-- -----------------------------------------------------------------------------
-- 0012: Dashboard & Analytics aggregate RPC functions
-- These functions run aggregation queries inside PostgreSQL so the frontend
-- receives pre-computed JSON instead of raw rows. This significantly reduces
-- network payload and eliminates client-side reduce/filter overhead.
CREATE OR REPLACE FUNCTION public.get_dashboard_metrics(
p_start_date TIMESTAMP WITH TIME ZONE,
p_end_date TIMESTAMP WITH TIME ZONE
)
RETURNS JSONB
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_user_id UUID := auth.uid();
v_income NUMERIC;
v_expense NUMERIC;
v_net_profit NUMERIC;
v_active_projects INT;
v_completed_tasks INT;
v_avg_mood NUMERIC;
v_finance_trend JSONB;
v_mood_trend JSONB;
BEGIN
IF v_user_id IS NULL THEN
RAISE EXCEPTION 'Not authenticated';
END IF;
-- 1. Net Profit
SELECT COALESCE(SUM(amount), 0) INTO v_income
FROM finance_transactions
WHERE user_id = v_user_id AND type = 'income'
AND transaction_date >= p_start_date::date AND transaction_date <= p_end_date::date;
SELECT COALESCE(SUM(amount), 0) INTO v_expense
FROM finance_transactions
WHERE user_id = v_user_id AND type = 'expense'
AND transaction_date >= p_start_date::date AND transaction_date <= p_end_date::date;
v_net_profit := v_income - v_expense;
-- 2. Active Projects
SELECT COUNT(*) INTO v_active_projects
FROM projects
WHERE user_id = v_user_id AND status = 'active';
-- 3. Completed Tasks
SELECT COUNT(*) INTO v_completed_tasks
FROM tasks
WHERE user_id = v_user_id AND status = 'done'
AND COALESCE(updated_at, created_at) >= p_start_date AND COALESCE(updated_at, created_at) <= p_end_date;
-- 4. Average Mood
SELECT COALESCE(ROUND(AVG(mood_score)::numeric, 1), 0) INTO v_avg_mood
FROM daily_logs
WHERE user_id = v_user_id
AND log_date >= p_start_date::date AND log_date <= p_end_date::date;
-- 5. Finance Trend (group by date)
SELECT COALESCE(jsonb_agg(
jsonb_build_object(
'date', t.t_date,
'income', t.inc,
'expense', t.exp
)
), '[]'::jsonb) INTO v_finance_trend
FROM (
SELECT
transaction_date AS t_date,
SUM(CASE WHEN type = 'income' THEN amount ELSE 0 END) AS inc,
SUM(CASE WHEN type = 'expense' THEN amount ELSE 0 END) AS exp
FROM finance_transactions
WHERE user_id = v_user_id
AND transaction_date >= p_start_date::date AND transaction_date <= p_end_date::date
GROUP BY transaction_date
ORDER BY transaction_date ASC
) t;
-- 6. Mood Trend
SELECT COALESCE(jsonb_agg(
jsonb_build_object(
'date', log_date,
'mood', mood_score,
'energy', energy_score
)
), '[]'::jsonb) INTO v_mood_trend
FROM (
SELECT log_date, mood_score, energy_score
FROM daily_logs
WHERE user_id = v_user_id
AND log_date >= p_start_date::date AND log_date <= p_end_date::date
ORDER BY log_date ASC
) m;
RETURN jsonb_build_object(
'netProfit', v_net_profit,
'activeProjectsCount', v_active_projects,
'completedTasksCount', v_completed_tasks,
'avgMood', v_avg_mood::text,
'financeTrend', v_finance_trend,
'moodTrend', v_mood_trend
);
END;
$$;
CREATE OR REPLACE FUNCTION public.get_analytics_metrics(
p_start_date TIMESTAMP WITH TIME ZONE,
p_end_date TIMESTAMP WITH TIME ZONE
)
RETURNS JSONB
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_user_id UUID := auth.uid();
v_project_income JSONB;
v_completed_tasks INT;
v_active_tasks INT;
BEGIN
IF v_user_id IS NULL THEN
RAISE EXCEPTION 'Not authenticated';
END IF;
-- 1. Project Income Data
SELECT COALESCE(jsonb_agg(
jsonb_build_object(
'name', t.p_name,
'value', t.total_amount
)
), '[]'::jsonb) INTO v_project_income
FROM (
SELECT
COALESCE(p.name, 'Bilinmeyen') AS p_name,
SUM(f.amount) AS total_amount
FROM finance_transactions f
LEFT JOIN projects p ON f.project_id = p.id
WHERE f.user_id = v_user_id AND f.type = 'income'
AND f.transaction_date >= p_start_date::date AND f.transaction_date <= p_end_date::date
GROUP BY p.name
ORDER BY total_amount DESC
) t;
-- 2. Task completion stats
SELECT COUNT(*) INTO v_completed_tasks
FROM tasks
WHERE user_id = v_user_id AND status = 'done'
AND COALESCE(due_at, created_at) >= p_start_date AND COALESCE(due_at, created_at) <= p_end_date;
SELECT COUNT(*) INTO v_active_tasks
FROM tasks
WHERE user_id = v_user_id AND status != 'done' AND status != 'cancelled'
AND COALESCE(due_at, created_at) >= p_start_date AND COALESCE(due_at, created_at) <= p_end_date;
RETURN jsonb_build_object(
'projectIncomeData', v_project_income,
'completedTasks', v_completed_tasks,
'activeTasks', v_active_tasks
);
END;
$$;
notify pgrst, 'reload schema'; notify pgrst, 'reload schema';