cospend: filter recent activity by chart category selection
All checks were successful
CI / update (push) Successful in 1m27s

Clicking a category on the bar chart now filters the recent activity
list to show only payments in that category. Includes a clear filter
button and empty state message. Also increases recent splits from 10
to 30 for better coverage when filtering.
This commit is contained in:
2026-02-04 16:57:44 +01:00
parent 8776ab894b
commit 83de5fed34
3 changed files with 92 additions and 6 deletions

View File

@@ -2,7 +2,7 @@
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { Chart, registerables } from 'chart.js'; import { Chart, registerables } from 'chart.js';
let { data = { labels: [], datasets: [] }, title = '', height = '400px' } = $props<{ data?: any, title?: string, height?: string }>(); let { data = { labels: [], datasets: [] }, title = '', height = '400px', onFilterChange = null } = $props<{ data?: any, title?: string, height?: string, onFilterChange?: ((categories: string[] | null) => void) | null }>();
let canvas = $state(); let canvas = $state();
let chart = $state(); let chart = $state();
@@ -42,6 +42,19 @@
return categoryColorMap[category] || nordColors[index % nordColors.length]; return categoryColorMap[category] || nordColors[index % nordColors.length];
} }
function emitFilter() {
if (!onFilterChange || !chart) return;
const allVisible = chart.data.datasets.every((_, idx) => !chart.getDatasetMeta(idx).hidden);
if (allVisible) {
onFilterChange(null);
} else {
const visible = chart.data.datasets
.filter((_, idx) => !chart.getDatasetMeta(idx).hidden)
.map(ds => ds.label.toLowerCase());
onFilterChange(visible);
}
}
function createChart() { function createChart() {
if (!canvas || !data.datasets) return; if (!canvas || !data.datasets) return;
@@ -135,7 +148,6 @@
}, },
onClick: (event, legendItem, legend) => { onClick: (event, legendItem, legend) => {
const datasetIndex = legendItem.datasetIndex; const datasetIndex = legendItem.datasetIndex;
const clickedMeta = chart.getDatasetMeta(datasetIndex);
// Check if only this dataset is currently visible // Check if only this dataset is currently visible
const onlyThisVisible = chart.data.datasets.every((dataset, idx) => { const onlyThisVisible = chart.data.datasets.every((dataset, idx) => {
@@ -156,6 +168,7 @@
} }
chart.update(); chart.update();
emitFilter();
} }
}, },
title: { title: {
@@ -229,6 +242,7 @@
} }
chart.update(); chart.update();
emitFilter();
} }
} }
}, },

View File

@@ -89,7 +89,7 @@ export const GET: RequestHandler = async ({ locals, url }) => {
}, },
{ $unwind: '$paymentId' }, { $unwind: '$paymentId' },
{ $sort: { 'paymentId.date': -1, 'paymentId.createdAt': -1 } }, { $sort: { 'paymentId.date': -1, 'paymentId.createdAt': -1 } },
{ $limit: 10 } { $limit: 30 }
]); ]);
// For settlements, fetch the other user's split info // For settlements, fetch the other user's split info

View File

@@ -24,6 +24,13 @@
let error = $state(null); let error = $state(null);
let monthlyExpensesData = $state(data.monthlyExpensesData || { labels: [], datasets: [] }); let monthlyExpensesData = $state(data.monthlyExpensesData || { labels: [], datasets: [] });
let expensesLoading = $state(false); let expensesLoading = $state(false);
let categoryFilter = $state(null);
let filteredSplits = $derived(
categoryFilter
? (balance.recentSplits || []).filter(split => categoryFilter.includes(split.paymentId?.category))
: balance.recentSplits || []
);
// Component references for refreshing // Component references for refreshing
let enhancedBalanceComponent; let enhancedBalanceComponent;
@@ -177,6 +184,7 @@
data={monthlyExpensesData} data={monthlyExpensesData}
title="Monthly Expenses by Category" title="Monthly Expenses by Category"
height="400px" height="400px"
onFilterChange={(categories) => categoryFilter = categories}
/> />
{:else} {:else}
<div class="loading"> <div class="loading">
@@ -194,9 +202,17 @@
<div class="error">Error: {error}</div> <div class="error">Error: {error}</div>
{:else if balance.recentSplits && balance.recentSplits.length > 0} {:else if balance.recentSplits && balance.recentSplits.length > 0}
<div class="recent-activity"> <div class="recent-activity">
<h2>Recent Activity</h2> <div class="recent-activity-header">
<h2>Recent Activity{#if categoryFilter} <span class="filter-label">{categoryFilter.map(c => getCategoryName(c)).join(', ')}</span>{/if}</h2>
{#if categoryFilter}
<button class="clear-filter" onclick={() => categoryFilter = null}>Clear filter</button>
{/if}
</div>
{#if filteredSplits.length === 0}
<p class="no-results">No recent activity in {categoryFilter.map(c => getCategoryName(c)).join(', ')}.</p>
{/if}
<div class="activity-dialog"> <div class="activity-dialog">
{#each balance.recentSplits as split} {#each filteredSplits as split}
{#if isSettlementPayment(split.paymentId)} {#if isSettlementPayment(split.paymentId)}
<!-- Settlement Payment Display - User -> User Flow --> <!-- Settlement Payment Display - User -> User Flow -->
<a <a
@@ -363,11 +379,49 @@
margin-right: auto; margin-right: auto;
} }
.recent-activity h2 { .recent-activity-header {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 1rem;
margin-bottom: 1rem; margin-bottom: 1rem;
}
.recent-activity h2 {
margin: 0;
color: var(--nord0); color: var(--nord0);
} }
.filter-label {
font-weight: 400;
font-size: 1rem;
color: var(--nord3);
}
.clear-filter {
background: none;
border: 1px solid var(--nord4);
border-radius: 0.5rem;
padding: 0.25rem 0.75rem;
color: var(--nord3);
cursor: pointer;
font-size: 0.85rem;
white-space: nowrap;
transition: all 0.2s;
}
.clear-filter:hover {
border-color: var(--blue);
color: var(--blue);
}
.no-results {
text-align: center;
color: var(--nord3);
font-style: italic;
padding: 1rem 0;
}
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
.recent-activity { .recent-activity {
background: var(--accent-dark); background: var(--accent-dark);
@@ -377,6 +431,24 @@
.recent-activity h2 { .recent-activity h2 {
color: var(--font-default-dark); color: var(--font-default-dark);
} }
.filter-label {
color: var(--nord4);
}
.clear-filter {
border-color: var(--nord3);
color: var(--nord4);
}
.clear-filter:hover {
border-color: var(--blue);
color: var(--blue);
}
.no-results {
color: var(--nord4);
}
} }
.activity-dialog { .activity-dialog {