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 * * * *" }), + ]); + }); +});