diff --git a/AGENTS.md b/AGENTS.md
index dd2dbd47..0f3986c7 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -13,7 +13,7 @@ The UI must be delightful, with FigJam as the bar: considered typography, micro-
## Conventions
- **Commits**: small Conventional Commits, `type(scope): what changed, in plain words`, lowercase, no trailing period, no attribution lines (e.g. `fix(stacks): a stack's name is a 16px heading, in proportion with the cards around it`). Work goes straight to `main`, which Workers Builds deploys to production; push a branch only for a Worker Preview or a risky change (such as a board schema change). Run `pnpm check`, `pnpm test` and `pnpm build` (and the site build if `site/` or docs changed) before pushing.
-- **Comments**: plain sentences saying what something is and why, in British spelling; a block comment at the top of a module explains its part of the design, and the Yjs schemas are documented as tables at the top of their files (`src/shared/shapes.ts`, `src/shared/widgets.ts`). Cite issues as `(#60)` where they're a decision's history. Point at code, a site docs page or a section of this file, never at a document that may not exist.
+- **Comments** are for a future reader of the code, not whoever reviews the change: plain sentences saying what something is and why, in British spelling. A block comment at the top of a module explains its part of the design, and the Yjs schemas are documented as tables at the top of their files (`src/shared/shapes.ts`, `src/shared/widgets.ts`). Anywhere else, a comment earns its place only when the code can't be made clearer, or to give a reason a reader would otherwise get wrong (an ordering constraint, a footgun, an invariant). A comment is never a summary of the change, a message to the reviewer, a justification for a decision ("intentionally", "for safety", "unlike X"), an account of what was tried and rejected, or a restatement of what the code plainly does. Never cite issues, pull requests or review threads, and never number comments: a decision's history goes in its commit message. Point at code, a site docs page or a section of this file, never at a document that may not exist. Tool directives (`oxlint-disable`, `@ts-expect-error`) keep the short reason that says why the rule doesn't apply there.
- **Code**: TypeScript throughout, formatted with tabs. Format, lint and type check are oxfmt, oxlint and tsgolint through `vp check` (no prettier or eslint). Import test APIs from `vite-plus/test`. Pure logic goes in `src/shared/`, so it's unit-tested in Node and shared by the server, the client, the playground and the demo.
- **Testing approach**: unit-test pure logic in Node; test server behaviour through the built Worker in Miniflare (`test/harness.ts`), with fakes for GitHub, other forges, AI and email; use Playwright (`e2e/`) only for what needs a browser. Every new `/play` scenario or gallery state goes in `e2e/play.spec.ts` or `e2e/gallery.spec.ts`, and new galleries in `e2e/a11y.spec.ts`'s list. See "Testing".
- **Parallel agents never run the full e2e suite.** Each run starts its own dev server and browsers, and several at once overload the machine until runs time out and flake. Run `pnpm check`, `pnpm test`, `pnpm --filter site build` and only the specs you touched (`pnpm e2e e2e/display.spec.ts --workers=2`, with your own `E2E_PORT` in a worktree), and stop any dev server you start. The full suite runs once on `main`, and in CI.
@@ -47,7 +47,7 @@ The UI must be delightful, with FigJam as the bar: considered typography, micro-
- **`cloudflare.config.ts` is the source of truth for the Worker**: name (`NAME`), entrypoint, compatibility, bindings, observability, via `defineConfig` from `cf/config` (no Wrangler dependency), which both `cf` and the Vite plugin read. The entrypoint uses the `with { type: "cf-worker" }` import attribute, which makes `Env` and `exports` inference work: keep that form.
- **Bindings** are declared there (`env: { MY_KV: bindings.kv() }`), and `Env` is inferred from them (`pnpm types`). Durable Object bindings to this same Worker are untyped; `src/server/namespaces.ts` restores the class types. Integration tests declare the same bindings for Miniflare in `test/harness.ts`: keep the two in step.
- **`compatibility.ts`** holds the compatibility date and flags, shared with the test runtime so tests match production. It's a separate file because `cloudflare.config.ts`'s exports are schema-validated.
-- **Previews**: the function form of `defineConfig` receives `isPreview` and returns a complete config for both (previews inherit nothing). Previews get no secrets, so code must treat `GITHUB_WEBHOOK_SECRET`, `GITHUB_TOKEN` and `TACK_SECRET` as optional.
+- **Previews**: the function form of `defineConfig` receives `isPreview` and returns a complete config for both (previews inherit nothing). Previews get no secrets and no cron triggers (Preview uploads refuse them), so code must treat `GITHUB_WEBHOOK_SECRET`, `GITHUB_TOKEN` and `TACK_SECRET` as optional.
- **Secrets**: one declared with `bindings.secret()` is _required_ on deploy, and deploys keep secrets that aren't declared. So production declares only `TACK_SECRET`; the optional `GITHUB_WEBHOOK_SECRET`, `GITHUB_TOKEN`, `TACK_FORGES` and `TACK_DEV_AUTH` are declared only in `mode === "development"` (so `.dev.vars` can set them) and kept in production when set on the Worker. **Never declare a new optional secret for production**: it breaks every new instance's first deploy.
- **The Deploy to Cloudflare button** reads only `wrangler.jsonc` (generated by `scripts/wrangler-config.ts` with the converter `cf` uses; never edit it by hand), prompts for the secrets in `.dev.vars.example` (`TACK_SECRET` only, described in `package.json`'s `cloudflare.bindings`), and runs `package.json`'s `deploy` script. It deploys the name in `cloudflare.config.ts`. The self-hosting pages (`deploy.mdx`, `configuration.md`, `upgrades.md`) describe it.
- **Version pins that move together**: the `vite@*` and `vitest@*` overrides in `pnpm-workspace.yaml` pin Vite to `@voidzero-dev/vite-plus-core` at the installed `vite-plus` version, and Vitest to what `vp toolchain vitest` reports. Update them whenever `vite-plus` changes. There are no direct `vite` or `vitest` dependencies. The site keeps its own Vite for Astro (`astro>vite`).
diff --git a/cloudflare.config.ts b/cloudflare.config.ts
index 3d2d8c47..658f12d4 100644
--- a/cloudflare.config.ts
+++ b/cloudflare.config.ts
@@ -113,10 +113,9 @@ export default defineConfig(({ isPreview, mode }) => ({
Instance: exports.durableObject({ storage: "sqlite" }),
Directory: exports.durableObject({ storage: "sqlite" }),
},
- triggers: [
- // Redeliver failed GitHub webhooks (src/server/github/redeliver.ts).
- triggers.scheduled({ schedule: "*/10 * * * *" }),
- ],
+ // Redeliver failed GitHub webhooks (src/server/github/redeliver.ts).
+ // Preview uploads refuse triggers, and webhooks only go to production.
+ ...(isPreview ? {} : { triggers: [triggers.scheduled({ schedule: "*/10 * * * *" })] }),
observability: {
enabled: true,
},
diff --git a/e2e/export.spec.ts b/e2e/export.spec.ts
index 0103e795..dd5f7922 100644
--- a/e2e/export.spec.ts
+++ b/e2e/export.spec.ts
@@ -104,7 +104,7 @@ test.describe("exporting from the playground", () => {
const dialog = await openExport(page);
await dialog.getByRole("radio", { name: "JSON" }).click();
await expect(dialog).toContainText(/\d+ cards/);
- await expect(dialog).toContainText("Comments, history, votes");
+ await expect(dialog).toContainText("History and staged changes stay here");
const [json] = await Promise.all([
page.waitForEvent("download"),
dialog.getByRole("button", { name: "Download JSON" }).click(),
@@ -118,6 +118,49 @@ test.describe("exporting from the playground", () => {
const next = await openExport(page);
await expect(next.getByRole("radio", { name: "JSON" })).toHaveAttribute("aria-checked", "true");
});
+
+ test("with what people did, when asked, for reading but not importing", async ({ page }) => {
+ await openScenario(page, "vote");
+ const dialog = await openExport(page);
+ await dialog.getByRole("radio", { name: "JSON" }).click();
+ const include = dialog.getByRole("switch", { name: "Include votes, stamps and comments" });
+ await expect(include).not.toBeChecked();
+ await expect(dialog).toContainText("for reading the board elsewhere");
+ await dialog.getByText("Include votes, stamps and comments").click();
+ await expect(include).toBeChecked();
+ await expect(dialog).toContainText("2 votes");
+ await expect(dialog).toContainText("except in hidden votes");
+ const [json] = await Promise.all([
+ page.waitForEvent("download"),
+ dialog.getByRole("button", { name: "Download JSON" }).click(),
+ ]);
+ const text = (await saved(json)).toString();
+ const { activity } = JSON.parse(text) as {
+ activity: {
+ votes: { hidden: boolean; closedAt?: string; results?: { by?: unknown[] }[] }[];
+ items: { title: string }[];
+ };
+ };
+ const [hidden, open] = [
+ activity.votes.find((vote) => vote.hidden),
+ activity.votes.find((vote) => !vote.hidden),
+ ];
+ // Closed, so its results are in, but never who voted for what.
+ expect(hidden?.closedAt).toBeDefined();
+ expect(hidden?.results?.length).toBeGreaterThan(0);
+ for (const row of hidden?.results ?? []) expect(row).not.toHaveProperty("by");
+ expect(open?.closedAt).toBeUndefined();
+ expect(open?.results?.[0]?.by?.length).toBeGreaterThan(0);
+ expect(activity.items.length).toBeGreaterThan(0);
+ // Importing it leaves what people did behind.
+ const read = readBoardExport(text);
+ expect(read.ok && read.data).not.toHaveProperty("activity");
+ await expect(dialog).toBeHidden();
+ const next = await openExport(page);
+ await expect(
+ next.getByRole("switch", { name: "Include votes, stamps and comments" }),
+ ).toBeChecked();
+ });
});
test.describe.serial("importing and backing up a real board", () => {
diff --git a/scripts/wrangler-config.ts b/scripts/wrangler-config.ts
index efa8946b..301dbf5d 100644
--- a/scripts/wrangler-config.ts
+++ b/scripts/wrangler-config.ts
@@ -52,17 +52,25 @@ const HEADER = `// Generated from cloudflare.config.ts by \`node scripts/wrangle
// cloudflare.config.ts.
`;
-/** wrangler.jsonc as it should be, for production. */
-export async function wranglerConfig(): Promise {
+/** cloudflare.config.ts as cf reads it, for a production build or a Preview's. */
+export async function workerConfig(options: {
+ isPreview: boolean;
+ mode: string;
+}): Promise<{ worker: Record }> {
const config = await configModule();
- const { result } = await config.loadAndParseConfig(join(ROOT, "cloudflare.config.ts"), {
- isPreview: false,
- mode: "production",
- });
+ const { result } = await config.loadAndParseConfig(join(ROOT, "cloudflare.config.ts"), options);
if (!result.success) {
throw new Error(`cloudflare.config.ts is invalid: ${JSON.stringify(result.error)}`);
}
- const wrangler = config.convertToWranglerConfig(result.data);
+ return result.data as { worker: Record };
+}
+
+/** wrangler.jsonc as it should be, for production. */
+export async function wranglerConfig(): Promise {
+ const config = await configModule();
+ const wrangler = config.convertToWranglerConfig(
+ await workerConfig({ isPreview: false, mode: "production" }),
+ );
// The converter gives an absolute entrypoint; the file is shared.
if (typeof wrangler.main === "string") wrangler.main = relative(ROOT, wrangler.main);
return `${HEADER}${JSON.stringify(wrangler, null, "\t")}\n`;
diff --git a/site/src/content/docs/boards/export.md b/site/src/content/docs/boards/export.md
index e103ec92..3bfa6667 100644
--- a/site/src/content/docs/boards/export.md
+++ b/site/src/content/docs/boards/export.md
@@ -27,14 +27,26 @@ Tack remembers your format, scale and background in this browser.
A board's file is its content, not anyone's own part of it:
-- comments, history and visits, which aren't part of the board's content
-- votes on decisions, dot votes and stamps: who voted for what is theirs
+- history and visits, which aren't part of the board's content
+- comments, votes on decisions, dot votes, stamps and hands up, unless you [include them](#including-votes-stamps-and-comments)
- stickies still face down in silent mode: their text is their author's until it's revealed
- staged changes, and where cards came from (inboxes, rules, lists)
- cards from repositories you can't read, and the issues action items link to there; the dialog says how many
Cards are their links, so an imported board shows them as they are now on GitHub, to whoever can read them.
+## Including votes, stamps and comments
+
+To read a board somewhere else, after a planning session say, or to hand it to a tool or an AI agent, turn on **Include votes, stamps and comments** under JSON. The file then also says what people did on the board, beside its content:
+
+- **Dot votes**: each vote's settings, whether it's closed, how many people voted, and each shape's dots, ๐ reactions counted as votes, total and rank. Like the board, a vote that hides its dots never says who put them where, and has no results in the file until it closes.
+- **Decisions' votes**: who agreed and who disagreed.
+- **Stamps** and **hands up**: who, on each shape.
+- **Comments**: each thread still showing, with its messages, who wrote them, what it's pinned to and whether it's resolved.
+- **What the cards show**: each item's title, state, labels, assignees, comment and reaction counts, and for discussions their category and upvotes, as you see them as you export.
+
+It's only ever about what's in the file, so nothing on a card from a repository you can't read, or on a sticky still face down, goes in. The dialog counts what it adds, and Tack remembers the choice in this browser. Importing a file like this makes a board from its content alone: everything it says people did stays behind. Backups never include it.
+
## Importing
On the home page, choose **New board**, then **Import a board** under **From a file**. Drop a board's `.json` on it, choose the file, or paste its JSON. Tack checks it and shows what's in it; name the board, choose who can open it, and choose **Import board**.
diff --git a/site/src/content/docs/help/faq.md b/site/src/content/docs/help/faq.md
index d7ca1219..637dda54 100644
--- a/site/src/content/docs/help/faq.md
+++ b/site/src/content/docs/help/faq.md
@@ -47,7 +47,7 @@ Only where it helps and you can see it: naming [stacks](/boards/stacks/), sortin
## Can I export a board?
-Yes: as a PNG or SVG (the whole board, or what's selected), or as JSON to import into a new board, here or on another Tack. A board's owner can also have Tack back it up to a branch on GitHub. See [Export, import and backups](/boards/export/). You can also copy shapes between boards, and pasting them into another app gives plain text: stickies' text and cards' links.
+Yes: as a PNG or SVG (the whole board, or what's selected), or as JSON to import into a new board, here or on another Tack, or to read elsewhere with its votes, stamps and comments. A board's owner can also have Tack back it up to a branch on GitHub. See [Export, import and backups](/boards/export/). You can also copy shapes between boards, and pasting them into another app gives plain text: stickies' text and cards' links.
## Is there an API?
diff --git a/src/client/export/ExportDialog.tsx b/src/client/export/ExportDialog.tsx
index 2165872c..84812b1e 100644
--- a/src/client/export/ExportDialog.tsx
+++ b/src/client/export/ExportDialog.tsx
@@ -4,12 +4,14 @@ import {
type ExportOmissions,
type ExportedImage,
exportBoard,
+ type ExportSummary,
exportFileName,
summariseExport,
} from "../../shared/boardExport.ts";
import { describeBytes } from "../../shared/images.ts";
import { Modal } from "../boards/Modal.tsx";
import { cardStore } from "../cardStore.ts";
+import { useComments } from "../comments/store.ts";
import { useImages } from "../images/service.ts";
import { SHORTCUTS, isApplePlatform, matchesCombo } from "../shortcuts.ts";
import type { ToastStore } from "../Toasts.tsx";
@@ -26,6 +28,8 @@ import "./export.css";
// board's menu. Images show a preview as they'll come out, and say how big
// they'll be; JSON says what it holds and what it leaves out, and carries
// the files its image shapes show (fetched from the board as it's chosen).
+// JSON can also carry what people did on the board (votes, stamps, hands up,
+// comments and the items its cards show), for reading it elsewhere.
const FORMATS: { format: ExportFormat; label: string; hint: string }[] = [
{ format: "png", label: "PNG", hint: "An image, for docs, slides and chat" },
@@ -44,16 +48,19 @@ interface Prefs {
format: ExportFormat;
scale: number;
background: boolean;
+ /** JSON: include what people did on the board. */
+ activity: boolean;
}
function loadPrefs(): Prefs {
- const fallback: Prefs = { format: "png", scale: 2, background: true };
+ const fallback: Prefs = { format: "png", scale: 2, background: true, activity: false };
try {
const raw = JSON.parse(localStorage.getItem(PREFS_KEY) ?? "null") as Partial | null;
return {
format: raw?.format === "svg" || raw?.format === "json" ? raw.format : fallback.format,
scale: SCALES.includes(raw?.scale as never) ? (raw?.scale as number) : fallback.scale,
background: typeof raw?.background === "boolean" ? raw.background : fallback.background,
+ activity: typeof raw?.activity === "boolean" ? raw.activity : fallback.activity,
};
} catch {
return fallback;
@@ -170,7 +177,7 @@ function ExportPanel({
});
setError(null);
};
- const { format, scale, background } = prefs;
+ const { format, scale, background, activity } = prefs;
const image = format !== "json";
const title = boardTitle(name);
@@ -217,6 +224,7 @@ function ExportPanel({
const size = region ? imageSize(region, format === "svg" ? 1 : scale) : null;
// JSON: what's in it, and what's left out, worked out once it's chosen.
+ const { threads } = useComments();
const json = useMemo(() => {
if (format !== "json") return null;
const { data, omitted } = exportBoard(doc, {
@@ -225,9 +233,12 @@ function ExportPanel({
const card = cardStore.get(key);
return !(card && "missing" in card && card.reason === "private");
},
+ ...(activity
+ ? { activity: { cards: (key: string) => cardStore.get(key), threads: threads.values() } }
+ : {}),
});
return { data, omitted, summary: summariseExport(data) };
- }, [doc, format, title]);
+ }, [doc, format, title, activity, threads]);
// Its image shapes' files, fetched from the board for the file to carry.
const images = useImages();
@@ -415,6 +426,8 @@ function ExportPanel({
omitted={json.omitted}
files={files}
bytes={file?.bytes ?? null}
+ activity={activity}
+ onActivity={(include) => update({ activity: include })}
/>
)
)}
@@ -507,13 +520,18 @@ function JsonSummary({
omitted,
files,
bytes,
+ activity,
+ onActivity,
}: {
- summary: ReturnType;
+ summary: ExportSummary;
omitted: ExportOmissions;
files: Files;
/** The file's size, once its images are in. */
bytes: number | null;
+ activity: boolean;
+ onActivity: (include: boolean) => void;
}) {
+ const activityId = useId();
const counts = Object.entries(NOUNS).flatMap(([type, [one, many]]) => {
const n = summary.counts[type as keyof typeof summary.counts] ?? 0;
return n > 0 ? [plural(n, one, many)] : [];
@@ -527,11 +545,36 @@ function JsonSummary({
ยท {bytes === null ? "โฆ" : describeBytes(bytes)}
+
+
+
+ {summary.activity ? (
+
+ ) : (
+ "Who voted, stamped, put their hand up and commented, and what the cards show, for reading the board elsewhere. Importing leaves them out."
+ )}
+
+
-
- Comments, history, votes, stamps and staged changes stay here: theyโre peopleโs own, or
- only mean something on this board.
-
+ {activity && (
+
+ It names who voted, stamped, put their hand up and commented, except in hidden votes, as
+ the board does. Importing it leaves all that behind.
+
+ )}
+
History and staged changes stay here: they only mean something on this board.
{omitted.cards > 0 && (
{plural(omitted.cards, "card")} from repositories you canโt read{" "}
@@ -550,6 +593,18 @@ function JsonSummary({
);
}
+/** What people did, as the file carries it: "2 votes ยท 9 stamps ยท 12 comment threads ยท 3 cardsโ details". */
+function ActivityCounts({ counts }: { counts: NonNullable }) {
+ const parts = [
+ counts.votes ? plural(counts.votes, "vote") : null,
+ counts.stamps ? plural(counts.stamps, "stamp") : null,
+ counts.volunteers ? plural(counts.volunteers, "hand up", "hands up") : null,
+ counts.comments ? plural(counts.comments, "comment thread") : null,
+ counts.items ? plural(counts.items, "cardโs details", "cardsโ details") : null,
+ ].filter((part) => part !== null);
+ return <>{parts.length ? parts.join(" ยท ") : "No votes, stamps, hands up or comments yet"}>;
+}
+
/** The board's images in the file: a bar while they're fetched, then how many went in. */
function ImagesNote({ files }: { files: Files }) {
if (files.status === "fetching") {
diff --git a/src/client/export/export.css b/src/client/export/export.css
index 1d929d4c..97fb481b 100644
--- a/src/client/export/export.css
+++ b/src/client/export/export.css
@@ -395,6 +395,23 @@
transition: transform var(--t-base) var(--ease-out);
}
+/* Including what people did: a switch, and what it'd add (or what it's for). */
+.export-json-activity {
+ display: grid;
+ gap: 4px;
+ padding-top: 10px;
+ border-top: 1px solid var(--line);
+}
+
+.export-json-activity-note {
+ margin: 0;
+ padding-left: 38px;
+ color: var(--ink-soft);
+ font-size: 12.5px;
+ line-height: 1.45;
+ font-variant-numeric: tabular-nums;
+}
+
.export-json-notes {
display: grid;
gap: 6px;
diff --git a/src/shared/boardExport.ts b/src/shared/boardExport.ts
index e377553c..06e69067 100644
--- a/src/shared/boardExport.ts
+++ b/src/shared/boardExport.ts
@@ -3,6 +3,11 @@ import * as v from "valibot";
import * as Y from "yjs";
import { isDate } from "./actionItems.ts";
import { type CreatedBoard, MAX_TITLE, type Visibility } from "./boards.ts";
+import { type Card, type CardKind, type CardState, type MissingCard, isMissing } from "./cards.ts";
+import { type CommentAuthor, type CommentThread, liveMessages } from "./comments.ts";
+import { readVotes as readDecisionVotes, tally } from "./decisions.ts";
+import { closedAt, readVotes, tallyVote, turnout } from "./dotvotes.ts";
+import { type StampEmoji, stampCounts, stampsMap } from "./facilitation.ts";
import { IMAGE_HASH, IMAGE_LIMITS, IMAGE_TYPES, type ImageType, isImageHash } from "./images.ts";
import { type PreparedPlan, preparedMap, readPrepared, readStep } from "./prepared.ts";
import { LINK_LIMITS, linkUrl, readPreview } from "./linkPreview.ts";
@@ -18,6 +23,7 @@ import {
import { type ShapeType, shapesMap } from "./shapes.ts";
import { isStackable } from "./stacks.ts";
import { TEMPLATE_REPOS_MAX, cleanRepos } from "./templates.ts";
+import { readVolunteers, volunteersMap } from "./volunteers.ts";
// A board as a file (#59): its shapes (cards, stickies, text, sections,
// connectors, decisions, widgets, action items, stacks, images), rules and
@@ -50,6 +56,14 @@ import { TEMPLATE_REPOS_MAX, cleanRepos } from "./templates.ts";
// Every property is copied by name (`KEEP`), so a property added to shapes
// later stays out of exports until it's added here on purpose.
//
+// A download may also carry what people did on the board, when the exporter
+// asks (`activity`): dot votes' results, decisions' votes, stamps, hands up,
+// comments and the items its cards show, for reading the board somewhere
+// else. It's only ever about shapes in the file, and says no more than the
+// board shows anyone who can open it: a hidden vote never names who voted,
+// and gives no results until it closes. Backups never carry it. Importing
+// drops it unread, and it doesn't count towards what an import may weigh.
+//
// Importing (`readBoardExport`, then `importBoard`) checks everything with
// Valibot, gives every shape, rule and step a fresh id (references remapped,
// dangling ones dropped), and restacks shapes in the file's order.
@@ -108,6 +122,93 @@ export interface BoardExport {
* uploads them to the board an import makes (`splitImages`).
*/
images?: ExportedImage[];
+ /** What people did on the board, when the exporter asked for it. Never imported. */
+ activity?: ExportedActivity;
+}
+
+/** What people did on a board, about the shapes in its export (by their ids). */
+export interface ExportedActivity {
+ /** Dot votes, oldest first. */
+ votes: ExportedVote[];
+ /** Decisions' agree and disagree votes, for decisions anyone's voted on. */
+ decisions: { shape: string; agree: ExportedPerson[]; disagree: ExportedPerson[] }[];
+ /** Stamps, by shape and kind, in the order people stamped. */
+ stamps: { shape: string; emoji: StampEmoji; people: ExportedPerson[] }[];
+ /** Hands up, by shape, in the order people put them up. */
+ volunteers: { shape: string; people: ExportedPerson[] }[];
+ /** Comment threads still showing, oldest first. */
+ comments: ExportedThread[];
+ /** What the cards (and decisions' and action items' links) show, as the exporter saw it. */
+ items: ExportedItem[];
+}
+
+export interface ExportedPerson {
+ name: string;
+ login?: string;
+}
+
+export interface ExportedVote {
+ id: string;
+ title?: string;
+ /** The section it covers, or null for the whole board. */
+ scope: string | null;
+ /** Dots per person. */
+ dots: number;
+ hidden: boolean;
+ /** Whether ๐ reactions on cards' items counted as votes. */
+ reactions: boolean;
+ startedAt: string;
+ endsAt?: string;
+ /** When it closed; absent while it's open. */
+ closedAt?: string;
+ /** How many people have put dots down. */
+ voters: number;
+ /** Most votes first. Absent while a hidden vote is open. */
+ results?: ExportedResult[];
+}
+
+export interface ExportedResult {
+ shape: string;
+ /** 1 for the most votes; shapes tied share a rank. */
+ rank: number;
+ dots: number;
+ reactions: number;
+ total: number;
+ /** Who put dots on it: never for a hidden vote. */
+ by?: (ExportedPerson & { dots: number })[];
+ /** Whose ๐ counted, where known: never for a hidden vote. */
+ reactors?: string[];
+}
+
+export interface ExportedThread {
+ id: string;
+ /** The shape it's pinned to; absent for a pin on the canvas. */
+ shape?: string;
+ x: number;
+ y: number;
+ resolved?: { by: ExportedPerson; at: string };
+ messages: { author: ExportedPerson; body: string; at: string; editedAt?: string }[];
+}
+
+export interface ExportedItem {
+ key: string;
+ kind: CardKind;
+ title: string;
+ state: CardState;
+ stateReason?: string;
+ url: string;
+ labels: string[];
+ assignees: string[];
+ author?: string;
+ comments: number;
+ reactions: number;
+ thumbsUp?: number;
+ milestone?: string;
+ /** Discussions: their category, upvotes and whether they're answered. */
+ category?: string;
+ upvotes?: number;
+ answered?: boolean;
+ updatedAt: string;
}
/** An image file in an export. */
@@ -171,12 +272,19 @@ export interface ExportOptions {
* Cards it refuses are left out; decisions and action items lose the link.
*/
includeCard?: (key: string) => boolean;
+ /** Carry what people did on the board too (`ExportedActivity`). */
+ activity?: {
+ /** The items cards show, as the exporter sees them. */
+ cards: (key: string) => Card | MissingCard | undefined;
+ /** The board's comment threads, which aren't in the document. */
+ threads: Iterable;
+ };
}
/** A board's document as an export, and what was left out. */
export function exportBoard(
doc: Y.Doc,
- { title, now = new Date().toISOString(), includeCard = () => true }: ExportOptions,
+ { title, now = new Date().toISOString(), includeCard = () => true, activity }: ExportOptions,
): { data: BoardExport; omitted: ExportOmissions } {
const omitted: ExportOmissions = { cards: 0, faceDown: 0 };
const entries: { id: string; z: string; props: Record }[] = [];
@@ -268,11 +376,173 @@ export function exportBoard(
rules,
prepared,
...(scopeRepos(doc).length ? { repos: scopeRepos(doc) } : {}),
+ ...(activity
+ ? { activity: exportActivity(doc, shapes, { ...activity, includeCard, now }) }
+ : {}),
},
omitted,
};
}
+function person({ name, login }: { name: string; login?: string }): ExportedPerson {
+ return login ? { name, login } : { name };
+}
+
+function commenter(author: CommentAuthor): ExportedPerson {
+ return { name: author.name ?? author.login, login: author.login };
+}
+
+const iso = (ms: number) => new Date(ms).toISOString();
+
+/** What people did on the board, about the shapes an export kept. */
+function exportActivity(
+ doc: Y.Doc,
+ shapes: readonly ExportedShape[],
+ {
+ cards,
+ threads,
+ includeCard,
+ now,
+ }: NonNullable & {
+ includeCard: (key: string) => boolean;
+ now: string;
+ },
+): ExportedActivity {
+ // Where each shape is in the file, to list what's about them in the same order.
+ const at = new Map(shapes.map((shape, i) => [shape.id, i]));
+ const kept = new Set(at.keys());
+ const byShape = (a: { shape: string }, b: { shape: string }) =>
+ (at.get(a.shape) ?? 0) - (at.get(b.shape) ?? 0);
+ // The tally reads only items the exporter may: anyone else's ๐s aren't theirs to count.
+ const readable = (key: string) => (includeCard(key) ? cards(key) : undefined);
+
+ const votes = readVotes(doc).map((vote): ExportedVote => {
+ const closed = closedAt(vote, Date.parse(now));
+ const out: ExportedVote = {
+ id: vote.id,
+ ...(vote.title ? { title: vote.title } : {}),
+ scope: vote.scope,
+ dots: vote.dots,
+ hidden: vote.hidden,
+ reactions: vote.reactions,
+ startedAt: vote.startedAt,
+ ...(vote.endsAt ? { endsAt: vote.endsAt } : {}),
+ ...(closed ? { closedAt: closed } : {}),
+ voters: turnout(vote).voters,
+ };
+ if (vote.hidden && !closed) return out;
+ out.results = tallyVote(vote, doc, readable)
+ .filter((row) => kept.has(row.shape))
+ .map((row) => ({
+ shape: row.shape,
+ rank: row.rank,
+ dots: row.dots,
+ reactions: row.reactions,
+ total: row.total,
+ ...(vote.hidden
+ ? {}
+ : {
+ by: row.dotters.map((dotter) => ({ ...person(dotter), dots: dotter.dots })),
+ reactors: row.reactors,
+ }),
+ }));
+ return out;
+ });
+
+ const decisions: ExportedActivity["decisions"] = [];
+ const items: ExportedItem[] = [];
+ const seen = new Set();
+ for (const shape of shapes) {
+ if (shape.type === "decision") {
+ const votes = shapesMap(doc).get(shape.id)?.get("votes");
+ const { agree, disagree, total } = tally(readDecisionVotes(votes));
+ if (total > 0) {
+ decisions.push({
+ shape: shape.id,
+ agree: agree.map(person),
+ disagree: disagree.map(person),
+ });
+ }
+ }
+ // Shapes in the file keep only refs the exporter may read.
+ const ref = typeof shape.ref === "string" ? shape.ref : null;
+ if (!ref || seen.has(ref)) continue;
+ seen.add(ref);
+ const card = cards(ref);
+ if (card && !isMissing(card)) items.push(exportItem(card));
+ }
+
+ const stamps: ExportedActivity["stamps"] = [];
+ for (const [shape, counts] of stampCounts(stampsMap(doc).entries(), null)) {
+ if (!kept.has(shape)) continue;
+ for (const count of counts) {
+ stamps.push({ shape, emoji: count.emoji, people: count.people.map(person) });
+ }
+ }
+ stamps.sort(byShape);
+
+ const volunteers: ExportedActivity["volunteers"] = [];
+ for (const [shape, people] of readVolunteers(volunteersMap(doc).entries())) {
+ if (kept.has(shape)) volunteers.push({ shape, people: people.map(person) });
+ }
+ volunteers.sort(byShape);
+
+ const ids = new Set(shapesMap(doc).keys());
+ const comments: ExportedThread[] = [];
+ for (const thread of [...threads].sort((a, b) => a.createdAt - b.createdAt)) {
+ const messages = liveMessages(thread);
+ if (messages.length === 0) continue;
+ const { shape, x, y } = thread.anchor;
+ // On a shape the file left out (a card the exporter can't read, a face-down sticky):
+ // the thread's about it. A shape that's been deleted leaves a pin on the canvas.
+ if (shape && ids.has(shape) && !kept.has(shape)) continue;
+ comments.push({
+ id: thread.id,
+ ...(shape && kept.has(shape) ? { shape } : {}),
+ x,
+ y,
+ ...(thread.resolved
+ ? { resolved: { by: commenter(thread.resolved.by), at: iso(thread.resolved.at) } }
+ : {}),
+ messages: messages.map((message) => ({
+ author: commenter(message.author),
+ body: message.body,
+ at: iso(message.at),
+ ...(message.editedAt ? { editedAt: iso(message.editedAt) } : {}),
+ })),
+ });
+ }
+
+ return { votes, decisions, stamps, volunteers, comments, items };
+}
+
+function exportItem(card: Card): ExportedItem {
+ const discussion = card.discussion;
+ return {
+ key: card.key,
+ kind: card.kind,
+ title: card.title,
+ state: card.state,
+ ...(card.stateReason ? { stateReason: card.stateReason } : {}),
+ url: card.url,
+ labels: card.labels.map((label) => label.name),
+ assignees: card.assignees.map((assignee) => assignee.login),
+ ...(card.author ? { author: card.author.login } : {}),
+ comments: card.comments,
+ reactions: card.reactions,
+ ...(card.thumbsUp ? { thumbsUp: card.thumbsUp.count } : {}),
+ ...(card.milestone ? { milestone: card.milestone.title } : {}),
+ ...(discussion
+ ? {
+ category: discussion.category.name,
+ ...(discussion.upvotes !== undefined ? { upvotes: discussion.upvotes } : {}),
+ answered: discussion.answered,
+ }
+ : {}),
+ updatedAt: card.updatedAt,
+ };
+}
+
/** An export as a file's text: tabs, one property per line, so it diffs well in git. */
export function serialiseExport(data: BoardExport): string {
return `${JSON.stringify(data, null, "\t")}\n`;
@@ -554,9 +824,13 @@ export function readBoardExport(input: unknown): ReadResult {
} catch {
return { ok: false, error: { reason: "not_json" } };
}
- // Past the limit only with images, which are weighed apart.
+ // Past the limit only with images and activity, which are weighed apart (activity's dropped).
if (bytes > BOARD_EXPORT_MAX_BYTES) {
- const { images: _images, ...rest } = (raw ?? {}) as Record;
+ const {
+ images: _images,
+ activity: _activity,
+ ...rest
+ } = (raw ?? {}) as Record;
if (new TextEncoder().encode(JSON.stringify(rest)).byteLength > BOARD_EXPORT_MAX_BYTES) {
return { ok: false, error: { reason: "too_large" } };
}
@@ -633,16 +907,32 @@ export interface ExportSummary {
counts: Partial>;
rules: number;
prepared: number;
+ /** What people did, counted, when the export carries it: stamps and hands up by person. */
+ activity?: { votes: number; stamps: number; volunteers: number; comments: number; items: number };
}
export function summariseExport(data: BoardExport): ExportSummary {
const counts: Partial> = {};
for (const shape of data.shapes) counts[shape.type] = (counts[shape.type] ?? 0) + 1;
+ const { activity } = data;
+ const people = (groups: readonly { people: readonly unknown[] }[]) =>
+ groups.reduce((n, group) => n + group.people.length, 0);
return {
shapes: data.shapes.length,
counts,
rules: data.rules.length,
prepared: data.prepared.length,
+ ...(activity
+ ? {
+ activity: {
+ votes: activity.votes.length,
+ stamps: people(activity.stamps),
+ volunteers: people(activity.volunteers),
+ comments: activity.comments.length,
+ items: activity.items.length,
+ },
+ }
+ : {}),
};
}
diff --git a/test/board-export.test.ts b/test/board-export.test.ts
index 04ef825a..2ca3bd23 100644
--- a/test/board-export.test.ts
+++ b/test/board-export.test.ts
@@ -28,12 +28,17 @@ import {
splitImages,
summariseExport,
} from "../src/shared/boardExport.ts";
-import { castVote } from "../src/shared/decisions.ts";
+import type { Card, MissingCard } from "../src/shared/cards.ts";
+import type { CommentThread } from "../src/shared/comments.ts";
+import { addDecision, castVote } from "../src/shared/decisions.ts";
+import { closeVote, placeDot, readVotes, startVote } from "../src/shared/dotvotes.ts";
+import { stampsMap, toggleStamp } from "../src/shared/facilitation.ts";
import { hashImage } from "../src/shared/images.ts";
import { preparedMap, readPrepared } from "../src/shared/prepared.ts";
import { addRule, listRules, scopeRepos, setScopeRepos } from "../src/shared/rules.ts";
import { addShape, shapesMap } from "../src/shared/shapes.ts";
import { readShapes } from "../src/shared/structure.ts";
+import { toggleVolunteer, volunteersMap } from "../src/shared/volunteers.ts";
const BY = { name: "Priya Shah", color: "#b4467a", login: "priya" };
const NOW = "2026-10-11T09:00:00.000Z";
@@ -224,6 +229,413 @@ describe("exporting a board", () => {
});
});
+describe("what people did on a board", () => {
+ const KAI = { name: "Kai", color: "#123456", login: "kai" };
+ const PRIYA = { name: "Priya Shah", color: "#b4467a", login: "priya" };
+ const at = (minute: number) => `2026-10-11T09:${String(minute).padStart(2, "0")}:00.000Z`;
+
+ function item(key: string, extra: Partial = {}): Card {
+ return {
+ key,
+ forge: "github",
+ host: "github.com",
+ repo: key.slice("github.com/".length).split("#")[0] ?? "",
+ item: `#${key.split("#")[1]}`,
+ kind: "issue",
+ title: "Uploads fail on Safari",
+ state: "open",
+ stateReason: null,
+ labels: [{ name: "bug", color: "d73a4a" }],
+ assignees: [{ login: "kai", avatarUrl: "https://avatars.example/kai" }],
+ author: { login: "priya", avatarUrl: "https://avatars.example/priya" },
+ comments: 4,
+ reactions: 3,
+ thumbsUp: { count: 2, logins: ["kai", "zed"] },
+ url: `https://github.com/${key.slice("github.com/".length).replace("#", "/issues/")}`,
+ updatedAt: at(1),
+ ...extra,
+ };
+ }
+
+ const cards = (key: string): Card | MissingCard | undefined =>
+ key.includes("/secret") ? { key, missing: true, reason: "private" } : item(key);
+
+ function author(login: string, name: string | null): CommentThread["messages"][number]["author"] {
+ return { id: login.length, login, name, avatarUrl: `https://avatars.example/${login}` };
+ }
+
+ const THREADS: CommentThread[] = [
+ {
+ id: "t1",
+ anchor: { x: 30, y: 50, shape: "n1", dx: 10, dy: 10 },
+ createdAt: Date.parse(at(10)),
+ updatedAt: Date.parse(at(12)),
+ resolved: null,
+ messages: [
+ {
+ id: "m1",
+ author: author("priya", "Priya Shah"),
+ body: "Can we ship this @kai?",
+ at: Date.parse(at(10)),
+ mentions: ["kai"],
+ },
+ {
+ id: "m2",
+ author: author("kai", null),
+ body: "",
+ at: Date.parse(at(11)),
+ deleted: true,
+ },
+ {
+ id: "m3",
+ author: author("kai", null),
+ body: "Yes, Friday",
+ at: Date.parse(at(12)),
+ editedAt: Date.parse(at(13)),
+ },
+ ],
+ },
+ // On a card from a repository the exporter can't read: stays out.
+ {
+ id: "t2",
+ anchor: { x: 600, y: 300, shape: "c2", dx: 0, dy: 0 },
+ createdAt: Date.parse(at(14)),
+ updatedAt: Date.parse(at(14)),
+ resolved: null,
+ messages: [
+ {
+ id: "m4",
+ author: author("kai", "Kai"),
+ body: "About the secret",
+ at: Date.parse(at(14)),
+ },
+ ],
+ },
+ // A free pin, resolved.
+ {
+ id: "t3",
+ anchor: { x: -40, y: 900 },
+ createdAt: Date.parse(at(15)),
+ updatedAt: Date.parse(at(16)),
+ resolved: { by: author("priya", "Priya Shah"), at: Date.parse(at(16)) },
+ messages: [
+ { id: "m5", author: author("kai", "Kai"), body: "Parking this", at: Date.parse(at(15)) },
+ ],
+ },
+ // Everything in it deleted: gone.
+ {
+ id: "t4",
+ anchor: { x: 0, y: 0 },
+ createdAt: Date.parse(at(17)),
+ updatedAt: Date.parse(at(17)),
+ resolved: null,
+ messages: [
+ { id: "m6", author: author("kai", "Kai"), body: "", at: Date.parse(at(17)), deleted: true },
+ ],
+ },
+ ];
+
+ /** The retro board after a session: three dot votes, stamps, hands up. */
+ function session(): { doc: Y.Doc; votes: string[] } {
+ const doc = board();
+ const open = startVote(doc, { scope: "s1", dots: 3, reactions: true, by: BY, at: at(2) });
+ placeDot(doc, open, KAI, "n1", at(3));
+ placeDot(doc, open, KAI, "n1", at(3));
+ placeDot(doc, open, KAI, "c1", at(3));
+ placeDot(doc, open, PRIYA, "c1", at(4));
+ // Face down, so it isn't in the file, and nor are its dots.
+ placeDot(doc, open, PRIYA, "n2", at(4));
+ closeVote(doc, open, BY, at(5));
+ const secret = startVote(doc, { scope: null, hidden: true, by: BY, at: at(7) });
+ placeDot(doc, secret, KAI, "n1", at(7));
+ const sealed = startVote(doc, {
+ scope: null,
+ title: "Next",
+ hidden: true,
+ endsAt: at(8),
+ by: BY,
+ at: at(6),
+ });
+ placeDot(doc, sealed, PRIYA, "c1", at(7));
+ toggleStamp(doc, "n1", "๐", KAI, at(20));
+ toggleStamp(doc, "n1", "๐", PRIYA, at(21));
+ toggleStamp(doc, "n1", "๐", PRIYA, at(22));
+ toggleStamp(doc, "c2", "๐", KAI, at(23));
+ toggleVolunteer(doc, "c1", { ...KAI, avatarUrl: "https://avatars.example/kai" }, at(24));
+ toggleVolunteer(doc, "c1", PRIYA, at(25));
+ toggleVolunteer(doc, "c2", PRIYA, at(25));
+ addDecision(doc, "d2", { x: 900, y: 400, title: "Weekly releases" });
+ castVote(doc, "d2", KAI, "agree");
+ castVote(doc, "d2", PRIYA, "disagree");
+ return { doc, votes: [open, sealed, secret] };
+ }
+
+ it("stays out unless asked for", () => {
+ const { doc } = session();
+ const { data } = exportBoard(doc, { title: "Retro", now: NOW });
+ expect(data).not.toHaveProperty("activity");
+ const json = serialiseExport(data);
+ expect(json).not.toContain("Friday");
+ expect(json).not.toContain("๐");
+ });
+
+ it("holds votes' results, stamps, hands up, comments and items, for the shapes in the file", () => {
+ const { doc, votes } = session();
+ const [open, sealed, secret] = votes;
+ const { data } = exportBoard(doc, {
+ title: "Retro",
+ now: at(30),
+ includeCard: readable,
+ activity: { cards, threads: THREADS },
+ });
+ const activity = data.activity;
+ expect(activity?.votes).toEqual([
+ {
+ id: open,
+ scope: "s1",
+ dots: 3,
+ hidden: false,
+ reactions: true,
+ startedAt: at(2),
+ closedAt: at(5),
+ voters: 2,
+ // zed's ๐ counts; kai's doesn't, as kai voted here.
+ results: [
+ {
+ shape: "c1",
+ rank: 1,
+ dots: 2,
+ reactions: 1,
+ total: 3,
+ by: [
+ { name: "Kai", login: "kai", dots: 1 },
+ { name: "Priya Shah", login: "priya", dots: 1 },
+ ],
+ reactors: ["zed"],
+ },
+ {
+ shape: "n1",
+ rank: 2,
+ dots: 2,
+ reactions: 0,
+ total: 2,
+ by: [{ name: "Kai", login: "kai", dots: 2 }],
+ reactors: [],
+ },
+ ],
+ },
+ // Hidden, and closed when its time ran out: totals, never who.
+ {
+ id: sealed,
+ title: "Next",
+ scope: null,
+ dots: 3,
+ hidden: true,
+ reactions: false,
+ startedAt: at(6),
+ endsAt: at(8),
+ closedAt: at(8),
+ voters: 1,
+ results: [{ shape: "c1", rank: 1, dots: 1, reactions: 0, total: 1 }],
+ },
+ // Hidden and still open: only how many have voted.
+ {
+ id: secret,
+ scope: null,
+ dots: 3,
+ hidden: true,
+ reactions: false,
+ startedAt: at(7),
+ voters: 1,
+ },
+ ]);
+ // d1 was decided before anyone voted: decided decisions don't take votes.
+ expect(activity?.decisions).toEqual([
+ {
+ shape: "d2",
+ agree: [{ name: "Kai", login: "kai" }],
+ disagree: [{ name: "Priya Shah", login: "priya" }],
+ },
+ ]);
+ expect(activity?.stamps).toEqual([
+ {
+ shape: "n1",
+ emoji: "๐",
+ people: [
+ { name: "Kai", login: "kai" },
+ { name: "Priya Shah", login: "priya" },
+ ],
+ },
+ { shape: "n1", emoji: "๐", people: [{ name: "Priya Shah", login: "priya" }] },
+ ]);
+ expect(activity?.volunteers).toEqual([
+ {
+ shape: "c1",
+ people: [
+ { name: "Kai", login: "kai" },
+ { name: "Priya Shah", login: "priya" },
+ ],
+ },
+ ]);
+ expect(activity?.comments).toEqual([
+ {
+ id: "t1",
+ shape: "n1",
+ x: 30,
+ y: 50,
+ messages: [
+ {
+ author: { name: "Priya Shah", login: "priya" },
+ body: "Can we ship this @kai?",
+ at: at(10),
+ },
+ {
+ author: { name: "kai", login: "kai" },
+ body: "Yes, Friday",
+ at: at(12),
+ editedAt: at(13),
+ },
+ ],
+ },
+ {
+ id: "t3",
+ x: -40,
+ y: 900,
+ resolved: { by: { name: "Priya Shah", login: "priya" }, at: at(16) },
+ messages: [{ author: { name: "Kai", login: "kai" }, body: "Parking this", at: at(15) }],
+ },
+ ]);
+ expect(activity?.items).toEqual([
+ {
+ key: "github.com/acme/app#1",
+ kind: "issue",
+ title: "Uploads fail on Safari",
+ state: "open",
+ url: "https://github.com/acme/app/issues/1",
+ labels: ["bug"],
+ assignees: ["kai"],
+ author: "priya",
+ comments: 4,
+ reactions: 3,
+ thumbsUp: 2,
+ updatedAt: at(1),
+ },
+ ]);
+ const json = serialiseExport(data);
+ expect(json).not.toContain("secret");
+ expect(json).not.toContain("๐");
+ });
+
+ it("says what a discussion has, and leaves out items it has no data for", () => {
+ const doc = new Y.Doc();
+ addShape(doc, "c", {
+ type: "card",
+ x: 0,
+ y: 0,
+ z: "a0",
+ parent: null,
+ ref: "github.com/acme/app#9",
+ });
+ addShape(doc, "u", {
+ type: "card",
+ x: 0,
+ y: 0,
+ z: "a1",
+ parent: null,
+ ref: "github.com/acme/app#10",
+ });
+ const discussion = item("github.com/acme/app#9", {
+ kind: "discussion",
+ title: "Multi-tenancy",
+ state: "closed",
+ stateReason: "resolved",
+ milestone: { title: "1.0", dueOn: null },
+ discussion: {
+ category: { name: "Ideas", emoji: "๐ก", answerable: false },
+ answered: false,
+ upvotes: 13,
+ locked: false,
+ },
+ });
+ const { data } = exportBoard(doc, {
+ title: "Ideas",
+ activity: { cards: (key) => (key === discussion.key ? discussion : undefined), threads: [] },
+ });
+ expect(data.activity?.items).toEqual([
+ {
+ key: "github.com/acme/app#9",
+ kind: "discussion",
+ title: "Multi-tenancy",
+ state: "closed",
+ stateReason: "resolved",
+ url: "https://github.com/acme/app/issues/9",
+ labels: ["bug"],
+ assignees: ["kai"],
+ author: "priya",
+ comments: 4,
+ reactions: 3,
+ thumbsUp: 2,
+ milestone: "1.0",
+ category: "Ideas",
+ upvotes: 13,
+ answered: false,
+ updatedAt: at(1),
+ },
+ ]);
+ expect(data.activity).toMatchObject({ votes: [], stamps: [], volunteers: [], comments: [] });
+ });
+
+ it("is counted for the dialog, and left behind by an import", () => {
+ const { doc } = session();
+ const { data } = exportBoard(doc, {
+ title: "Retro",
+ includeCard: readable,
+ activity: { cards, threads: THREADS },
+ });
+ expect(summariseExport(data).activity).toEqual({
+ votes: 3,
+ stamps: 3,
+ volunteers: 2,
+ comments: 2,
+ items: 1,
+ });
+ expect(summariseExport(exportBoard(doc, { title: "Retro" }).data)).not.toHaveProperty(
+ "activity",
+ );
+ const read = readBoardExport(serialiseExport(data));
+ expect(read.ok).toBe(true);
+ if (!read.ok) return;
+ expect(read.data).not.toHaveProperty("activity");
+ const copy = new Y.Doc();
+ importBoard(copy, read.data, { by: BY, newId: counter() });
+ expect(readVotes(copy)).toEqual([]);
+ expect(stampsMap(copy).size).toBe(0);
+ expect(volunteersMap(copy).size).toBe(0);
+ });
+
+ it("doesn't count towards what an import may weigh", () => {
+ const { doc } = session();
+ const long = "x".repeat(BOARD_EXPORT_MAX_BYTES);
+ const { data } = exportBoard(doc, {
+ title: "Retro",
+ activity: {
+ cards,
+ threads: [
+ {
+ id: "long",
+ anchor: { x: 0, y: 0 },
+ createdAt: 0,
+ updatedAt: 0,
+ resolved: null,
+ messages: [{ id: "m", author: author("kai", "Kai"), body: long, at: 0 }],
+ },
+ ],
+ },
+ });
+ expect(readBoardExport(serialiseExport(data)).ok).toBe(true);
+ });
+});
+
describe("reading an export", () => {
const valid = () => exportBoard(board(), { title: "Retro", now: NOW }).data;
diff --git a/test/wrangler-config.test.ts b/test/wrangler-config.test.ts
index f4e99c65..75665fda 100644
--- a/test/wrangler-config.test.ts
+++ b/test/wrangler-config.test.ts
@@ -4,7 +4,7 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vite-plus/test";
-import { WRANGLER_CONFIG, wranglerConfig } from "../scripts/wrangler-config.ts";
+import { WRANGLER_CONFIG, workerConfig, wranglerConfig } from "../scripts/wrangler-config.ts";
describe("what the Deploy to Cloudflare button reads", () => {
it("wrangler.jsonc matches cloudflare.config.ts (run `node scripts/wrangler-config.ts`)", async () => {
@@ -44,3 +44,14 @@ describe("what the Deploy to Cloudflare button reads", () => {
expect(pkg.scripts.build).toBeTruthy();
});
});
+
+describe("a Preview's config", () => {
+ it("has no cron triggers, which Preview uploads refuse, while production keeps them", async () => {
+ const preview = await workerConfig({ isPreview: true, mode: "production" });
+ expect(preview.worker).not.toHaveProperty("triggers");
+ const production = await workerConfig({ isPreview: false, mode: "production" });
+ expect(production.worker.triggers).toEqual([
+ expect.objectContaining({ schedule: "*/10 * * * *" }),
+ ]);
+ });
+});