feat: add simple results dashboard

This commit is contained in:
Matthias Nannt
2022-06-15 12:54:10 +09:00
parent bae1aff064
commit 0f32f941fa
7 changed files with 179 additions and 53 deletions

View File

@@ -39,11 +39,7 @@ export default function LayoutShare({ title, formId, currentStep, children }) {
{/* Main content */}
<main>
<div className="mx-auto max-w-7xl sm:px-6 lg:px-8">
{/* Replace with your content */}
{children}
{/* /End replace */}
</div>
<div className="mx-auto max-w-7xl sm:px-6 lg:px-8">{children}</div>
</main>
</div>
</div>

View File

@@ -1,3 +1,78 @@
export default function ResultsDashboard({}) {
return <p>Dashboard</p>;
import { ClockIcon, InboxIcon, UsersIcon } from "@heroicons/react/outline";
import { useMemo } from "react";
import {
getSubmissionAnalytics,
useSubmissionSessions,
} from "../../lib/submissionSessions";
import { timeSince } from "../../lib/utils";
export default function ResultsDashboard({ formId }) {
const { submissionSessions, isLoadingSubmissionSessions } =
useSubmissionSessions(formId);
const analytics = useMemo(() => {
if (!isLoadingSubmissionSessions) {
return getSubmissionAnalytics(submissionSessions);
}
}, [isLoadingSubmissionSessions]);
const stats = useMemo(() => {
if (analytics) {
return [
{
id: "uniqueUsers",
name: "Unique Users",
stat: analytics.uniqueUsers,
icon: UsersIcon,
},
{
id: "totalSubmissions",
name: "Total Submissions",
stat: analytics.totalSubmissions,
icon: InboxIcon,
},
{
id: "uniqueUsers",
name: "Last Submission",
stat: timeSince(analytics.lastSubmissionAt) || "-",
icon: ClockIcon,
},
];
}
}, [analytics]);
return (
<main>
<div className="mx-auto max-w-7xl sm:px-6 lg:px-8">
<div>
{stats ? (
<dl className="grid grid-cols-1 gap-5 mt-8 sm:grid-cols-2 lg:grid-cols-3">
{stats.map((item) => (
<div
key={item.id}
className="relative px-4 bg-white rounded-lg shadow pt-5overflow-hidden sm:pt-6 sm:px-6"
>
<dt>
<div className="absolute p-3 bg-red-500 rounded-md">
<item.icon
className="w-6 h-6 text-white"
aria-hidden="true"
/>
</div>
<p className="ml-16 text-sm font-medium text-gray-500 truncate">
{item.name}
</p>
</dt>
<dd className="flex items-baseline ml-16 sm:pb-7">
<p className="text-2xl font-semibold text-gray-900">
{item.stat}
</p>
</dd>
</div>
))}
</dl>
) : null}
</div>
</div>
</main>
);
}

View File

@@ -38,9 +38,18 @@ export default function ResultsResponses({ formId }: ResultsResponseProps) {
<div className="flex flex-col flex-1 w-full mx-auto overflow-visible max-w-screen">
<div className="relative z-0 flex flex-1 overflow-visible">
<main className="relative z-0 flex-1 overflow-y-auto focus:outline-none xl:order-last">
<div className="overflow-visible bg-white shadow sm:rounded-lg">
{activeSubmissionSession && (
<div className="px-4 py-5 sm:p-12">
<div className="overflow-visible sm:rounded-lg">
{!activeSubmissionSession ? (
<button
type="button"
className="relative block p-12 mx-auto mt-8 text-center border-2 border-gray-300 border-dashed rounded-lg w-96 hover:border-gray-400 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
<span className="block mt-2 text-sm font-medium text-gray-500">
Select a response on the left to see the details here
</span>
</button>
) : (
<div className="px-4 py-5 bg-white shadow sm:p-12">
<div className="grid grid-cols-2 gap-8 divide-x">
<div className="flow-root">
<h1 className="mb-8 text-gray-700">
@@ -105,53 +114,60 @@ export default function ResultsResponses({ formId }: ResultsResponseProps) {
)}
</div>
</main>
<aside className="flex-shrink-0 hidden border-r border-gray-200 xl:order-first xl:flex xl:flex-col w-96">
<aside className="flex-shrink-0 hidden border-r border-gray-200 md:order-first md:flex md:flex-col w-96">
<DownloadResponses formId={formId} />
<div className="pt-6 pb-1">
<h2 className="px-5 text-lg font-medium text-gray-900">
Responses
</h2>
</div>
<RadioGroup
value={activeSubmissionSession}
onChange={setActiveSubmissionSession}
className="flex-1 min-h-0 overflow-y-auto"
>
<div className="relative">
<ul className="relative z-0 divide-y divide-gray-200">
{submissionSessions.map((submissionSession) => (
<RadioGroup.Option
key={submissionSession.id}
value={submissionSession}
className={({ checked }) =>
classNames(
checked ? "bg-gray-100" : "",
"relative flex items-center px-6 py-5 space-x-3 "
)
}
>
<div className="flex-1 min-w-0">
<button
onClick={() =>
setActiveSubmissionSession(submissionSession)
}
className="w-full text-left focus:outline-none"
>
{/* Extend touch target to entire panel */}
<span className="absolute inset-0" aria-hidden="true" />
<p className="text-sm font-medium text-gray-900">
{convertDateTimeString(submissionSession.createdAt)}
</p>
<p className="text-sm text-gray-500 truncate">
{submissionSession.events.length} events
</p>
</button>
</div>
</RadioGroup.Option>
))}
</ul>
</div>
</RadioGroup>
{submissionSessions.length === 0 ? (
<p className="px-5 mt-3 text-sm text-gray-500">No responses yet</p>
) : (
<RadioGroup
value={activeSubmissionSession}
onChange={setActiveSubmissionSession}
className="flex-1 min-h-0 overflow-y-auto"
>
<div className="relative">
<ul className="relative z-0 divide-y divide-gray-200">
{submissionSessions.map((submissionSession) => (
<RadioGroup.Option
key={submissionSession.id}
value={submissionSession}
className={({ checked }) =>
classNames(
checked ? "bg-gray-100" : "",
"relative flex items-center px-6 py-5 space-x-3 "
)
}
>
<div className="flex-1 min-w-0">
<button
onClick={() =>
setActiveSubmissionSession(submissionSession)
}
className="w-full text-left focus:outline-none"
>
{/* Extend touch target to entire panel */}
<span
className="absolute inset-0"
aria-hidden="true"
/>
<p className="text-sm font-medium text-gray-900">
{convertDateTimeString(submissionSession.createdAt)}
</p>
<p className="text-sm text-gray-500 truncate">
{submissionSession.events.length} events
</p>
</button>
</div>
</RadioGroup.Option>
))}
</ul>
</div>
</RadioGroup>
)}
</aside>
</div>
</div>

View File

@@ -1,4 +1,5 @@
import useSWR from "swr";
import { SubmissionSession } from "./types";
import { fetcher } from "./utils";
export const useSubmissionSessions = (formId: string) => {
@@ -56,3 +57,34 @@ export const getSubmission = (submissionSession, schema) => {
}
return submission;
};
export const getSubmissionAnalytics = (
submissionSessions: SubmissionSession[]
) => {
const uniqueUsers = [];
let totalSubmissions = 0;
let lastSubmissionAt = null;
for (const submissionSession of submissionSessions) {
// collect unique users
if (!uniqueUsers.includes(submissionSession.userFingerprint)) {
uniqueUsers.push(submissionSession.userFingerprint);
}
if (submissionSession.events.length > 0) {
totalSubmissions += 1;
const lastSubmission =
submissionSession.events[submissionSession.events.length - 1];
if (!lastSubmissionAt) {
lastSubmissionAt = lastSubmission.createdAt;
} else if (
Date.parse(lastSubmission.createdAt) > Date.parse(lastSubmissionAt)
) {
lastSubmissionAt = lastSubmission.createdAt;
}
}
}
return {
lastSubmissionAt,
uniqueUsers: uniqueUsers.length,
totalSubmissions: totalSubmissions,
};
};

View File

@@ -1,4 +1,5 @@
import intlFormat from "date-fns/intlFormat";
import { formatDistance } from "date-fns";
export const fetcher = (url) => fetch(url).then((res) => res.json());
@@ -94,3 +95,10 @@ export const convertTimeString = (dateString: string) => {
}
);
};
export const timeSince = (dateString: string) => {
const date = new Date(dateString);
return formatDistance(date, new Date(), {
addSuffix: true,
});
};

View File

@@ -120,7 +120,6 @@ export default function SignIn({ csrfToken }: props) {
export const getServerSideProps: GetServerSideProps = async (context) => {
const csrfToken = await getCsrfToken(context);
console.log("csrfToken", JSON.stringify(csrfToken));
return {
props: { csrfToken },
};

View File

@@ -27,7 +27,7 @@ export default function Share() {
resultMode={resultMode}
setResultMode={setResultMode}
>
{resultMode === "dashboard" && <ResultsDashboard />}
{resultMode === "dashboard" && <ResultsDashboard formId={formId} />}
{resultMode === "responses" && (
<>
<ResultsResponses formId={formId} />