Source
This app’s code and revision history are public. Download a copy or explore the files below.
Public
The app’s code and revision history are public.
Run it yourself
Take the whole tree and run it on your own machine. The download needs no account. Cloning needs a sign-in.
Download .zip
bounded clone cf614aedcf4cdabfa9c45095
A copy you download is not bounded. The guarantees this app runs under - declared egress, proven invariants, writes that fail closed - live in the runtime serving this page, not in the files. What you get is auditable source you can read and run, with no runtime guarantees attached.
src/Results.tsx
// Live results. Summary: the count and a chart or the latest answers per question. Responses: every response,
// searchable, as a table on wide screens and a list on phones, each one opening in a sheet. Both export CSV.
import { useEffect, useMemo, useState } from "react";
import { set } from "@bounded-sh/client";
import { explain } from "./lib/bounded";
import { Dialog, toast, copy, confirmAction, useMedia } from "./ui";
import { SITE_URL } from "../functions/lib/config";
import {
parseSubmission, newestFirst, summarize, columns, toCsv, formatAnswer, type FormDoc, type Question, type ResponseDoc, type Submission, type Summary as QSummary,
} from "../functions/lib/forms";
const PAGE = 50;
function ago(sec: number): string {
const s = Math.max(0, Date.now() / 1000 - sec);
if (s < 60) return "just now";
if (s < 3600) return `${Math.floor(s / 60)} min ago`;
if (s < 86400) return `${Math.floor(s / 3600)} h ago`;
return `on ${new Date(sec * 1000).toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" })}`;
}
const when = (sec: number) => new Date(sec * 1000).toLocaleString(undefined, { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" });
function downloadCsv(form: FormDoc, questions: Question[], subs: Submission[]) {
const a = document.createElement("a");
a.href = URL.createObjectURL(new Blob([toCsv(questions, subs)], { type: "text/csv;charset=utf-8" }));
a.download = `${form.title.replace(/[^\w-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60) || "responses"}.csv`;
a.click();
URL.revokeObjectURL(a.href);
}
const DOWNLOAD_ICON = <svg width="16" height="16" viewBox="0 0 20 20" aria-hidden="true"><path d="M10 3.5v9M6 9l4 4 4-4M4 16h12" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" /></svg>;
function useSubs(rows: ResponseDoc[] | undefined) {
return useMemo(() => (rows || []).map(parseSubmission).sort(newestFirst), [rows]);
}
function ResultsSkeleton() {
return (
<div className="results" aria-busy="true" aria-label="Loading responses">
<span className="skeleton" style={{ height: 44, width: 220 }} />
{[0, 1].map((i) => <span key={i} className="skeleton" style={{ height: 150, maxWidth: 760, borderRadius: 18 }} />)}
</div>
);
}
function NoResponses({ formId, closed }: { formId: string; closed?: boolean | null }) {
return (
<div className="empty-state enter">
<img className="art" src="/art/card-forms.webp" alt="" width="180" height="180" />
<h2>No responses yet</h2>
<p>{closed ? "This form is closed. Reopen it to take responses." : "Send people the link. Responses show up here the moment they arrive."}</p>
{!closed && (
<div className="row" style={{ justifyContent: "center" }}>
<button className="btn" type="button" onClick={() => copy(`${SITE_URL}/f/${formId}`, "Form link copied")}>Copy form link</button>
<a className="btn btn-line" href={`/f/${formId}/share`} data-nav>More ways to share</a>
</div>
)}
</div>
);
}
export function Summary({ formId, form, questions, rows }: { formId: string; form: FormDoc; questions: Question[]; rows: ResponseDoc[] | undefined }) {
const subs = useSubs(rows);
const summary = useMemo(() => summarize(questions, subs, 3), [questions, subs]);
if (!rows) return <ResultsSkeleton />;
if (!subs.length) return <NoResponses formId={formId} closed={form.closed} />;
return (
<div className="results">
<section className="results-top">
<div>
<p className="count"><strong>{subs.length.toLocaleString()}</strong> {subs.length === 1 ? "response" : "responses"}</p>
<p className="muted small">Last one {ago(subs[0].createdAt)}. New responses appear here live.</p>
</div>
<button className="btn btn-line" type="button" onClick={() => downloadCsv(form, questions, subs)}>{DOWNLOAD_ICON}Download CSV</button>
</section>
<section className="summary-list" aria-label="Summary per question">
{summary.map((s, i) => <SummaryCard key={s.key} s={s} n={i + 1} formId={formId} />)}
</section>
</div>
);
}
function SummaryCard({ s, n, formId }: { s: QSummary; n: number; formId: string }) {
const scale = s.type === "scale";
return (
<article className="card sum-card">
<header className="sum-head">
<span className="q-num" aria-hidden="true">{n}</span>
<div>
<h3 className="sum-label">{s.label}</h3>
<p className="muted small">{s.answered.toLocaleString()} {s.answered === 1 ? "answer" : "answers"}</p>
</div>
</header>
{s.options && (
<ul className="bars">
{s.options.map((o) => (
<li key={o.option}>
<div className="bar-line"><span>{o.option}</span><span className="bar-num">{o.count} · {o.percent}%</span></div>
<div className="bar"><span style={{ width: `${o.percent}%` }} /></div>
</li>
))}
</ul>
)}
{s.distribution && !scale && (
<>
<p className="avg">{s.average != null ? s.average.toFixed(1) : "-"} <span className="muted">out of 5</span></p>
<ul className="bars">
{[...s.distribution].map((c, i) => ({ c, r: i + 1 })).reverse().map(({ c, r }) => (
<li key={r} className="bar-row">
<span className="bar-key">{r}</span>
<div className="bar"><span style={{ width: `${s.answered ? (c / s.answered) * 100 : 0}%` }} /></div>
<span className="bar-num">{c}</span>
</li>
))}
</ul>
</>
)}
{scale && s.distribution && (
<>
<div className="scale-stats">
<p className="avg">{s.average != null ? s.average.toFixed(1) : "-"} <span className="muted">average out of 10</span></p>
{s.netScore != null && <p className="avg">{s.netScore > 0 ? `+${s.netScore}` : s.netScore} <span className="muted">Net Promoter Score</span></p>}
</div>
<div className="cols" role="img" aria-label={s.distribution.map((c, r) => `${r}: ${c}`).join(", ")}>
{s.distribution.map((c, r) => {
const max = Math.max(...s.distribution!, 1);
return (
<div key={r} className={`col${r >= 9 ? " col-high" : r <= 6 ? " col-low" : ""}`}>
<span className="col-num">{c || ""}</span>
<span className="col-track"><span className="col-bar" style={{ height: `${(c / max) * 100}%` }} /></span>
<span className="col-key">{r}</span>
</div>
);
})}
</div>
<p className="hint">Net Promoter Score: the percent who picked 9 or 10, minus the percent who picked 0 to 6.</p>
</>
)}
{s.type === "number" && s.average != null && <p className="avg">{s.average.toLocaleString()} <span className="muted">average</span></p>}
{s.recent && (s.recent.length ? (
<ul className="recent">{s.recent.map((r, i) => <li key={i}>{r}</li>)}</ul>
) : <p className="muted small">No answers yet.</p>)}
{s.recent && s.answered > s.recent.length && (
<a className="see-all" href={`/f/${formId}/responses`} data-nav>See all {s.answered.toLocaleString()} answers</a>
)}
</article>
);
}
export function Responses({ formId, form, questions, rows }: { formId: string; form: FormDoc; questions: Question[]; rows: ResponseDoc[] | undefined }) {
const subs = useSubs(rows);
const cols = useMemo(() => columns(questions, subs), [questions, subs]);
const [query, setQuery] = useState("");
const [shown, setShown] = useState(PAGE);
const [openId, setOpenId] = useState<string | null>(null);
const phone = useMedia("(max-width: 720px)");
const q = query.trim().toLowerCase();
const list = useMemo(() => (q ? subs.filter((s) => cols.some((c) => formatAnswer(s.answers[c.key]).toLowerCase().includes(q))) : subs), [subs, cols, q]);
if (!rows) return <ResultsSkeleton />;
if (!subs.length) return <NoResponses formId={formId} closed={form.closed} />;
return (
<div className="results">
<div className="resp-bar">
<input className="input resp-search" type="search" value={query} onChange={(e) => { setQuery(e.target.value); setShown(PAGE); }}
placeholder="Search responses" aria-label="Search responses" enterKeyHint="search" />
<button className="btn btn-line" type="button" onClick={() => downloadCsv(form, questions, subs)}>{DOWNLOAD_ICON}Download CSV</button>
</div>
<p className="muted small" aria-live="polite">
{q ? `${list.length.toLocaleString()} of ${subs.length.toLocaleString()} responses match.` : `${subs.length.toLocaleString()} ${subs.length === 1 ? "response" : "responses"}, newest first. ${phone ? "Tap" : "Click"} one to see all of it.`}
</p>
{!list.length ? (
<div className="empty-state"><h2>No matches</h2><p>No response contains "{query.trim()}".</p><button className="btn btn-line" type="button" onClick={() => setQuery("")}>Clear search</button></div>
) : phone ? (
<ul className="resp-cards">
{list.slice(0, shown).map((s) => {
const lines = cols.filter((c) => formatAnswer(s.answers[c.key])).slice(0, 3);
return (
<li key={s.id}>
<button type="button" className="resp-card press" onClick={() => setOpenId(s.id)}>
<span className="resp-when">{when(s.createdAt)}</span>
{lines.map((c) => <span key={c.key} className="resp-line"><span className="muted">{c.label}</span> {formatAnswer(s.answers[c.key])}</span>)}
{!lines.length && <span className="resp-line muted">No answers</span>}
</button>
</li>
);
})}
</ul>
) : (
<div className="table-wrap">
<table className="table resp-table">
<thead>
<tr><th scope="col">Submitted</th>{cols.map((c) => <th key={c.key} scope="col" title={c.label}>{c.label}</th>)}</tr>
</thead>
<tbody>
{list.slice(0, shown).map((s) => (
<tr key={s.id} onClick={() => setOpenId(s.id)} tabIndex={0} aria-label={`Response from ${when(s.createdAt)}`}
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setOpenId(s.id); } }}>
<td className="nowrap muted">{when(s.createdAt)}</td>
{cols.map((c) => <td key={c.key}><span className="cell">{formatAnswer(s.answers[c.key])}</span></td>)}
</tr>
))}
</tbody>
</table>
</div>
)}
{list.length > shown && <button className="btn btn-line btn-block" type="button" onClick={() => setShown((n) => n + PAGE)}>Show {Math.min(PAGE, list.length - shown)} more of {(list.length - shown).toLocaleString()}</button>}
{openId && <ResponseSheet formId={formId} list={list} id={openId} cols={cols} onOpen={setOpenId} onClose={() => setOpenId(null)} />}
</div>
);
}
function ResponseSheet({ formId, list, id, cols, onOpen, onClose }: {
formId: string; list: Submission[]; id: string; cols: ReturnType<typeof columns>; onOpen: (id: string) => void; onClose: () => void;
}) {
const [busy, setBusy] = useState(false);
const i = list.findIndex((s) => s.id === id);
const sub = list[i];
const newer = list[i - 1], older = list[i + 1];
// Arrow keys step through responses on a keyboard.
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (document.querySelectorAll(".dialog").length > 1) return;
if (e.key === "ArrowLeft" && newer) onOpen(newer.id);
if (e.key === "ArrowRight" && older) onOpen(older.id);
};
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [newer, older, onOpen]);
// Deleted here or by another owner: move on to the next one, or close.
useEffect(() => { if (!sub) onClose(); }, [sub, onClose]);
if (!sub) return null;
const remove = async () => {
if (!(await confirmAction({ title: "Delete this response?", body: "It is deleted for every owner and cannot be undone.", action: "Delete", danger: true }))) return;
setBusy(true);
try {
await set(`forms/${formId}/responses/${sub.id}`, null);
toast("Response deleted");
if (older || newer) onOpen((older || newer).id);
} catch (e) {
toast(explain(e));
}
setBusy(false);
};
return (
<Dialog title={`Response ${i + 1} of ${list.length.toLocaleString()}`} onClose={onClose} footer={<>
<button className="btn btn-quiet btn-danger" type="button" onClick={remove} disabled={busy}>Delete</button>
<span className="grow foot-gap" />
<button className="btn btn-line" type="button" onClick={() => newer && onOpen(newer.id)} disabled={!newer}>
<svg width="16" height="16" viewBox="0 0 20 20" aria-hidden="true"><path d="M12.5 4.5 7 10l5.5 5.5" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" /></svg>Newer
</button>
<button className="btn btn-line" type="button" onClick={() => older && onOpen(older.id)} disabled={!older}>
Older<svg width="16" height="16" viewBox="0 0 20 20" aria-hidden="true"><path d="M7.5 4.5 13 10l-5.5 5.5" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" /></svg>
</button>
</>}>
<p className="muted small">Sent {new Date(sub.createdAt * 1000).toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" })}</p>
<dl className="resp-detail" key={sub.id}>
{cols.map((c) => (
<div key={c.key}>
<dt>{c.label}</dt>
<dd>{formatAnswer(sub.answers[c.key]) || <span className="muted">No answer</span>}</dd>
</div>
))}
</dl>
</Dialog>
);
}
Change history
Quality pass: conditional questions, 0 to 10 scale, QR code, prefill links, results tabs, API docs
Bounded source sync
Bounded source sync
Bounded source sync
Bounded source sync
Bounded source sync