From 859fdd2f839f9ab8113ce897f7dd9665d084d61e Mon Sep 17 00:00:00 2001 From: thedittya471 Date: Tue, 4 Aug 2026 01:15:35 +0530 Subject: [PATCH] refactor: remove redundant analytics components and consolidate platform configurations --- .github/workflows/deploy.yml | 73 +- .gitignore | 3 +- apps/api/src/lib/rate-limit-store.ts | 18 - apps/api/src/lib/rate-limiter.ts | 107 +++ apps/api/src/routes/upload.ts | 13 +- apps/api/src/server.ts | 25 +- apps/api/tsup.config.ts | 2 +- apps/web/app/about/page.tsx | 4 +- apps/web/app/dashboard/analytics/page.tsx | 429 ----------- apps/web/app/dashboard/page.tsx | 2 +- .../app/dashboard/sketches/[formId]/page.tsx | 546 ++++++------- apps/web/app/dashboard/sketches/page.tsx | 8 +- apps/web/app/forms/[formId]/page.tsx | 30 +- apps/web/app/page.tsx | 4 +- apps/web/components/DashboardNav.tsx | 3 +- apps/web/components/Footer.tsx | 7 +- apps/web/components/NotFoundPanel.tsx | 1 - .../analytics/AnalyticsFormPicker.tsx | 87 --- .../components/analytics/DeviceBreakdown.tsx | 80 -- .../components/analytics/EngagementStats.tsx | 93 --- .../web/components/analytics/FieldDropoff.tsx | 364 --------- apps/web/components/analytics/MetricsGrid.tsx | 44 -- .../components/analytics/PeriodComparison.tsx | 65 -- .../analytics/QuestionDistribution.tsx | 351 --------- .../components/analytics/ResponseTimeline.tsx | 96 --- apps/web/components/analytics/StatsRow.tsx | 64 -- .../analytics/SubmissionDetailModal.tsx | 174 ----- .../components/analytics/SubmissionsTable.tsx | 244 ------ .../components/analytics/TrafficSources.tsx | 83 -- apps/web/components/analytics/palette.ts | 37 - apps/web/components/builder/BuilderHeader.tsx | 166 ++-- apps/web/components/builder/ResponsesView.tsx | 724 ++++++++++++++++++ apps/web/hooks/api/analytics/index.ts | 49 +- apps/worker/src/index.ts | 21 +- apps/worker/src/processors/analytics.ts | 25 - apps/worker/src/services.ts | 2 - docker-compose.prod.yml | 96 +++ docker/Dockerfile.api | 22 + docker/Dockerfile.web | 23 + packages/database/models/form-field-view.ts | 22 - packages/database/schema.ts | 1 - packages/queue/index.ts | 63 +- packages/queue/jobs.ts | 12 - packages/services/analytics/index.ts | 567 -------------- packages/services/analytics/model.ts | 142 ---- packages/services/form-submission/index.ts | 10 +- packages/services/form-submission/model.ts | 4 + packages/trpc/server/index.ts | 2 - .../trpc/server/routes/analytics/model.ts | 10 - .../trpc/server/routes/analytics/route.ts | 93 --- packages/trpc/server/routes/form/model.ts | 2 + packages/trpc/server/routes/form/route.ts | 27 + packages/trpc/server/services/index.ts | 2 - 53 files changed, 1421 insertions(+), 3721 deletions(-) delete mode 100644 apps/api/src/lib/rate-limit-store.ts create mode 100644 apps/api/src/lib/rate-limiter.ts delete mode 100644 apps/web/app/dashboard/analytics/page.tsx delete mode 100644 apps/web/components/analytics/AnalyticsFormPicker.tsx delete mode 100644 apps/web/components/analytics/DeviceBreakdown.tsx delete mode 100644 apps/web/components/analytics/EngagementStats.tsx delete mode 100644 apps/web/components/analytics/FieldDropoff.tsx delete mode 100644 apps/web/components/analytics/MetricsGrid.tsx delete mode 100644 apps/web/components/analytics/PeriodComparison.tsx delete mode 100644 apps/web/components/analytics/QuestionDistribution.tsx delete mode 100644 apps/web/components/analytics/ResponseTimeline.tsx delete mode 100644 apps/web/components/analytics/StatsRow.tsx delete mode 100644 apps/web/components/analytics/SubmissionDetailModal.tsx delete mode 100644 apps/web/components/analytics/SubmissionsTable.tsx delete mode 100644 apps/web/components/analytics/TrafficSources.tsx delete mode 100644 apps/web/components/analytics/palette.ts create mode 100644 apps/web/components/builder/ResponsesView.tsx delete mode 100644 apps/worker/src/processors/analytics.ts create mode 100644 docker-compose.prod.yml create mode 100644 docker/Dockerfile.api create mode 100644 docker/Dockerfile.web delete mode 100644 packages/database/models/form-field-view.ts delete mode 100644 packages/services/analytics/index.ts delete mode 100644 packages/services/analytics/model.ts delete mode 100644 packages/trpc/server/routes/analytics/model.ts delete mode 100644 packages/trpc/server/routes/analytics/route.ts diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index ed5f683..27ab765 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -23,48 +23,15 @@ jobs: steps: - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v4 - - - uses: actions/setup-node@v4 - with: - node-version: 22 - cache: pnpm - - - name: Install - run: pnpm install --frozen-lockfile - - - name: Build - run: pnpm build - - - name: Stage release bundle + - name: Create release zip run: | - set -eux - mkdir -p _release/web _release/api _release/db - - # Web: Next.js standalone is self-contained; just copy static + public next to it. - cp -r apps/web/.next/standalone/. _release/web/ - mkdir -p _release/web/apps/web/.next - cp -r apps/web/.next/static _release/web/apps/web/.next/static - cp -r apps/web/public _release/web/apps/web/public - - # API: pnpm deploy produces a self-contained dir (dist + node_modules + package.json) - pnpm --filter @repo/api deploy --prod _release/api - # DB: same trick, plus the drizzle SQL + migrate.mjs. - pnpm --filter @repo/database deploy --prod _release/db - cp -r packages/database/drizzle _release/db/drizzle - cp packages/database/migrate.mjs _release/db/migrate.mjs - - cp ecosystem.config.cjs _release/ecosystem.config.cjs + zip -r release.zip . -x "node_modules/*" ".git/*" ".next/*" "apps/web/.next/*" "apps/api/dist/*" "stress-tests/*" - name: Write .env from secret env: PROD_ENV: ${{ secrets.PROD_ENV }} run: | - # File is world-readable on the runner so the scp-action's - # Docker container can read it. It only lives here for ~10s - # before the runner is destroyed. Once it lands on the VM - # the next step chmod's it back to 600. - printf '%s\n' "$PROD_ENV" > _release/.env + printf '%s\n' "$PROD_ENV" > _env - name: Copy files to VM uses: appleboy/scp-action@v0.1.7 @@ -73,12 +40,10 @@ jobs: username: ${{ secrets.SSH_USER }} key: ${{ secrets.SSH_PRIVATE_KEY }} port: ${{ secrets.SSH_PORT }} - source: "_release/*,_release/.env" + source: "release.zip,_env" target: "/home/dittya/projects/canvasflow" - strip_components: 1 - overwrite: true - - name: Migrate + reload PM2 + - name: Deploy via Docker Compose uses: appleboy/ssh-action@v1.0.3 with: host: ${{ secrets.SSH_HOST }} @@ -88,30 +53,22 @@ jobs: script: | set -eu cd /home/dittya/projects/canvasflow + + # Extract release bundle + unzip -o release.zip + rm release.zip + mv _env .env chmod 600 .env - # dotenv reads .env from cwd in each process — symlink so - # both api and web (and the migrator) all find it. - ln -sf "$PWD/.env" api/.env - ln -sf "$PWD/.env" web/apps/web/.env - ln -sf "$PWD/.env" db/.env - - # Apply pending DB migrations. If this fails, pm2 reload is - # never reached and the running app keeps serving the old code. - set -a; . ./.env; set +a - (cd db && node migrate.mjs) + # Build and deploy Docker containers + docker compose -f docker-compose.prod.yml up --build -d - # Start or reload PM2. - if pm2 describe canvasflow-api >/dev/null 2>&1; then - pm2 reload ecosystem.config.cjs --update-env - else - pm2 start ecosystem.config.cjs - pm2 save - fi + # Cleanup builder artifacts and dangling cache + docker image prune -f - name: Summary if: success() run: | - echo "### ✅ Deployed from \`${{ github.sha }}\`" >> "$GITHUB_STEP_SUMMARY" + echo "### ✅ Deployed from \`${{ github.sha }}\` via Docker Compose" >> "$GITHUB_STEP_SUMMARY" echo "- web: https://canvasflow.dittya.dev" >> "$GITHUB_STEP_SUMMARY" echo "- api: https://api.canvasflow.dittya.dev" >> "$GITHUB_STEP_SUMMARY" diff --git a/.gitignore b/.gitignore index f62cd8b..13cad58 100644 --- a/.gitignore +++ b/.gitignore @@ -37,4 +37,5 @@ yarn-error.log* .DS_Store *.pem -.kiro \ No newline at end of file +.kiro +stress-tests/ \ No newline at end of file diff --git a/apps/api/src/lib/rate-limit-store.ts b/apps/api/src/lib/rate-limit-store.ts deleted file mode 100644 index 360dfe0..0000000 --- a/apps/api/src/lib/rate-limit-store.ts +++ /dev/null @@ -1,18 +0,0 @@ -import RedisStore from "rate-limit-redis"; -import type { Store } from "express-rate-limit"; -import { isRedisConfigured, redisKey, redisReady } from "@repo/redis"; - -export function redisRateLimitStore(bucket: string): Store | undefined { - if (!isRedisConfigured()) return undefined; - - return new RedisStore({ - sendCommand: async (...args: string[]) => { - const connection = await redisReady(); - if (!connection) throw new Error("Redis unavailable for rate limiting"); - - return (connection as unknown as { call: (...a: string[]) => Promise }).call(...args); - }, - - prefix: `${redisKey("rl", bucket)}:`, - }) as unknown as Store; -} diff --git a/apps/api/src/lib/rate-limiter.ts b/apps/api/src/lib/rate-limiter.ts new file mode 100644 index 0000000..cd5e744 --- /dev/null +++ b/apps/api/src/lib/rate-limiter.ts @@ -0,0 +1,107 @@ +import type { Request, Response, NextFunction } from "express"; +import { randomUUID } from "node:crypto"; +import { isRedisConfigured, redisKey, redisReady } from "@repo/redis"; + +interface RateLimiterOptions { + bucketName: string; + max: number; // Bucket capacity (max burst size) + windowMs: number; // Time window in milliseconds (leak duration) + message?: string | object; +} + +const gcraScript = ` +local key = KEYS[1] +local capacity = tonumber(ARGV[1]) +local window_ms = tonumber(ARGV[2]) +local now = tonumber(ARGV[3]) + +local emission_interval = window_ms / capacity +local limit = capacity * emission_interval + +local tat = tonumber(redis.call('GET', key)) + +if not tat then + tat = now +else + tat = math.max(tat, now) +end + +local tat_diff = tat - now + +if tat_diff > limit then + return {0, math.ceil(tat_diff - limit)} +else + local new_tat = tat + emission_interval + redis.call('SET', key, new_tat, 'PX', math.ceil(new_tat - now)) + return {1, 0} +end +`; + +export function leakyBucketRateLimiter(opts: RateLimiterOptions) { + const { bucketName, max, windowMs, message } = opts; + + return async (req: Request, res: Response, next: NextFunction) => { + if (!isRedisConfigured()) { + return next(); + } + + try { + const client = await redisReady(); + if (!client) { + return next(); + } + + // 1. Identify client + let clientKey = ""; + + // Try resolving auth session token + const sessionToken = req.headers.cookie?.match(/better-auth\.session_token=([^;]+)/)?.[1]; + if (sessionToken) { + clientKey = `session:${sessionToken}`; + } else { + // Fallback to visitor cookie + let visitorId = req.cookies?.["cf_visitor_id"] as string | undefined; + if (!visitorId) { + visitorId = randomUUID(); + res.cookie("cf_visitor_id", visitorId, { + maxAge: 365 * 24 * 60 * 60 * 1000, // 1 year + httpOnly: true, + sameSite: "lax", + path: "/", + }); + } + clientKey = `visitor:${visitorId}`; + } + + const key = `${redisKey("rl", bucketName)}:${clientKey}`; + const now = Date.now(); + + // Execute atomic GCRA script + const result = await client.eval( + gcraScript, + 1, + key, + max.toString(), + windowMs.toString(), + now.toString() + ) as [number, number]; + + const [allowed, retryAfterMs] = result; + + if (allowed === 1) { + res.setHeader("X-RateLimit-Limit", max); + return next(); + } else { + const retryAfterSec = Math.max(1, Math.ceil(retryAfterMs / 1000)); + res.setHeader("Retry-After", retryAfterSec); + res.setHeader("X-RateLimit-Limit", max); + + const errorMsg = message || { error: "Too many requests — slow down and try again shortly." }; + return res.status(429).json(typeof errorMsg === "string" ? { error: errorMsg } : errorMsg); + } + } catch (err) { + console.error("[rate-limiter] error executing leaky bucket:", err); + return next(); + } + }; +} diff --git a/apps/api/src/routes/upload.ts b/apps/api/src/routes/upload.ts index 2e7c680..fe04d7d 100644 --- a/apps/api/src/routes/upload.ts +++ b/apps/api/src/routes/upload.ts @@ -6,14 +6,14 @@ import path from "node:path"; import express, { type NextFunction, type Request, type Response } from "express"; import multer, { MulterError } from "multer"; -import rateLimit, { ipKeyGenerator } from "express-rate-limit"; + import { logger } from "@repo/logger"; import { enqueueUpload, isQueueAvailable } from "@repo/queue"; import { storageKindFor } from "@repo/services/form-upload"; import { formUploadService } from "@repo/trpc/server/services"; import { env } from "../env"; -import { redisRateLimitStore } from "../lib/rate-limit-store"; +import { leakyBucketRateLimiter } from "../lib/rate-limiter"; export const uploadRouter: express.Router = express.Router(); @@ -107,13 +107,10 @@ const upload = multer({ }, }); -const uploadLimiter = rateLimit({ - windowMs: 60_000, +const uploadLimiter = leakyBucketRateLimiter({ + bucketName: "upload", max: env.RATE_LIMIT_UPLOAD_MAX, - standardHeaders: "draft-7", - legacyHeaders: false, - keyGenerator: (req) => ipKeyGenerator(req.ip ?? "unknown"), - store: redisRateLimitStore("upload"), + windowMs: 60_000, message: { error: "Too many uploads — wait a minute and try again." }, }); diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index 58dd6d1..a8f9f04 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -2,7 +2,7 @@ import express from "express"; import { logger } from "@repo/logger"; import cors from "cors"; import compression from "compression"; -import rateLimit, { ipKeyGenerator } from "express-rate-limit"; + import * as trpcExpress from "@trpc/server/adapters/express"; import { generateOpenApiDocument, createOpenApiExpressMiddleware } from "trpc-to-openapi"; @@ -14,7 +14,7 @@ import { toNodeHandler } from "better-auth/node"; import { env } from "./env"; import { uploadRouter, uploadErrorHandler } from "./routes/upload"; -import { redisRateLimitStore } from "./lib/rate-limit-store"; +import { leakyBucketRateLimiter } from "./lib/rate-limiter"; export const app = express(); @@ -75,29 +75,22 @@ app.use( app.use(cookieParser()); -const publicWriteLimiter = rateLimit({ - windowMs: 60_000, +const publicWriteLimiter = leakyBucketRateLimiter({ + bucketName: "public-write", max: env.RATE_LIMIT_PUBLIC_WRITE_MAX, - standardHeaders: "draft-7", - legacyHeaders: false, - store: redisRateLimitStore("public-write"), + windowMs: 60_000, message: { error: "Too many requests — slow down and try again in a minute." }, }); -const authGlobalLimiter = rateLimit({ - windowMs: 60_000, +const authGlobalLimiter = leakyBucketRateLimiter({ + bucketName: "auth-global", max: env.RATE_LIMIT_AUTH_MAX, - standardHeaders: "draft-7", - legacyHeaders: false, - keyGenerator: (req) => - req.headers.cookie?.match(/better-auth\.session_token=([^;]+)/)?.[1] ?? - ipKeyGenerator(req.ip ?? "unknown"), - store: redisRateLimitStore("auth-global"), + windowMs: 60_000, message: { error: "Request rate exceeded for this session." }, }); app.use( - ["/trpc/analytics.recordFieldAnswer", "/trpc/form.submitForm", "/trpc/feedback.submitFeedback"], + ["/trpc/form.submitForm", "/trpc/feedback.submitFeedback"], publicWriteLimiter, ); diff --git a/apps/api/tsup.config.ts b/apps/api/tsup.config.ts index ed6c050..ddaa732 100644 --- a/apps/api/tsup.config.ts +++ b/apps/api/tsup.config.ts @@ -13,6 +13,6 @@ export default defineConfig({ minify: false, sourcemap: false, banner: { - js: `import { createRequire } from 'module'; const require = createRequire(import.meta.url);`, + js: `import { createRequire as __createRequire } from 'module'; const require = __createRequire(import.meta.url);`, }, }); diff --git a/apps/web/app/about/page.tsx b/apps/web/app/about/page.tsx index e456465..748c29a 100644 --- a/apps/web/app/about/page.tsx +++ b/apps/web/app/about/page.tsx @@ -205,8 +205,8 @@ export default function AboutPage() { dropped off, and every response in a table you can export — no spreadsheets to stitch together first.

