feat(trips): trip tracking with analytics, tag conversion, and transaction assignment
Adds trips table usage across API and UI: trip CRUD, per-trip analytics (category/daily/merchant/tag/participant breakdowns), tag-to-trip conversion, trip assignment via transaction overrides, and trip filter in the transactions view. Recovered from working tree after local git corruption; feature was already live via host-context Docker builds.
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
export const CATEGORY_COLORS: Record<string, string> = {
|
||||
groceries: "#22c55e",
|
||||
dining: "#f97316",
|
||||
transport: "#06b6d4",
|
||||
fuel: "#eab308",
|
||||
shopping: "#ec4899",
|
||||
utilities: "#8b5cf6",
|
||||
entertainment: "#f43f5e",
|
||||
travel: "#0ea5e9",
|
||||
health: "#10b981",
|
||||
insurance: "#64748b",
|
||||
subscriptions: "#a78bfa",
|
||||
cash_advance: "#dc2626",
|
||||
government: "#78716c",
|
||||
education: "#3b82f6",
|
||||
rent: "#d97706",
|
||||
transfers: "#6b7280",
|
||||
income: "#34d399",
|
||||
investment: "#818cf8",
|
||||
personal_care: "#fb7185",
|
||||
pets: "#86efac",
|
||||
gifts: "#fcd34d",
|
||||
charity: "#a3e635",
|
||||
home_goods: "#67e8f9",
|
||||
home_maintenance: "#c084fc",
|
||||
other: "#71717a",
|
||||
};
|
||||
|
||||
export const TOOLTIP_STYLE = {
|
||||
background: "#18181b",
|
||||
border: "1px solid #3f3f46",
|
||||
borderRadius: 8,
|
||||
fontSize: 12,
|
||||
};
|
||||
+95
-1
@@ -1,7 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import type { TransactionRow, StatementRow, TagRow } from "./queries";
|
||||
import type { TransactionRow, StatementRow, TagRow, TripRow, TripAnalytics } from "./queries";
|
||||
export type { TripRow, TripAnalytics };
|
||||
import type { CurrentUser } from "./auth";
|
||||
|
||||
interface TransactionsResponse {
|
||||
@@ -27,6 +28,7 @@ interface TransactionFilters {
|
||||
amount_min?: number;
|
||||
amount_max?: number;
|
||||
has_split?: string;
|
||||
trip_id?: string;
|
||||
}
|
||||
|
||||
function buildParams(filters: TransactionFilters): string {
|
||||
@@ -137,6 +139,7 @@ export function useUpdateTransaction() {
|
||||
description?: string;
|
||||
amount?: number;
|
||||
transaction_date?: string;
|
||||
trip_id?: number | null;
|
||||
}) => {
|
||||
const res = await fetch(`/api/transactions/${id}`, {
|
||||
method: "PATCH",
|
||||
@@ -801,3 +804,94 @@ export function useMerchantTransactions(merchant: string | null) {
|
||||
enabled: !!merchant,
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Trips ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export function useTrips() {
|
||||
return useQuery<TripRow[]>({
|
||||
queryKey: ["trips"],
|
||||
queryFn: async () => (await fetch("/api/trips")).json(),
|
||||
});
|
||||
}
|
||||
|
||||
export function useTrip(id: number) {
|
||||
return useQuery<TripRow>({
|
||||
queryKey: ["trip", id],
|
||||
queryFn: async () => (await fetch(`/api/trips/${id}`)).json(),
|
||||
enabled: id > 0,
|
||||
});
|
||||
}
|
||||
|
||||
export function useTripAnalytics(id: number) {
|
||||
return useQuery<TripAnalytics>({
|
||||
queryKey: ["trip-analytics", id],
|
||||
queryFn: async () => (await fetch(`/api/trips/${id}/analytics`)).json(),
|
||||
enabled: id > 0,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateTrip() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (data: Omit<TripRow, "id" | "owner_id" | "created_at" | "total_spend" | "transaction_count">) => {
|
||||
const res = await fetch("/api/trips", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!res.ok) throw new Error((await res.json()).error || "Failed");
|
||||
return res.json() as Promise<TripRow>;
|
||||
},
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["trips"] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateTrip() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async ({ id, ...data }: Partial<TripRow> & { id: number }) => {
|
||||
const res = await fetch(`/api/trips/${id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!res.ok) throw new Error((await res.json()).error || "Failed");
|
||||
return res.json() as Promise<TripRow>;
|
||||
},
|
||||
onSuccess: (_d, { id }) => {
|
||||
qc.invalidateQueries({ queryKey: ["trips"] });
|
||||
qc.invalidateQueries({ queryKey: ["trip", id] });
|
||||
qc.invalidateQueries({ queryKey: ["trip-analytics", id] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteTrip() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (id: number) => {
|
||||
await fetch(`/api/trips/${id}`, { method: "DELETE" });
|
||||
},
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["trips"] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useAssignTransactionsToTrip() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async ({ tripId, transactionIds }: { tripId: number | null; transactionIds: number[] }) => {
|
||||
const res = await fetch("/api/transactions/bulk", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "assign_trip", ids: transactionIds, trip_id: tripId }),
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to assign trip");
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["transactions"] });
|
||||
qc.invalidateQueries({ queryKey: ["trips"] });
|
||||
qc.invalidateQueries({ queryKey: ["trip-analytics"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -37,6 +37,10 @@ export interface TransactionRow {
|
||||
tags: TagRow[];
|
||||
// splits
|
||||
splits: { participant_id: number; name: string; share_percent: number; settled: boolean }[];
|
||||
// trip
|
||||
trip_id: number | null;
|
||||
trip_name: string | null;
|
||||
trip_color: string | null;
|
||||
}
|
||||
|
||||
export interface StatementRow {
|
||||
@@ -83,6 +87,7 @@ interface TransactionFilters {
|
||||
amount_min?: number;
|
||||
amount_max?: number;
|
||||
has_split?: string;
|
||||
trip_id?: string;
|
||||
}
|
||||
|
||||
export async function getTransactions(ownerId: number, filters: TransactionFilters) {
|
||||
@@ -154,6 +159,12 @@ export async function getTransactions(ownerId: number, filters: TransactionFilte
|
||||
} else if (filters.has_split === "no") {
|
||||
conditions.push(`NOT EXISTS (SELECT 1 FROM transaction_splits ts_f WHERE ts_f.transaction_id = t.id)`);
|
||||
}
|
||||
if (filters.trip_id === "unassigned") {
|
||||
conditions.push(`o.trip_id IS NULL`);
|
||||
} else if (filters.trip_id) {
|
||||
conditions.push(`o.trip_id = $${paramIdx++}`);
|
||||
params.push(Number(filters.trip_id));
|
||||
}
|
||||
|
||||
const where = `WHERE ${conditions.join(" AND ")}`;
|
||||
|
||||
@@ -181,6 +192,9 @@ export async function getTransactions(ownerId: number, filters: TransactionFilte
|
||||
COALESCE(t.owner_id, s.owner_id) as owner_id,
|
||||
p.name as owner_name,
|
||||
COALESCE(src.created_at, t.created_at) as created_at,
|
||||
o.trip_id,
|
||||
tr.name as trip_name,
|
||||
tr.color as trip_color,
|
||||
txn_tags.tags,
|
||||
txn_splits.splits
|
||||
FROM transactions t
|
||||
@@ -188,6 +202,7 @@ export async function getTransactions(ownerId: number, filters: TransactionFilte
|
||||
LEFT JOIN statements s ON s.id = t.statement_id
|
||||
LEFT JOIN participants p ON p.id = COALESCE(t.owner_id, s.owner_id)
|
||||
LEFT JOIN transactions src ON src.reconciled_with_id = t.id AND src.statement_id IS NULL
|
||||
LEFT JOIN trips tr ON tr.id = o.trip_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT COALESCE(json_agg(json_build_object('id', tg.id, 'name', tg.name, 'color', tg.color) ORDER BY tg.name), '[]'::json) as tags
|
||||
FROM transaction_tags tt
|
||||
@@ -604,3 +619,222 @@ export async function getSharedTransactions(ownerId: number, tagIds?: number[],
|
||||
splits: typeof r.split_data === "string" ? JSON.parse(r.split_data) : r.split_data,
|
||||
}));
|
||||
}
|
||||
|
||||
// ─── Trips ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface TripRow {
|
||||
id: number;
|
||||
owner_id: number;
|
||||
name: string;
|
||||
description: string | null;
|
||||
start_date: string | null;
|
||||
end_date: string | null;
|
||||
color: string;
|
||||
archived: boolean;
|
||||
created_at: string;
|
||||
total_spend: number;
|
||||
transaction_count: number;
|
||||
}
|
||||
|
||||
export interface TripAnalytics {
|
||||
trip: TripRow;
|
||||
total_spend: number;
|
||||
transaction_count: number;
|
||||
num_days: number;
|
||||
daily_average: number;
|
||||
category_breakdown: { category: string; amount: number; count: number }[];
|
||||
daily_spend: { date: string; amount: number }[];
|
||||
top_merchants: { merchant: string; amount: number; count: number }[];
|
||||
tag_breakdown: { tag_id: number; name: string; color: string; amount: number; count: number }[];
|
||||
participant_splits: { participant_id: number; name: string; owed: number; settled: number; unsettled: number }[];
|
||||
}
|
||||
|
||||
export async function getTrips(ownerId: number): Promise<TripRow[]> {
|
||||
return queryRaw<TripRow>(`
|
||||
SELECT
|
||||
t.*,
|
||||
COALESCE(SUM(
|
||||
CASE WHEN tx.transaction_type IN ('debit','fee','interest') THEN tx.amount ELSE 0 END
|
||||
), 0)::float AS total_spend,
|
||||
COUNT(o.transaction_id)::int AS transaction_count
|
||||
FROM trips t
|
||||
LEFT JOIN transaction_overrides o ON o.trip_id = t.id
|
||||
LEFT JOIN transactions tx ON tx.id = o.transaction_id
|
||||
WHERE t.owner_id = $1
|
||||
GROUP BY t.id
|
||||
ORDER BY t.created_at DESC
|
||||
`, [ownerId]);
|
||||
}
|
||||
|
||||
export async function getTripById(id: number, ownerId: number): Promise<TripRow | null> {
|
||||
const rows = await queryRaw<TripRow>(`
|
||||
SELECT
|
||||
t.*,
|
||||
COALESCE(SUM(
|
||||
CASE WHEN tx.transaction_type IN ('debit','fee','interest') THEN tx.amount ELSE 0 END
|
||||
), 0)::float AS total_spend,
|
||||
COUNT(o.transaction_id)::int AS transaction_count
|
||||
FROM trips t
|
||||
LEFT JOIN transaction_overrides o ON o.trip_id = t.id
|
||||
LEFT JOIN transactions tx ON tx.id = o.transaction_id
|
||||
WHERE t.id = $1 AND t.owner_id = $2
|
||||
GROUP BY t.id
|
||||
`, [id, ownerId]);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
export async function getTripAnalytics(tripId: number, ownerId: number): Promise<TripAnalytics> {
|
||||
const trip = await getTripById(tripId, ownerId);
|
||||
if (!trip) throw new Error("Trip not found");
|
||||
|
||||
const [categoryRows, dailyRows, merchantRows, tagRows, splitRows] = await Promise.all([
|
||||
queryRaw<{ category: string; amount: number; count: number }>(`
|
||||
SELECT
|
||||
COALESCE(o.category_override, tx.category, 'other') AS category,
|
||||
SUM(tx.amount)::float AS amount,
|
||||
COUNT(*)::int AS count
|
||||
FROM transaction_overrides o
|
||||
JOIN transactions tx ON tx.id = o.transaction_id
|
||||
WHERE o.trip_id = $1
|
||||
AND tx.transaction_type IN ('debit','fee','interest')
|
||||
GROUP BY 1
|
||||
ORDER BY 2 DESC
|
||||
`, [tripId]),
|
||||
|
||||
queryRaw<{ date: string; amount: number }>(`
|
||||
SELECT
|
||||
tx.transaction_date::text AS date,
|
||||
SUM(tx.amount)::float AS amount
|
||||
FROM transaction_overrides o
|
||||
JOIN transactions tx ON tx.id = o.transaction_id
|
||||
WHERE o.trip_id = $1
|
||||
AND tx.transaction_type IN ('debit','fee','interest')
|
||||
GROUP BY 1
|
||||
ORDER BY 1
|
||||
`, [tripId]),
|
||||
|
||||
queryRaw<{ merchant: string; amount: number; count: number }>(`
|
||||
SELECT
|
||||
COALESCE(o.merchant_normalized, tx.merchant_normalized, tx.merchant_name, tx.description) AS merchant,
|
||||
SUM(tx.amount)::float AS amount,
|
||||
COUNT(*)::int AS count
|
||||
FROM transaction_overrides o
|
||||
JOIN transactions tx ON tx.id = o.transaction_id
|
||||
WHERE o.trip_id = $1
|
||||
AND tx.transaction_type IN ('debit','fee','interest')
|
||||
GROUP BY 1
|
||||
ORDER BY 2 DESC
|
||||
LIMIT 10
|
||||
`, [tripId]),
|
||||
|
||||
queryRaw<{ tag_id: number; name: string; color: string; amount: number; count: number }>(`
|
||||
SELECT
|
||||
tg.id AS tag_id, tg.name, tg.color,
|
||||
SUM(tx.amount)::float AS amount,
|
||||
COUNT(DISTINCT tx.id)::int AS count
|
||||
FROM transaction_overrides o
|
||||
JOIN transactions tx ON tx.id = o.transaction_id
|
||||
JOIN transaction_tags tt ON tt.transaction_id = tx.id
|
||||
JOIN tags tg ON tg.id = tt.tag_id
|
||||
WHERE o.trip_id = $1
|
||||
AND tx.transaction_type IN ('debit','fee','interest')
|
||||
GROUP BY tg.id
|
||||
ORDER BY 4 DESC
|
||||
`, [tripId]),
|
||||
|
||||
queryRaw<{ participant_id: number; name: string; owed: number; settled: number; unsettled: number }>(`
|
||||
SELECT
|
||||
p.id AS participant_id,
|
||||
p.name,
|
||||
SUM(ts.share_percent / 100.0 * tx.amount)::float AS owed,
|
||||
SUM(CASE WHEN ts.settled THEN ts.share_percent / 100.0 * tx.amount ELSE 0 END)::float AS settled,
|
||||
SUM(CASE WHEN NOT ts.settled THEN ts.share_percent / 100.0 * tx.amount ELSE 0 END)::float AS unsettled
|
||||
FROM transaction_overrides o
|
||||
JOIN transactions tx ON tx.id = o.transaction_id
|
||||
JOIN transaction_splits ts ON ts.transaction_id = tx.id
|
||||
JOIN participants p ON p.id = ts.participant_id
|
||||
WHERE o.trip_id = $1
|
||||
AND tx.transaction_type IN ('debit','fee','interest')
|
||||
GROUP BY p.id
|
||||
ORDER BY 3 DESC
|
||||
`, [tripId]),
|
||||
]);
|
||||
|
||||
const num_days = (trip.start_date && trip.end_date)
|
||||
? Math.max(1, Math.round((new Date(trip.end_date).getTime() - new Date(trip.start_date).getTime()) / 86400000) + 1)
|
||||
: Math.max(dailyRows.length, 1);
|
||||
|
||||
return {
|
||||
trip,
|
||||
total_spend: trip.total_spend,
|
||||
transaction_count: trip.transaction_count,
|
||||
num_days,
|
||||
daily_average: trip.total_spend / num_days,
|
||||
category_breakdown: categoryRows,
|
||||
daily_spend: dailyRows,
|
||||
top_merchants: merchantRows,
|
||||
tag_breakdown: tagRows,
|
||||
participant_splits: splitRows,
|
||||
};
|
||||
}
|
||||
|
||||
export async function createTrip(
|
||||
ownerId: number,
|
||||
data: { name: string; description?: string | null; start_date?: string | null; end_date?: string | null; color?: string }
|
||||
): Promise<TripRow> {
|
||||
const rows = await queryRaw<TripRow>(`
|
||||
INSERT INTO trips (owner_id, name, description, start_date, end_date, color)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING *, 0::float AS total_spend, 0::int AS transaction_count
|
||||
`, [ownerId, data.name, data.description ?? null, data.start_date ?? null, data.end_date ?? null, data.color ?? '#6366f1']);
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
export async function updateTrip(
|
||||
id: number,
|
||||
ownerId: number,
|
||||
data: Partial<{ name: string; description: string | null; start_date: string | null; end_date: string | null; color: string; archived: boolean }>
|
||||
): Promise<TripRow | null> {
|
||||
const setClauses: string[] = [];
|
||||
const params: unknown[] = [];
|
||||
let idx = 1;
|
||||
if (data.name !== undefined) { setClauses.push(`name = $${idx++}`); params.push(data.name); }
|
||||
if ('description' in data) { setClauses.push(`description = $${idx++}`); params.push(data.description ?? null); }
|
||||
if ('start_date' in data) { setClauses.push(`start_date = $${idx++}`); params.push(data.start_date ?? null); }
|
||||
if ('end_date' in data) { setClauses.push(`end_date = $${idx++}`); params.push(data.end_date ?? null); }
|
||||
if (data.color !== undefined) { setClauses.push(`color = $${idx++}`); params.push(data.color); }
|
||||
if (data.archived !== undefined) { setClauses.push(`archived = $${idx++}`); params.push(data.archived); }
|
||||
if (!setClauses.length) return getTripById(id, ownerId);
|
||||
params.push(id, ownerId);
|
||||
const rows = await queryRaw<TripRow>(`
|
||||
UPDATE trips SET ${setClauses.join(', ')}
|
||||
WHERE id = $${idx++} AND owner_id = $${idx}
|
||||
RETURNING *, 0::float AS total_spend, 0::int AS transaction_count
|
||||
`, params);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
export async function deleteTrip(id: number, ownerId: number): Promise<void> {
|
||||
await queryRaw(`DELETE FROM trips WHERE id = $1 AND owner_id = $2`, [id, ownerId]);
|
||||
}
|
||||
|
||||
export async function assignTransactionsToTrip(
|
||||
tripId: number | null,
|
||||
transactionIds: number[]
|
||||
): Promise<void> {
|
||||
if (!transactionIds.length) return;
|
||||
await queryRaw(`
|
||||
INSERT INTO transaction_overrides (transaction_id, trip_id)
|
||||
SELECT unnest($1::int[]), $2
|
||||
ON CONFLICT (transaction_id)
|
||||
DO UPDATE SET trip_id = EXCLUDED.trip_id
|
||||
`, [transactionIds, tripId]);
|
||||
}
|
||||
|
||||
export async function getTagTransactionIds(tagId: number): Promise<number[]> {
|
||||
const rows = await queryRaw<{ transaction_id: number }>(
|
||||
`SELECT transaction_id FROM transaction_tags WHERE tag_id = $1`,
|
||||
[tagId]
|
||||
);
|
||||
return rows.map((r) => r.transaction_id);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user