Skip to content
Open
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
1 change: 1 addition & 0 deletions .agents/skills/databuddy-internal/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ Read [codebase-map.md](./references/codebase-map.md) when you need deeper routin
- For dashboard navigation audits, check all route surfaces: `components/layout/navigation/navigation-config.tsx`, `components/ui/command-search.tsx`, and local `PageNavigation` layouts under `app/**/layout.tsx` before calling a page orphaned.
- When fixing broken dashboard links to moved sections, update the real docs/search/navigation links and section anchors directly; do not add compatibility redirect pages unless explicitly requested.
- Custom events UI is shared in `apps/dashboard/components/events/custom-events`; keep many-series legends outside the Recharts plot, use compact controls for property-summary event selection, and avoid separate event-count chip/list sections.
- Goals and Funnels are sibling conversion surfaces; keep Goals list-first and visually aligned with `app/(main)/websites/[id]/funnels` instead of adding separate summary-card chrome.
- Insights merged feed (`use-insights-feed`) collapses history + AI by `insightSignalDedupeKey` in `apps/dashboard/lib/insight-signal-key.ts` so the list is one row per signal (latest wins).
- Insights page (`app/(main)/insights`) should stay focused on the brief + signal queue; do not add generic global analytics KPI cards or top pages/referrers/countries tables there.
- Theme: `apps/dashboard/app/globals.css`. **`--border` is intentionally subtle**; do not crank it darker for “contrast” unless **iza** asks—prefer text tokens or layout for readability.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,8 @@ import { formatNumber } from "@/lib/formatters";
import { cn } from "@/lib/utils";
import {
DotsThreeIcon,
EyeIcon,
MouseMiddleClickIcon,
PencilSimpleIcon,
TargetIcon,
TrashIcon,
WarningCircleIcon,
} from "@databuddy/ui/icons";
Expand All @@ -23,30 +22,49 @@ interface GoalItemProps {
onEdit: (goal: Goal) => void;
}

const GOAL_TYPE_CONFIG = {
PAGE_VIEW: {
icon: EyeIcon,
bg: "bg-blue-500/10 text-blue-600 dark:text-blue-400",
},
EVENT: {
icon: MouseMiddleClickIcon,
bg: "bg-violet-500/10 text-violet-600 dark:text-violet-400",
},
CUSTOM: {
icon: MouseMiddleClickIcon,
bg: "bg-muted text-muted-foreground",
},
} as const;

function GoalProgress({ rate }: { rate: number }) {
const clampedRate = Math.max(0, Math.min(100, rate));
return (
<div className="h-2 w-32 overflow-hidden rounded-full bg-muted lg:w-44">
<span className="block h-5 w-32 overflow-hidden rounded bg-muted lg:w-44">
<div
className="h-full rounded-full bg-chart-1 transition-[width]"
className="h-full rounded bg-chart-1 transition-[width]"
style={{ width: `${clampedRate}%` }}
/>
</div>
</span>
);
}

function formatGoalType(type: Goal["type"]) {
if (type === "PAGE_VIEW") {
return "Page View";
}
if (type === "EVENT") {
return "Event";
}
return "Custom";
}

function GoalMetadata({ goal }: { goal: Goal }) {
return (
<p className="mt-1 flex min-w-0 flex-wrap items-center gap-x-1.5 gap-y-0.5 text-muted-foreground text-xs">
<span className="shrink-0">{formatGoalType(goal.type)}</span>
<span aria-hidden="true" className="shrink-0">
·
</span>
<span className="min-w-0 max-w-full truncate font-mono">
{goal.target}
</span>
{goal.description ? (
<>
<span aria-hidden="true" className="shrink-0">
·
</span>
<span className="min-w-0 max-w-full truncate">
{goal.description}
</span>
</>
) : null}
</p>
);
}