- - Explore analytics → + + Explore responses → diff --git a/apps/web/app/dashboard/analytics/page.tsx b/apps/web/app/dashboard/analytics/page.tsx deleted file mode 100644 index 75323d6..0000000 --- a/apps/web/app/dashboard/analytics/page.tsx +++ /dev/null @@ -1,429 +0,0 @@ -"use client"; - -import React, { Suspense, useCallback, useEffect, useMemo, useState } from "react"; -import dynamic from "next/dynamic"; -import Link from "next/link"; -import { useRouter, useSearchParams } from "next/navigation"; -import { Download } from "lucide-react"; -import { toast } from "sonner"; - -import { useListFormsByUserId, useGetFormById } from "~/hooks/api/form"; -import { - useGetFormAnalytics, - useGetDetailedAnalytics, - useGetSubmissions, -} from "~/hooks/api/analytics"; -import { useDebounce } from "~/hooks/useDebounce"; - -import { AnalyticsFormPicker } from "~/components/analytics/AnalyticsFormPicker"; -import { MetricsGrid } from "~/components/analytics/MetricsGrid"; -import { StatsRow } from "~/components/analytics/StatsRow"; -import { SubmissionsTable } from "~/components/analytics/SubmissionsTable"; -import { DEVICE_COLORS } from "~/components/analytics/palette"; - -const ResponseTimeline = dynamic( - () => import("~/components/analytics/ResponseTimeline").then((m) => m.ResponseTimeline), - { ssr: false }, -); -const DeviceBreakdown = dynamic( - () => import("~/components/analytics/DeviceBreakdown").then((m) => m.DeviceBreakdown), - { ssr: false }, -); -const QuestionDistribution = dynamic( - () => import("~/components/analytics/QuestionDistribution").then((m) => m.QuestionDistribution), - { ssr: false }, -); -const TrafficSources = dynamic( - () => import("~/components/analytics/TrafficSources").then((m) => m.TrafficSources), - { ssr: false }, -); -const FieldDropoff = dynamic( - () => import("~/components/analytics/FieldDropoff").then((m) => m.FieldDropoff), - { ssr: false }, -); -const PeriodComparison = dynamic( - () => import("~/components/analytics/PeriodComparison").then((m) => m.PeriodComparison), - { ssr: false }, -); -const EngagementStats = dynamic( - () => import("~/components/analytics/EngagementStats").then((m) => m.EngagementStats), - { ssr: false }, -); - -type TabId = "summary" | "responses" | "dropoff" | "segments"; - -const TABS: { id: TabId; label: string }[] = [ - { id: "summary", label: "Summary" }, - { id: "responses", label: "Responses" }, - { id: "dropoff", label: "Drop-off" }, - { id: "segments", label: "Segments" }, -]; - -interface SubmissionValue { - formFieldId: string; - value: any; -} - -interface Submission { - id: string; - formId: string; - values: SubmissionValue[]; - createdAt: string; -} - -export function AnalyticsPage() { - const router = useRouter(); - const searchParams = useSearchParams(); - const [searchQuery, setSearchQuery] = useState(""); - // Debounce so filtering and any future expensive operations on the - // submissions list don't run on every keystroke. - const debouncedSearchQuery = useDebounce(searchQuery, 200); - const [viewingSubmission, setViewingSubmission] = useState(null); - - const [tab, setTab] = useState("summary"); - - const selectedFormId = searchParams.get("form"); - - const setSelectedFormId = useCallback( - (id: string) => { - router.replace(`/dashboard/analytics?form=${id}`, { scroll: false }); - }, - [router], - ); - - const { forms, isLoading: isLoadingForms } = useListFormsByUserId(); - const { form, isLoading: isLoadingForm } = useGetFormById(selectedFormId || ""); - const { analytics, isLoading: isLoadingAnalytics } = useGetFormAnalytics(selectedFormId || ""); - const { - submissions, - isLoading: isLoadingSubmissions, - hasNextPage, - isFetchingNextPage, - fetchNextPage, - } = useGetSubmissions(selectedFormId || ""); - const { detailedAnalytics } = useGetDetailedAnalytics(selectedFormId || ""); - - useEffect(() => { - if (forms && forms.length > 0 && !selectedFormId) { - const firstForm = forms[0]; - if (firstForm) setSelectedFormId(firstForm.id); - } - }, [forms, selectedFormId, setSelectedFormId]); - - const getRespondentDetails = useCallback( - (sub: Submission) => { - let name = "Anonymous"; - let email = "no-email@anonymous.com"; - - if (form?.fields) { - const emailField = form.fields.find( - (f) => f.type === "EMAIL" || f.label.toLowerCase().includes("email"), - ); - if (emailField) { - const val = sub.values.find((v) => v.formFieldId === emailField.id); - if (val?.value) email = String(val.value); - } - - const nameField = form.fields.find( - (f) => - f.type === "TEXT" && - (f.label.toLowerCase().includes("name") || - f.label.toLowerCase().includes("respondent")), - ); - if (nameField) { - const val = sub.values.find((v) => v.formFieldId === nameField.id); - if (val?.value) name = String(val.value); - } else if (email && email !== "no-email@anonymous.com") { - name = email.split("@")[0] || "Anonymous"; - } - } - return { name, email }; - }, - [form], - ); - - const handleExportCSV = () => { - if (!form || !submissions || submissions.length === 0) { - toast.error("No submissions available to export"); - return; - } - - const headers = ["Submission ID", "Submitted At"]; - form.fields.forEach((f) => headers.push(f.label)); - const csvRows = [headers.join(",")]; - - submissions.forEach((sub) => { - const row = [sub.id, new Date(sub.createdAt).toLocaleString()]; - form.fields.forEach((f) => { - const answer = sub.values.find((v) => v.formFieldId === f.id); - let valStr = ""; - if (answer?.value !== undefined && answer?.value !== null) { - if (Array.isArray(answer.value)) { - valStr = `"${answer.value.join("; ")}"`; - } else { - valStr = `"${String(answer.value).replace(/"/g, '""')}"`; - } - } - row.push(valStr); - }); - csvRows.push(row.join(",")); - }); - - const blob = new Blob([csvRows.join("\n")], { - type: "text/csv;charset=utf-8;", - }); - const url = URL.createObjectURL(blob); - const link = document.createElement("a"); - link.setAttribute("href", url); - link.setAttribute( - "download", - `${form.title.toLowerCase().replace(/\s+/g, "_")}_submissions.csv`, - ); - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - toast.success("CSV downloaded"); - }; - - // Derived metrics - const totalResponses = analytics?.totalResponses ?? 0; - const avgPerDay = analytics?.avgSubmissionsPerDay ?? 0; - const avgPerWeek = analytics?.avgSubmissionsPerWeek ?? 0; - const peakDay = analytics?.peakDay ?? null; - - const deviceData = useMemo(() => { - const deviceBreakdown = analytics?.deviceBreakdown ?? []; - const desktop = deviceBreakdown.find((d) => d.device === "desktop")?.count ?? 0; - const mobile = deviceBreakdown.find((d) => d.device === "mobile")?.count ?? 0; - const tablet = deviceBreakdown.find((d) => d.device === "tablet")?.count ?? 0; - - return [ - { name: "Desktop", value: desktop, color: DEVICE_COLORS.Desktop }, - { name: "Mobile", value: mobile, color: DEVICE_COLORS.Mobile }, - { name: "Tablet", value: tablet, color: DEVICE_COLORS.Tablet }, - ]; - }, [analytics]); - - const dailyTrends = analytics?.dailyTrends ?? []; - - const filteredSubmissions = useMemo(() => { - const matchQuery = debouncedSearchQuery.toLowerCase(); - return submissions.filter((sub) => { - const details = getRespondentDetails(sub); - return ( - details.name.toLowerCase().includes(matchQuery) || - details.email.toLowerCase().includes(matchQuery) || - sub.id.toLowerCase().includes(matchQuery) - ); - }); - }, [submissions, debouncedSearchQuery, getRespondentDetails]); - - const isLoading = isLoadingForm || isLoadingAnalytics || isLoadingSubmissions; - - return ( -
-
-

- Analytics - . -

-

- Read the numbers behind every form you publish. -

-
- - - -
- {isLoading ? ( -
-
-
- Loading analytics -
-
- ) : !form ? ( -
-
-

No form selected

-

Pick a form

-

- Choose a form from the sidebar to load its analytics and response history. -

-
-
- ) : ( -
- {/* chrome bar */} -
- CanvasFlow · {form.title} -
- - Edit - - -
-
- -
-

- {form.title} · Overview -

-

- Live breakdown of responses across time, devices, and questions. -

- -
- {TABS.map((t) => ( - - ))} -
- -
- -
- -
- {tab === "summary" && ( -
- -
- - -
- {/* Waits on the detailed query rather than gating on a - plan — the trend buckets simply aren't loaded yet. */} - {detailedAnalytics && ( -
- -
- )} -
- )} - - {tab === "responses" && ( - - )} - - {tab === "dropoff" && ( -
- - -
- )} - - {tab === "segments" && ( -
- - - - - - - -
- )} -
-
-
- )} -
-
- ); -} - -function SegmentGroup({ - label, - hint, - children, -}: { - label: string; - hint: string; - children: React.ReactNode; -}) { - return ( -
-
-

{label}

-

- {hint} -

-
- {children} -
- ); -} - -export default function AnalyticsPageWrapper() { - return ( - - - - ); -} diff --git a/apps/web/app/dashboard/page.tsx b/apps/web/app/dashboard/page.tsx index 6e8ac6c..5674291 100644 --- a/apps/web/app/dashboard/page.tsx +++ b/apps/web/app/dashboard/page.tsx @@ -143,7 +143,7 @@ export default function DashboardPage() {
-

Analytics

+

Responses

Response trends

diff --git a/apps/web/app/dashboard/sketches/[formId]/page.tsx b/apps/web/app/dashboard/sketches/[formId]/page.tsx index 4cb3722..15616bb 100644 --- a/apps/web/app/dashboard/sketches/[formId]/page.tsx +++ b/apps/web/app/dashboard/sketches/[formId]/page.tsx @@ -1,7 +1,7 @@ "use client"; import React from "react"; -import { useRouter } from "next/navigation"; +import { useRouter, useSearchParams } from "next/navigation"; import Link from "next/link"; import { Background, BackgroundVariant, Panel, ReactFlow, ReactFlowProvider } from "@xyflow/react"; import "@xyflow/react/dist/style.css"; @@ -22,6 +22,7 @@ import { MobileAddFieldSheet } from "~/components/builder/mobile/MobileAddFieldS import { MobileFieldEditorSheet } from "~/components/builder/mobile/MobileFieldEditorSheet"; import { ShareCollaboratorsDialog } from "~/components/builder/ShareCollaboratorsDialog"; import { FormSettingsDialog } from "~/components/builder/FormSettingsDialog"; +import { ResponsesView } from "~/components/builder/ResponsesView"; import { useBuilderState } from "~/components/builder/useBuilderState"; function BuilderCanvas() { @@ -125,6 +126,10 @@ function BuilderCanvas() { deleteFormAsync, } = useBuilderState(); + const searchParams = useSearchParams(); + const activeTabParam = searchParams?.get("tab") || "questions"; + const activeTab = (activeTabParam === "responses" || activeTabParam === "summary") ? "responses" : "questions"; + if (formLoading || fieldsLoading) { return (
@@ -167,15 +172,15 @@ function BuilderCanvas() { You don't have access to edit this form

- You only have viewer access to “{form.title}”. You can view its submissions - and analytics, but you cannot make changes to the fields. + You only have viewer access to “{form.title}”. You can view its submissions, + but you cannot make changes to the fields.

- View analytics + View responses Back to studio @@ -188,285 +193,300 @@ function BuilderCanvas() { return (
- { - void refetchForm(); - }} - onShare={() => setShowShareDialog(true)} - onSettings={() => setShowSettingsDialog(true)} - view={view} - onViewChange={handleViewChange} - /> - -
-
- -
- updateSegmentLocal(id, { title })} - onMoveSegment={handleMoveSegment} - onDeleteSegment={handleDeleteSegment} - /> -
- -
-
+ {activeTab === "questions" && ( + { + void refetchForm(); + }} + onShare={() => setShowShareDialog(true)} + onSettings={() => setShowSettingsDialog(true)} + view={view} + onViewChange={handleViewChange} + /> + )} - {view === "outline" ? ( -
- -
- ) : ( -
-
-
- Canvas - - {visibleSortedFields.length}{" "} - {visibleSortedFields.length === 1 ? "field" : "fields"} - + {activeTab === "questions" ? ( + <> +
+
+ +
+ updateSegmentLocal(id, { title })} + onMoveSegment={handleMoveSegment} + onDeleteSegment={handleDeleteSegment} + /> +
+
- -
-
- - +
+ ) : ( +
+
+
+ Canvas + + {visibleSortedFields.length}{" "} + {visibleSortedFields.length === 1 ? "field" : "fields"} + +
- - -
+ +
+ - - - - -
-
- )} + - { - if (!selectedField) return; - updateLocal(selectedField.id, { segmentId }); - if (segmentId && selectedSegmentId !== null) setSelectedSegmentId(segmentId); - }} - ruleSummaries={selectedFieldRuleSummaries} - incompleteRuleCount={selectedFieldIncompleteRules} - onEditBranching={ - selectedField ? () => setBranchingFieldId(selectedField.id) : undefined - } - isLastInSegment={isSelectedFieldLastInSegment} - /> + + + + + + +
+ + )} - -
+ { + if (!selectedField) return; + updateLocal(selectedField.id, { segmentId }); + if (segmentId && selectedSegmentId !== null) setSelectedSegmentId(segmentId); + }} + ruleSummaries={selectedFieldRuleSummaries} + incompleteRuleCount={selectedFieldIncompleteRules} + onEditBranching={ + selectedField ? () => setBranchingFieldId(selectedField.id) : undefined + } + isLastInSegment={isSelectedFieldLastInSegment} + /> -
-
-
+ +
+
+ + + {mobileSegmentsOpen && ( + updateSegmentLocal(id, { title })} + onMoveSegment={handleMoveSegment} + onDeleteSegment={handleDeleteSegment} + /> )} - {mobileSegmentsOpen ? ( - - ) : ( - - )} - - +
- {mobileSegmentsOpen && ( - updateSegmentLocal(id, { title })} - onMoveSegment={handleMoveSegment} - onDeleteSegment={handleDeleteSegment} + setMobileAddOpen(true)} /> - )} +
- setMobileAddOpen(true)} - /> -
-
- -
- setMobileAddOpen(false)} - onSelect={handleMobileAddField} - /> - { - if (!selectedField) return; - updateLocal(selectedField.id, { segmentId }); - if (segmentId && selectedSegmentId !== null) setSelectedSegmentId(segmentId); - }} - ruleSummaries={selectedFieldRuleSummaries} - incompleteRuleCount={selectedFieldIncompleteRules} - onEditBranching={ - selectedField - ? () => { - handleCloseMobileEditor(); - setBranchingFieldId(selectedField.id); - } - : undefined - } - isLastInSegment={isSelectedFieldLastInSegment} +
+ setMobileAddOpen(false)} + onSelect={handleMobileAddField} + /> + { + if (!selectedField) return; + updateLocal(selectedField.id, { segmentId }); + if (segmentId && selectedSegmentId !== null) setSelectedSegmentId(segmentId); + }} + ruleSummaries={selectedFieldRuleSummaries} + incompleteRuleCount={selectedFieldIncompleteRules} + onEditBranching={ + selectedField + ? () => { + handleCloseMobileEditor(); + setBranchingFieldId(selectedField.id); + } + : undefined + } + isLastInSegment={isSelectedFieldLastInSegment} + /> +
+ + ) : activeTab === "responses" ? ( + router.replace(`/dashboard/sketches/${formId}?tab=${tab}`, { scroll: false })} + onShare={() => setShowShareDialog(true)} /> -
+ ) : null} - Continue editing + Edit )} + + Responses +
diff --git a/apps/web/components/DashboardNav.tsx b/apps/web/components/DashboardNav.tsx index e6aa022..a2e3523 100644 --- a/apps/web/components/DashboardNav.tsx +++ b/apps/web/components/DashboardNav.tsx @@ -4,7 +4,7 @@ import React, { useState } from "react"; import Link from "next/link"; import Image from "next/image"; import { usePathname } from "next/navigation"; -import { BarChart3, ChevronRight, Compass, Menu, PencilRuler, Plus, X } from "lucide-react"; +import { ChevronRight, Compass, Menu, PencilRuler, Plus, X } from "lucide-react"; import { useDashboard } from "~/providers/dashboard-provider"; import { useGetLoggedInUserInfo } from "~/hooks/api/auth"; @@ -14,7 +14,6 @@ import { avatarSeed, GlyphAvatar, resolvePreset } from "~/components/profile/Gly const LINKS = [ { href: "/dashboard", label: "Studio", icon: Compass }, { href: "/dashboard/sketches", label: "Forms", icon: PencilRuler }, - { href: "/dashboard/analytics", label: "Analytics", icon: BarChart3 }, ]; export default function DashboardNav() { diff --git a/apps/web/components/Footer.tsx b/apps/web/components/Footer.tsx index 8644309..e29b1dc 100644 --- a/apps/web/components/Footer.tsx +++ b/apps/web/components/Footer.tsx @@ -65,12 +65,7 @@ const Footer = () => { > Your Forms - - Analytics - +
diff --git a/apps/web/components/NotFoundPanel.tsx b/apps/web/components/NotFoundPanel.tsx index 702d8dc..4df3db3 100644 --- a/apps/web/components/NotFoundPanel.tsx +++ b/apps/web/components/NotFoundPanel.tsx @@ -210,7 +210,6 @@ export function NotFoundPanel() { ["Learn more", "/learn-more"], ["About", "/about"], ["Your forms", "/dashboard/sketches"], - ["Analytics", "/dashboard/analytics"], ].map(([label, href]) => ( {label} diff --git a/apps/web/components/analytics/AnalyticsFormPicker.tsx b/apps/web/components/analytics/AnalyticsFormPicker.tsx deleted file mode 100644 index e0cac0e..0000000 --- a/apps/web/components/analytics/AnalyticsFormPicker.tsx +++ /dev/null @@ -1,87 +0,0 @@ -"use client"; - -import React from "react"; -import { trpc } from "~/trpc/client"; - -interface FormItem { - id: string; - title: string; - isPublished: boolean; -} - -interface AnalyticsFormPickerProps { - isLoadingForms: boolean; - forms: FormItem[] | undefined; - selectedFormId: string | null; - setSelectedFormId: (id: string) => void; -} - -export function AnalyticsFormPicker({ - isLoadingForms, - forms, - selectedFormId, - setSelectedFormId, -}: AnalyticsFormPickerProps) { - const utils = trpc.useUtils(); - const prefetchForm = (id: string) => { - void utils.form.getFormById.prefetch({ id }); - void utils.analytics.getFormAnalytics.prefetch({ formId: id }); - void utils.analytics.getSubmissions.prefetchInfinite({ formId: id, limit: 100 }); - }; - - return ( -
-

Form

- - {isLoadingForms ? ( -

Loading

- ) : !forms || forms.length === 0 ? ( -

No forms yet

- ) : ( -
- {forms.map((f) => { - const isActive = selectedFormId === f.id; - return ( - - ); - })} -
- )} -
- ); -} diff --git a/apps/web/components/analytics/DeviceBreakdown.tsx b/apps/web/components/analytics/DeviceBreakdown.tsx deleted file mode 100644 index 02ee6ec..0000000 --- a/apps/web/components/analytics/DeviceBreakdown.tsx +++ /dev/null @@ -1,80 +0,0 @@ -"use client"; - -import React from "react"; -import { Cell, Pie, PieChart, ResponsiveContainer } from "recharts"; - -interface DeviceData { - name: string; - value: number; - color: string; -} - -interface DeviceBreakdownProps { - deviceData: DeviceData[]; -} - -export function DeviceBreakdown({ deviceData }: DeviceBreakdownProps) { - const total = deviceData.reduce((sum, d) => sum + d.value, 0); - - return ( -
-
-

Devices

-

Device breakdown

-

From submissions

-
- - {total === 0 ? ( -
- No device data recorded yet. -
- ) : ( -
-
- - - - {deviceData.map((entry, index) => ( - - ))} - - - -
- {total} - total -
-
- -
- {deviceData.map((dev, idx) => { - const pct = total > 0 ? ((dev.value / total) * 100).toFixed(0) : "0"; - return ( -
-
- - {dev.name} -
- - {dev.value} ({pct}%) - -
- ); - })} -
-
- )} -
- ); -} diff --git a/apps/web/components/analytics/EngagementStats.tsx b/apps/web/components/analytics/EngagementStats.tsx deleted file mode 100644 index 9cef8de..0000000 --- a/apps/web/components/analytics/EngagementStats.tsx +++ /dev/null @@ -1,93 +0,0 @@ -"use client"; - -import React from "react"; -import { Repeat, Timer, TrendingUp, Zap } from "lucide-react"; - -import { SERIES } from "./palette"; - -interface EngagementStatsProps { - avgTimeSpentMs: number | null; - medianResponseTime: number | null; - returningRate: number; - velocityFirst24h: number; -} - -const formatDuration = (ms: number | null) => { - if (ms == null || ms <= 0) return "—"; - const s = Math.round(ms / 1000); - if (s < 60) return `${s}s`; - const m = Math.floor(s / 60); - const rem = s % 60; - return rem === 0 ? `${m}m` : `${m}m ${rem}s`; -}; - -const formatLag = (minutes: number | null) => { - if (minutes == null) return "—"; - if (minutes < 60) return `${minutes}m`; - const h = minutes / 60; - if (h < 24) return `${h.toFixed(1)}h`; - return `${(h / 24).toFixed(1)}d`; -}; - -export function EngagementStats({ - avgTimeSpentMs, - medianResponseTime, - returningRate, - velocityFirst24h, -}: EngagementStatsProps) { - const stats = [ - { - label: "Avg time to complete", - value: formatDuration(avgTimeSpentMs), - hint: "from open to submit", - icon: Timer, - colour: SERIES[0], - }, - { - label: "First 24h", - value: velocityFirst24h.toLocaleString(), - hint: "responses just after publish", - icon: Zap, - colour: SERIES[1], - }, - { - label: "Median lag", - value: formatLag(medianResponseTime), - hint: "publish to typical response", - icon: TrendingUp, - colour: SERIES[2], - }, - { - label: "Returning", - value: `${returningRate}%`, - hint: "repeat respondents (est.)", - icon: Repeat, - colour: SERIES[3], - }, - ]; - - return ( -
- {stats.map((s) => { - const Icon = s.icon; - return ( -
-
-

{s.label}

- -
-

- {s.value} -

-

- {s.hint} -

-
- ); - })} -
- ); -} diff --git a/apps/web/components/analytics/FieldDropoff.tsx b/apps/web/components/analytics/FieldDropoff.tsx deleted file mode 100644 index 7f3a686..0000000 --- a/apps/web/components/analytics/FieldDropoff.tsx +++ /dev/null @@ -1,364 +0,0 @@ -"use client"; - -import React, { useMemo, useState } from "react"; -import { ChevronRight, Search, X } from "lucide-react"; - -import { rateColor, SEMANTIC } from "./palette"; - -interface FieldRate { - fieldId: string; - fieldLabel: string; - rate: number; -} - -interface FormField { - id: string; - label: string; - type: string; -} - -interface SubmissionValue { - formFieldId: string; - value: unknown; -} - -interface Submission { - id: string; - values: SubmissionValue[]; - createdAt: string; -} - -interface FieldDropoffProps { - fieldCompletionRates: FieldRate[]; - fields: FormField[]; - submissions: Submission[]; -} - -const formatValue = (value: unknown): string => { - if (value === null || value === undefined || value === "") return ""; - if (Array.isArray(value)) return value.map((v) => String(v)).join(", "); - if (typeof value === "boolean") return value ? "Yes" : "No"; - if (typeof value === "object") return JSON.stringify(value); - return String(value); -}; - -export function FieldDropoff({ fieldCompletionRates, fields, submissions }: FieldDropoffProps) { - const [openFieldId, setOpenFieldId] = useState(null); - - const answersByField = useMemo(() => { - const map = new Map(); - for (const sub of submissions) { - for (const v of sub.values ?? []) { - const text = formatValue(v.value); - if (!text) continue; - const list = map.get(v.formFieldId) ?? []; - list.push({ submissionId: sub.id, createdAt: sub.createdAt, text }); - map.set(v.formFieldId, list); - } - } - return map; - }, [submissions]); - - const rows = useMemo(() => { - const byId = new Map(fieldCompletionRates.map((r) => [r.fieldId, r])); - const ordered: (FieldRate & { type?: string })[] = []; - for (const f of fields) { - const r = byId.get(f.id); - if (r) { - ordered.push({ ...r, type: f.type }); - byId.delete(f.id); - } - } - for (const leftover of byId.values()) ordered.push(leftover); - return ordered; - }, [fieldCompletionRates, fields]); - - const openField = rows.find((r) => r.fieldId === openFieldId) ?? null; - const openAnswers = openFieldId ? (answersByField.get(openFieldId) ?? []) : []; - - if (rows.length === 0) { - return ( -
-

No fields

-

- Add fields to this form to see per-question completion. -

-
- ); - } - - return ( - <> -
-
-
-

Drop-off by field

-

- Share of people who started the form and answered each field. Select one to read its - answers. -

-
- {rows.length} fields -
- -
    - {rows.map((row, i) => { - const answered = answersByField.get(row.fieldId)?.length ?? 0; - const colour = rateColor(row.rate); - return ( -
  • - -
  • - ); - })} -
-
- - {openField && ( - setOpenFieldId(null)} - /> - )} - - ); -} - -/* ─── answers dialog ─────────────────────────────────────────────────── */ - -function FieldAnswersDialog({ - label, - rate, - answers, - onClose, -}: { - label: string; - rate: number; - answers: { submissionId: string; createdAt: string; text: string }[]; - onClose: () => void; -}) { - const [query, setQuery] = useState(""); - - React.useEffect(() => { - const onKey = (e: KeyboardEvent) => { - if (e.key === "Escape") onClose(); - }; - document.addEventListener("keydown", onKey); - return () => document.removeEventListener("keydown", onKey); - }, [onClose]); - - const filtered = useMemo(() => { - const q = query.trim().toLowerCase(); - if (!q) return answers; - return answers.filter((a) => a.text.toLowerCase().includes(q)); - }, [answers, query]); - - const tally = useMemo(() => { - const counts = new Map(); - for (const a of answers) counts.set(a.text, (counts.get(a.text) ?? 0) + 1); - const list = [...counts.entries()].sort((a, b) => b[1] - a[1]); - return list.length > 0 && list.length < answers.length ? list.slice(0, 6) : []; - }, [answers]); - - return ( -
-
e.stopPropagation()} - > -
- {/* header */} -
-
-

Field responses

-

- {label} -

-

- {rate}% completion ·{" "} - {answers.length}{" "} - {answers.length === 1 ? "answer" : "answers"} -

-
- -
- - {/* most common answers */} - {tally.length > 0 && ( -
-

Most common

-
- {tally.map(([text, count]) => { - const pct = answers.length > 0 ? (count / answers.length) * 100 : 0; - return ( -
- - {text} - - - - - - {count} - -
- ); - })} -
-
- )} - - {/* search */} -
-
- - - setQuery(e.target.value)} - placeholder="Search answers..." - className="cf-dark-input h-10 pr-3 pl-10 text-[13px]" - /> -
-
- - {/* answer list */} -
- {filtered.length === 0 ? ( -

- {answers.length === 0 - ? "Nobody has answered this field yet." - : "No answers match that search."} -

- ) : ( -
    - {filtered.map((a, i) => ( -
  • -

    - {a.text} -

    -

    - {new Date(a.createdAt).toLocaleString()} -

    -
  • - ))} -
- )} -
- -
- - Showing {filtered.length} of {answers.length} - - -
-
-
-
- ); -} diff --git a/apps/web/components/analytics/MetricsGrid.tsx b/apps/web/components/analytics/MetricsGrid.tsx deleted file mode 100644 index 684caee..0000000 --- a/apps/web/components/analytics/MetricsGrid.tsx +++ /dev/null @@ -1,44 +0,0 @@ -"use client"; - -import React from "react"; - -interface MetricsGridProps { - totalResponses: number; - avgPerDay: number; -} - -export function MetricsGrid({ totalResponses, avgPerDay }: MetricsGridProps) { - const stats = [ - { - title: "Total responses", - val: totalResponses.toLocaleString(), - sub: `${avgPerDay.toFixed(1)} per day on average`, - }, - { - title: "Avg / day", - val: avgPerDay.toFixed(1), - sub: "Across the last 30 days", - }, - ]; - - return ( -
- {stats.map((stat) => ( -
-

- {stat.val} -

-

- {stat.title} -

-

- {stat.sub} -

-
- ))} -
- ); -} diff --git a/apps/web/components/analytics/PeriodComparison.tsx b/apps/web/components/analytics/PeriodComparison.tsx deleted file mode 100644 index 187bc1c..0000000 --- a/apps/web/components/analytics/PeriodComparison.tsx +++ /dev/null @@ -1,65 +0,0 @@ -"use client"; - -import React from "react"; - -import { SERIES } from "./palette"; - -interface PeriodComparisonProps { - trend30d: number; - trend60d: number; - trend90d: number; -} - -export function PeriodComparison({ trend30d, trend60d, trend90d }: PeriodComparisonProps) { - const buckets = [ - { label: "Last 30d", value: trend30d, colour: SERIES[0] }, - { label: "31–60d", value: Math.max(trend60d - trend30d, 0), colour: SERIES[1] }, - { label: "61–90d", value: Math.max(trend90d - trend60d, 0), colour: SERIES[3] }, - ]; - - const max = Math.max(...buckets.map((b) => b.value), 1); - const previous = buckets[1]!.value; - const delta = previous > 0 ? ((trend30d - previous) / previous) * 100 : null; - - return ( -
-
-
-

Momentum

-

Last 90 days

-
- {delta !== null && ( - = 0 ? SERIES[1] : SERIES[5] }} - > - {delta >= 0 ? "▲" : "▼"} {Math.abs(delta).toFixed(0)}% vs prior 30d - - )} -
- - {/* Column chart, drawn with divs — three bars do not justify pulling - recharts into this panel. */} -
- {buckets.map((b) => ( -
- {b.value.toLocaleString()} -
- - {b.label} - -
- ))} -
-
- ); -} diff --git a/apps/web/components/analytics/QuestionDistribution.tsx b/apps/web/components/analytics/QuestionDistribution.tsx deleted file mode 100644 index 1d68bbc..0000000 --- a/apps/web/components/analytics/QuestionDistribution.tsx +++ /dev/null @@ -1,351 +0,0 @@ -"use client"; - -import React, { useState } from "react"; -import { ChevronDown, Star } from "lucide-react"; - -import { SEMANTIC, seriesColor } from "./palette"; - -interface OptionCount { - value: string; - count: number; - percent: number; -} -interface ToggleCounts { - yes: number; - no: number; -} -interface RatingDistItem { - rating: number; - count: number; - percent: number; -} - -interface QuestionItem { - fieldId: string; - fieldLabel: string; - fieldType: string; - totalAnswered: number; - optionCounts?: OptionCount[]; - averageRating?: number; - ratingDistribution?: RatingDistItem[]; - toggleCounts?: ToggleCounts; - textSamples?: string[]; -} - -interface QuestionDistributionProps { - questionDistribution: QuestionItem[]; -} - -const CHOICE_TYPES = ["SELECT", "RADIO", "CHECKBOX"]; -const TEXT_TYPES = ["TEXT", "TEXTAREA", "EMAIL", "NUMBER", "PHONE", "URL", "DATE", "TIME"]; - -function FieldTypePill({ type }: { type: string }) { - return ( - - {type.toLowerCase()} - - ); -} - -/** Choice bar. Each option gets its own hue so the split is readable at a glance. */ -function ChoiceBar({ option, index }: { option: OptionCount; index: number }) { - const colour = seriesColor(index); - return ( -
-
- {option.value} - - {option.count} ({option.percent}%) - -
-
-
-
-
- ); -} - -function RatingStars({ avg }: { avg: number }) { - const full = Math.floor(avg); - const frac = avg - full; - return ( -
-
- {Array.from({ length: 5 }).map((_, i) => { - const filled = i < full ? 1 : i === full && frac > 0 ? frac : 0; - return ( -
- -
- -
-
- ); - })} -
- {avg.toFixed(1)} - avg -
- ); -} - -function RatingDistBar({ dist }: { dist: RatingDistItem[] }) { - return ( -
- {dist.map((d) => ( -
- - {d.rating} - - -
-
-
- - {d.count} - -
- ))} -
- ); -} - -function ToggleBar({ counts }: { counts: ToggleCounts }) { - const total = counts.yes + counts.no; - const yesPct = total > 0 ? Math.round((counts.yes / total) * 100) : 0; - const noPct = 100 - yesPct; - return ( -
-
-
-
-
-
- {[ - { label: "Yes", n: counts.yes, pct: yesPct, colour: SEMANTIC.good }, - { label: "No", n: counts.no, pct: noPct, colour: SEMANTIC.bad }, - ].map((r) => ( -
- - {r.label} - - {r.n} ({r.pct}%) - -
- ))} -
-
- ); -} - -function TextSamples({ samples, totalAnswered }: { samples: string[]; totalAnswered: number }) { - const shown = samples.slice(0, 3); - const remaining = totalAnswered - shown.length; - return ( -
- {shown.map((s, i) => ( -
- {s.length > 80 ? s.slice(0, 80) + "…" : s} -
- ))} - {remaining > 0 && ( -

- +{remaining} more -

- )} -
- ); -} - -function collapsedSummary(q: QuestionItem): string | null { - if (q.totalAnswered === 0) return "No answers yet"; - if (CHOICE_TYPES.includes(q.fieldType) && q.optionCounts?.length) { - const top = q.optionCounts[0]!; - return `Top: ${top.value} (${top.percent}%)`; - } - if (q.fieldType === "RATING" && q.averageRating !== undefined) { - return `Average ${q.averageRating.toFixed(1)} of 5`; - } - if (q.fieldType === "TOGGLE" && q.toggleCounts) { - const total = q.toggleCounts.yes + q.toggleCounts.no; - const pct = total > 0 ? Math.round((q.toggleCounts.yes / total) * 100) : 0; - return `${pct}% answered yes`; - } - if (TEXT_TYPES.includes(q.fieldType)) return "Free text answers"; - return null; -} - -function QuestionBody({ q }: { q: QuestionItem }) { - const isChoice = CHOICE_TYPES.includes(q.fieldType); - const isText = TEXT_TYPES.includes(q.fieldType); - const isRating = q.fieldType === "RATING"; - const isToggle = q.fieldType === "TOGGLE"; - - if (q.totalAnswered === 0) { - return ( -

- 0 responses for this field -

- ); - } - - return ( - <> - {isChoice && q.optionCounts && ( -
- {q.optionCounts.map((opt, i) => ( - - ))} -
- )} - - {isRating && ( -
- {q.averageRating !== undefined && } - {q.ratingDistribution && } -
- )} - - {isToggle && q.toggleCounts && } - - {isText && ( -
-

Recent answers

- {q.textSamples && q.textSamples.length > 0 ? ( - - ) : ( -

- {q.totalAnswered} answer{q.totalAnswered !== 1 ? "s" : ""} recorded -

- )} -
- )} - - {!isChoice && !isRating && !isToggle && !isText && ( -

- {q.totalAnswered} answer{q.totalAnswered !== 1 ? "s" : ""} recorded -

- )} - - ); -} - -export function QuestionDistribution({ questionDistribution }: QuestionDistributionProps) { - const [openId, setOpenId] = useState(null); - - return ( -
-
-
-

Questions

-

Question breakdown

-

- Select a question to see how it was answered. -

-
- {questionDistribution.length} fields -
- - {questionDistribution.length === 0 ? ( -

- No fields found for this form. -

- ) : ( -
    - {questionDistribution.map((q, i) => { - const isOpen = openId === q.fieldId; - const summary = collapsedSummary(q); - return ( -
  • - - - {isOpen && ( -
    - -
    - )} -
  • - ); - })} -
- )} -
- ); -} diff --git a/apps/web/components/analytics/ResponseTimeline.tsx b/apps/web/components/analytics/ResponseTimeline.tsx deleted file mode 100644 index e9bd8ba..0000000 --- a/apps/web/components/analytics/ResponseTimeline.tsx +++ /dev/null @@ -1,96 +0,0 @@ -"use client"; - -import React from "react"; -import { - Area, - AreaChart, - ResponsiveContainer, - Tooltip as ChartTooltip, - XAxis, - YAxis, -} from "recharts"; - -import { CHROME, SEMANTIC } from "./palette"; - -interface TrendPoint { - date: string; - count: number; -} - -interface ResponseTimelineProps { - totalResponses: number; - trends: TrendPoint[]; -} - -export function ResponseTimeline({ totalResponses, trends }: ResponseTimelineProps) { - const chartData = trends.map((t) => ({ name: t.date, Responses: t.count })); - - return ( -
-
-
-

Timeline

-

Response timeline

-

Last 30 days

-
-
- - Responses -
-
- - {totalResponses === 0 ? ( -
- No responses recorded. -
- ) : ( -
- - - - - - - - - - - - - - -
- )} -
- ); -} diff --git a/apps/web/components/analytics/StatsRow.tsx b/apps/web/components/analytics/StatsRow.tsx deleted file mode 100644 index 010a617..0000000 --- a/apps/web/components/analytics/StatsRow.tsx +++ /dev/null @@ -1,64 +0,0 @@ -"use client"; - -import React from "react"; -import { BarChart2, Calendar, RefreshCw, Zap } from "lucide-react"; - -interface StatsRowProps { - peakDay: string | null; - avgPerWeek: number; - velocityFirst24h?: number; - returningRate?: number; -} - -interface ChipDef { - label: string; - value: string; - icon: React.ElementType; -} - -export function StatsRow({ peakDay, avgPerWeek, velocityFirst24h, returningRate }: StatsRowProps) { - const chips: ChipDef[] = [ - { label: "Peak day", value: peakDay ?? "—", icon: Calendar }, - { label: "Avg / week", value: avgPerWeek.toFixed(1), icon: BarChart2 }, - ...(velocityFirst24h !== undefined - ? [ - { - label: "Launch velocity", - value: velocityFirst24h.toLocaleString(), - icon: Zap, - }, - ] - : []), - ...(returningRate !== undefined - ? [ - { - label: "Returning", - value: returningRate > 0 ? returningRate.toFixed(1) + "%" : "—", - icon: RefreshCw, - }, - ] - : []), - ]; - - return ( -
-
- {chips.map((chip, idx) => { - const Icon = chip.icon; - return ( -
- - {chip.label} - - {chip.value} - -
- ); - })} -
-
- ); -} diff --git a/apps/web/components/analytics/SubmissionDetailModal.tsx b/apps/web/components/analytics/SubmissionDetailModal.tsx deleted file mode 100644 index 0add771..0000000 --- a/apps/web/components/analytics/SubmissionDetailModal.tsx +++ /dev/null @@ -1,174 +0,0 @@ -"use client"; - -import React from "react"; -import { Download, X } from "lucide-react"; - -import { ModalOverlay } from "~/components/ui/ModalOverlay"; -import { downloadUrlFor, formatBytes } from "~/lib/upload"; - -interface SubmissionValue { - formFieldId: string; - value: any; -} - -interface Submission { - id: string; - formId: string; - values: SubmissionValue[]; - createdAt: string; -} - -interface FormField { - id: string; - label: string; - type: string; -} - -interface SubmissionDetailModalProps { - submission: Submission; - form: { fields: FormField[] }; - getRespondentDetails: (sub: Submission) => { name: string; email: string }; - onClose: () => void; -} - -export function SubmissionDetailModal({ - submission, - form, - getRespondentDetails, - onClose, -}: SubmissionDetailModalProps) { - const respondent = getRespondentDetails(submission); - - return ( - -
-
- - -

Submission

-

- {respondent.name} -

-

- {new Date(submission.createdAt).toLocaleString()} -

-
- -
- {form.fields.map((field) => { - const answer = submission.values.find((v) => v.formFieldId === field.id); - const hasValue = - answer?.value !== undefined && answer?.value !== null && answer?.value !== ""; - - if (field.type === "FILE_UPLOAD" && hasValue) { - const filesRaw = Array.isArray(answer?.value) ? answer.value : [answer?.value]; - const files = filesRaw.filter(Boolean) as Array<{ - uploadId?: string; - name?: string; - url?: string | null; - status?: string; - sizeBytes?: number; - }>; - - return ( -
-

{field.label}

-
- {files.length === 0 ? ( - No answer provided - ) : ( - files.map((file, i) => ( -
- {file.url ? ( - <> - - {file.name || "Attachment"} - - - - - - - ) : ( - - {file.name || "Attachment"} - - )} - - {typeof file.sizeBytes === "number" && ( - - {formatBytes(file.sizeBytes)} - - )} - - {!file.url && ( - - {file.status === "failed" ? "failed" : "still processing"} - - )} -
- )) - )} -
-
- ); - } - - let displayVal = "No answer provided"; - if (hasValue) { - if (Array.isArray(answer.value)) { - displayVal = answer.value.join(", "); - } else if (typeof answer.value === "boolean") { - displayVal = answer.value ? "Yes" : "No"; - } else { - displayVal = String(answer.value); - } - } - - return ( -
-

{field.label}

-
- {displayVal} -
-
- ); - })} -
- -
- -
-
-
- ); -} diff --git a/apps/web/components/analytics/SubmissionsTable.tsx b/apps/web/components/analytics/SubmissionsTable.tsx deleted file mode 100644 index f2f70f9..0000000 --- a/apps/web/components/analytics/SubmissionsTable.tsx +++ /dev/null @@ -1,244 +0,0 @@ -"use client"; - -import React, { useMemo, useRef } from "react"; -import dynamic from "next/dynamic"; -import { Eye, Search } from "lucide-react"; -import { useVirtualizer } from "@tanstack/react-virtual"; - -interface SubmissionValue { - formFieldId: string; - value: any; -} - -interface Submission { - id: string; - formId: string; - values: SubmissionValue[]; - createdAt: string; -} - -interface FormField { - id: string; - label: string; - type: string; -} - -interface SubmissionsTableProps { - filteredSubmissions: Submission[]; - searchQuery: string; - setSearchQuery: (q: string) => void; - getRespondentDetails: (sub: Submission) => { name: string; email: string }; - setViewingSubmission: (sub: Submission | null) => void; - viewingSubmission: Submission | null; - form: { fields: FormField[] } | null | undefined; - hasNextPage?: boolean; - isFetchingNextPage?: boolean; - fetchNextPage?: () => void; -} - -const SubmissionDetailModal = dynamic( - () => import("./SubmissionDetailModal").then((m) => m.SubmissionDetailModal), - { ssr: false }, -); - -const ROW_HEIGHT = 64; -const GRID_COLS = - "grid grid-cols-[minmax(0,1fr)_110px_160px_56px] sm:grid-cols-[minmax(0,1fr)_120px_180px_56px] gap-3 items-center"; - -export function SubmissionsTable({ - filteredSubmissions, - searchQuery, - setSearchQuery, - getRespondentDetails, - setViewingSubmission, - viewingSubmission, - form, - hasNextPage, - isFetchingNextPage, - fetchNextPage, -}: SubmissionsTableProps) { - const scrollRef = useRef(null); - - const rowVirtualizer = useVirtualizer({ - count: filteredSubmissions.length, - getScrollElement: () => scrollRef.current, - estimateSize: () => ROW_HEIGHT, - overscan: 6, - }); - - const virtualItems = rowVirtualizer.getVirtualItems(); - const totalSize = rowVirtualizer.getTotalSize(); - - React.useEffect(() => { - const last = virtualItems[virtualItems.length - 1]; - if (!last) return; - if (hasNextPage && !isFetchingNextPage && last.index >= filteredSubmissions.length - 3) { - fetchNextPage?.(); - } - }, [virtualItems, hasNextPage, isFetchingNextPage, filteredSubmissions.length, fetchNextPage]); - - const scrollHeight = useMemo(() => { - if (filteredSubmissions.length === 0) return 0; - return Math.min(filteredSubmissions.length * ROW_HEIGHT, 640); - }, [filteredSubmissions.length]); - - return ( - <> -
-
-
-

Latest

-

Responses

- {filteredSubmissions.length > 0 && ( -

- {filteredSubmissions.length} {filteredSubmissions.length === 1 ? "row" : "rows"} · - virtualised - {hasNextPage ? " · more available" : ""} -

- )} -
-
- setSearchQuery(e.target.value)} - className="w-full bg-(--cf-cream) rounded-md ring-1 ring-(--cf-line) focus:ring-2 focus:ring-(--cf-orange) focus:outline-none pl-9 pr-3 h-9.5 text-[13px] text-(--cf-ink) placeholder:text-(--cf-ink-soft)/55 transition-shadow" - /> - -
-
- -
- {filteredSubmissions.length === 0 ? ( -
- No submissions found. -
- ) : ( -
-
-
-
- Respondent -
-
- Status -
-
- Date -
-
- Action -
-
-
-
- {virtualItems.map((vi) => { - const sub = filteredSubmissions[vi.index]!; - const details = getRespondentDetails(sub); - const initials = details.name.substring(0, 2).toUpperCase(); - - return ( -
- {/* Respondent */} -
-
- {initials || "?"} -
-
-
- {details.name} -
-
- {details.email} -
-
-
- - {/* Status */} - - - Completed - - - {/* Date */} -
- {new Date(sub.createdAt).toLocaleString()} -
- - {/* Action */} -
- -
-
- ); - })} -
-
- - {(hasNextPage || isFetchingNextPage) && ( -
- {isFetchingNextPage ? ( - - - Loading older submissions... - - ) : ( - - )} -
- )} -
-
- )} -
-
- - {viewingSubmission && form && ( - setViewingSubmission(null)} - /> - )} - - ); -} diff --git a/apps/web/components/analytics/TrafficSources.tsx b/apps/web/components/analytics/TrafficSources.tsx deleted file mode 100644 index 9858697..0000000 --- a/apps/web/components/analytics/TrafficSources.tsx +++ /dev/null @@ -1,83 +0,0 @@ -"use client"; - -import React from "react"; -import { Globe } from "lucide-react"; - -import { seriesColor } from "./palette"; - -interface TrafficSourcesProps { - topReferrers: Array<{ referrer: string; count: number }>; -} - -export function TrafficSources({ topReferrers }: TrafficSourcesProps) { - const max = Math.max(...topReferrers.map((r) => r.count), 1); - const total = topReferrers.reduce((sum, r) => sum + r.count, 0); - - return ( -
-
-
-
- -

Referrers

-
-

Where responses came from

-
- {total > 0 && ( -
-

- {total.toLocaleString()} -

-

attributed

-
- )} -
- - {topReferrers.length === 0 ? ( -

- No referrer data yet. Attribution is recorded when someone opens the form from a link. -

- ) : ( -
- {topReferrers.map((r, i) => { - const barPct = (r.count / max) * 100; - const sharePct = total > 0 ? (r.count / total) * 100 : 0; - return ( -
- - {String(i + 1).padStart(2, "0")} - - -
-
- - {r.referrer} - - - {r.count.toLocaleString()} - - {sharePct.toFixed(0)}% - - -
-
-
-
-
-
- ); - })} -
- )} -
- ); -} diff --git a/apps/web/components/analytics/palette.ts b/apps/web/components/analytics/palette.ts deleted file mode 100644 index 331aeb5..0000000 --- a/apps/web/components/analytics/palette.ts +++ /dev/null @@ -1,37 +0,0 @@ -export const SERIES = [ - "#2d5cf6", // accent blue — first series, matches the primary action - "#3aa793", // teal - "#e3b23c", // yellow - "#6c5ce7", // purple - "#e0834a", // orange - "#dd6459", // red - "#a79ae4", // lavender -] as const; - -export const seriesColor = (i: number) => SERIES[i % SERIES.length] as string; - -export const SEMANTIC = { - good: "#3aa793", - warn: "#e3b23c", - bad: "#dd6459", - accent: "#2d5cf6", -} as const; - -export const rateColor = (rate: number) => { - if (rate >= 75) return SEMANTIC.good; - if (rate >= 40) return SEMANTIC.warn; - return SEMANTIC.bad; -}; - -export const CHROME = { - axis: "#5b6070", - grid: "rgba(26,29,41,0.10)", - ink: "#1a1d29", - surface: "#e8e8e8", -} as const; - -export const DEVICE_COLORS = { - Desktop: "#2d5cf6", - Mobile: "#3aa793", - Tablet: "#e3b23c", -} as const; diff --git a/apps/web/components/builder/BuilderHeader.tsx b/apps/web/components/builder/BuilderHeader.tsx index 6e4997a..192befb 100644 --- a/apps/web/components/builder/BuilderHeader.tsx +++ b/apps/web/components/builder/BuilderHeader.tsx @@ -21,16 +21,17 @@ export type BuilderView = "canvas" | "outline"; interface BuilderHeaderProps { form: - | { - title: string; - description?: string | null; - isPublished: boolean; - ownerEmail?: string | null; - role?: "owner" | "editor" | "viewer"; - permissions?: any; - } - | null - | undefined; + | { + title: string; + description?: string | null; + isPublished: boolean; + ownerEmail?: string | null; + role?: "owner" | "editor" | "viewer"; + permissions?: any; + submissionsCount?: number | null; + } + | null + | undefined; formId: string; isDirty: boolean; isSaving: boolean; @@ -70,7 +71,6 @@ export function BuilderHeader({ onViewChange, }: BuilderHeaderProps) { const isPublished = form?.isPublished ?? false; - const isOwner = form?.role === "owner"; const [menuOpen, setMenuOpen] = useState(false); const menuRef = useRef(null); const canDelete = form?.permissions?.settings?.canDelete ?? form?.role === "owner"; @@ -126,16 +126,14 @@ export function BuilderHeader({ {/* status pill — always visible */} {isPublished ? "Live" : "Draft"} @@ -150,58 +148,56 @@ export function BuilderHeader({ {/* right: actions */}
-
- {( - [ - { id: "canvas", label: "Canvas", Icon: LayoutGrid }, - { id: "outline", label: "Outline", Icon: ListOrdered }, - ] as const - ).map(({ id, label, Icon }) => { - const active = view === id; - return ( - - ); - })} -
+
+ {( + [ + { id: "canvas", label: "Canvas", Icon: LayoutGrid }, + { id: "outline", label: "Outline", Icon: ListOrdered }, + ] as const + ).map(({ id, label, Icon }) => { + const active = view === id; + return ( + + ); + })} +
-
+
- {/* Save — always visible, icon-only on phone */} - + {/* Save — always visible, icon-only on phone */} + Share - {isOwner && ( - - )} + {canDelete && ( <> @@ -286,18 +280,6 @@ export function BuilderHeader({ Share form - {isOwner && ( - - )} {canDelete && ( <>
@@ -317,7 +299,7 @@ export function BuilderHeader({ )}
- {/* Publish — always visible, always primary */} + {/* Publish — only on questions tab */} + +
+
+ +
+ {/* ── Main Title Area ── */} +
+

+ {submissions.length} {submissions.length === 1 ? "Response" : "Responses"} + . +

+
+ + {/* ── Google Forms Sub-navigation Tabs ── */} +
+
+ + + +
+ + +
+ + {/* ── 1. SUMMARY VIEW ── */} + {subTab === "summary" && ( +
+ {/* Top 3 Metric Cards */} +
+
+ + {submissions.length} + +

Total responses

+
+
+ + {summaryMetrics.completionRate} + +

Completion rate

+
+
+ + {summaryMetrics.avgTime} + +

Avg. time

+
+
+ + {/* Questions breakdown */} + {submissions.length === 0 ? ( +
+ +

No submissions yet

+

Waiting for responses

+
+ ) : ( +
+ {questionSummaries.map(({ field, answers, chartData, isChoice, ratingAvg }) => ( +
+
+
+

+ {field.label || "Untitled Question"} +

+

+ {answers.length} {answers.length === 1 ? "response" : "responses"} +

+
+ + {field.type} + +
+ + {answers.length === 0 ? ( +

No answers submitted yet for this field.

+ ) : isChoice ? ( +
+ {field.type === "RATING" && ( +
+ Average Rating: + + {ratingAvg.toFixed(2)} / 5.00 + +
+ )} + + {["RADIO", "SELECT", "TOGGLE"].includes(field.type) ? ( +
+
+ + + + {chartData.map((_, index) => ( + + ))} + + `${val} response(s)`} /> + + +
+
+ {chartData.map((d, index) => { + const pct = answers.length > 0 ? ((d.count / answers.length) * 100).toFixed(1) : 0; + return ( +
+ + + {d.name} + + {d.count} ({pct}%) +
+ ); + })} +
+
+ ) : ( +
+ + + + + `${val} response(s)`} /> + + {chartData.map((_, index) => ( + + ))} + + + +
+ )} +
+ ) : ( +
+ {answers.map((ans, aIdx) => ( +
+

+ {field.type === "FILE_UPLOAD" ? ( + + ) : ( + String(ans.value) + )} +

+
+ {ans.respondent} + {new Date(ans.submittedAt).toLocaleDateString()} +
+
+ ))} +
+ )} +
+ ))} +
+ )} +
+ )} + + {/* ── 2. QUESTION VIEW ── */} + {subTab === "question" && ( +
+ {fields.length === 0 ? ( +

No fields in this form.

+ ) : ( + <> + {/* Selector */} +
+
+ +
+
+ + +
+
+ + {/* List of answers */} + {selectedQuestionSummary && ( +
+
+

+ {selectedQuestionSummary.field.label || "Untitled Question"} +

+

+ {selectedQuestionSummary.answers.length} {selectedQuestionSummary.answers.length === 1 ? "response" : "responses"} +

+
+ +
+ {selectedQuestionSummary.answers.length === 0 ? ( +

No answers submitted for this question.

+ ) : ( + selectedQuestionSummary.answers.map((ans, idx) => ( +
+

+ {selectedQuestionSummary.field.type === "FILE_UPLOAD" ? ( + + ) : Array.isArray(ans.value) ? ( + ans.value.join(", ") + ) : ( + String(ans.value) + )} +

+
+ + + {ans.respondent} + + + + {new Date(ans.submittedAt).toLocaleString()} + +
+
+ )) + )} +
+
+ )} + + )} +
+ )} + + {/* ── 3. INDIVIDUAL VIEW ── */} + {subTab === "responses" && ( +
+ {submissions.length === 0 ? ( +
+ +

No submissions yet

+

Waiting for responses

+
+ ) : ( +
+ {submissions.map((sub) => ( +
+
+

+ {sub.respondentEmail || "Anonymous"} +

+

+ {new Date(sub.createdAt).toLocaleString()} + · + {sub.deviceType || "Desktop"} +

+
+ +
+ ))} +
+ )} +
+ )} +
+ + {/* ── Details Popup Modal ── */} + {viewingSub && ( +
+
+
+
+

+ Response Details + . +

+

+ {viewingSub.respondentEmail || "Anonymous"} · {new Date(viewingSub.createdAt).toLocaleString()} +

+
+ +
+ +
+ {fields.map((field) => { + const valObj = viewingSub.values?.find((v: any) => v.formFieldId === field.id); + const val = valObj?.value; + let display: React.ReactNode = No answer submitted; + + if (val !== undefined && val !== null && val !== "") { + if (field.type === "FILE_UPLOAD") { + display = ; + } else if (Array.isArray(val)) { + display = val.join(", "); + } else { + display = String(val); + } + } + + return ( +
+

+ {field.label || "Untitled Question"} +

+

{display}

+
+ ); + })} +
+
+
+ )} +
+ ); +} diff --git a/apps/web/hooks/api/analytics/index.ts b/apps/web/hooks/api/analytics/index.ts index 0438fed..e5aecd7 100644 --- a/apps/web/hooks/api/analytics/index.ts +++ b/apps/web/hooks/api/analytics/index.ts @@ -1,30 +1,10 @@ import { trpc } from "~/trpc/client"; -export const useGetFormAnalytics = (formId: string) => { - const { - data: analytics, - error, - isLoading, - isError, - isSuccess, - refetch, - } = trpc.analytics.getFormAnalytics.useQuery( - { formId }, - { - enabled: !!formId && formId.length === 36, - staleTime: 30_000, - refetchOnWindowFocus: false, - }, - ); - - return { analytics, error, isLoading, isError, isSuccess, refetch }; -}; - export const useGetSubmissions = (formId: string) => { - const PAGE_SIZE = 100; + const PAGE_SIZE = 200; const enabled = !!formId && formId.length === 36; - const result = trpc.analytics.getSubmissions.useInfiniteQuery( + const result = trpc.form.getSubmissions.useInfiniteQuery( { formId, limit: PAGE_SIZE }, { enabled, @@ -53,28 +33,3 @@ export const useGetSubmissions = (formId: string) => { isFetchingNextPage: result.isFetchingNextPage, }; }; - -export const useRecordFieldAnswer = () => { - const { - mutate: recordFieldAnswer, - mutateAsync: recordFieldAnswerAsync, - isPending, - } = trpc.analytics.recordFieldAnswer.useMutation(); - - return { recordFieldAnswer, recordFieldAnswerAsync, isPending }; -}; - -export const useGetDetailedAnalytics = (formId: string) => { - const { - data: detailedAnalytics, - error, - isLoading, - isError, - refetch, - } = trpc.analytics.getDetailedAnalytics.useQuery( - { formId }, - { enabled: !!formId && formId.length === 36, retry: false }, - ); - - return { detailedAnalytics, error, isLoading, isError, refetch }; -}; diff --git a/apps/worker/src/index.ts b/apps/worker/src/index.ts index a9a4803..66ffd94 100644 --- a/apps/worker/src/index.ts +++ b/apps/worker/src/index.ts @@ -12,12 +12,11 @@ import https from "node:https"; import { logger } from "@repo/logger"; import { closeRedis, isRedisConfigured } from "@repo/redis"; -import { createAnalyticsWorker, createUploadWorker, queueEnv } from "@repo/queue"; +import { createUploadWorker, queueEnv } from "@repo/queue"; import { env, isCloudinaryConfigured } from "./env"; import { cloudinaryStorage } from "./storage"; import { createUploadProcessor } from "./processors/upload"; -import { processFieldAnswers } from "./processors/analytics"; const agentOptions = { keepAlive: true, maxSockets: 256, maxFreeSockets: 32 }; http.globalAgent = new http.Agent(agentOptions); @@ -32,13 +31,13 @@ async function main() { if (!isCloudinaryConfigured()) { logger.warn( "[worker] Cloudinary is not configured. Upload jobs will be failed with a clear " + - "message; analytics jobs are unaffected. Set CLOUDINARY_CLOUD_NAME, " + - "CLOUDINARY_API_KEY and CLOUDINARY_API_SECRET to enable file storage.", + "message. Set CLOUDINARY_CLOUD_NAME, " + + "CLOUDINARY_API_KEY and CLOUDINARY_API_SECRET to enable file storage." + + " If this is development, you can proceed without it." ); } const uploadWorker = createUploadWorker(createUploadProcessor(cloudinaryStorage)); - const analyticsWorker = createAnalyticsWorker(processFieldAnswers); uploadWorker.on("failed", (job, err) => { logger.error(`[worker:upload] job ${job?.id ?? "unknown"} failed: ${err.message}`); @@ -47,16 +46,8 @@ async function main() { logger.error(`[worker:upload] worker error: ${err.message}`); }); - analyticsWorker.on("failed", (job, err) => { - logger.error(`[worker:analytics] job ${job?.id ?? "unknown"} failed: ${err.message}`); - }); - analyticsWorker.on("error", (err) => { - logger.error(`[worker:analytics] worker error: ${err.message}`); - }); - logger.info( - `[worker] listening — uploads x${queueEnv.UPLOAD_WORKER_CONCURRENCY}, ` + - `analytics x${queueEnv.ANALYTICS_WORKER_CONCURRENCY} (${env.NODE_ENV})`, + `[worker] listening — uploads x${queueEnv.UPLOAD_WORKER_CONCURRENCY} (${env.NODE_ENV})` ); let shuttingDown = false; @@ -74,7 +65,7 @@ async function main() { forceExit.unref(); try { - await Promise.allSettled([uploadWorker.close(), analyticsWorker.close()]); + await Promise.allSettled([uploadWorker.close()]); await closeRedis(); logger.info("[worker] stopped cleanly"); process.exit(0); diff --git a/apps/worker/src/processors/analytics.ts b/apps/worker/src/processors/analytics.ts deleted file mode 100644 index 9f05234..0000000 --- a/apps/worker/src/processors/analytics.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { Job } from "bullmq"; -import { logger } from "@repo/logger"; -import { queueEnv, type RecordFieldAnswersJob } from "@repo/queue"; - -import { analyticsService } from "../services"; - -export async function processFieldAnswers(job: Job): Promise { - const answers = job.data.answers ?? []; - if (answers.length === 0) return; - - const written = await analyticsService.recordFieldAnswersBatch( - answers, - queueEnv.ANALYTICS_INSERT_CHUNK, - ); - - if (written < answers.length) { - logger.warn( - `[worker:analytics] wrote ${written}/${answers.length} field answers — ` + - `the rest referenced forms or fields that no longer exist`, - ); - return; - } - - logger.debug(`[worker:analytics] wrote ${written} field answers`); -} diff --git a/apps/worker/src/services.ts b/apps/worker/src/services.ts index 7bb57df..37fecf3 100644 --- a/apps/worker/src/services.ts +++ b/apps/worker/src/services.ts @@ -1,4 +1,2 @@ -import AnalyticsService from "@repo/services/analytics"; import FormUploadService from "@repo/services/form-upload"; -export const analyticsService = new AnalyticsService(); export const formUploadService = new FormUploadService(); diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..4f7ca51 --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,96 @@ +services: + postgres: + image: postgres:15 + container_name: canvasflow-postgres-prod + restart: unless-stopped + environment: + POSTGRES_USER: ${POSTGRES_USER:-postgres} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres} + POSTGRES_DB: ${POSTGRES_DB:-dev} + command: + - postgres + - -c + - max_connections=300 + ports: + - "5432:5432" + volumes: + - pg_data_prod:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready --host=127.0.0.1 -p 5432 -U $${POSTGRES_USER:-postgres} -d $${POSTGRES_DB:-dev}"] + interval: 3s + timeout: 5s + retries: 15 + start_period: 10s + + redis: + image: redis:7-alpine + container_name: canvasflow-redis-prod + restart: unless-stopped + command: + - redis-server + - --maxmemory + - 512mb + - --maxmemory-policy + - noeviction + - --appendonly + - "no" + ports: + - "6379:6379" + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 3s + timeout: 5s + retries: 15 + start_period: 3s + + api: + build: + context: . + dockerfile: docker/Dockerfile.api + container_name: canvasflow-api-prod + restart: unless-stopped + ports: + - "8000:8000" + environment: + NODE_ENV: production + PORT: 8000 + DATABASE_URL: postgres://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@postgres:5432/${POSTGRES_DB:-dev} + REDIS_URL: redis://redis:6379 + BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET} + BETTER_AUTH_URL: ${BETTER_AUTH_URL} + GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID} + GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET} + GITHUB_CLIENT_ID: ${GITHUB_CLIENT_ID} + GITHUB_CLIENT_SECRET: ${GITHUB_CLIENT_SECRET} + IMAGEKIT_PUBLIC_KEY: ${IMAGEKIT_PUBLIC_KEY} + IMAGEKIT_PRIVATE_KEY: ${IMAGEKIT_PRIVATE_KEY} + IMAGEKIT_URL_ENDPOINT: ${IMAGEKIT_URL_ENDPOINT} + BASE_URL: ${BASE_URL} + TRUSTED_ORIGINS: ${TRUSTED_ORIGINS} + RATE_LIMIT_PUBLIC_WRITE_MAX: ${RATE_LIMIT_PUBLIC_WRITE_MAX} + RATE_LIMIT_AUTH_MAX: ${RATE_LIMIT_AUTH_MAX} + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + + web: + build: + context: . + dockerfile: docker/Dockerfile.web + args: + NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL} + container_name: canvasflow-web-prod + restart: unless-stopped + ports: + - "3000:3000" + environment: + NODE_ENV: production + PORT: 3000 + NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL} + depends_on: + - api + +volumes: + pg_data_prod: diff --git a/docker/Dockerfile.api b/docker/Dockerfile.api new file mode 100644 index 0000000..d227040 --- /dev/null +++ b/docker/Dockerfile.api @@ -0,0 +1,22 @@ +FROM node:22-alpine AS base +RUN npm install -g pnpm@9.0.0 +WORKDIR /app + +FROM base AS builder +COPY . . +RUN pnpm install --frozen-lockfile +RUN pnpm --filter @repo/api build +RUN pnpm --filter @repo/api deploy --prod /prod/api +RUN pnpm --filter @repo/database deploy --prod /prod/db +COPY packages/database/drizzle /prod/db/drizzle +COPY packages/database/migrate.mjs /prod/db/migrate.mjs + +FROM base AS runner +COPY --from=builder /prod/api /app/api +COPY --from=builder /prod/db /app/db + +EXPOSE 8000 +ENV NODE_ENV=production +ENV PORT=8000 + +CMD cd /app/db && node migrate.mjs && cd /app/api && node dist/index.js diff --git a/docker/Dockerfile.web b/docker/Dockerfile.web new file mode 100644 index 0000000..ec249a6 --- /dev/null +++ b/docker/Dockerfile.web @@ -0,0 +1,23 @@ +FROM node:22-alpine AS base +RUN npm install -g pnpm@9.0.0 +WORKDIR /app + +FROM base AS builder +COPY . . +ARG NEXT_PUBLIC_API_URL +ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL +RUN pnpm install --frozen-lockfile +RUN pnpm --filter web build + +FROM base AS runner +ENV NODE_ENV=production +ENV PORT=3000 +ENV HOSTNAME="0.0.0.0" + +COPY --from=builder /app/apps/web/public /app/apps/web/public +COPY --from=builder /app/apps/web/.next/standalone /app +COPY --from=builder /app/apps/web/.next/static /app/apps/web/.next/static + +EXPOSE 3000 + +CMD node apps/web/server.js diff --git a/packages/database/models/form-field-view.ts b/packages/database/models/form-field-view.ts deleted file mode 100644 index 9a1fef4..0000000 --- a/packages/database/models/form-field-view.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { index, jsonb, pgTable, uuid, timestamp } from "drizzle-orm/pg-core"; -import { formsTable } from "./form"; -import { formFieldsTable } from "./form-field"; - -export const formFieldViewsTable = pgTable( - "form_field_views", - { - id: uuid("id").primaryKey().defaultRandom(), - formId: uuid("form_id") - .references(() => formsTable.id, { onDelete: "cascade" }) - .notNull(), - fieldId: uuid("field_id") - .references(() => formFieldsTable.id, { onDelete: "cascade" }) - .notNull(), - value: jsonb("value"), - createdAt: timestamp("created_at").defaultNow().notNull(), - }, - (table) => ({ - formFieldIdx: index("form_field_views_form_field_idx").on(table.formId, table.fieldId), - formCreatedIdx: index("form_field_views_created_idx").on(table.formId, table.createdAt), - }), -); diff --git a/packages/database/schema.ts b/packages/database/schema.ts index c3a1835..7f728bf 100644 --- a/packages/database/schema.ts +++ b/packages/database/schema.ts @@ -6,6 +6,5 @@ export * from "./models/form-logic"; export * from "./models/form-submission"; export * from "./models/form-upload"; export * from "./models/form-draft"; -export * from "./models/form-field-view"; export * from "./models/form-collaborator"; export * from "./models/feedback"; diff --git a/packages/queue/index.ts b/packages/queue/index.ts index b9855f6..faf564e 100644 --- a/packages/queue/index.ts +++ b/packages/queue/index.ts @@ -1,16 +1,12 @@ import "dotenv/config"; -import { Queue, Worker, type ConnectionOptions, type JobsOptions, type Processor } from "bullmq"; +import { Queue, Worker, type ConnectionOptions, type Processor } from "bullmq"; import { blockingConnection, isRedisConfigured, redisEnv } from "@repo/redis"; import { env as queueEnv } from "./env"; import { - QUEUE_ANALYTICS, QUEUE_UPLOADS, JOB_PROCESS_UPLOAD, - JOB_RECORD_FIELD_ANSWERS, - type FieldAnswer, type ProcessUploadJob, - type RecordFieldAnswersJob, } from "./jobs"; export * from "./jobs"; @@ -56,7 +52,6 @@ function getQueue(name: string): Queue { } export const uploadsQueue = () => getQueue(QUEUE_UPLOADS); -export const analyticsQueue = () => getQueue(QUEUE_ANALYTICS); /* ── Producers ─────────────────────────────────────────────────────────── */ @@ -71,50 +66,6 @@ export async function enqueueUpload(payload: ProcessUploadJob): Promise { }); } -let answerBuffer: FieldAnswer[] = []; -let flushTimer: NodeJS.Timeout | null = null; - -async function flushAnswerBuffer(): Promise { - if (flushTimer) { - clearTimeout(flushTimer); - flushTimer = null; - } - if (answerBuffer.length === 0) return; - const batch = answerBuffer; - answerBuffer = []; - - try { - await analyticsQueue().add( - JOB_RECORD_FIELD_ANSWERS, - { answers: batch } satisfies RecordFieldAnswersJob, - { - priority: 10, - attempts: 2, - }, - ); - } catch (err) { - console.error( - `[queue:analytics] dropped ${batch.length} field answer(s):`, - err instanceof Error ? err.message : err, - ); - } -} -export function enqueueFieldAnswer(answer: FieldAnswer): void { - if (!isQueueAvailable()) return; - - answerBuffer.push(answer); - - if (answerBuffer.length >= queueEnv.ANALYTICS_BATCH_MAX) { - void flushAnswerBuffer(); - return; - } - - if (!flushTimer) { - flushTimer = setTimeout(() => void flushAnswerBuffer(), queueEnv.ANALYTICS_BATCH_MS); - flushTimer.unref?.(); - } -} - export function createUploadWorker(processor: Processor): Worker { return new Worker(QUEUE_UPLOADS, processor, { connection: blockingConnection() as unknown as ConnectionOptions, @@ -126,19 +77,7 @@ export function createUploadWorker(processor: Processor): Work }); } -export function createAnalyticsWorker(processor: Processor): Worker { - return new Worker(QUEUE_ANALYTICS, processor, { - connection: blockingConnection() as unknown as ConnectionOptions, - prefix: bullPrefix, - concurrency: queueEnv.ANALYTICS_WORKER_CONCURRENCY, - lockDuration: 30_000, - maxStalledCount: 2, - }); -} - export async function drainProducers(): Promise { - await flushAnswerBuffer(); - await Promise.allSettled([...queues.values()].map((queue) => queue.close())); queues.clear(); diff --git a/packages/queue/jobs.ts b/packages/queue/jobs.ts index b25d88c..3b123e4 100644 --- a/packages/queue/jobs.ts +++ b/packages/queue/jobs.ts @@ -1,8 +1,6 @@ export const QUEUE_UPLOADS = "uploads"; -export const QUEUE_ANALYTICS = "analytics"; export const JOB_PROCESS_UPLOAD = "process-upload"; -export const JOB_RECORD_FIELD_ANSWERS = "record-field-answers"; export interface ProcessUploadJob { uploadId: string; @@ -11,13 +9,3 @@ export interface ProcessUploadJob { mimeType: string; originalName: string; } - -export interface FieldAnswer { - formId: string; - fieldId: string; - value: unknown; -} - -export interface RecordFieldAnswersJob { - answers: FieldAnswer[]; -} diff --git a/packages/services/analytics/index.ts b/packages/services/analytics/index.ts deleted file mode 100644 index b7d2856..0000000 --- a/packages/services/analytics/index.ts +++ /dev/null @@ -1,567 +0,0 @@ -import { db, eq, and, desc, gte, lt, count } from "@repo/database"; -import { enqueueFieldAnswer, isQueueAvailable } from "@repo/queue"; -import { formsTable } from "@repo/database/models/form"; -import { formFieldsTable } from "@repo/database/models/form-field"; -import { formSubmissionsTable } from "@repo/database/models/form-submission"; -import { formFieldViewsTable } from "@repo/database/models/form-field-view"; -import { requireViewer } from "../form"; - -import { - getFormAnalyticsInput, - type GetFormAnalyticsInputType, - getSubmissionsListInput, - type GetSubmissionsListInputType, - getDetailedAnalyticsInput, - type GetDetailedAnalyticsInputType, - recordFieldAnswerInput, - type RecordFieldAnswerInputType, -} from "./model"; - -const DAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; - -class AnalyticsService { - public async getFormAnalytics(payload: GetFormAnalyticsInputType & { ownerId: string }) { - const { formId } = await getFormAnalyticsInput.parseAsync(payload); - const { ownerId: userId } = payload; - - await requireViewer(formId, userId); - - const thirtyDaysAgo = new Date(); - thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 29); - thirtyDaysAgo.setHours(0, 0, 0, 0); - - // Run all DB queries in parallel - const [totalResponseRow, deviceRows, recentTimestamps] = await Promise.all([ - // Total submissions (all time) - db - .select({ value: count() }) - .from(formSubmissionsTable) - .where(eq(formSubmissionsTable.formId, formId)), - - // Device breakdown from submissions - db - .select({ deviceType: formSubmissionsTable.deviceType, value: count() }) - .from(formSubmissionsTable) - .where(eq(formSubmissionsTable.formId, formId)) - .groupBy(formSubmissionsTable.deviceType), - - // Submission timestamps for last 30 days (no values jsonb — just timestamps) - db - .select({ createdAt: formSubmissionsTable.createdAt }) - .from(formSubmissionsTable) - .where( - and( - eq(formSubmissionsTable.formId, formId), - gte(formSubmissionsTable.createdAt, thirtyDaysAgo), - ), - ), - ]); - - const totalResponses = Number(totalResponseRow[0]?.value ?? 0); - const deviceMap: Record = { desktop: 0, mobile: 0, tablet: 0 }; - deviceRows.forEach((r) => { - if (!r.deviceType) return; - const n = Number(r.value); - const dev = r.deviceType.toLowerCase(); - if (dev.includes("mobile")) deviceMap["mobile"] = (deviceMap["mobile"] ?? 0) + n; - else if (dev.includes("tablet")) deviceMap["tablet"] = (deviceMap["tablet"] ?? 0) + n; - else deviceMap["desktop"] = (deviceMap["desktop"] ?? 0) + n; - }); - const deviceBreakdown = Object.entries(deviceMap).map(([device, cnt]) => ({ - device, - count: cnt, - })); - - // ─── Daily trends (last 30 days, zero-filled) ───────────────────────── - const dailyMap: Record = {}; - for (let i = 29; i >= 0; i--) { - const d = new Date(); - d.setDate(d.getDate() - i); - const key = d.toLocaleDateString("en-US", { month: "short", day: "numeric" }); - dailyMap[key] = 0; - } - recentTimestamps.forEach((s) => { - const key = new Date(s.createdAt).toLocaleDateString("en-US", { - month: "short", - day: "numeric", - }); - if (dailyMap[key] !== undefined) dailyMap[key]++; - }); - const dailyTrends = Object.entries(dailyMap).map(([date, cnt]) => ({ date, count: cnt })); - - // ─── Peak day of week ───────────────────────────────────────────────── - const dowMap: Record = {}; - for (let d = 0; d < 7; d++) dowMap[d] = 0; - recentTimestamps.forEach((s) => { - const dow = new Date(s.createdAt).getDay(); - dowMap[dow] = (dowMap[dow] ?? 0) + 1; - }); - const peakDowEntry = Object.entries(dowMap).reduce( - (best, [d, cnt]) => (cnt > best.count ? { day: Number(d), count: cnt } : best), - { day: 0, count: 0 }, - ); - const peakDay = peakDowEntry.count > 0 ? (DAYS[peakDowEntry.day] ?? null) : null; - - // ─── Averages ───────────────────────────────────────────────────────── - const avgSubmissionsPerDay = parseFloat((recentTimestamps.length / 30).toFixed(1)); - const avgSubmissionsPerWeek = parseFloat((recentTimestamps.length / (30 / 7)).toFixed(1)); - - return { - totalResponses, - deviceBreakdown, - dailyTrends, - peakDay, - avgSubmissionsPerDay, - avgSubmissionsPerWeek, - }; - } - public async getDetailedAnalytics(payload: GetDetailedAnalyticsInputType & { ownerId: string }) { - const { formId } = await getDetailedAnalyticsInput.parseAsync(payload); - const { ownerId: userId } = payload; - - await requireViewer(formId, userId); - - const formRows = await db - .select({ - id: formsTable.id, - publishedAt: formsTable.publishedAt, - }) - .from(formsTable) - .where(eq(formsTable.id, formId)); - - if (!formRows[0]) throw new Error("Form not found"); - const form = formRows[0]; - - const now = new Date(); - const ago30 = new Date(now); - ago30.setDate(now.getDate() - 30); - ago30.setHours(0, 0, 0, 0); - const ago60 = new Date(now); - ago60.setDate(now.getDate() - 60); - ago60.setHours(0, 0, 0, 0); - const ago90 = new Date(now); - ago90.setDate(now.getDate() - 90); - ago90.setHours(0, 0, 0, 0); - - const [ - fields, - allSubmissions, - count30d, - count60d, - count90d, - referrerRows, - utmRows, - fieldViewRows, - rawFieldViewRows, - ] = await Promise.all([ - db - .select({ - id: formFieldsTable.id, - label: formFieldsTable.label, - type: formFieldsTable.type, - options: formFieldsTable.options, - }) - .from(formFieldsTable) - .where(eq(formFieldsTable.formId, formId)), - - db - .select({ - values: formSubmissionsTable.values, - createdAt: formSubmissionsTable.createdAt, - timeSpentMs: formSubmissionsTable.timeSpentMs, - }) - .from(formSubmissionsTable) - .where(eq(formSubmissionsTable.formId, formId)) - .orderBy(desc(formSubmissionsTable.createdAt)) - .limit(5000), // safety cap — enough for any real analytics calculation - - db - .select({ value: count() }) - .from(formSubmissionsTable) - .where( - and(eq(formSubmissionsTable.formId, formId), gte(formSubmissionsTable.createdAt, ago30)), - ), - - db - .select({ value: count() }) - .from(formSubmissionsTable) - .where( - and(eq(formSubmissionsTable.formId, formId), gte(formSubmissionsTable.createdAt, ago60)), - ), - - db - .select({ value: count() }) - .from(formSubmissionsTable) - .where( - and(eq(formSubmissionsTable.formId, formId), gte(formSubmissionsTable.createdAt, ago90)), - ), - - // Top referrers from submissions - db - .select({ referrer: formSubmissionsTable.referrer, value: count() }) - .from(formSubmissionsTable) - .where(eq(formSubmissionsTable.formId, formId)) - .groupBy(formSubmissionsTable.referrer), - - // UTM source breakdown from submissions - db - .select({ utmSource: formSubmissionsTable.utmSource, value: count() }) - .from(formSubmissionsTable) - .where(eq(formSubmissionsTable.formId, formId)) - .groupBy(formSubmissionsTable.utmSource), - - // Per-field answer counts from form_field_views (the real completion source) - db - .select({ fieldId: formFieldViewsTable.fieldId, value: count() }) - .from(formFieldViewsTable) - .where(eq(formFieldViewsTable.formId, formId)) - .groupBy(formFieldViewsTable.fieldId), - - // Raw field view rows with values — used for question distribution - db - .select({ - fieldId: formFieldViewsTable.fieldId, - value: formFieldViewsTable.value, - createdAt: formFieldViewsTable.createdAt, - }) - .from(formFieldViewsTable) - .where(eq(formFieldViewsTable.formId, formId)) - .orderBy(desc(formFieldViewsTable.createdAt)), - ]); - // ─── Day-of-week breakdown ───────────────────────────────────────────── - const SHORT_DAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; - const dowMap: Record = {}; - for (let d = 0; d < 7; d++) dowMap[d] = 0; - allSubmissions.forEach((s) => { - const dow = new Date(s.createdAt).getDay(); - dowMap[dow] = (dowMap[dow] ?? 0) + 1; - }); - const dowBreakdown = SHORT_DAYS.map((day, i) => ({ day, count: dowMap[i] ?? 0 })); - - // ─── Response velocity (first 24h after publish) ─────────────────────── - let velocityFirst24h = 0; - if (form.publishedAt) { - const publishedAt = new Date(form.publishedAt); - const cutoff = new Date(publishedAt.getTime() + 24 * 60 * 60 * 1000); - velocityFirst24h = allSubmissions.filter((s) => { - const t = new Date(s.createdAt); - return t >= publishedAt && t <= cutoff; - }).length; - } - - const TEXT_TYPES = ["TEXT", "TEXTAREA", "EMAIL", "NUMBER", "PHONE", "URL", "DATE", "TIME"]; - const CHOICE_TYPES_SET = ["SELECT", "RADIO", "CHECKBOX"]; - - const totalSubmissions = allSubmissions.length; - - const fieldViewsByField = new Map>(); - rawFieldViewRows.forEach((r) => { - if (!fieldViewsByField.has(r.fieldId)) fieldViewsByField.set(r.fieldId, []); - fieldViewsByField.get(r.fieldId)!.push({ value: r.value, createdAt: r.createdAt }); - }); - - const questionDistribution = fields.map((field) => { - // Use rawFieldViewRows if available, otherwise fall back to allSubmissions - const fieldViewEntries = fieldViewsByField.get(field.id); - const useFieldViews = fieldViewEntries && fieldViewEntries.length > 0; - - // Build the answers array from whichever source is available - const answers: Array<{ value: unknown }> = useFieldViews - ? fieldViewEntries!.filter( - (e) => e.value !== null && e.value !== undefined && e.value !== "", - ) - : allSubmissions - .map((s) => - (s.values as Array<{ formFieldId: string; value: unknown }>).find( - (v) => v.formFieldId === field.id, - ), - ) - .filter( - (a): a is { formFieldId: string; value: unknown } => - a !== undefined && a.value !== null && a.value !== "", - ); - - const totalAnswered = useFieldViews - ? (fieldViewsByField.get(field.id)?.length ?? 0) - : answers.length; - - if (CHOICE_TYPES_SET.includes(field.type)) { - const valueCounts: Record = {}; - answers.forEach((a) => { - const vals = Array.isArray(a.value) ? a.value : [a.value]; - vals.forEach((v) => { - const s = String(v); - valueCounts[s] = (valueCounts[s] ?? 0) + 1; - }); - }); - const optionCounts = Object.entries(valueCounts) - .sort((a, b) => b[1] - a[1]) - .map(([value, cnt]) => ({ - value, - count: cnt, - percent: answers.length > 0 ? parseFloat(((cnt / answers.length) * 100).toFixed(1)) : 0, - })); - return { - fieldId: field.id, - fieldLabel: field.label, - fieldType: field.type, - totalAnswered, - optionCounts, - }; - } else if (field.type === "RATING") { - const nums = answers.map((a) => Number(a.value)).filter((n) => !isNaN(n)); - const averageRating = - nums.length > 0 - ? parseFloat((nums.reduce((s, n) => s + n, 0) / nums.length).toFixed(1)) - : 0; - const ratingCounts: Record = { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0 }; - nums.forEach((n) => { - const clamped = Math.min(5, Math.max(1, Math.round(n))); - ratingCounts[clamped] = (ratingCounts[clamped] ?? 0) + 1; - }); - const ratingDistribution = [1, 2, 3, 4, 5].map((r) => ({ - rating: r, - count: ratingCounts[r] ?? 0, - percent: - nums.length > 0 - ? parseFloat((((ratingCounts[r] ?? 0) / nums.length) * 100).toFixed(1)) - : 0, - })); - return { - fieldId: field.id, - fieldLabel: field.label, - fieldType: field.type, - totalAnswered, - averageRating, - ratingDistribution, - }; - } else if (field.type === "TOGGLE") { - const yes = answers.filter((a) => a.value === true || a.value === "true").length; - const no = answers.filter((a) => a.value === false || a.value === "false").length; - return { - fieldId: field.id, - fieldLabel: field.label, - fieldType: field.type, - totalAnswered, - toggleCounts: { yes, no }, - }; - } else if (TEXT_TYPES.includes(field.type)) { - const textSamples = answers - .slice(0, 5) - .map((a) => String(a.value)) - .filter((s) => s.trim().length > 0); - return { - fieldId: field.id, - fieldLabel: field.label, - fieldType: field.type, - totalAnswered, - textSamples, - }; - } else { - return { fieldId: field.id, fieldLabel: field.label, fieldType: field.type, totalAnswered }; - } - }); - - // ─── Median response time (minutes from publishedAt to submission) ───── - let medianResponseTime: number | null = null; - if (form.publishedAt && allSubmissions.length > 0) { - const publishedAt = new Date(form.publishedAt); - const deltas = allSubmissions - .map((s) => (new Date(s.createdAt).getTime() - publishedAt.getTime()) / 60000) - .filter((d) => d >= 0) - .sort((a, b) => a - b); - if (deltas.length > 0) { - const mid = Math.floor(deltas.length / 2); - medianResponseTime = - deltas.length % 2 === 0 - ? parseFloat(((deltas[mid - 1]! + deltas[mid]!) / 2).toFixed(1)) - : parseFloat(deltas[mid]!.toFixed(1)); - } - } - - // ─── Returning rate ──────────────────────────────────────────────────── - const emailField = - fields.find((f) => f.type === "EMAIL") ?? fields.find((f) => TEXT_TYPES.includes(f.type)); - let returningRate = 0; - if (emailField && totalSubmissions > 0) { - const valueCounts: Record = {}; - allSubmissions.forEach((s) => { - const entry = (s.values as Array<{ formFieldId: string; value: unknown }>).find( - (v) => v.formFieldId === emailField.id, - ); - if (entry?.value) { - const key = String(entry.value).trim().toLowerCase(); - if (key) valueCounts[key] = (valueCounts[key] ?? 0) + 1; - } - }); - const duplicateCount = Object.values(valueCounts) - .filter((c) => c > 1) - .reduce((sum, c) => sum + c, 0); - returningRate = parseFloat(((duplicateCount / totalSubmissions) * 100).toFixed(1)); - } - - // ─── Peak day ────────────────────────────────────────────────────────── - const fullDayNames = [ - "Sunday", - "Monday", - "Tuesday", - "Wednesday", - "Thursday", - "Friday", - "Saturday", - ]; - const peakEntry = dowBreakdown.reduce( - (best, entry, i) => (entry.count > best.count ? { count: entry.count, idx: i } : best), - { count: 0, idx: 0 }, - ); - const peakDay = peakEntry.count > 0 ? (fullDayNames[peakEntry.idx] ?? null) : null; - const fieldViewMap = new Map( - fieldViewRows.map((r) => [r.fieldId, Number(r.value)]), - ); - const maxFieldInteractions = fieldViewRows.reduce((m, r) => Math.max(m, Number(r.value)), 0); - const denominator = maxFieldInteractions > 0 ? maxFieldInteractions : totalSubmissions; - - const fieldCompletionRates = fields.map((field) => { - const answeredCount = fieldViewMap.get(field.id) ?? 0; - const rate = - denominator > 0 ? parseFloat(((answeredCount / denominator) * 100).toFixed(1)) : 0; - return { fieldId: field.id, fieldLabel: field.label, rate }; - }); - - const timings = allSubmissions - .map((s) => s.timeSpentMs) - .filter((t): t is number => t !== null && t !== undefined && t > 0); - const avgTimeSpentMs = - timings.length > 0 ? Math.round(timings.reduce((s, t) => s + t, 0) / timings.length) : null; - - const topReferrers = referrerRows - .filter((r) => r.referrer !== null && r.referrer !== undefined && r.referrer !== "") - .map((r) => { - let domain = r.referrer ?? "direct"; - try { - domain = new URL(r.referrer ?? "").hostname.replace(/^www\./, ""); - } catch {} - return { referrer: domain, count: Number(r.value) }; - }) - .reduce( - (acc, cur) => { - const existing = acc.find((a) => a.referrer === cur.referrer); - if (existing) { - existing.count += cur.count; - } else { - acc.push({ ...cur }); - } - return acc; - }, - [] as { referrer: string; count: number }[], - ) - .sort((a, b) => b.count - a.count) - .slice(0, 10); - - // Add "Direct" entry for null/empty referrers - const directCount = referrerRows - .filter((r) => !r.referrer || r.referrer === "") - .reduce((s, r) => s + Number(r.value), 0); - if (directCount > 0) topReferrers.push({ referrer: "Direct", count: directCount }); - topReferrers.sort((a, b) => b.count - a.count); - - // ─── UTM source breakdown ────────────────────────────────────────────── - const utmSources = utmRows - .filter((r) => r.utmSource !== null && r.utmSource !== undefined && r.utmSource !== "") - .map((r) => ({ source: r.utmSource ?? "unknown", count: Number(r.value) })) - .sort((a, b) => b.count - a.count) - .slice(0, 10); - - return { - dowBreakdown, - trend30d: Number(count30d[0]?.value ?? 0), - trend60d: Number(count60d[0]?.value ?? 0), - trend90d: Number(count90d[0]?.value ?? 0), - velocityFirst24h, - questionDistribution, - medianResponseTime, - returningRate, - peakDay, - fieldCompletionRates, - avgTimeSpentMs, - topReferrers, - utmSources, - }; - } - - public async getSubmissionsList(payload: GetSubmissionsListInputType & { ownerId: string }) { - const { formId, cursor, limit } = await getSubmissionsListInput.parseAsync(payload); - const { ownerId: userId } = payload; - - await requireViewer(formId, userId); - - const pageSize = limit ?? 50; - const cursorDate = cursor ? new Date(cursor) : null; - const whereClause = cursorDate - ? and(eq(formSubmissionsTable.formId, formId), lt(formSubmissionsTable.createdAt, cursorDate)) - : eq(formSubmissionsTable.formId, formId); - - const rows = await db - .select() - .from(formSubmissionsTable) - .where(whereClause) - .orderBy(desc(formSubmissionsTable.createdAt)) - .limit(pageSize + 1); - - const hasMore = rows.length > pageSize; - const submissions = hasMore ? rows.slice(0, pageSize) : rows; - const nextCursor = hasMore - ? submissions[submissions.length - 1]!.createdAt.toISOString() - : null; - - return { submissions, nextCursor }; - } - - public async recordFieldAnswer(payload: RecordFieldAnswerInputType) { - const { formId, fieldId, value } = await recordFieldAnswerInput.parseAsync(payload); - - if (isQueueAvailable()) { - enqueueFieldAnswer({ formId, fieldId, value: value ?? null }); - return { success: true }; - } - - await db.insert(formFieldViewsTable).values({ formId, fieldId, value: value ?? null }); - return { success: true }; - } - - public async recordFieldAnswersBatch( - answers: Array<{ formId: string; fieldId: string; value: unknown }>, - chunkSize = 500, - ): Promise { - if (answers.length === 0) return 0; - - let written = 0; - - for (let offset = 0; offset < answers.length; offset += chunkSize) { - const chunk = answers.slice(offset, offset + chunkSize).map((answer) => ({ - formId: answer.formId, - fieldId: answer.fieldId, - value: answer.value ?? null, - })); - - try { - await db.insert(formFieldViewsTable).values(chunk); - written += chunk.length; - } catch (err: unknown) { - const code = - (err as { code?: string })?.code ?? (err as { cause?: { code?: string } })?.cause?.code; - - if (code !== "23503") throw err; - - const settled = await Promise.allSettled( - chunk.map((row) => db.insert(formFieldViewsTable).values(row)), - ); - written += settled.filter((result) => result.status === "fulfilled").length; - } - } - - return written; - } -} - -export default AnalyticsService; diff --git a/packages/services/analytics/model.ts b/packages/services/analytics/model.ts deleted file mode 100644 index a1ba070..0000000 --- a/packages/services/analytics/model.ts +++ /dev/null @@ -1,142 +0,0 @@ -import { z } from "zod"; - -// ─── Inputs ────────────────────────────────────────────────────────────────── - -export const getFormAnalyticsInput = z.object({ - formId: z.string().uuid().describe("Form ID"), -}); -export type GetFormAnalyticsInputType = z.infer; - -export const getSubmissionsListInput = z.object({ - formId: z.string().uuid().describe("Form ID"), - cursor: z - .string() - .datetime() - .optional() - .nullable() - .describe("ISO timestamp of the last submission from the previous page"), - limit: z - .number() - .int() - .min(1) - .max(200) - .optional() - .default(50) - .describe("Max submissions to return (default 50, max 200)"), -}); -export type GetSubmissionsListInputType = z.infer; - -// ─── Shared sub-shapes ──────────────────────────────────────────────────────── - -export const submissionValueOutput = z.object({ - formFieldId: z.string().uuid(), - value: z.any(), -}); - -export const submissionOutput = z.object({ - id: z.string().uuid(), - formId: z.string().uuid(), - values: z.array(submissionValueOutput), - createdAt: z.any(), -}); - -// ─── Analytics output ───────────────────────────────────────────────────────── - -export const getFormAnalyticsOutput = z.object({ - totalResponses: z.number(), - - deviceBreakdown: z.array( - z.object({ - device: z.string(), - count: z.number(), - }), - ), - - dailyTrends: z.array( - z.object({ - date: z.string(), - count: z.number(), - }), - ), - - peakDay: z.string().nullable(), - avgSubmissionsPerDay: z.number(), - avgSubmissionsPerWeek: z.number(), -}); -export type GetFormAnalyticsOutputType = z.infer; - -export const getSubmissionsListOutput = z.object({ - submissions: z.array(submissionOutput), - nextCursor: z.string().nullable(), -}); -export type GetSubmissionsListOutputType = z.infer; - -export const recordFieldAnswerInput = z.object({ - formId: z.string().uuid(), - fieldId: z.string().uuid(), - value: z.any(), // the answer the visitor entered -}); -export type RecordFieldAnswerInputType = z.infer; - -export const recordFieldAnswerOutput = z.object({ - success: z.boolean(), -}); -export type RecordFieldAnswerOutputType = z.infer; - -export const getDetailedAnalyticsInput = z.object({ - formId: z.string().uuid(), -}); -export type GetDetailedAnalyticsInputType = z.infer; - -export const questionDistributionOutput = z.object({ - fieldId: z.string().uuid(), - fieldLabel: z.string(), - fieldType: z.string(), - totalAnswered: z.number(), - optionCounts: z - .array( - z.object({ - value: z.string(), - count: z.number(), - percent: z.number(), - }), - ) - .optional(), - averageRating: z.number().optional(), - ratingDistribution: z - .array( - z.object({ - rating: z.number(), - count: z.number(), - percent: z.number(), - }), - ) - .optional(), - // For TOGGLE: true/false counts - toggleCounts: z.object({ yes: z.number(), no: z.number() }).optional(), - // For TEXT/TEXTAREA/EMAIL/NUMBER/PHONE/URL/DATE/TIME: up to 5 recent samples - textSamples: z.array(z.string()).optional(), -}); - -export const getDetailedAnalyticsOutput = z.object({ - dowBreakdown: z.array(z.object({ day: z.string(), count: z.number() })), - trend30d: z.number(), - trend60d: z.number(), - trend90d: z.number(), - velocityFirst24h: z.number(), - questionDistribution: z.array(questionDistributionOutput), - medianResponseTime: z.number().nullable(), - returningRate: z.number(), - peakDay: z.string().nullable(), - fieldCompletionRates: z.array( - z.object({ - fieldId: z.string(), - fieldLabel: z.string(), - rate: z.number(), - }), - ), - avgTimeSpentMs: z.number().nullable(), - topReferrers: z.array(z.object({ referrer: z.string(), count: z.number() })), - utmSources: z.array(z.object({ source: z.string(), count: z.number() })), -}); -export type GetDetailedAnalyticsOutputType = z.infer; diff --git a/packages/services/form-submission/index.ts b/packages/services/form-submission/index.ts index 3923f70..baa087f 100644 --- a/packages/services/form-submission/index.ts +++ b/packages/services/form-submission/index.ts @@ -1,6 +1,6 @@ import { db, eq, and, desc, lt } from "@repo/database"; import { formsTable } from "@repo/database/models/form"; -import { getFormBundle, invalidateFormCount } from "../form"; +import { getFormBundle, invalidateFormCount, requireViewer } from "../form"; import FormUploadService from "../form-upload"; import { assertRespondentAllowed, ALREADY_RESPONDED_ERROR, type Respondent } from "./access"; export * from "./access"; @@ -188,13 +188,7 @@ class FormSubmissionService { public async getSubmissions(payload: GetSubmissionsInputType) { const { formId, ownerId, cursor, limit } = await getSubmissionsInput.parseAsync(payload); - const formResult = await db - .select() - .from(formsTable) - .where(and(eq(formsTable.id, formId), eq(formsTable.ownerId, ownerId))); - if (!formResult[0]) { - throw new Error("Form not found or unauthorized"); - } + await requireViewer(formId, ownerId); const cursorDate = cursor ? new Date(cursor) : null; const whereClause = cursorDate diff --git a/packages/services/form-submission/model.ts b/packages/services/form-submission/model.ts index bdf55af..6903709 100644 --- a/packages/services/form-submission/model.ts +++ b/packages/services/form-submission/model.ts @@ -37,6 +37,10 @@ export const formSubmissionOutput = z.object({ id: z.string().uuid(), formId: z.string().uuid(), values: z.array(formSubmissionValueOutput), + visitorId: z.string().nullable().optional(), + respondentEmail: z.string().nullable().optional(), + timeSpentMs: z.number().nullable().optional(), + deviceType: z.string().nullable().optional(), createdAt: z.any(), }); export type FormSubmissionOutputType = z.infer; diff --git a/packages/trpc/server/index.ts b/packages/trpc/server/index.ts index 711f448..3153983 100644 --- a/packages/trpc/server/index.ts +++ b/packages/trpc/server/index.ts @@ -2,14 +2,12 @@ import { router } from "./trpc"; import { healthRouter } from "./routes/health/route"; import { formRouter } from "./routes/form/route"; -import { analyticsRouter } from "./routes/analytics/route"; import { userRouter } from "./routes/user/route"; import { feedbackRouter } from "./routes/feedback/route"; export const serverRouter = router({ health: healthRouter, form: formRouter, - analytics: analyticsRouter, user: userRouter, feedback: feedbackRouter, }); diff --git a/packages/trpc/server/routes/analytics/model.ts b/packages/trpc/server/routes/analytics/model.ts deleted file mode 100644 index 4e11c20..0000000 --- a/packages/trpc/server/routes/analytics/model.ts +++ /dev/null @@ -1,10 +0,0 @@ -export { - getFormAnalyticsInput as getFormAnalyticsInputModel, - getFormAnalyticsOutput as getFormAnalyticsOutputModel, - getSubmissionsListInput as getSubmissionsListInputModel, - getSubmissionsListOutput as getSubmissionsListOutputModel, - getDetailedAnalyticsInput, - getDetailedAnalyticsOutput, - recordFieldAnswerInput as recordFieldAnswerInputModel, - recordFieldAnswerOutput as recordFieldAnswerOutputModel, -} from "@repo/services/analytics/model"; diff --git a/packages/trpc/server/routes/analytics/route.ts b/packages/trpc/server/routes/analytics/route.ts deleted file mode 100644 index 449e23e..0000000 --- a/packages/trpc/server/routes/analytics/route.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { authenticatedProcedure, publicProcedure, router } from "../../trpc"; -import { generatePath } from "../../utils/path-generator"; -import { - getFormAnalyticsInputModel, - getFormAnalyticsOutputModel, - getSubmissionsListInputModel, - getSubmissionsListOutputModel, - getDetailedAnalyticsInput as getDetailedAnalyticsInputModel, - getDetailedAnalyticsOutput as getDetailedAnalyticsOutputModel, - recordFieldAnswerInputModel, - recordFieldAnswerOutputModel, -} from "./model"; -import { analyticsService } from "../../services"; - -const TAGS = ["Analytics"]; -const getPath = generatePath("/analytics"); - -export const analyticsRouter = router({ - // GET /analytics/getFormAnalytics/{formId} - getFormAnalytics: authenticatedProcedure - .meta({ - openapi: { - method: "GET", - path: getPath("/getFormAnalytics/{formId}"), - tags: TAGS, - protect: true, - }, - }) - .input(getFormAnalyticsInputModel) - .output(getFormAnalyticsOutputModel) - .query(async ({ input, ctx }) => { - return analyticsService.getFormAnalytics({ - formId: input.formId, - ownerId: ctx.user.id, - }); - }), - - // GET /analytics/getSubmissions/{formId} - getSubmissions: authenticatedProcedure - .meta({ - openapi: { - method: "GET", - path: getPath("/getSubmissions/{formId}"), - tags: TAGS, - protect: true, - }, - }) - .input(getSubmissionsListInputModel) - .output(getSubmissionsListOutputModel) - .query(async ({ input, ctx }) => { - return analyticsService.getSubmissionsList({ - formId: input.formId, - ownerId: ctx.user.id, - cursor: input.cursor ?? null, - limit: input.limit, - }); - }), - - // POST /analytics/recordFieldAnswer - recordFieldAnswer: publicProcedure - .meta({ - openapi: { - method: "POST", - path: getPath("/recordFieldAnswer"), - tags: TAGS, - protect: false, - }, - }) - .input(recordFieldAnswerInputModel) - .output(recordFieldAnswerOutputModel) - .mutation(async ({ input }) => { - return analyticsService.recordFieldAnswer(input); - }), - - // GET /analytics/getDetailedAnalytics/{formId} - getDetailedAnalytics: authenticatedProcedure - .meta({ - openapi: { - method: "GET", - path: getPath("/getDetailedAnalytics/{formId}"), - tags: TAGS, - protect: true, - }, - }) - .input(getDetailedAnalyticsInputModel) - .output(getDetailedAnalyticsOutputModel) - .query(async ({ input, ctx }) => { - return analyticsService.getDetailedAnalytics({ - formId: input.formId, - ownerId: ctx.user.id, - }); - }), -}); diff --git a/packages/trpc/server/routes/form/model.ts b/packages/trpc/server/routes/form/model.ts index a45f09c..49c6866 100644 --- a/packages/trpc/server/routes/form/model.ts +++ b/packages/trpc/server/routes/form/model.ts @@ -102,6 +102,8 @@ export { export { submitFormInput as submitFormInputModel, submitFormOutput as submitFormOutputModel, + getSubmissionsInput as getSubmissionsInputModel, + getSubmissionsOutput as getSubmissionsOutputModel, } from "@repo/services/form-submission/model"; export { diff --git a/packages/trpc/server/routes/form/route.ts b/packages/trpc/server/routes/form/route.ts index 6545f88..15af436 100644 --- a/packages/trpc/server/routes/form/route.ts +++ b/packages/trpc/server/routes/form/route.ts @@ -23,6 +23,7 @@ import { deleteFormOutputModel, submitFormInputModel, submitFormOutputModel, + getSubmissionsOutputModel, listFormFieldsInputModel, listFormFieldsOutputModel, getDashboardStatsOutputModel, @@ -555,4 +556,30 @@ export const formRouter = router({ .mutation(async ({ input, ctx }) => { return formDraftService.deleteDraft({ ...input, userId: ctx.user.id }); }), + + getSubmissions: authenticatedProcedure + .meta({ + openapi: { + method: "GET", + path: getPath("/getSubmissions/{formId}"), + tags: TAGS, + protect: true, + }, + }) + .input( + z.object({ + formId: z.string().uuid(), + cursor: z.string().datetime().optional().nullable(), + limit: z.number().int().min(1).max(200).optional().default(50), + }), + ) + .output(getSubmissionsOutputModel) + .query(async ({ input, ctx }) => { + return formSubmissionService.getSubmissions({ + formId: input.formId, + ownerId: ctx.user.id, + cursor: input.cursor, + limit: input.limit, + }); + }), }); diff --git a/packages/trpc/server/services/index.ts b/packages/trpc/server/services/index.ts index b1c3805..3031ed6 100644 --- a/packages/trpc/server/services/index.ts +++ b/packages/trpc/server/services/index.ts @@ -5,7 +5,6 @@ import FormLogicService from "@repo/services/form-logic"; import FormDraftService from "@repo/services/form-draft"; import FormSubmissionService from "@repo/services/form-submission"; import FormUploadService from "@repo/services/form-upload"; -import AnalyticsService from "@repo/services/analytics"; import FeedbackService from "@repo/services/feedback"; export const formService = new FormService(); @@ -15,5 +14,4 @@ export const formLogicService = new FormLogicService(); export const formDraftService = new FormDraftService(); export const formSubmissionService = new FormSubmissionService(); export const formUploadService = new FormUploadService(); -export const analyticsService = new AnalyticsService(); export const feedbackService = new FeedbackService();