Skip to content

Commit 0359fd3

Browse files
committed
Refactor charts to use server-side aggregation RPCs
Introduces SQL RPC functions for category breakdown, monthly trend, and period summary to offload aggregation from client to server. Refactors chart components and composables to fetch and use aggregated data from these RPCs, simplifying props and removing client-side calculations. Updates types to support new aggregated data structures and streamlines period filter options generation.
1 parent 0b59275 commit 0359fd3

8 files changed

Lines changed: 401 additions & 158 deletions

File tree

chart_aggregation_functions.sql

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
-- RPC functions for chart aggregations to reduce client-side calculations
2+
3+
-- 1. Get category breakdown for a specific period
4+
CREATE OR REPLACE FUNCTION get_category_breakdown(
5+
p_user_id UUID,
6+
p_start_date DATE,
7+
p_end_date DATE
8+
) RETURNS TABLE(
9+
category TEXT,
10+
amount NUMERIC,
11+
count BIGINT,
12+
percentage NUMERIC
13+
) AS $$
14+
DECLARE
15+
total_amount NUMERIC;
16+
BEGIN
17+
-- Calculate total amount for percentage calculation
18+
SELECT COALESCE(SUM(e.amount), 0) INTO total_amount
19+
FROM expenses e
20+
WHERE e.user_id = p_user_id
21+
AND e.date >= p_start_date
22+
AND e.date <= p_end_date;
23+
24+
-- Return category breakdown with percentage
25+
RETURN QUERY
26+
SELECT
27+
e.category,
28+
COALESCE(SUM(e.amount), 0) as amount,
29+
COUNT(*)::BIGINT as count,
30+
CASE
31+
WHEN total_amount > 0 THEN ROUND((COALESCE(SUM(e.amount), 0) / total_amount * 100), 2)
32+
ELSE 0
33+
END as percentage
34+
FROM expenses e
35+
WHERE e.user_id = p_user_id
36+
AND e.date >= p_start_date
37+
AND e.date <= p_end_date
38+
GROUP BY e.category
39+
ORDER BY SUM(e.amount) DESC;
40+
END;
41+
$$ LANGUAGE plpgsql SECURITY DEFINER;
42+
43+
-- 2. Get monthly trend data for a specific year
44+
CREATE OR REPLACE FUNCTION get_monthly_trend(
45+
p_user_id UUID,
46+
p_year INTEGER
47+
) RETURNS TABLE(
48+
month INTEGER,
49+
month_label TEXT,
50+
amount NUMERIC
51+
) AS $$
52+
BEGIN
53+
RETURN QUERY
54+
WITH months AS (
55+
SELECT
56+
month_num,
57+
CASE month_num
58+
WHEN 1 THEN '1月'
59+
WHEN 2 THEN '2月'
60+
WHEN 3 THEN '3月'
61+
WHEN 4 THEN '4月'
62+
WHEN 5 THEN '5月'
63+
WHEN 6 THEN '6月'
64+
WHEN 7 THEN '7月'
65+
WHEN 8 THEN '8月'
66+
WHEN 9 THEN '9月'
67+
WHEN 10 THEN '10月'
68+
WHEN 11 THEN '11月'
69+
WHEN 12 THEN '12月'
70+
END as month_name
71+
FROM generate_series(1, 12) as month_num
72+
)
73+
SELECT
74+
m.month_num as month,
75+
m.month_name as month_label,
76+
COALESCE(SUM(e.amount), 0) as amount
77+
FROM months m
78+
LEFT JOIN expenses e ON
79+
e.user_id = p_user_id
80+
AND EXTRACT(YEAR FROM e.date) = p_year
81+
AND EXTRACT(MONTH FROM e.date) = m.month_num
82+
GROUP BY m.month_num, m.month_name
83+
ORDER BY m.month_num;
84+
END;
85+
$$ LANGUAGE plpgsql SECURITY DEFINER;
86+
87+
-- 3. Get period summary (total amount and count)
88+
CREATE OR REPLACE FUNCTION get_period_summary(
89+
p_user_id UUID,
90+
p_start_date DATE,
91+
p_end_date DATE
92+
) RETURNS TABLE(
93+
total_amount NUMERIC,
94+
expense_count BIGINT
95+
) AS $$
96+
BEGIN
97+
RETURN QUERY
98+
SELECT
99+
COALESCE(SUM(amount), 0) as total_amount,
100+
COUNT(*)::BIGINT as expense_count
101+
FROM expenses
102+
WHERE user_id = p_user_id
103+
AND date >= p_start_date
104+
AND date <= p_end_date;
105+
END;
106+
$$ LANGUAGE plpgsql SECURITY DEFINER;
107+
108+
-- 4. Get expenses for a specific period (still needed for some components)
109+
CREATE OR REPLACE FUNCTION get_period_expenses(
110+
p_user_id UUID,
111+
p_start_date DATE,
112+
p_end_date DATE
113+
) RETURNS TABLE(
114+
id UUID,
115+
amount NUMERIC,
116+
category TEXT,
117+
date DATE,
118+
note TEXT,
119+
user_id UUID,
120+
created_at TIMESTAMPTZ
121+
) AS $$
122+
BEGIN
123+
RETURN QUERY
124+
SELECT
125+
e.id,
126+
e.amount,
127+
e.category,
128+
e.date,
129+
e.note,
130+
e.user_id,
131+
e.created_at
132+
FROM expenses e
133+
WHERE e.user_id = p_user_id
134+
AND e.date >= p_start_date
135+
AND e.date <= p_end_date
136+
ORDER BY e.created_at DESC;
137+
END;
138+
$$ LANGUAGE plpgsql SECURITY DEFINER;

