forms-free.openapps.xyz

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.
Owned app · rev ce303909347f
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.
← All files
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
ce30390934 · 5 hours ago
Bounded source sync
7d82add8ae · 1 day ago
Bounded source sync
8230fd7d59 · 1 day ago
Bounded source sync
d92a6e3b44 · 1 day ago
Bounded source sync
b548641922 · 1 day ago
Bounded source sync
b89074ffd2 · 1 day ago