Expand All @@ -61,36 +79,25 @@ export function GoalItem({
const analyticsError = analytics && !analytics.ok ? analytics.error : null;
const rate = analyticsData?.overall_conversion_rate ?? 0;
const users = analyticsData?.total_users_completed ?? 0;
const eligibleUsers = analyticsData?.total_users_entered ?? 0;
const config = GOAL_TYPE_CONFIG[goal.type] ?? GOAL_TYPE_CONFIG.PAGE_VIEW;
const TypeIcon = config.icon;

return (
<List.Row align="start" className={cn(!goal.isActive && "opacity-50")}>
<List.Cell className="pt-0.5">
<div
className={cn(
"flex size-8 items-center justify-center rounded",
config.bg
)}
>
<TypeIcon className="size-4" weight="duotone" />
<List.Row className={cn(!goal.isActive && "opacity-50")}>
<List.Cell>
<div className="flex size-8 shrink-0 items-center justify-center rounded border border-transparent bg-muted text-muted-foreground">
<TargetIcon className="size-4" weight="duotone" />
</div>
</List.Cell>

<List.Cell className="w-40 min-w-0 lg:w-52">
<p className="wrap-break-word text-pretty font-medium text-foreground text-sm">
{goal.name}
</p>
</List.Cell>

<List.Cell grow>
<p className="wrap-break-word text-pretty text-muted-foreground text-xs">
{goal.target}
</p>
<div className="min-w-0 flex-1 text-start">
<p className="truncate font-medium text-foreground text-sm">
{goal.name}
</p>
<GoalMetadata goal={goal} />
</div>
</List.Cell>

<List.Cell className="hidden items-start gap-3 pt-0.5 lg:flex">
<List.Cell className="hidden items-center gap-3 lg:flex">
{isLoadingAnalytics ? (
<>
<Skeleton className="h-5 w-32 rounded lg:w-44" />
Expand All @@ -115,13 +122,7 @@ export function GoalItem({
<span className="font-semibold text-sm tabular-nums">
{formatNumber(users)}
</span>
<span className="text-muted-foreground text-xs">Completed</span>
</div>
<div className="flex w-20 flex-col items-end">
<span className="font-semibold text-sm tabular-nums">
{formatNumber(eligibleUsers)}
</span>
<span className="text-muted-foreground text-xs">Eligible</span>
<span className="text-muted-foreground text-xs">Completions</span>
</div>
<div className="flex w-16 flex-col items-end">
<span className="font-semibold text-sm text-success tabular-nums">
Expand All @@ -133,7 +134,7 @@ export function GoalItem({
)}
</List.Cell>

<List.Cell className="w-14 pt-0.5 text-right lg:hidden">
<List.Cell className="w-14 text-right lg:hidden">
{isLoadingAnalytics ? (
<Skeleton className="ms-auto h-4 w-12 rounded" />
) : analyticsError ? (
Expand All @@ -148,7 +149,7 @@ export function GoalItem({
)}
</List.Cell>

<List.Cell action className="pt-0.5">
<List.Cell action>
<DropdownMenu>
<DropdownMenu.Trigger
aria-label="Goal actions"
Expand Down Expand Up @@ -180,19 +181,19 @@ export function GoalItem({

export function GoalItemSkeleton() {
return (
<div className="flex h-15 items-center gap-4 border-border/80 border-b px-4 last:border-b-0">
<Skeleton className="size-8 rounded" />
<div className="flex h-15 items-center gap-4 border-border/80 border-b px-4 py-3 last:border-b-0">
<Skeleton className="size-8 shrink-0 rounded" />
<div className="min-w-0 flex-1 space-y-1.5">
<Skeleton className="h-4 w-36" />
<Skeleton className="h-4 w-36 max-w-full" />
<Skeleton className="h-3 w-48 max-w-full" />
</div>
<div className="hidden items-center gap-3 lg:flex">
<div className="hidden shrink-0 items-center gap-3 lg:flex">
<Skeleton className="h-5 w-32 rounded lg:w-44" />
<Skeleton className="h-4 w-10 rounded" />
<Skeleton className="h-4 w-10 rounded" />
</div>
<Skeleton className="ms-auto h-4 w-12 rounded lg:hidden" />
<Skeleton className="size-8 rounded" />
<Skeleton className="ms-auto h-4 w-12 shrink-0 rounded lg:hidden" />
<Skeleton className="size-8 shrink-0 rounded" />
</div>
);
}
160 changes: 39 additions & 121 deletions apps/dashboard/app/(main)/websites/[id]/goals/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import { useDateFilters } from "@/hooks/use-date-filters";
import {
type CreateGoalData,
type Goal,
type GoalAnalyticsRecord,
useBulkGoalAnalytics,
useGoals,
} from "@/hooks/use-goals";
Expand All @@ -20,16 +19,9 @@ import type { DynamicQueryFilter, GoalFilter } from "@/types/api";
import { EditGoalDialog } from "./_components/edit-goal-dialog";
import { GoalItemSkeleton } from "./_components/goal-item";
import { GoalsList } from "./_components/goals-list";
import {
ArrowClockwiseIcon,
CheckCircleIcon,
PlusIcon,
TargetIcon,
TrendUpIcon,
} from "@databuddy/ui/icons";
import { Button, Card } from "@databuddy/ui";
import { ArrowClockwiseIcon, PlusIcon, TargetIcon } from "@databuddy/ui/icons";
import { Button } from "@databuddy/ui";
import { DeleteDialog } from "@databuddy/ui/client";
import { formatNumber } from "@/lib/formatters";
import { cn } from "@/lib/utils";
import { useAtomValue } from "jotai";

Expand Down Expand Up @@ -63,71 +55,6 @@ function toGoalFilters(filters: DynamicQueryFilter[]): GoalFilter[] {
}));
}

function GoalsSummary({
analytics,
goals,
isLoading,
}: {
analytics?: GoalAnalyticsRecord;
goals: Goal[];
isLoading: boolean;
}) {
const results = Object.values(analytics ?? {}).filter((result) => result.ok);
const completions = results.reduce(
(total, result) => total + result.data.total_users_completed,
0
);
const averageConversion =
results.length > 0
? results.reduce(
(total, result) => total + result.data.overall_conversion_rate,
0
) / results.length
: 0;

const cards = [
{
icon: TargetIcon,
label: "Active goals",
value: formatNumber(goals.filter((goal) => goal.isActive).length),
},
{
icon: CheckCircleIcon,
label: "Completions",
value: formatNumber(completions),
},
{
icon: TrendUpIcon,
label: "Avg. conversion",
value: `${averageConversion.toFixed(1)}%`,
},
];

return (
<div className="grid gap-3 md:grid-cols-3">
{cards.map(({ icon: Icon, label, value }) => (
<Card className="rounded" key={label}>
<Card.Content className="flex items-center gap-3 p-4">
<div className="flex size-9 shrink-0 items-center justify-center rounded bg-secondary text-muted-foreground">
<Icon className="size-4" weight="duotone" />
</div>
<div className="min-w-0">
<p className="text-muted-foreground text-xs">{label}</p>
{isLoading ? (
<div className="mt-1 h-5 w-16 animate-pulse rounded bg-muted" />
) : (
<p className="font-semibold text-foreground text-lg tabular-nums">
{value}
</p>
)}
</div>
</Card.Content>
</Card>
))}
</div>
);
}

export default function GoalsPage() {
const { id } = useParams();
const websiteId = id as string;
Expand Down Expand Up @@ -242,53 +169,44 @@ export default function GoalsPage() {
</TopBar.Actions>

<div className="min-h-0 flex-1 overflow-y-auto overscroll-none">
<div className="space-y-4 p-4 sm:p-5">
<List.Content
emptyProps={{
action: {
label: "Create a goal",
onClick: () => {
setEditingGoal(null);
setIsDialogOpen(true);
},
<List.Content
emptyProps={{
action: {
label: "Create a goal",
onClick: () => {
setEditingGoal(null);
setIsDialogOpen(true);
},
description:
"Track single-step conversions like signups, purchases, or activation events.",
icon: <TargetIcon className="size-6" weight="duotone" />,
title: "No goals yet",
}}
errorProps={{
action: { label: "Retry", onClick: () => refreshAction() },
description:
error?.message ??
"Something went wrong while loading goal data.",
icon: <TargetIcon className="size-6" weight="duotone" />,
title: "Failed to load goals",
}}
loading={<GoalsListSkeleton />}
outcome={listOutcome}
>
{(items) => (
<>
<GoalsSummary
analytics={goalAnalytics}
goals={items}
isLoading={analyticsLoading}
/>
<GoalsList
analyticsLoading={analyticsLoading}
goalAnalytics={goalAnalytics}
goals={items}
onDeleteGoal={(goalId) => setDeletingGoalId(goalId)}
onEditGoal={(goal) => {
setEditingGoal(goal);
setIsDialogOpen(true);
}}
/>
</>
)}
</List.Content>
</div>
},
description:
"Track single-step conversions like signups, purchases, or activation events.",
icon: <TargetIcon className="size-6" weight="duotone" />,
title: "No goals yet",
}}
errorProps={{
action: { label: "Retry", onClick: () => refreshAction() },
description:
error?.message ??
"Something went wrong while loading goal data.",
icon: <TargetIcon className="size-6" weight="duotone" />,
title: "Failed to load goals",
}}
loading={<GoalsListSkeleton />}
outcome={listOutcome}
>
{(items) => (
<GoalsList
analyticsLoading={analyticsLoading}
goalAnalytics={goalAnalytics}
goals={items}
onDeleteGoal={(goalId) => setDeletingGoalId(goalId)}
onEditGoal={(goal) => {
setEditingGoal(goal);
setIsDialogOpen(true);
}}
/>
)}
</List.Content>
</div>

{isDialogOpen && (
Expand Down
Loading
Loading