src/components/ChartsView.vue

Lines changed: 53 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -8,35 +8,34 @@
88
<template v-else>
99
<!-- Time Period Filter -->
1010
<PeriodFilter
11-
:expenses="expenses"
1211
v-model:model-period-type="selectedPeriodType"
1312
v-model:model-month="selectedMonth"
1413
v-model:model-year="selectedYear"
1514
/>
1615

1716
<!-- Summary Stats -->
1817
<SummaryStats
19-
:total="periodTotal"
20-
:count="periodExpenses.length"
18+
:total="periodSummary?.total_amount || 0"
19+
:count="periodSummary?.expense_count || 0"
2120
:period-label="periodLabel"
2221
/>
2322

2423
<!-- Category Breakdown Chart -->
25-
<CategoryChart :expenses="periodExpenses" />
24+
<CategoryChart :category-data="categoryBreakdown" />
2625

2726
<!-- Monthly Trend Chart (only show for year view) -->
2827
<TrendChart
29-
:expenses="periodExpenses"
28+
:trend-data="monthlyTrend"
3029
:year="selectedYear"
3130
:show-chart="selectedPeriodType === 'year'"
3231
/>
3332

3433
<!-- Category Details -->
35-
<CategoryDetails :expenses="periodExpenses" />
34+
<CategoryDetails :category-data="categoryBreakdown" />
3635

