Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions apps/dashboard/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,13 @@
"vitest": "catalog:"
},
"dependencies": {
"@lucia-auth/adapter-postgresql": "^3.1.2",
"@node-rs/argon2": "^2.0.2",
"@rocicorp/zero": "^0.24.3000000000",
"d3-scale": "^4.0.2",
"d3-shape": "^3.2.0",
"layerchart": "2.0.0-next.43",
"lucia": "^3.2.2",
"postgres": "catalog:",
"zod": "catalog:"
}
Expand Down
22 changes: 22 additions & 0 deletions apps/dashboard/src/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,25 @@ html,
body {
background-color: var(--color-rp-base);
}

/* LayerChart axis styling */
.lc-axis text {
fill: var(--color-rp-subtle);
stroke: none;
font-size: 12px;
}

.lc-axis line,
.lc-axis path {
stroke: var(--color-rp-overlay);
}

/* LayerChart tooltip styling */
.lc-tooltip {
background-color: var(--color-rp-surface);
border: 1px solid var(--color-rp-overlay);
border-radius: 4px;
padding: 8px;
color: var(--color-rp-text);
font-size: 12px;
}
12 changes: 12 additions & 0 deletions apps/dashboard/src/app.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import type { Session, User } from "lucia";

declare global {
namespace App {
interface Locals {
user: User | null;
session: Session | null;
}
}
}

export {};
2 changes: 1 addition & 1 deletion apps/dashboard/src/app.html
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<link rel="icon" href="%sveltekit.assets%/favicon.svg" type="image/svg+xml" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
Expand Down
34 changes: 34 additions & 0 deletions apps/dashboard/src/hooks.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { lucia } from "$lib/server/auth";
import type { Handle } from "@sveltejs/kit";

export const handle: Handle = async ({ event, resolve }) => {
const sessionId = event.cookies.get(lucia.sessionCookieName);

if (!sessionId) {
event.locals.user = null;
event.locals.session = null;
return resolve(event);
}

const { session, user } = await lucia.validateSession(sessionId);

if (session && session.fresh) {
const sessionCookie = lucia.createSessionCookie(session.id);
event.cookies.set(sessionCookie.name, sessionCookie.value, {
path: ".",
...sessionCookie.attributes,
});
}

if (!session) {
const sessionCookie = lucia.createBlankSessionCookie();
event.cookies.set(sessionCookie.name, sessionCookie.value, {
path: ".",
...sessionCookie.attributes,
});
}

event.locals.user = user;
event.locals.session = session;
return resolve(event);
};
3 changes: 2 additions & 1 deletion apps/dashboard/src/lib/components/EventsList.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
let { projectId }: { projectId: string } = $props();

// Fetch a larger set and paginate client-side
const eventsQuery = useQuery<Event>(recentEvents(projectId, 500));
// Use getter function so query re-runs if projectId changes
const eventsQuery = useQuery<Event>(() => recentEvents(projectId, 500));

// Client-side pagination
let displayLimit = $state(100);
Expand Down
3 changes: 2 additions & 1 deletion apps/dashboard/src/lib/components/FlagsList.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
let { projectId }: { projectId: string } = $props();

const zero = getZero();
const flagsQuery = useQuery<Flag>(flagsForProject(projectId));
// Use getter function so query re-runs if projectId changes
const flagsQuery = useQuery<Flag>(() => flagsForProject(projectId));

// Form state
let newFlagKey = $state("");
Expand Down
93 changes: 76 additions & 17 deletions apps/dashboard/src/lib/components/OverviewDashboard.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -7,33 +7,60 @@
recentEvents,
} from "$lib/zero/queries";
import type { Event, Session, Flag } from "$lib/zero/schema";
import EventsOverTimeChart from "./charts/EventsOverTimeChart.svelte";
import SessionsTrendChart from "./charts/SessionsTrendChart.svelte";
import TopEventsChart from "./charts/TopEventsChart.svelte";

// Props - projectId is guaranteed to be defined when this component mounts
let { projectId }: { projectId: string } = $props();

// Use synced queries
const eventsQuery = useQuery<Event>(eventsInWindow(projectId, 7));
const sessionsQuery = useQuery<Session>(sessionsInWindow(projectId, 7));
const flagsQuery = useQuery<Flag>(enabledFlags(projectId));
const recentEventsQuery = useQuery<Event>(recentEvents(projectId, 10));
// Date range state
let days = $state(7);

