feat(analytics): refine timeline visualization and add print support

This commit is contained in:
2026-02-17 21:17:11 -05:00
parent 568d408587
commit 72971a4b49
82 changed files with 6670 additions and 2448 deletions

View File

@@ -1,6 +1,8 @@
"use client";
import { useMemo } from "react";
import { DesignerRoot } from "~/components/experiments/designer/DesignerRoot";
import { useActionRegistry } from "~/components/experiments/designer/ActionRegistry";
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
import type { ExperimentStep } from "~/lib/experiment-designer/types";
@@ -9,6 +11,10 @@ interface DesignerPageClientProps {
id: string;
name: string;
description: string | null;
status: string;
studyId: string;
createdAt: Date;
updatedAt: Date;
study: {
id: string;
name: string;
@@ -28,6 +34,22 @@ export function DesignerPageClient({
experiment,
initialDesign,
}: DesignerPageClientProps) {
// Initialize action registry early to prevent CLS
useActionRegistry();
// Calculate design statistics
const designStats = useMemo(() => {
if (!initialDesign) return undefined;
const stepCount = initialDesign.steps.length;
const actionCount = initialDesign.steps.reduce(
(sum, step) => sum + step.actions.length,
0
);
return { stepCount, actionCount };
}, [initialDesign]);
// Set breadcrumbs
useBreadcrumbsEffect([
{
@@ -55,5 +77,12 @@ export function DesignerPageClient({
},
]);
return <DesignerRoot experimentId={experiment.id} initialDesign={initialDesign} />;
return (
<DesignerRoot
experimentId={experiment.id}
initialDesign={initialDesign}
experiment={experiment}
designStats={designStats}
/>
);
}

View File

@@ -121,7 +121,8 @@ export default async function ExperimentDesignerPage({
};
});
const mapped: ExperimentStep[] = exec.steps.map((s, idx) => {
const actions: ExperimentAction[] = s.actions.map((a) => {
// Recursive function to hydrate actions with children
const hydrateAction = (a: any): ExperimentAction => {
// Normalize legacy plugin action ids and provenance
const rawType = a.type ?? "";
@@ -188,11 +189,24 @@ export default async function ExperimentDesignerPage({
const pluginId = legacy?.pluginId;
const pluginVersion = legacy?.pluginVersion;
// Extract children from parameters for control flow actions
const params = (a.parameters ?? {}) as Record<string, unknown>;
let children: ExperimentAction[] | undefined = undefined;
// Handle control flow structures (sequence, parallel, loop only)
// Branch actions control step routing, not nested actions
const childrenRaw = params.children;
// Recursively hydrate nested children for container actions
if (Array.isArray(childrenRaw) && childrenRaw.length > 0) {
children = childrenRaw.map((child: any) => hydrateAction(child));
}
return {
id: a.id,
type: typeOut,
name: a.name,
parameters: (a.parameters ?? {}) as Record<string, unknown>,
parameters: params,
category: categoryOut,
source: {
kind: sourceKind,
@@ -202,8 +216,11 @@ export default async function ExperimentDesignerPage({
baseActionId: legacy?.baseId,
},
execution,
children, // Add children at top level
};
});
};
const actions: ExperimentAction[] = s.actions.map((a) => hydrateAction(a));
return {
id: s.id,
name: s.name,

View File

@@ -1,164 +0,0 @@
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import * as z from "zod";
import { Button } from "~/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "~/components/ui/form";
import { Input } from "~/components/ui/input";
import { Textarea } from "~/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "~/components/ui/select";
import { toast } from "sonner";
import { api } from "~/trpc/react";
import { useRouter } from "next/navigation";
import { type experiments, experimentStatusEnum } from "~/server/db/schema";
import { type InferSelectModel } from "drizzle-orm";
type Experiment = InferSelectModel<typeof experiments>;
const formSchema = z.object({
name: z.string().min(2, {
message: "Name must be at least 2 characters.",
}),
description: z.string().optional(),
status: z.enum(experimentStatusEnum.enumValues),
});
interface ExperimentFormProps {
experiment: Experiment;
}
export function ExperimentForm({ experiment }: ExperimentFormProps) {
const router = useRouter();
const updateExperiment = api.experiments.update.useMutation({
onSuccess: () => {
toast.success("Experiment updated successfully");
router.refresh();
router.push(`/studies/${experiment.studyId}/experiments/${experiment.id}`);
},
onError: (error) => {
toast.error(`Error updating experiment: ${error.message}`);
},
});
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
name: experiment.name,
description: experiment.description ?? "",
status: experiment.status,
},
});
function onSubmit(values: z.infer<typeof formSchema>) {
updateExperiment.mutate({
id: experiment.id,
name: values.name,
description: values.description,
status: values.status,
});
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="Experiment name" {...field} />
</FormControl>
<FormDescription>
The name of your experiment.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Textarea
placeholder="Describe your experiment..."
className="resize-none"
{...field}
/>
</FormControl>
<FormDescription>
A short description of the experiment goals.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="status"
render={({ field }) => (
<FormItem>
<FormLabel>Status</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a status" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="draft">Draft</SelectItem>
<SelectItem value="testing">Testing</SelectItem>
<SelectItem value="ready">Ready</SelectItem>
<SelectItem value="deprecated">Deprecated</SelectItem>
</SelectContent>
</Select>
<FormDescription>
The current status of the experiment.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="flex gap-4">
<Button type="submit" disabled={updateExperiment.isPending}>
{updateExperiment.isPending ? "Saving..." : "Save Changes"}
</Button>
<Button
type="button"
variant="outline"
onClick={() =>
router.push(
`/studies/${experiment.studyId}/experiments/${experiment.id}`,
)
}
>
Cancel
</Button>
</div>
</form>
</Form>
);
}

View File

@@ -1,58 +0,0 @@
import { notFound } from "next/navigation";
import { type experiments } from "~/server/db/schema";
import { type InferSelectModel } from "drizzle-orm";
type Experiment = InferSelectModel<typeof experiments>;
import { api } from "~/trpc/server";
import { ExperimentForm } from "./experiment-form";
import {
EntityView,
EntityViewHeader,
EntityViewSection,
} from "~/components/ui/entity-view";
import { Button } from "~/components/ui/button";
import Link from "next/link";
import { ArrowLeft } from "lucide-react";
interface ExperimentEditPageProps {
params: Promise<{ id: string; experimentId: string }>;
}
export default async function ExperimentEditPage({
params,
}: ExperimentEditPageProps) {
const { id: studyId, experimentId } = await params;
const experiment = await api.experiments.get({ id: experimentId });
if (!experiment) {
notFound();
}
// Ensure experiment belongs to study
if (experiment.studyId !== studyId) {
notFound();
}
// Convert to type expected by form
const experimentData: Experiment = {
...experiment,
status: experiment.status as Experiment["status"],
};
return (
<EntityView>
<EntityViewHeader
title="Edit Experiment"
subtitle={`Update settings for ${experiment.name}`}
icon="Edit"
/>
<div className="max-w-2xl">
<EntityViewSection title="Experiment Details" icon="Settings">
<ExperimentForm experiment={experimentData} />
</EntityViewSection>
</div>
</EntityView>
);
}

View File

@@ -195,12 +195,6 @@ export default function ExperimentDetailPage({
actions={
canEdit ? (
<>
<Button asChild variant="outline">
<Link href={`/studies/${studyId}/experiments/${experimentId}/edit`}>
<Edit className="mr-2 h-4 w-4" />
Edit
</Link>
</Button>
<Button asChild variant="outline">
<Link href={`/studies/${studyId}/experiments/${experimentId}/designer`}>
<Settings className="mr-2 h-4 w-4" />
@@ -442,15 +436,9 @@ export default function ExperimentDetailPage({
{
label: "Export Data",
icon: "Download" as const,
href: `/studies/${studyId}/experiments/${experimentId}/export`,
},
...(canEdit
? [
{
label: "Edit Experiment",
icon: "Edit" as const,
href: `/studies/${studyId}/experiments/${experimentId}/edit`,
},
{
label: "Open Designer",
icon: "Palette" as const,