3736
<!-- Empty State -->
3837
<EmptyState
39-
v-if="periodExpenses.length === 0"
38+
v-if="!categoryBreakdown || categoryBreakdown.length === 0"
4039
:period-label="periodLabel"
4140
/>
4241
</template>
@@ -53,7 +52,7 @@ import PeriodFilter from './charts/PeriodFilter.vue'
5352
import SummaryStats from './charts/SummaryStats.vue'
5453
import EmptyState from './charts/EmptyState.vue'
5554
import { useExpenseManagement } from '../composables/useExpenseManagement'
56-
import { type Expense } from '../types'
55+
import { type CategoryBreakdownData, type MonthlyTrendData, type PeriodSummaryData } from '../types'
5756
5857
const props = withDefaults(defineProps<{
5958
refreshTrigger?: number
@@ -62,20 +61,53 @@ const props = withDefaults(defineProps<{
6261
})
6362
6463
const route = useRoute()
65-
const { loadAllExpenses, refreshTrigger: globalRefreshTrigger } = useExpenseManagement()
64+
const { getCategoryBreakdown, getMonthlyTrend, getPeriodSummary, refreshTrigger: globalRefreshTrigger } = useExpenseManagement()
6665
67-
const expenses = ref<Expense[]>([])
6866
const loadingExpenses = ref(false)
6967
const selectedPeriodType = ref<'month' | 'year'>('month')
7068
const selectedMonth = ref('')
7169
const selectedYear = ref('')
7270
73-
// Load all expenses for charts
74-
const loadExpensesData = async () => {
71+
// Aggregated data from RPC calls
72+
const categoryBreakdown = ref<CategoryBreakdownData[]>([])
73+
const monthlyTrend = ref<MonthlyTrendData[]>([])
74+
const periodSummary = ref<PeriodSummaryData | null>(null)
75+
76+
// Calculate date range based on selected period
77+
const dateRange = computed(() => {
78+
if (selectedPeriodType.value === 'month') {
79+
if (!selectedMonth.value) return null
80+
const [year, month] = selectedMonth.value.split('-')
81+
const startDate = `${year}-${month}-01`
82+
const endDate = new Date(parseInt(year), parseInt(month), 0).toISOString().split('T')[0]
83+
return { startDate, endDate }
84+
} else {
85+
if (!selectedYear.value) return null
86+
const startDate = `${selectedYear.value}-01-01`
87+
const endDate = `${selectedYear.value}-12-31`
88+
return { startDate, endDate }
89+
}
90+
})
91+
92+
// Load aggregated data using RPC calls
93+
const loadAggregatedData = async () => {
7594
loadingExpenses.value = true
7695
try {
77-
const data = await loadAllExpenses()
78-
expenses.value = data
96+
const range = dateRange.value
97+
if (!range) return
98+
99+
// Load data in parallel
100+
const [categoryData, summaryData, trendData] = await Promise.all([
101+
getCategoryBreakdown(range.startDate, range.endDate),
102+
getPeriodSummary(range.startDate, range.endDate),
103+
selectedPeriodType.value === 'year' && selectedYear.value
104+
? getMonthlyTrend(parseInt(selectedYear.value))
105+
: Promise.resolve([])
106+
])
107+
108+
categoryBreakdown.value = categoryData
109+
periodSummary.value = summaryData
110+
monthlyTrend.value = trendData
79111
} finally {
80112
loadingExpenses.value = false
81113
}
@@ -86,46 +118,25 @@ onMounted(async () => {
86118
const now = new Date()
87119
selectedMonth.value = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
88120
selectedYear.value = now.getFullYear().toString()
89-
90-
// Load data when component mounts
91-
await loadExpensesData()
92121
})
93122
123+
// Watch for period changes to reload data
124+
watch([selectedPeriodType, selectedMonth, selectedYear], () => {
125+
loadAggregatedData()
126+
}, { immediate: true })
127+
94128
// Watch for route changes to reload data
95129
watch(() => route.name, async (newRouteName) => {
96130
if (newRouteName === 'Charts') {
97-
await loadExpensesData()
131+
await loadAggregatedData()
98132
}
99133
})
100134
101135
// Watch for refresh trigger
102136
watch([() => props.refreshTrigger, globalRefreshTrigger], async () => {
103-
await loadExpensesData()
137+
await loadAggregatedData()
104138
})
105139
106-
// Filter expenses based on selected period
107-
const periodExpenses = computed(() => {
108-
if (selectedPeriodType.value === 'month') {
109-
if (!selectedMonth.value) return []
110-
const [year, month] = selectedMonth.value.split('-')
111-
return expenses.value.filter(expense => {
112-
const expenseDate = new Date(expense.date)
113-
return expenseDate.getFullYear() === parseInt(year) &&
114-
expenseDate.getMonth() === parseInt(month) - 1
115-
})
116-
} else {
117-
if (!selectedYear.value) return []
118-
return expenses.value.filter(expense => {
119-
const expenseDate = new Date(expense.date)
120-
return expenseDate.getFullYear() === parseInt(selectedYear.value)
121-
})
122-
}
123-
})
124-
125-
const periodTotal = computed(() =>
126-
periodExpenses.value.reduce((total, expense) => total + expense.amount, 0)
127-
)
128-
129140
const periodLabel = computed(() => {
130141
if (selectedPeriodType.value === 'month') {
131142
if (!selectedMonth.value) return '本月'

src/components/charts/CategoryChart.vue

Lines changed: 26 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<template>
2-
<v-card class="mb-6" elevation="1" v-if="expenses.length > 0">
2+
<v-card class="mb-6" elevation="1" v-if="categoryData && categoryData.length > 0">
33
<v-card-title class="pb-2">
44
<v-icon class="mr-2">mdi-chart-donut</v-icon>
55
分类支出占比
@@ -23,11 +23,18 @@ import {
2323
DoughnutController
2424
} from 'chart.js'
2525
import { useCategories, type CategoryKey } from '../../composables/useCategories'
26-
import { type Expense, type ChartProps } from '../../types'
26+
import { type CategoryBreakdownData } from '../../types'
2727
2828
Chart.register(ArcElement, Title, Tooltip, Legend, DoughnutController)
2929
30-
const props = defineProps<ChartProps>()
30+
interface Props {
31+
categoryData?: CategoryBreakdownData[]
32+
}
33+
34+
const props = withDefaults(defineProps<Props>(), {
35+
categoryData: () => []
36+
})
37+
3138
const chartRef = ref<HTMLCanvasElement>()
3239
let chart: Chart | null = null
3340
@@ -41,40 +48,31 @@ const formatAmount = (amount: number) => {
4148
}
4249
4350
const categoryBreakdown = computed(() => {
44-
const breakdown: Record<CategoryKey, { amount: number; count: number; percentage: number }> = {} as any
51+
if (!props.categoryData) return {}
4552
46-
props.expenses.forEach(expense => {
47-
if (!breakdown[expense.category]) {
48-
breakdown[expense.category] = { amount: 0, count: 0, percentage: 0 }
49-
}
50-
breakdown[expense.category].amount += expense.amount
51-
breakdown[expense.category].count += 1
52-
})
53+
const breakdown: Record<string, { amount: number; count: number; percentage: number }> = {}
5354
54-
const total = props.expenses.reduce((sum, exp) => sum + exp.amount, 0)
55-
Object.keys(breakdown).forEach(category => {
56-
const cat = category as CategoryKey
57-
breakdown[cat].percentage = total > 0 ? (breakdown[cat].amount / total) * 100 : 0
55+
props.categoryData.forEach(item => {
56+
breakdown[item.category] = {
57+
amount: item.amount,
58+
count: item.count,
59+
percentage: item.percentage
60+
}
5861
})
5962
60-
// Sort by amount descending
61-
const sortedBreakdown: Record<CategoryKey, any> = {} as any
62-
Object.entries(breakdown)
63-
.sort(([,a], [,b]) => b.amount - a.amount)
64-
.forEach(([category, data]) => {
65-
sortedBreakdown[category as CategoryKey] = data
66-
})
67-
68-
return sortedBreakdown
63+
return breakdown
6964
})
7065
71-
const total = computed(() => props.expenses.reduce((sum, exp) => sum + exp.amount, 0))
66+
const total = computed(() => {
67+
if (!props.categoryData) return 0
68+
return props.categoryData.reduce((sum, item) => sum + item.amount, 0)
69+
})
7270
7371
const createChart = () => {
74-
if (!chartRef.value) return
72+
if (!chartRef.value || !props.categoryData || props.categoryData.length === 0) return
7573
76-
const categories = Object.keys(categoryBreakdown.value) as CategoryKey[]
77-
const amounts = categories.map(cat => categoryBreakdown.value[cat].amount)
74+
const categories = props.categoryData.map(item => item.category as CategoryKey)
75+
const amounts = props.categoryData.map(item => item.amount)
7876
const colors = categories.map(cat => getCategoryChartColor(cat))
7977
8078
// Don't create chart if no data

0 commit comments

Comments
 (0)