// Reactive synced queries - use getter functions so queries re-run when params change
const eventsQuery = useQuery<Event>(() => eventsInWindow(projectId, days));
const sessionsQuery = useQuery<Session>(() => sessionsInWindow(projectId, days));
const flagsQuery = useQuery<Flag>(() => enabledFlags(projectId));
const recentQuery = useQuery<Event>(() => recentEvents(projectId, 10));

// Computed values
const loading = $derived(
eventsQuery.loading || sessionsQuery.loading || flagsQuery.loading || recentQuery.loading
);
const eventCount = $derived(eventsQuery.data.length);
const sessionCount = $derived(sessionsQuery.data.length);
const flagCount = $derived(flagsQuery.data.length);
const recentEventsList = $derived(recentEventsQuery.data);

const loading = $derived(
eventsQuery.loading ||
sessionsQuery.loading ||
flagsQuery.loading ||
recentEventsQuery.loading
);
</script>

<!-- Date Range Picker -->
<div class="flex items-center justify-between mb-6">
<h2 class="text-lg font-semibold text-rp-text">Analytics Overview</h2>
<div class="flex gap-2">
<button
class="px-3 py-1 text-sm rounded {days === 7 ? 'bg-rp-iris text-rp-base' : 'bg-rp-surface text-rp-subtle hover:bg-rp-overlay'}"
onclick={() => days = 7}
>
7 days
</button>
<button
class="px-3 py-1 text-sm rounded {days === 30 ? 'bg-rp-iris text-rp-base' : 'bg-rp-surface text-rp-subtle hover:bg-rp-overlay'}"
onclick={() => days = 30}
>
30 days
</button>
<button
class="px-3 py-1 text-sm rounded {days === 90 ? 'bg-rp-iris text-rp-base' : 'bg-rp-surface text-rp-subtle hover:bg-rp-overlay'}"
onclick={() => days = 90}
>
90 days
</button>
</div>
</div>

