Index: app/(app)/analytics/analytics-client.tsx
===================================================================
--- app/(app)/analytics/analytics-client.tsx	(revision 9ec098528fb384ef3c18265624016eccda36e715)
+++ app/(app)/analytics/analytics-client.tsx	(revision 9ec098528fb384ef3c18265624016eccda36e715)
@@ -0,0 +1,586 @@
+"use client";
+
+import { useMemo, useRef, useState } from 'react';
+import { usePathname, useRouter, useSearchParams } from 'next/navigation';
+import { CalendarDaysIcon, CheckIcon, XMarkIcon, ArrowsPointingOutIcon } from '@heroicons/react/24/outline';
+import { formatMKD } from '@/app/lib/utils';
+import UrlSearchInput from '@/app/ui/url-search-input';
+import AccountFilterIcon from '@/app/ui/account-filter-icon';
+import type { AnalyticsData, AnalyticsTagTotal, AnalyticsTrendPoint } from '@/app/lib/queries';
+
+const COLORS = ['#60a5fa', '#34d399', '#fbbf24', '#f472b6', '#a78bfa', '#fb7185', '#22d3ee', '#f97316'];
+
+export default function AnalyticsClient({ data }: { data: AnalyticsData }) {
+    const searchParams = useSearchParams();
+    const pathname = usePathname();
+    const { replace } = useRouter();
+    const startInputRef = useRef<HTMLInputElement>(null);
+    const endInputRef = useRef<HTMLInputElement>(null);
+    const [trendZoomOpen, setTrendZoomOpen] = useState(false);
+
+    const query = searchParams.get('query') ?? '';
+    const period = (searchParams.get('period') ?? 'month') as 'month' | 'year' | 'range';
+    const startDate = searchParams.get('startDate') ?? '';
+    const endDate = searchParams.get('endDate') ?? '';
+    const selectedFocusTags = (searchParams.get('focusTags') ?? '').split(',').filter(Boolean);
+
+    const updateParams = (mutate: (params: URLSearchParams) => void) => {
+        const params = new URLSearchParams(searchParams);
+        mutate(params);
+        const nextQuery = params.toString();
+        replace(nextQuery ? `${pathname}?${nextQuery}` : pathname);
+    };
+
+    const setPeriod = (value: 'month' | 'year' | 'range') => {
+        updateParams((params) => {
+            params.set('period', value);
+            if (value !== 'range') {
+                params.delete('startDate');
+                params.delete('endDate');
+            } else if (!params.get('startDate') || !params.get('endDate')) {
+                const now = new Date();
+                const start = new Date(now.getFullYear(), now.getMonth(), 1);
+                const end = new Date(now.getFullYear(), now.getMonth() + 1, 0);
+                params.set('startDate', toDateInputValue(start));
+                params.set('endDate', toDateInputValue(end));
+            }
+        });
+    };
+
+    const setRangeDate = (key: 'startDate' | 'endDate', value: string) => {
+        updateParams((params) => {
+            params.set('period', 'range');
+            if (value) {
+                params.set(key, value);
+            } else {
+                params.delete(key);
+            }
+        });
+    };
+
+    const toggleFocusTag = (tag: string) => {
+        updateParams((params) => {
+            const current = (params.get('focusTags') ?? '').split(',').filter(Boolean);
+            const next = current.includes(tag)
+                ? current.filter((value) => value !== tag)
+                : [...current, tag];
+
+            if (next.length > 0) {
+                params.set('focusTags', next.join(','));
+            } else {
+                params.delete('focusTags');
+            }
+        });
+    };
+
+    const clearAllFilters = () => {
+        replace(pathname);
+    };
+
+    const mainTagSlices = useMemo(() => toSlices(data.tagTotals), [data.tagTotals]);
+    const focusTagSlices = useMemo(() => toSlices(data.focusTagTotals), [data.focusTagTotals]);
+    const visibleTags = useMemo(() => {
+        if (!query.trim()) return data.tags;
+        const lowered = query.trim().toLowerCase();
+        return data.tags.filter((tag) => tag.toLowerCase().includes(lowered));
+    }, [data.tags, query]);
+
+    return (
+        <div className="mt-8 space-y-6 text-white">
+            <div className="rounded-3xl border border-white/10 bg-black/25 backdrop-blur-md p-4 md:p-5 space-y-4">
+                <div className="flex gap-2 items-center">
+                    <div className="flex-1 min-w-0">
+                        <UrlSearchInput
+                            placeholder="Search tags, accounts, or transactions..."
+                            debounceMs={250}
+                        />
+                    </div>
+                    <AccountFilterIcon accounts={data.accounts} />
+                </div>
+
+                <div className="space-y-2">
+                    <div className="inline-flex w-full justify-center gap-2 rounded-2xl border border-white/10 bg-white/5 p-1">
+                        {([['month', 'Monthly'], ['year', 'Yearly'], ['range', 'Range']] as const).map(([value, label]) => (
+                            <button
+                                key={value}
+                                type="button"
+                                onClick={() => setPeriod(value)}
+                                className={`rounded-xl px-4 py-2 text-sm font-medium transition whitespace-nowrap ${period === value
+                                        ? 'bg-sky-500 text-slate-950'
+                                        : 'text-white/65 hover:text-white hover:bg-white/10'
+                                    }`}
+                            >
+                                {label}
+                            </button>
+                        ))}
+                    </div>
+
+                    {period === 'range' && (
+                        <div className="grid grid-cols-2 gap-2">
+                            <DatePickerButton
+                                label="Start Date"
+                                value={startDate}
+                                inputRef={startInputRef}
+                                onClick={() => startInputRef.current?.showPicker?.() ?? startInputRef.current?.click()}
+                                onChange={(value) => setRangeDate('startDate', value)}
+                            />
+                            <DatePickerButton
+                                label="End Date"
+                                value={endDate}
+                                inputRef={endInputRef}
+                                onClick={() => endInputRef.current?.showPicker?.() ?? endInputRef.current?.click()}
+                                onChange={(value) => setRangeDate('endDate', value)}
+                            />
+                        </div>
+                    )}
+                </div>
+            </div>
+
+            <RingCard
+                title="Top tags"
+                slices={mainTagSlices}
+                emptyMessage="No tags match the current filters."
+            />
+
+            <TrendCard trend={data.trend} period={period} onZoom={() => setTrendZoomOpen(true)} />
+
+            <div className="rounded-3xl border border-white/10 bg-black/25 backdrop-blur-md p-5 space-y-4">
+                <div className="flex flex-wrap items-center justify-between gap-3">
+                    <div className="text-lg font-semibold text-white">Custom tag mix</div>
+
+                    {selectedFocusTags.length > 0 && (
+                        <button
+                            type="button"
+                            onClick={() => updateParams((params) => params.delete('focusTags'))}
+                            className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/8 px-3 py-1.5 text-xs text-white/70 hover:text-white hover:bg-white/12"
+                        >
+                            <XMarkIcon className="h-4 w-4" />
+                            Clear tag mix
+                        </button>
+                    )}
+                </div>
+
+                <div className="flex flex-wrap gap-2">
+                    {visibleTags.length === 0 ? (
+                        <div className="text-sm text-white/45">No tags available for this account set.</div>
+                    ) : (
+                        visibleTags.map((tag) => {
+                            const selected = selectedFocusTags.includes(tag);
+                            return (
+                                <button
+                                    key={tag}
+                                    type="button"
+                                    onClick={() => toggleFocusTag(tag)}
+                                    className={`inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-sm transition ${selected
+                                        ? 'border-sky-400/40 bg-sky-500/15 text-white'
+                                        : 'border-white/10 bg-white/5 text-white/65 hover:bg-white/10 hover:text-white'
+                                        }`}
+                                >
+                                    {selected && <CheckIcon className="h-4 w-4" />}
+                                    <span>{tag}</span>
+                                </button>
+                            );
+                        })
+                    )}
+                </div>
+
+                {selectedFocusTags.length > 0 && (
+                    <RingCard
+                        title="Selected tags"
+                        slices={focusTagSlices}
+                        emptyMessage="None of the selected tags produced spending in the current filters."
+                        compact
+                    />
+                )}
+            </div>
+
+            <div className="flex justify-center">
+                <button
+                    type="button"
+                    onClick={clearAllFilters}
+                    className="rounded-full border border-white/10 bg-white/5 px-4 py-2 text-sm text-white/70 hover:text-white hover:bg-white/10"
+                >
+                    Reset analytics filters
+                </button>
+            </div>
+
+            {trendZoomOpen && (
+                <TrendZoomModal trend={data.trend} period={period} onClose={() => setTrendZoomOpen(false)} />
+            )}
+        </div>
+    );
+}
+
+function DatePickerButton({
+    label,
+    value,
+    inputRef,
+    onChange,
+    onClick,
+}: {
+    label: string;
+    value: string;
+    inputRef: React.RefObject<HTMLInputElement | null>;
+    onChange: (value: string) => void;
+    onClick: () => void;
+}) {
+    const displayValue = value ? formatDateForButton(value) : 'Pick date';
+
+    return (
+        <div className="relative">
+            <button
+                type="button"
+                onClick={onClick}
+                aria-label={label}
+                className="flex w-full items-center justify-center gap-3 rounded-2xl border border-white/10 bg-white/5 px-4 py-3 text-sm text-white/80 hover:bg-white/10 transition"
+            >
+                <CalendarDaysIcon className="h-4 w-4 shrink-0 text-white/50" />
+                <span className="truncate text-white/75">{displayValue}</span>
+            </button>
+            <input
+                ref={inputRef}
+                type="date"
+                className="pointer-events-none absolute h-0 w-0 opacity-0"
+                value={value}
+                onChange={(e) => onChange(e.target.value)}
+            />
+        </div>
+    );
+}
+
+function TrendCard({
+    trend,
+    period,
+    onZoom,
+}: {
+    trend: AnalyticsTrendPoint[];
+    period: 'month' | 'year' | 'range';
+    onZoom: () => void;
+}) {
+    const values = trend.map((point) => Number(point.spent));
+    const max = Math.max(...values, 1);
+    const height = 380;
+    const width = Math.max(1200, trend.length * 82);
+    const padX = 76;
+    const padTop = 36;
+    const padBottom = 80;
+    const innerWidth = width - padX * 2;
+    const innerHeight = height - padTop - padBottom;
+    const step = trend.length > 1 ? innerWidth / (trend.length - 1) : 0;
+
+    const linePoints = trend.map((point, index) => {
+        const x = padX + index * step;
+        const y = padTop + innerHeight - (Number(point.spent) / max) * innerHeight;
+        return { x, y, label: point.label };
+    });
+
+    const linePath = linePoints
+        .map((point, index) => `${index === 0 ? 'M' : 'L'} ${point.x} ${point.y}`)
+        .join(' ');
+    const areaPath =
+        linePoints.length > 0
+            ? `${linePath} L ${linePoints[linePoints.length - 1].x} ${height - padBottom} L ${linePoints[0].x} ${height - padBottom} Z`
+            : '';
+
+    const tickCount = period === 'year' ? 6 : 8;
+    const tickStep = Math.max(1, Math.ceil(Math.max(linePoints.length, 1) / tickCount));
+
+    return (
+        <button
+            type="button"
+            onClick={onZoom}
+            className="w-full rounded-3xl border border-white/10 bg-black/25 backdrop-blur-md p-5 overflow-hidden text-left transition hover:bg-black/30"
+        >
+            <div className="flex items-center justify-between gap-4">
+                <div className="text-lg font-semibold text-white">Spending trend</div>
+            </div>
+
+            <div className="mt-4 overflow-hidden rounded-2xl border border-white/10 bg-slate-950/60">
+                <div className="overflow-x-auto no-scrollbar">
+                    <svg viewBox={`0 0 ${width} ${height}`} className="h-[320px] min-w-[1000px] w-full">
+                        <defs>
+                            <linearGradient id="trendFill" x1="0" y1="0" x2="0" y2="1">
+                                <stop offset="0%" stopColor="#38bdf8" stopOpacity="0.35" />
+                                <stop offset="100%" stopColor="#38bdf8" stopOpacity="0.02" />
+                            </linearGradient>
+                        </defs>
+
+                        {[0, 0.25, 0.5, 0.75, 1].map((ratio) => {
+                            const y = padTop + innerHeight - innerHeight * ratio;
+                            return (
+                                <g key={ratio}>
+                                    <line x1={padX} x2={width - padX} y1={y} y2={y} stroke="rgba(255,255,255,0.08)" strokeDasharray="5 6" />
+                                    <text x={16} y={y + 4} fill="rgba(255,255,255,0.35)" fontSize="18">
+                                        {formatAxisValue(max * ratio)}
+                                    </text>
+                                </g>
+                            );
+                        })}
+
+                        {areaPath && <path d={areaPath} fill="url(#trendFill)" />}
+                        {linePath && <path d={linePath} fill="none" stroke="#38bdf8" strokeWidth="4" strokeLinecap="round" strokeLinejoin="round" />}
+
+                        {linePoints.map((point) => (
+                            <circle key={`${point.label}-${point.x}`} cx={point.x} cy={point.y} r="6" fill="#e0f2fe" stroke="#0284c7" strokeWidth="4" />
+                        ))}
+
+                        {linePoints.map((point, index) => {
+                            if (index % tickStep !== 0 && index !== linePoints.length - 1) return null;
+                            return (
+                                <text key={`${point.label}-${index}`} x={point.x} y={height - 28} fill="rgba(255,255,255,0.45)" fontSize="16" textAnchor="middle">
+                                    {point.label}
+                                </text>
+                            );
+                        })}
+                    </svg>
+                </div>
+            </div>
+        </button>
+    );
+}
+
+function RingCard({
+    title,
+    slices,
+    emptyMessage,
+    compact = false,
+}: {
+    title: string;
+    slices: { label: string; value: number }[];
+    emptyMessage: string;
+    compact?: boolean;
+}) {
+    const total = slices.reduce((sum, slice) => sum + slice.value, 0);
+    const radius = compact ? 70 : 82;
+    const strokeWidth = compact ? 18 : 22;
+    const circumference = 2 * Math.PI * radius;
+    const segments = slices.reduce(
+        (acc, slice, index) => {
+            const portion = total > 0 ? slice.value / total : 0;
+            const dash = Math.max(portion * circumference, 0.01);
+            acc.push({
+                label: slice.label,
+                color: COLORS[index % COLORS.length],
+                dash,
+                offset: acc.length === 0 ? 0 : acc[acc.length - 1].offset + acc[acc.length - 1].dash,
+            });
+            return acc;
+        },
+        [] as Array<{ label: string; color: string; dash: number; offset: number }>,
+    );
+
+    return (
+        <div className="rounded-3xl border border-white/10 bg-black/25 backdrop-blur-md p-5 overflow-hidden">
+            <div className="text-lg font-semibold text-white">{title}</div>
+
+            {total > 0 ? (
+                <div className="mt-4 space-y-4">
+                    <div className="flex items-center justify-center">
+                        <svg viewBox="0 0 220 220" className={compact ? 'h-48 w-48' : 'h-56 w-56'}>
+                            <circle cx="110" cy="110" r={radius} fill="none" stroke="rgba(255,255,255,0.08)" strokeWidth={strokeWidth} />
+                            {segments.map((segment) => (
+                                <circle
+                                    key={segment.label}
+                                    cx="110"
+                                    cy="110"
+                                    r={radius}
+                                    fill="none"
+                                    stroke={segment.color}
+                                    strokeWidth={strokeWidth}
+                                    strokeDasharray={`${segment.dash} ${circumference}`}
+                                    strokeDashoffset={-segment.offset}
+                                    transform="rotate(-90 110 110)"
+                                    strokeLinecap="butt"
+                                />
+                            ))}
+                            <circle cx="110" cy="110" r={radius - strokeWidth * 0.55} fill="#050816" opacity="0.96" />
+                            <text x="110" y="106" textAnchor="middle" fill="white" fontSize="18" fontWeight="700">
+                                {formatMKD(total)}
+                            </text>
+                            <text x="110" y="128" textAnchor="middle" fill="rgba(255,255,255,0.45)" fontSize="12">
+                                inclusive spend
+                            </text>
+                        </svg>
+                    </div>
+
+                    <div className="space-y-2">
+                        {slices.map((slice, index) => {
+                            const pct = total > 0 ? Math.round((slice.value / total) * 100) : 0;
+                            return (
+                                <div key={slice.label} className="flex items-center gap-3 rounded-2xl border border-white/8 bg-white/5 px-4 py-3 min-w-0">
+                                    <span className="h-3 w-3 rounded-full shrink-0" style={{ backgroundColor: COLORS[index % COLORS.length] }} />
+                                    <div className="min-w-0 flex-1">
+                                        <div className="truncate text-sm font-medium text-white">{slice.label}</div>
+                                        <div className="text-xs text-white/45">{pct}% of total</div>
+                                    </div>
+                                    <div className="text-sm font-semibold text-white whitespace-nowrap">{formatMKD(slice.value)}</div>
+                                </div>
+                            );
+                        })}
+                    </div>
+                </div>
+            ) : (
+                <div className="mt-4 rounded-2xl border border-dashed border-white/12 bg-white/5 px-4 py-8 text-center text-sm text-white/50">
+                    {emptyMessage}
+                </div>
+            )}
+        </div>
+    );
+}
+
+function TrendZoomModal({
+    trend,
+    period,
+    onClose,
+}: {
+    trend: AnalyticsTrendPoint[];
+    period: 'month' | 'year' | 'range';
+    onClose: () => void;
+}) {
+    return (
+        <div className="fixed inset-0 z-[70] flex items-center justify-center bg-black/70 px-4 py-6 backdrop-blur-sm">
+            <div className="relative w-full max-w-[96vw] rounded-3xl border border-white/10 bg-slate-950/95 shadow-2xl">
+                <div className="flex items-center justify-between gap-4 border-b border-white/10 px-4 py-3">
+                    <div className="text-lg font-semibold text-white">Spending trend</div>
+                    <button
+                        type="button"
+                        onClick={onClose}
+                        className="rounded-full border border-white/10 bg-white/5 p-2 text-white/70 hover:text-white hover:bg-white/10"
+                        aria-label="Close trend chart"
+                    >
+                        <XMarkIcon className="h-5 w-5" />
+                    </button>
+                </div>
+                <div className="max-h-[82vh] overflow-auto p-4">
+                    <div className="min-w-[1400px]">
+                        <TrendGraph trend={trend} period={period} zoomed />
+                    </div>
+                </div>
+            </div>
+        </div>
+    );
+}
+
+function TrendGraph({
+    trend,
+    period,
+    zoomed = false,
+}: {
+    trend: AnalyticsTrendPoint[];
+    period: 'month' | 'year' | 'range';
+    zoomed?: boolean;
+}) {
+    const values = trend.map((point) => Number(point.spent));
+    const max = Math.max(...values, 1);
+    const height = zoomed ? 560 : 380;
+    const width = Math.max(1200, trend.length * 82);
+    const padX = zoomed ? 88 : 76;
+    const padTop = zoomed ? 44 : 36;
+    const padBottom = zoomed ? 96 : 80;
+    const innerWidth = width - padX * 2;
+    const innerHeight = height - padTop - padBottom;
+    const step = trend.length > 1 ? innerWidth / (trend.length - 1) : 0;
+
+    const linePoints = trend.map((point, index) => {
+        const x = padX + index * step;
+        const y = padTop + innerHeight - (Number(point.spent) / max) * innerHeight;
+        return { x, y, label: point.label };
+    });
+
+    const linePath = linePoints
+        .map((point, index) => `${index === 0 ? 'M' : 'L'} ${point.x} ${point.y}`)
+        .join(' ');
+    const areaPath =
+        linePoints.length > 0
+            ? `${linePath} L ${linePoints[linePoints.length - 1].x} ${height - padBottom} L ${linePoints[0].x} ${height - padBottom} Z`
+            : '';
+
+    const tickCount = period === 'year' ? 6 : 8;
+    const tickStep = Math.max(1, Math.ceil(Math.max(linePoints.length, 1) / tickCount));
+
+    return (
+        <div className="rounded-3xl border border-white/10 bg-black/25 backdrop-blur-md p-5 overflow-hidden">
+            <div className="mb-4 flex items-center justify-between gap-4">
+                <div className="text-lg font-semibold text-white">Spending trend</div>
+            </div>
+
+            <div className="overflow-x-auto rounded-2xl border border-white/10 bg-slate-950/60">
+                <svg viewBox={`0 0 ${width} ${height}`} className="h-[520px] min-w-[1400px] w-full">
+                    <defs>
+                        <linearGradient id="trendFillZoom" x1="0" y1="0" x2="0" y2="1">
+                            <stop offset="0%" stopColor="#38bdf8" stopOpacity="0.35" />
+                            <stop offset="100%" stopColor="#38bdf8" stopOpacity="0.02" />
+                        </linearGradient>
+                    </defs>
+
+                    {[0, 0.25, 0.5, 0.75, 1].map((ratio) => {
+                        const y = padTop + innerHeight - innerHeight * ratio;
+                        return (
+                            <g key={ratio}>
+                                <line x1={padX} x2={width - padX} y1={y} y2={y} stroke="rgba(255,255,255,0.08)" strokeDasharray="5 6" />
+                                <text x={18} y={y + 5} fill="rgba(255,255,255,0.4)" fontSize="18">
+                                    {formatAxisValue(max * ratio)}
+                                </text>
+                            </g>
+                        );
+                    })}
+
+                    {areaPath && <path d={areaPath} fill="url(#trendFillZoom)" />}
+                    {linePath && <path d={linePath} fill="none" stroke="#38bdf8" strokeWidth="4" strokeLinecap="round" strokeLinejoin="round" />}
+
+                    {linePoints.map((point) => (
+                        <circle key={`${point.label}-${point.x}`} cx={point.x} cy={point.y} r="6" fill="#e0f2fe" stroke="#0284c7" strokeWidth="4" />
+                    ))}
+
+                    {linePoints.map((point, index) => {
+                        if (index % tickStep !== 0 && index !== linePoints.length - 1) return null;
+                        return (
+                            <text key={`${point.label}-${index}`} x={point.x} y={height - 32} fill="rgba(255,255,255,0.45)" fontSize="16" textAnchor="middle">
+                                {point.label}
+                            </text>
+                        );
+                    })}
+                </svg>
+            </div>
+        </div>
+    );
+}
+
+function toSlices(items: AnalyticsTagTotal[]) {
+    return items.map((item) => ({
+        label: item.tag_name,
+        value: Number(item.spent),
+    }));
+}
+
+function formatAxisValue(value: number) {
+    if (value <= 0) {
+        return '0';
+    }
+
+    if (value >= 1000) {
+        return `${Math.round(value / 1000)}k`;
+    }
+
+    return String(Math.round(value));
+}
+
+function toDateInputValue(date: Date) {
+    const year = date.getFullYear();
+    const month = String(date.getMonth() + 1).padStart(2, '0');
+    const day = String(date.getDate()).padStart(2, '0');
+    return `${year}-${month}-${day}`;
+}
+
+function formatDateForButton(value: string) {
+    const date = new Date(`${value}T00:00:00`);
+    if (Number.isNaN(date.getTime())) {
+        return value;
+    }
+
+    return new Intl.DateTimeFormat('en-US', {
+        month: 'short',
+        day: 'numeric',
+        year: 'numeric',
+    }).format(date);
+}
Index: app/(app)/analytics/page.tsx
===================================================================
--- app/(app)/analytics/page.tsx	(revision 3e7a6b7451c51e34994dbbed21b43a46a400f8e2)
+++ app/(app)/analytics/page.tsx	(revision 9ec098528fb384ef3c18265624016eccda36e715)
@@ -1,5 +1,46 @@
+import { auth } from '@/auth';
+import { redirect } from 'next/navigation';
 import { poppins } from '@/app/ui/fonts';
+import { getAnalyticsData } from '@/app/lib/queries';
+import AnalyticsClient from './analytics-client';
 
-export default function Page() {
+export default async function Page(props: {
+    searchParams?: Promise<{
+        query?: string;
+        accountId?: string;
+        period?: string;
+        startDate?: string;
+        endDate?: string;
+        focusTags?: string;
+    }>;
+}) {
+    const session = await auth();
+    if (!session?.user?.id) {
+        redirect('/login?callbackUrl=/analytics');
+    }
+
+    const userId = Number(session.user.id);
+    if (!Number.isInteger(userId)) {
+        redirect('/login?callbackUrl=/analytics');
+    }
+
+    const searchParams = await props.searchParams;
+    const query = searchParams?.query || '';
+    const accountId = searchParams?.accountId ? Number(searchParams.accountId) : undefined;
+    const period = searchParams?.period === 'year' || searchParams?.period === 'range' ? searchParams.period : 'month';
+    const startDate = searchParams?.startDate || undefined;
+    const endDate = searchParams?.endDate || undefined;
+    const focusTags = searchParams?.focusTags ? searchParams.focusTags.split(',').filter(Boolean) : [];
+
+    const data = await getAnalyticsData({
+        userId,
+        query,
+        accountId: Number.isInteger(accountId) ? accountId : undefined,
+        period,
+        startDate,
+        endDate,
+        focusTags,
+    });
+
     return (
         <div className="w-full px-6 pt-10 pb-10">
@@ -17,7 +58,5 @@
             </h1>
 
-            <div className="mt-10 rounded-3xl bg-white/5 border border-white/10 p-6 text-white/80">
-                Analytics placeholder
-            </div>
+            <AnalyticsClient data={data} />
         </div>
     );
Index: app/lib/queries.ts
===================================================================
--- app/lib/queries.ts	(revision 3e7a6b7451c51e34994dbbed21b43a46a400f8e2)
+++ app/lib/queries.ts	(revision 9ec098528fb384ef3c18265624016eccda36e715)
@@ -32,4 +32,26 @@
 };
 
+export type AnalyticsPeriod = 'month' | 'year' | 'range';
+
+export type AnalyticsTagTotal = {
+    tag_name: string;
+    spent: string;
+};
+
+export type AnalyticsTrendPoint = {
+    bucket_key: string;
+    label: string;
+    spent: string;
+};
+
+export type AnalyticsData = {
+    accounts: TransactionAccountLite[];
+    tags: string[];
+    totalSpent: string;
+    tagTotals: AnalyticsTagTotal[];
+    focusTagTotals: AnalyticsTagTotal[];
+    trend: AnalyticsTrendPoint[];
+};
+
 export type HistoryTransaction = {
     transaction_id: number;
@@ -45,4 +67,240 @@
 function endOfMonth(d: Date) {
     return new Date(d.getFullYear(), d.getMonth() + 1, 0, 23, 59, 59, 999);
+}
+
+function startOfYear(d: Date) {
+    return new Date(d.getFullYear(), 0, 1);
+}
+
+function endOfYear(d: Date) {
+    return new Date(d.getFullYear(), 11, 31, 23, 59, 59, 999);
+}
+
+function parseDateInput(dateStr: string | undefined, fallback: Date) {
+    if (!dateStr) return fallback;
+    const parsed = new Date(`${dateStr}T00:00:00`);
+    return Number.isNaN(parsed.getTime()) ? fallback : parsed;
+}
+
+function bucketKeyForDate(date: Date, granularity: 'day' | 'month') {
+    const year = date.getFullYear();
+    const month = String(date.getMonth() + 1).padStart(2, '0');
+    if (granularity === 'month') {
+        return `${year}-${month}`;
+    }
+    const day = String(date.getDate()).padStart(2, '0');
+    return `${year}-${month}-${day}`;
+}
+
+function trendLabelForDate(date: Date, granularity: 'day' | 'month') {
+    if (granularity === 'month') {
+        return new Intl.DateTimeFormat('en-US', { month: 'short' }).format(date);
+    }
+
+    return String(date.getDate());
+}
+
+function buildTrendSeries(params: {
+    start: Date;
+    end: Date;
+    granularity: 'day' | 'month';
+    rows: { bucket_key: string; spent: string }[];
+}) {
+    const { start, end, granularity, rows } = params;
+    const lookup = new Map(rows.map((row) => [row.bucket_key, row.spent]));
+    const points: AnalyticsTrendPoint[] = [];
+
+    const cursor = new Date(start);
+    while (cursor <= end) {
+        const key = bucketKeyForDate(cursor, granularity);
+        points.push({
+            bucket_key: key,
+            label: trendLabelForDate(cursor, granularity),
+            spent: lookup.get(key) ?? '0.00',
+        });
+
+        if (granularity === 'month') {
+            cursor.setMonth(cursor.getMonth() + 1, 1);
+        } else {
+            cursor.setDate(cursor.getDate() + 1);
+        }
+    }
+
+    return points;
+}
+
+export async function getAnalyticsData(params: {
+    userId: number;
+    query?: string;
+    accountId?: number;
+    period?: AnalyticsPeriod;
+    startDate?: string;
+    endDate?: string;
+    focusTags?: string[];
+}): Promise<AnalyticsData> {
+    const now = new Date();
+    const period = params.period ?? 'month';
+    const defaultMonthStart = startOfMonth(now);
+    const defaultMonthEnd = endOfMonth(now);
+    const defaultYearStart = startOfYear(now);
+    const defaultYearEnd = endOfYear(now);
+
+    const start =
+        period === 'year'
+            ? defaultYearStart
+            : period === 'range'
+                ? parseDateInput(params.startDate, defaultMonthStart)
+                : defaultMonthStart;
+    const end =
+        period === 'year'
+            ? defaultYearEnd
+            : period === 'range'
+                ? parseDateInput(params.endDate, defaultMonthEnd)
+                : defaultMonthEnd;
+
+    const startBoundary = new Date(start);
+    startBoundary.setHours(0, 0, 0, 0);
+    const endBoundary = new Date(end);
+    endBoundary.setHours(23, 59, 59, 999);
+
+    const granularity: 'day' | 'month' = period === 'year' ? 'month' : 'day';
+    const searchPattern = params.query ? `%${params.query}%` : null;
+    const accountId = params.accountId ?? null;
+    const focusTags = params.focusTags && params.focusTags.length > 0 ? params.focusTags : null;
+    const focusTagCount = focusTags ? focusTags.length : 0;
+    const dateFormat = granularity === 'month' ? 'YYYY-MM' : 'YYYY-MM-DD';
+
+    const [accounts, tags, totalSpentRow, tagTotals, focusTagTotals, trendRows] = await Promise.all([
+        getUserTransactionAccounts(params.userId),
+        getUserTagsForHistory(params.userId),
+        sql<{ spent: string | null }[]>`
+            SELECT COALESCE(SUM(tb.spent_amount), 0)::numeric(12,2) AS spent
+            FROM transaction_breakdown tb
+            JOIN transaction_account ta ON ta.transaction_account_id = tb.transaction_account_id
+            JOIN transaction t ON t.transaction_id = tb.transaction_id
+            WHERE ta.user_id = ${params.userId}
+                AND (${accountId}::int IS NULL OR tb.transaction_account_id = ${accountId})
+                AND t.date >= ${startBoundary.toISOString()}
+                AND t.date <= ${endBoundary.toISOString()}
+                AND (
+                    ${searchPattern}::text IS NULL
+                    OR t.transaction_name ILIKE ${searchPattern}
+                    OR ta.account_name ILIKE ${searchPattern}
+                    OR EXISTS (
+                        SELECT 1
+                        FROM tag_assigned_to_transaction tat2
+                        JOIN tag tg2 ON tg2.tag_id = tat2.tag_id
+                        WHERE tat2.transaction_id = t.transaction_id
+                            AND tg2.tag_name ILIKE ${searchPattern}
+                    )
+                )
+        `,
+        sql<AnalyticsTagTotal[]>`
+            SELECT
+                tg.tag_name,
+                COALESCE(SUM(COALESCE(tb.spent_amount, 0)), 0)::numeric(12,2)::text AS spent
+            FROM transaction t
+            JOIN transaction_breakdown tb ON tb.transaction_id = t.transaction_id
+            JOIN transaction_account ta ON ta.transaction_account_id = tb.transaction_account_id
+            JOIN tag_assigned_to_transaction tat ON tat.transaction_id = t.transaction_id
+            JOIN tag tg ON tg.tag_id = tat.tag_id
+            WHERE ta.user_id = ${params.userId}
+                AND (${accountId}::int IS NULL OR tb.transaction_account_id = ${accountId})
+                AND t.date >= ${startBoundary.toISOString()}
+                AND t.date <= ${endBoundary.toISOString()}
+                AND tg.tag_name NOT LIKE '__note:%'
+                AND (
+                    ${searchPattern}::text IS NULL
+                    OR t.transaction_name ILIKE ${searchPattern}
+                    OR ta.account_name ILIKE ${searchPattern}
+                    OR tg.tag_name ILIKE ${searchPattern}
+                    OR EXISTS (
+                        SELECT 1
+                        FROM tag_assigned_to_transaction tat2
+                        JOIN tag tg2 ON tg2.tag_id = tat2.tag_id
+                        WHERE tat2.transaction_id = t.transaction_id
+                            AND tg2.tag_name ILIKE ${searchPattern}
+                    )
+                )
+            GROUP BY tg.tag_name
+            ORDER BY COALESCE(SUM(COALESCE(tb.spent_amount, 0)), 0) DESC, tg.tag_name ASC
+            LIMIT 12
+        `,
+        focusTagCount > 0
+            ? sql<AnalyticsTagTotal[]>`
+                SELECT
+                    tg.tag_name,
+                    COALESCE(SUM(COALESCE(tb.spent_amount, 0)), 0)::numeric(12,2)::text AS spent
+                FROM transaction t
+                JOIN transaction_breakdown tb ON tb.transaction_id = t.transaction_id
+                JOIN transaction_account ta ON ta.transaction_account_id = tb.transaction_account_id
+                JOIN tag_assigned_to_transaction tat ON tat.transaction_id = t.transaction_id
+                JOIN tag tg ON tg.tag_id = tat.tag_id
+                WHERE ta.user_id = ${params.userId}
+                    AND (${accountId}::int IS NULL OR tb.transaction_account_id = ${accountId})
+                    AND t.date >= ${startBoundary.toISOString()}
+                    AND t.date <= ${endBoundary.toISOString()}
+                    AND tg.tag_name NOT LIKE '__note:%'
+                    AND tg.tag_name = ANY(${focusTags ?? []}::text[])
+                    AND (
+                        ${searchPattern}::text IS NULL
+                        OR t.transaction_name ILIKE ${searchPattern}
+                        OR ta.account_name ILIKE ${searchPattern}
+                        OR tg.tag_name ILIKE ${searchPattern}
+                        OR EXISTS (
+                            SELECT 1
+                            FROM tag_assigned_to_transaction tat2
+                            JOIN tag tg2 ON tg2.tag_id = tat2.tag_id
+                            WHERE tat2.transaction_id = t.transaction_id
+                                AND tg2.tag_name ILIKE ${searchPattern}
+                        )
+                    )
+                GROUP BY tg.tag_name
+                ORDER BY COALESCE(SUM(COALESCE(tb.spent_amount, 0)), 0) DESC, tg.tag_name ASC
+            `
+            : Promise.resolve([] as AnalyticsTagTotal[]),
+        sql<{ bucket_key: string; spent: string }[]>`
+            SELECT
+                TO_CHAR(DATE_TRUNC(${granularity}, t.date), ${dateFormat}) AS bucket_key,
+                COALESCE(SUM(COALESCE(tb.spent_amount, 0)), 0)::numeric(12,2)::text AS spent
+            FROM transaction t
+            JOIN transaction_breakdown tb ON tb.transaction_id = t.transaction_id
+            JOIN transaction_account ta ON ta.transaction_account_id = tb.transaction_account_id
+            WHERE ta.user_id = ${params.userId}
+                AND (${accountId}::int IS NULL OR tb.transaction_account_id = ${accountId})
+                AND t.date >= ${startBoundary.toISOString()}
+                AND t.date <= ${endBoundary.toISOString()}
+                AND (
+                    ${searchPattern}::text IS NULL
+                    OR t.transaction_name ILIKE ${searchPattern}
+                    OR ta.account_name ILIKE ${searchPattern}
+                    OR EXISTS (
+                        SELECT 1
+                        FROM tag_assigned_to_transaction tat2
+                        JOIN tag tg2 ON tg2.tag_id = tat2.tag_id
+                        WHERE tat2.transaction_id = t.transaction_id
+                            AND tg2.tag_name ILIKE ${searchPattern}
+                    )
+                )
+            GROUP BY 1
+            ORDER BY 1 ASC
+        `,
+    ]);
+
+    const trend = buildTrendSeries({
+        start: startBoundary,
+        end: endBoundary,
+        granularity,
+        rows: trendRows,
+    });
+
+    return {
+        accounts,
+        tags,
+        totalSpent: totalSpentRow[0]?.spent ?? '0.00',
+        tagTotals,
+        focusTagTotals,
+        trend,
+    };
 }
 