<!-- Stats Cards -->
<div class="grid grid-cols-3 gap-6">
<div class="bg-rp-surface rounded-lg border border-rp-overlay p-6">
<div class="text-sm text-rp-muted">Events (7d)</div>
<div class="text-sm text-rp-muted">Events ({days}d)</div>
<div class="text-3xl font-bold text-rp-text">
{#if loading}
<span class="text-rp-overlay">...</span>
Expand All @@ -44,7 +71,7 @@
</div>

<div class="bg-rp-surface rounded-lg border border-rp-overlay p-6">
<div class="text-sm text-rp-muted">Sessions (7d)</div>
<div class="text-sm text-rp-muted">Sessions ({days}d)</div>
<div class="text-3xl font-bold text-rp-text">
{#if loading}
<span class="text-rp-overlay">...</span>
Expand All @@ -66,14 +93,46 @@
</div>
</div>

<!-- Charts Row -->
<div class="grid grid-cols-2 gap-6 mt-6">
<div class="bg-rp-surface rounded-lg border border-rp-overlay p-6">
<h3 class="font-semibold text-rp-text mb-4">Events Over Time</h3>
{#if loading}
<div class="h-48 flex items-center justify-center text-rp-muted">Loading...</div>
{:else}
<EventsOverTimeChart events={eventsQuery.data} {days} />
{/if}
</div>

<div class="bg-rp-surface rounded-lg border border-rp-overlay p-6">
<h3 class="font-semibold text-rp-text mb-4">Sessions Over Time</h3>
{#if loading}
<div class="h-48 flex items-center justify-center text-rp-muted">Loading...</div>
{:else}
<SessionsTrendChart sessions={sessionsQuery.data} {days} />
{/if}
</div>
</div>

<!-- Top Events Chart -->
<div class="bg-rp-surface rounded-lg border border-rp-overlay p-6 mt-6">
<h3 class="font-semibold text-rp-text mb-4">Top Events</h3>
{#if loading}
<div class="h-48 flex items-center justify-center text-rp-muted">Loading...</div>
{:else}
<TopEventsChart events={eventsQuery.data} limit={5} />
{/if}
</div>

<!-- Recent Events Table -->
<div class="bg-rp-surface rounded-lg border border-rp-overlay mt-6">
<div class="p-4 border-b border-rp-overlay">
<h3 class="font-semibold text-rp-text">Recent Events</h3>
</div>
<div class="p-4">
{#if loading}
<p class="text-rp-muted">Loading events...</p>
{:else if recentEventsList.length === 0}
{:else if recentQuery.data.length === 0}
<p class="text-rp-muted">No events yet. Integrate the SDK to start tracking.</p>
{:else}
<table class="w-full">
Expand All @@ -85,7 +144,7 @@
</tr>
</thead>
<tbody>
{#each recentEventsList as event}
{#each recentQuery.data as event}
<tr class="border-t border-rp-overlay">
<td class="py-2 font-mono text-sm text-rp-text">{event.event_name}</td>
<td class="py-2 text-sm text-rp-subtle">
Expand Down
5 changes: 3 additions & 2 deletions apps/dashboard/src/lib/components/SessionDetail.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@

let { sessionId }: { sessionId: string } = $props();

const sessionQuery = useQueryOne<Session>(sessionById(sessionId));
const eventsQuery = useQuery<Event>(eventsForSession(sessionId));
// Use getter functions so queries re-run if sessionId changes
const sessionQuery = useQueryOne<Session>(() => sessionById(sessionId));
const eventsQuery = useQuery<Event>(() => eventsForSession(sessionId));

const loading = $derived(sessionQuery.loading || eventsQuery.loading);
const session = $derived(sessionQuery.data);
Expand Down
3 changes: 2 additions & 1 deletion apps/dashboard/src/lib/components/SessionsList.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@

let { projectId }: { projectId: string } = $props();

const sessionsQuery = useQuery<Session>(recentSessions(projectId, 100));
// Use getter function so query re-runs if projectId changes
const sessionsQuery = useQuery<Session>(() => recentSessions(projectId, 100));

function formatTimestamp(ts: number): string {
return new Date(ts).toLocaleString();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
<script lang="ts">
import { Chart, Svg, Axis, Area, Spline, Tooltip, Highlight } from "layerchart";
import { scaleTime, scaleLinear } from "d3-scale";
import { curveMonotoneX } from "d3-shape";
import type { Event } from "$lib/zero/schema";

let { events = [], days = 7 }: { events?: Event[]; days?: number } = $props();

// Aggregate events by day
const data = $derived.by(() => {
const now = Date.now();
const counts: Record<string, number> = {};

// Initialize all days with 0
for (let i = days - 1; i >= 0; i--) {
const d = now - i * 24 * 60 * 60 * 1000;
const dateStr = new Date(d).toISOString().split("T")[0];
counts[dateStr] = 0;
}

// Count events per day (only if events exist)
if (events && events.length > 0) {
for (const event of events) {
if (event?.timestamp) {
const dateStr = new Date(event.timestamp).toISOString().split("T")[0];
if (dateStr in counts) {
counts[dateStr]++;
}
}
}
}

// Convert to array format for chart
return Object.entries(counts).map(([dateStr, count]) => ({
date: new Date(dateStr),
count,
}));
});

// Pre-compute scales with proper domains
const xScale = $derived.by(() => {
if (data.length === 0) return scaleTime();
const dates = data.map(d => d.date);
return scaleTime().domain([dates[0], dates[dates.length - 1]]);
});

const yScale = $derived.by(() => {
const maxCount = Math.max(1, ...data.map(d => d.count));
return scaleLinear().domain([0, maxCount]);
});

const hasData = $derived(data.length > 0);
</script>

<div class="h-48 w-full">
{#if hasData}
<Chart
{data}
x="date"
{xScale}
y="count"
{yScale}
yNice
padding={{ left: 40, bottom: 24, right: 8, top: 8 }}
>
<Svg>
<Axis placement="left" grid={{ class: "stroke-rp-overlay" }} />
<Axis
placement="bottom"
format={(d) => {
const date = d as Date;
return `${date.getMonth() + 1}/${date.getDate()}`;
}}
/>
<Area
line={{ class: "stroke-2 stroke-rp-iris" }}
class="fill-rp-iris/20"
curve={curveMonotoneX}
/>
<Spline class="stroke-2 stroke-rp-iris" curve={curveMonotoneX} />
<Highlight points={{ class: "fill-rp-iris" }} lines />
</Svg>
<Tooltip.Root let:data>
{#if data}
<Tooltip.Header>
{data.date.toLocaleDateString()}
</Tooltip.Header>
<Tooltip.List>
<Tooltip.Item label="Events" value={data.count} />
</Tooltip.List>
{/if}
</Tooltip.Root>
</Chart>
{:else}
<div class="h-full flex items-center justify-center text-rp-muted">
No data available
</div>
{/if}
</div>
Loading