From 82fa87b9f193728c9025ed606b89a699dadeb0ee Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 9 Oct 2026 15:47:08 +0000 Subject: [PATCH 1/5] Read and edit the website's flowchart JSON in core The app shows the website's Flowchart without React Flow, so the map's node and edge JSON gets a plain helper in packages/core: the website's starter map, edge kinds read from the label or stroke the website writes, cards grouped by role and as a tree, and add, edit, link and delete that copy every node and edge they touch, so fields React Flow added survive. The test edits a website-shaped map and checks that everything the app did not edit comes back unchanged, and that a link is the same edge the website's onConnect makes through React Flow's addEdge. Co-Authored-By: Claude Opus 5.5 --- packages/core/src/flowchart.ts | 342 ++++++++++++++++++ src/test/flowchart/flowchart-document.test.ts | 116 ++++++ 2 files changed, 458 insertions(+) create mode 100644 packages/core/src/flowchart.ts create mode 100644 src/test/flowchart/flowchart-document.test.ts diff --git a/packages/core/src/flowchart.ts b/packages/core/src/flowchart.ts new file mode 100644 index 000000000..f6769e277 --- /dev/null +++ b/packages/core/src/flowchart.ts @@ -0,0 +1,342 @@ +/** + * The website's Flowchart (src/pages/FlowchartPage.tsx) as plain data: the + * React Flow node and edge JSON saved in the `flowcharts` table, read and + * edited without React Flow so the iOS app can show it as columns and a tree. + * + * Every edit returns new arrays and copies each node and edge it changes with + * a spread, so fields this file does not know (positions, sizes, handles, + * styles React Flow added) survive the round trip to the website. + */ + +export const FLOW_NODE_TYPES = ['thesis', 'contention', 'attack', 'rebuttal', 'dropped'] as const; +export type FlowNodeType = (typeof FLOW_NODE_TYPES)[number]; + +export const FLOW_EDGE_KINDS = ['support', 'attack', 'rebuttal'] as const; +export type FlowEdgeKind = (typeof FLOW_EDGE_KINDS)[number]; + +export const FLOW_STRENGTHS = ['strong', 'moderate', 'weak'] as const; +export type FlowStrength = (typeof FLOW_STRENGTHS)[number]; + +export interface FlowNodeData { + label: string; + description?: string; + type: FlowNodeType; + strength?: FlowStrength; + [key: string]: unknown; +} + +export interface FlowNode { + id: string; + type?: string; + position: { x: number; y: number }; + data: FlowNodeData; + [key: string]: unknown; +} + +export interface FlowEdge { + id: string; + source: string; + target: string; + [key: string]: unknown; +} + +/** A saved map's `nodes` and `edges` columns. Nodes and edges this file cannot read are kept as they are. */ +export interface FlowchartDocument { + nodes: unknown[]; + edges: unknown[]; +} + +/** The website's edge labels and stroke colours (edgeStyles). Edges carry no kind of their own, only these. */ +export const FLOW_EDGE_STYLES: Record = { + support: { label: 'Support', stroke: '#10b981' }, + attack: { label: 'Attack', stroke: '#ef4444' }, + rebuttal: { label: 'Rebuttal', stroke: '#3b82f6' }, +}; + +/** The thesis cannot be deleted on the website; it is found by this id. */ +export const THESIS_ID = 'thesis'; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isNodeType(value: unknown): value is FlowNodeType { + return typeof value === 'string' && (FLOW_NODE_TYPES as readonly string[]).includes(value); +} + +export function isFlowNode(value: unknown): value is FlowNode { + return ( + isRecord(value) && + typeof value.id === 'string' && + isRecord(value.position) && + typeof value.position.x === 'number' && + typeof value.position.y === 'number' && + isRecord(value.data) && + typeof value.data.label === 'string' && + isNodeType(value.data.type) + ); +} + +export function isFlowEdge(value: unknown): value is FlowEdge { + return isRecord(value) && typeof value.id === 'string' && typeof value.source === 'string' && typeof value.target === 'string'; +} + +/** A saved row's columns as a document. Anything that is not a list becomes an empty one, as the columns default to `[]`. */ +export function readFlowchart(nodes: unknown, edges: unknown): FlowchartDocument { + return { nodes: Array.isArray(nodes) ? nodes : [], edges: Array.isArray(edges) ? edges : [] }; +} + +/** The nodes the website can draw, in their saved order. */ +export function flowNodes(doc: FlowchartDocument): FlowNode[] { + return doc.nodes.filter(isFlowNode); +} + +/** The edges between two drawable nodes. */ +export function flowEdges(doc: FlowchartDocument): FlowEdge[] { + const ids = new Set(flowNodes(doc).map((node) => node.id)); + return doc.edges.filter((edge): edge is FlowEdge => isFlowEdge(edge) && ids.has(edge.source) && ids.has(edge.target)); +} + +export function findNode(doc: FlowchartDocument, id: string): FlowNode | undefined { + return flowNodes(doc).find((node) => node.id === id); +} + +/** + * An edge's kind, read from what the website writes: its label from the + * edge-type picker, else its stroke colour. The starter's thesis edges have + * neither and are supports; edges imported from a case have only a stroke. + */ +export function edgeKind(edge: FlowEdge): FlowEdgeKind { + const label = typeof edge.label === 'string' ? edge.label : null; + const stroke = isRecord(edge.style) && typeof edge.style.stroke === 'string' ? edge.style.stroke.toLowerCase() : null; + for (const kind of FLOW_EDGE_KINDS) if (label === FLOW_EDGE_STYLES[kind].label) return kind; + for (const kind of FLOW_EDGE_KINDS) if (stroke === FLOW_EDGE_STYLES[kind].stroke) return kind; + return 'support'; +} + +type Translate = (key: string) => string; + +/** The website's starter map (createInitialNodes and initialEdges), translated. */ +export function starterFlowchart(t: Translate): FlowchartDocument { + const node = (id: string, x: number, y: number, label: string, description: string, type: FlowNodeType, strength?: FlowStrength): FlowNode => ({ + id, + type: 'custom', + position: { x, y }, + data: { label: t(`flowchart.starter.${label}`), description: t(`flowchart.starter.${description}`), type, ...(strength ? { strength } : {}) }, + }); + const arrow = { type: 'arrowclosed' }; + return { + nodes: [ + node('thesis', 400, 50, 'thesisLabel', 'thesisDesc', 'thesis'), + node('c1', 150, 200, 'argument1Label', 'argument1Desc', 'contention', 'strong'), + node('c2', 400, 200, 'argument2Label', 'argument2Desc', 'contention', 'moderate'), + node('c3', 650, 200, 'argument3Label', 'argument3Desc', 'contention', 'weak'), + node('a1', 150, 350, 'opponentAttackLabel', 'opponentAttackDesc', 'attack'), + node('r1', 150, 500, 'rebuttalLabel', 'rebuttalDesc', 'rebuttal'), + ], + edges: [ + { id: 'e-thesis-c1', source: 'thesis', target: 'c1', animated: true, markerEnd: arrow }, + { id: 'e-thesis-c2', source: 'thesis', target: 'c2', animated: true, markerEnd: arrow }, + { id: 'e-thesis-c3', source: 'thesis', target: 'c3', animated: true, markerEnd: arrow }, + { id: 'e-c1-a1', source: 'c1', target: 'a1', style: { stroke: '#ef4444' }, markerEnd: arrow }, + { id: 'e-a1-r1', source: 'a1', target: 'r1', style: { stroke: '#3b82f6' }, markerEnd: arrow }, + ], + }; +} + +export interface CardFields { + label: string; + description: string; + type: FlowNodeType; + strength: FlowStrength; +} + +/** The website's node data from its add and edit dialog: strength only for arguments (contentions). */ +function cardData(fields: CardFields): Pick { + return { + label: fields.label, + description: fields.description, + type: fields.type, + strength: fields.type === 'contention' ? fields.strength : undefined, + }; +} + +/** The rows autoLayout puts each type on. */ +const LANE_Y: Record = { thesis: 60, contention: 230, attack: 420, rebuttal: 610, dropped: 420 }; +const LANE_SPACING = 280; + +/** + * A new card's place: the website drops it at random, so the app uses the + * website's own auto layout instead, at the end of its type's row. + */ +function newCardPosition(doc: FlowchartDocument, type: FlowNodeType): { x: number; y: number } { + const lane = flowNodes(doc).filter((node) => node.data.type === type); + const x = lane.length === 0 ? 400 : Math.max(...lane.map((node) => node.position.x)) + LANE_SPACING; + return { x, y: LANE_Y[type] }; +} + +/** Adds a card with the website's id scheme. `now` is the time in milliseconds. */ +export function addCard(doc: FlowchartDocument, fields: CardFields, now: number): { doc: FlowchartDocument; id: string } { + const taken = new Set(flowNodes(doc).map((node) => node.id)); + let stamp = now; + while (taken.has(`node-${stamp}`)) stamp += 1; + const id = `node-${stamp}`; + const node: FlowNode = { id, type: 'custom', position: newCardPosition(doc, fields.type), data: cardData(fields) }; + return { doc: { ...doc, nodes: [...doc.nodes, node] }, id }; +} + +/** Edits a card in place, as the website's edit dialog does: other data fields stay. */ +export function editCard(doc: FlowchartDocument, id: string, fields: CardFields): FlowchartDocument { + return { + ...doc, + nodes: doc.nodes.map((node) => (isFlowNode(node) && node.id === id ? { ...node, data: { ...node.data, ...cardData(fields) } } : node)), + }; +} + +export function canDeleteCard(id: string): boolean { + return id !== THESIS_ID; +} + +/** Removes a card and every link to or from it. The thesis stays, as on the website. */ +export function deleteCard(doc: FlowchartDocument, id: string): FlowchartDocument { + if (!canDeleteCard(id)) return doc; + return { + nodes: doc.nodes.filter((node) => !(isRecord(node) && node.id === id)), + edges: doc.edges.filter((edge) => !(isRecord(edge) && (edge.source === id || edge.target === id))), + }; +} + +/** + * Whether `source` may link to `target`. React Flow refuses a second edge + * between the same two nodes in the same direction, whatever its kind; the + * app also refuses a card linking to itself. + */ +export function canLink(doc: FlowchartDocument, source: string, target: string): boolean { + if (source === target || !findNode(doc, source) || !findNode(doc, target)) return false; + return !doc.edges.some((edge) => isRecord(edge) && edge.source === source && edge.target === target); +} + +/** + * Links two cards as the website's onConnect does: from the claim that is + * supported, attacked or rebutted (`source`) to the card that responds + * (`target`), with the kind's label and colour and React Flow's edge id. + */ +export function linkCards(doc: FlowchartDocument, source: string, target: string, kind: FlowEdgeKind): FlowchartDocument { + if (!canLink(doc, source, target)) return doc; + const style = FLOW_EDGE_STYLES[kind]; + const edge: FlowEdge = { + source, + sourceHandle: null, + target, + targetHandle: null, + animated: true, + label: style.label, + style: { stroke: style.stroke, strokeWidth: 2 }, + markerEnd: { type: 'arrowclosed' }, + id: `reactflow__edge-${source}-${target}`, + }; + return { ...doc, edges: [...doc.edges, edge] }; +} + +/** Removes one link, as deleting a selected edge on the website does. */ +export function unlinkCards(doc: FlowchartDocument, edgeId: string): FlowchartDocument { + return { ...doc, edges: doc.edges.filter((edge) => !(isRecord(edge) && edge.id === edgeId)) }; +} + +export interface CardLink { + edgeId: string; + kind: FlowEdgeKind; + /** The card at the other end. */ + other: FlowNode; +} + +/** + * A card's links. `responds` are the claims this card supports, attacks or + * rebuts (edges into it); `answeredBy` are the cards that respond to it + * (edges out of it). + */ +export function cardLinks(doc: FlowchartDocument, id: string): { responds: CardLink[]; answeredBy: CardLink[] } { + const nodes = new Map(flowNodes(doc).map((node) => [node.id, node])); + const responds: CardLink[] = []; + const answeredBy: CardLink[] = []; + for (const edge of flowEdges(doc)) { + if (edge.target === id) responds.push({ edgeId: edge.id, kind: edgeKind(edge), other: nodes.get(edge.source)! }); + if (edge.source === id) answeredBy.push({ edgeId: edge.id, kind: edgeKind(edge), other: nodes.get(edge.target)! }); + } + return { responds, answeredBy }; +} + +/** An attack nobody has rebutted yet: no rebuttal link, and no rebuttal card, answers it. */ +export function isUnanswered(doc: FlowchartDocument, node: FlowNode): boolean { + if (node.data.type !== 'attack') return false; + return !cardLinks(doc, node.id).answeredBy.some((link) => link.kind === 'rebuttal' || link.other.data.type === 'rebuttal'); +} + +/** The claim a card answers for its meta line: the first one linked with the card's own kind, else the first one. */ +export function answeredClaim(doc: FlowchartDocument, node: FlowNode): FlowNode | null { + const { responds } = cardLinks(doc, node.id); + const own = node.data.type === 'attack' ? 'attack' : node.data.type === 'rebuttal' ? 'rebuttal' : 'support'; + return (responds.find((link) => link.kind === own) ?? responds[0])?.other ?? null; +} + +export interface FlowColumns { + /** The thesis, arguments and dropped cards. */ + yourCase: FlowNode[]; + attacks: FlowNode[]; + rebuttals: FlowNode[]; +} + +/** Cards by role, for the Flow tab. Attacks follow the order of what they attack, rebuttals the order of what they rebut. */ +export function flowColumns(doc: FlowchartDocument): FlowColumns { + const nodes = flowNodes(doc); + const yourCase = [ + ...nodes.filter((node) => node.data.type === 'thesis'), + ...nodes.filter((node) => node.data.type === 'contention' || node.data.type === 'dropped'), + ]; + const orderBy = (cards: FlowNode[], before: FlowNode[]) => { + const rank = new Map(before.map((node, index) => [node.id, index])); + const place = (node: FlowNode) => { + const claim = answeredClaim(doc, node); + return claim && rank.has(claim.id) ? rank.get(claim.id)! : before.length; + }; + return cards.map((node, index) => ({ node, index, at: place(node) })).sort((a, b) => a.at - b.at || a.index - b.index).map((entry) => entry.node); + }; + const attacks = orderBy(nodes.filter((node) => node.data.type === 'attack'), yourCase); + const rebuttals = orderBy(nodes.filter((node) => node.data.type === 'rebuttal'), attacks); + return { yourCase, attacks, rebuttals }; +} + +export interface MapRow { + node: FlowNode; + depth: number; + /** The link from the row above it in the tree, or null for a root. */ + via: FlowEdgeKind | null; +} + +/** + * The Map tab's tree: from the thesis down every link, each card once, then + * every card no link reached (an unlinked dropped card, a loose attack) as + * its own root, in saved order. + */ +export function mapRows(doc: FlowchartDocument): MapRow[] { + const nodes = flowNodes(doc); + const edges = flowEdges(doc); + const byId = new Map(nodes.map((node) => [node.id, node])); + const seen = new Set(); + const rows: MapRow[] = []; + const visit = (node: FlowNode, depth: number, via: FlowEdgeKind | null) => { + if (seen.has(node.id)) return; + seen.add(node.id); + rows.push({ node, depth, via }); + for (const edge of edges) { + if (edge.source === node.id) visit(byId.get(edge.target)!, depth + 1, edgeKind(edge)); + } + }; + const roots = [ + ...nodes.filter((node) => node.data.type === 'thesis'), + ...nodes.filter((node) => node.data.type !== 'thesis' && !edges.some((edge) => edge.target === node.id)), + ...nodes, + ]; + for (const root of roots) visit(root, 0, null); + return rows; +} diff --git a/src/test/flowchart/flowchart-document.test.ts b/src/test/flowchart/flowchart-document.test.ts new file mode 100644 index 000000000..f751cc355 --- /dev/null +++ b/src/test/flowchart/flowchart-document.test.ts @@ -0,0 +1,116 @@ +import { addEdge, MarkerType, type Edge } from 'reactflow'; +import { describe, expect, it } from 'vitest'; + +import { + addCard, + canLink, + deleteCard, + edgeKind, + editCard, + flowEdges, + linkCards, + readFlowchart, + unlinkCards, + type FlowEdge, +} from '@debatelab/core/flowchart'; + +// A map as the website saves it after dragging and connecting: React Flow +// adds sizes, selection and absolute positions, and an imported case brings +// stroke-only edges. The app edits it; whatever it does not edit must come back unchanged. +const websiteNodes = [ + { + id: 'thesis', type: 'custom', position: { x: 400, y: 50 }, width: 212, height: 96, selected: false, positionAbsolute: { x: 400, y: 50 }, dragging: false, + data: { label: 'Ban cars', description: 'From city centres', type: 'thesis' }, + }, + { + id: 'c-0', type: 'custom', position: { x: 120, y: 240 }, width: 230, height: 120, sourcePosition: 'bottom', + data: { label: 'Air quality', description: 'Health duty', type: 'contention', strength: 'strong', note: 'kept' }, + }, + { + id: 'a-0', type: 'custom', position: { x: 120, y: 450 }, width: 200, height: 88, + data: { label: 'Shift workers', description: 'No alternative', type: 'attack' }, + }, + { id: 'group-1', type: 'group', position: { x: 0, y: 0 }, data: { label: 'Not a card' } }, +]; + +const websiteEdges = [ + { id: 'e-thesis-c-0', source: 'thesis', target: 'c-0', animated: true, markerEnd: { type: 'arrowclosed' } }, + { id: 'e-c-0-a-0', source: 'c-0', target: 'a-0', label: 'Attack', style: { stroke: '#ef4444', strokeWidth: 2 }, markerEnd: { type: 'arrowclosed' }, selected: true }, + { id: 'e-a-0-ghost', source: 'a-0', target: 'ghost', style: { stroke: '#3b82f6' } }, +]; + +const json = (value: unknown) => JSON.parse(JSON.stringify(value)) as unknown; + +describe('flowchart document round trip', () => { + it('keeps every field the app does not edit', () => { + const original = json({ nodes: websiteNodes, edges: websiteEdges }) as { nodes: unknown[]; edges: unknown[] }; + let doc = readFlowchart(json(websiteNodes), json(websiteEdges)); + + const added = addCard(doc, { label: 'Night buses', description: '', type: 'rebuttal', strength: 'moderate' }, 1_700_000_000_000); + doc = linkCards(added.doc, 'a-0', added.id, 'rebuttal'); + doc = editCard(doc, 'c-0', { label: 'Clean air', description: 'Health duty', type: 'contention', strength: 'moderate' }); + const extra = addCard(doc, { label: 'Temp', description: 'x', type: 'dropped', strength: 'weak' }, 1_700_000_000_000); + doc = deleteCard(linkCards(extra.doc, 'thesis', extra.id, 'support'), extra.id); + + const saved = json(doc) as { nodes: Record[]; edges: Record[] }; + // Untouched nodes and edges, including ones the app cannot draw, are byte for byte the same. + expect(saved.nodes[0]).toEqual(original.nodes[0]); + expect(saved.nodes[2]).toEqual(original.nodes[2]); + expect(saved.nodes[3]).toEqual(original.nodes[3]); + expect(saved.edges.slice(0, 3)).toEqual(original.edges); + // The edited node keeps its sizes, handles and unknown data, and only the edited fields change. + expect(saved.nodes[1]).toEqual({ + ...(original.nodes[1] as object), + data: { label: 'Clean air', description: 'Health duty', type: 'contention', strength: 'moderate', note: 'kept' }, + }); + // The new card is the website's shape, on the auto-layout row for its type. + expect(saved.nodes[4]).toEqual({ + id: 'node-1700000000000', type: 'custom', position: { x: 400, y: 610 }, + data: { label: 'Night buses', description: '', type: 'rebuttal' }, + }); + expect(saved.nodes).toHaveLength(5); + expect(saved.edges).toHaveLength(4); + }); + + it('links cards exactly as the website connects them', () => { + const doc = readFlowchart(json(websiteNodes), json(websiteEdges)); + const linked = linkCards(doc, 'thesis', 'a-0', 'attack'); + const ours = json(linked.edges[linked.edges.length - 1]); + // FlowchartPage's onConnect: React Flow's connection, the picker's style, then addEdge. + const theirs = addEdge( + { + source: 'thesis', sourceHandle: null, target: 'a-0', targetHandle: null, + animated: true, label: 'Attack', style: { stroke: '#ef4444', strokeWidth: 2 }, markerEnd: { type: MarkerType.ArrowClosed }, + }, + websiteEdges as Edge[], + ); + expect(ours).toEqual(json(theirs[theirs.length - 1])); + }); + + it('refuses the links React Flow refuses, and a card linked to itself', () => { + const doc = readFlowchart(json(websiteNodes), json(websiteEdges)); + expect(canLink(doc, 'c-0', 'a-0')).toBe(false); + expect(linkCards(doc, 'c-0', 'a-0', 'rebuttal')).toBe(doc); + expect(canLink(doc, 'a-0', 'c-0')).toBe(true); + expect(canLink(doc, 'a-0', 'a-0')).toBe(false); + expect(canLink(doc, 'a-0', 'group-1')).toBe(false); + }); + + it('keeps the thesis and drops a deleted card with its links', () => { + const doc = readFlowchart(json(websiteNodes), json(websiteEdges)); + expect(deleteCard(doc, 'thesis')).toBe(doc); + const without = deleteCard(doc, 'a-0'); + expect(without.nodes.map((node) => (node as { id: string }).id)).toEqual(['thesis', 'c-0', 'group-1']); + expect(without.edges.map((edge) => (edge as { id: string }).id)).toEqual(['e-thesis-c-0']); + expect(unlinkCards(doc, 'e-thesis-c-0').edges).toHaveLength(2); + }); + + it('reads an edge kind from its label, then its stroke, else as a support', () => { + const doc = readFlowchart(json(websiteNodes), json(websiteEdges)); + const kinds = (doc.edges as FlowEdge[]).map(edgeKind); + expect(kinds).toEqual(['support', 'attack', 'rebuttal']); + expect(edgeKind({ id: 'x', source: 'a', target: 'b', label: 'Support', style: { stroke: '#ef4444' } })).toBe('support'); + // An edge to a node that is gone is kept in the document but not drawn. + expect(flowEdges(doc).map((edge) => edge.id)).toEqual(['e-thesis-c-0', 'e-c-0-a-0']); + }); +}); From 403a6206599e931e7d033e0cea3bdfeba4a1dbc5 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 9 Oct 2026 15:59:02 +0000 Subject: [PATCH 2/5] Add the Flowchart to the app Prepare's Flowchart now opens the website's argument map in mock A's phone look instead of the "on its way" screen. It opens the Current Debate's newest saved map, or the website's starter map, and shows it as role columns (your case, their attacks, your rebuttals) or as a tree from the thesis. A card opens a sheet to link it, edit it, change its type or delete it; "+" adds a card; "..." saves, saves as, switches maps, exports and deletes. Maps are the same flowcharts rows the website saves, with the same React Flow JSON, so a map made on the phone opens on the website. Save over an opened map updates its row, which the website never does; Save as is the website's Save. Edits stay in session memory per account and debate until saved, as the website does not warn before replacing its canvas. One-off maps are saved with no debate, as on the website. Export is a PDF of the tree with the website's file name, or the tree copied as text. The form sheet, one-off notice and keepIfSame come unchanged from #129. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/AGENTS.md | 5 +- .../app/(tabs)/(prepare)/coming/[tool].tsx | 13 - apps/mobile/src/app/_layout.tsx | 5 + apps/mobile/src/app/flowchart/card.tsx | 49 +++ apps/mobile/src/app/flowchart/claim.tsx | 262 ++++++++++++ apps/mobile/src/app/flowchart/index.tsx | 378 ++++++++++++++++++ apps/mobile/src/app/flowchart/maps.tsx | 196 +++++++++ .../mobile/src/components/document-header.tsx | 14 +- .../src/components/document-header.web.tsx | 9 +- .../src/components/flowchart-card-form.tsx | 85 ++++ .../mobile/src/components/flowchart-views.tsx | 180 +++++++++ apps/mobile/src/components/one-off-notice.tsx | 46 +++ apps/mobile/src/components/prepare-tools.ts | 6 +- apps/mobile/src/components/sheet.tsx | 40 ++ apps/mobile/src/components/work-row.tsx | 4 +- apps/mobile/src/lib/export-html.ts | 29 ++ apps/mobile/src/lib/flowchart-drafts.ts | 139 +++++++ apps/mobile/src/lib/flowchart-text.ts | 97 +++++ apps/mobile/src/lib/flowcharts.ts | 90 +++++ apps/mobile/src/lib/keep-if-same.ts | 4 + packages/core/src/flowchart.ts | 3 +- src/locales/en.json | 69 ++++ src/locales/es.json | 69 ++++ src/locales/hu.json | 69 ++++ 24 files changed, 1837 insertions(+), 24 deletions(-) delete mode 100644 apps/mobile/src/app/(tabs)/(prepare)/coming/[tool].tsx create mode 100644 apps/mobile/src/app/flowchart/card.tsx create mode 100644 apps/mobile/src/app/flowchart/claim.tsx create mode 100644 apps/mobile/src/app/flowchart/index.tsx create mode 100644 apps/mobile/src/app/flowchart/maps.tsx create mode 100644 apps/mobile/src/components/flowchart-card-form.tsx create mode 100644 apps/mobile/src/components/flowchart-views.tsx create mode 100644 apps/mobile/src/components/one-off-notice.tsx create mode 100644 apps/mobile/src/components/sheet.tsx create mode 100644 apps/mobile/src/lib/flowchart-drafts.ts create mode 100644 apps/mobile/src/lib/flowchart-text.ts create mode 100644 apps/mobile/src/lib/flowcharts.ts create mode 100644 apps/mobile/src/lib/keep-if-same.ts diff --git a/apps/mobile/AGENTS.md b/apps/mobile/AGENTS.md index ab7376fba..747f5eb86 100644 --- a/apps/mobile/AGENTS.md +++ b/apps/mobile/AGENTS.md @@ -16,14 +16,15 @@ The iOS app (`@debatelab/mobile`): Expo SDK 57, React Native 0.86, React 19, Exp ## Layout -- `src/app/`: routes. `sign-in.tsx`; `(tabs)/` with one route group per tab: Debate, Prepare, Practice, Library, You; the form sheets `debates.tsx` (switcher), `new-debate.tsx`, `delete-account.tsx`, `add-link.tsx`, `new-note.tsx` and `source/[id].tsx`; and `ask.tsx`, `generate.tsx`, `case/[id].tsx`, `attack/`, `brainstorm/` and `factoids/` (each a form `index.tsx` and a document `[id].tsx`), full screen above the tabs. All are declared in the root `_layout.tsx`. `(prepare)/coming/[tool].tsx` (Flowchart) and `(practice)/soon/[tool].tsx` stand in for tools not in the app yet. +- `src/app/`: routes. `sign-in.tsx`; `(tabs)/` with one route group per tab: Debate, Prepare, Practice, Library, You; the form sheets `debates.tsx` (switcher), `new-debate.tsx`, `delete-account.tsx`, `add-link.tsx`, `new-note.tsx` and `source/[id].tsx`; and `ask.tsx`, `generate.tsx`, `case/[id].tsx`, `attack/`, `brainstorm/` and `factoids/` (each a form `index.tsx` and a document `[id].tsx`), and `flowchart/` (the screen `index.tsx` with its `card`, `claim` and `maps` form sheets), full screen above the tabs. All are declared in the root `_layout.tsx`. `(practice)/soon/[tool].tsx` stands in for Practice tools not in the app yet. - `src/components/`: shared UI. `app-tabs.tsx` uses native tabs; `app-tabs.web.tsx` draws the mock's tab bar for the web preview. - `src/lib/`: auth (`auth.tsx`), the Supabase client, session storage (AES key in SecureStore, ciphertext in AsyncStorage) and i18n. `debates.ts`, `settings.tsx`, `subscription.ts` and `account.ts` read and write the website's tables the way its stores do (`src/lib/stores/debate-store.ts`, `settings-store.ts`, `src/hooks/use-subscription.ts`, `src/lib/account.ts`), so the app and the website share one Current Debate and one set of settings. Change them together with the website code they mirror. - `sources.ts` mirrors the session store's `addSource` and `removeSource` and `src/lib/source-import.ts`. The Library always shows the Current Debate, also in one-off mode, as on the website. Adding a file (the website parses files in the browser) and Scan pages are not in the app yet. - Ask mirrors `src/lib/ai/streaming.ts` and `askChat` in `src/lib/ai/client.ts`: `ask.ts` sends the requests (plain questions stream from `ai-stream` through `expo/fetch`; with Citations, Web search, Pros/Cons or Argument map on, the whole answer comes from `ai`, because `ai-stream` refuses those options), `ai-request.ts` holds the shared POST with the website's headers, timeout and error kinds, `feature-config.ts` reads each tool's model from `user_settings.feature_configs`, `context-request.ts` builds the Current Debate context with the website's default selection and pins, and `chats.ts` saves finished exchanges to `chats` and `messages`. The SSE parser is shared in `packages/core/src/ai/sse.ts`. `markdown.ts` reads answers, because react-markdown needs the DOM. - Generate and Attack mirror `generateCase` and `attackCase` in `src/lib/ai/client.ts` and the session store's `recordResult`: `results.ts` sends the requests through `ai-request.ts`, checks the output with the website's transform (moved to `packages/core/src/ai/generate-output.ts`), keeps researched sources in the debate's Auto-saved folder (`packages/core/src/ai/discovered-sources.ts`), saves the server's source rankings onto the Library sources a result used (`packages/core/src/ai/source-assessments.ts`, also for Ask's whole answers), says when the server shortened the input, and saves rows to `cases` and `attacks`. `motion.ts` and `motion-gate.ts` are the website's motion gate. `open-results.ts` holds results made in this session, so one-off work and failed saves still open as documents. It and the last Generate and Attack forms live in `session-memory.ts`, which the auth provider clears on every account change, including sign-out. `components/document.tsx` draws results as documents (mock 03). Export renders `export-html.ts` to a PDF with expo-print and shares it (`export.ts`); the web preview cannot print, so `export.web.ts` keeps the HTML and copied text on `window` for the screenshot script. Word export and Google Docs are not in the app. - Brainstorm and Factoids mirror `brainstorm` and `factoids` in `src/lib/ai/client.ts` and their pages, through the same `results.ts`: the brainstorm transform and the verified-factoid selection live in `packages/core/src/ai/brainstorm-output.ts` and `factoids-output.ts`, rows go to `brainstorms` and `factoids`. Brainstorm has the motion gate; Factoids, like Attack, follows the Current Debate and saves with side `neutral`. A factoid's bookmark saves it to the Current Debate's `Factoids` folder, and a search suggestion reopens the Factoids form with that query. `prepareToolHref` in `components/prepare-tools.ts` is where both tabs open a tool. -- One-off mode is session-only and never written, as on the website. Students prepare both sides of a motion, so a debate has no side: the Debate tab's In favor / Against toggle only chooses which side's work it shows, using the website's rule (`'both'` counts for either side, neutral and side-less work for neither). It starts on In favor and is never stored. +- Flowchart mirrors the website's `FlowchartPage.tsx` and the session store's `saveFlowchart` and `deleteSavedFlowchart` without React Flow: the saved `flowcharts` row keeps the website's React Flow node and edge JSON, and `packages/core/src/flowchart.ts` reads and edits it (starter map, edge kinds from the label or stroke the website writes, role columns, the tree, and edits that keep every field they do not touch). `flowcharts.ts` reads and writes the rows; Save over an opened map updates it, Save as inserts a row as the website's Save does. `flowchart-drafts.ts` holds each debate's open map and unsaved edits in session memory, shared by the screen and its sheets. Maps follow `activeDebateId`: one-off maps are saved with no debate, as on the website. The website's PNG export is a PDF of the Map tree here (`flowchartExportHtml`). +- One-off mode is session-only and never written, as on the website; only maps saved in it are kept, with no debate, as the website keeps them. Students prepare both sides of a motion, so a debate has no side: the Debate tab's In favor / Against toggle only chooses which side's work it shows, using the website's rule (`'both'` counts for either side, neutral and side-less work for neither). It starts on In favor and is never stored. - `src/theme/`: colours from the mocks and the website's dark palette, Inter fonts. - `scripts/screenshots.ts`: web-export screenshots. `scripts/render-tab-icons.ts` redraws the tab bar PNGs. diff --git a/apps/mobile/src/app/(tabs)/(prepare)/coming/[tool].tsx b/apps/mobile/src/app/(tabs)/(prepare)/coming/[tool].tsx deleted file mode 100644 index f89f641d2..000000000 --- a/apps/mobile/src/app/(tabs)/(prepare)/coming/[tool].tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { useLocalSearchParams } from 'expo-router'; -import { useTranslation } from 'react-i18next'; - -import { PlaceholderScreen } from '@/components/placeholder-screen'; -import { PREPARE_TOOLS } from '@/components/prepare-tools'; - -/** A Prepare tool that is not in the app yet. */ -export default function PrepareToolScreen() { - const { t } = useTranslation(); - const { tool } = useLocalSearchParams<{ tool: string }>(); - const entry = PREPARE_TOOLS.find((item) => item.key === tool); - return ; -} diff --git a/apps/mobile/src/app/_layout.tsx b/apps/mobile/src/app/_layout.tsx index 29815cd40..2a322830a 100644 --- a/apps/mobile/src/app/_layout.tsx +++ b/apps/mobile/src/app/_layout.tsx @@ -90,6 +90,11 @@ function Root() { + {/* Flowchart full screen above the tabs, its card, claim and saved-map sheets as form sheets, as in mock A. */} + + + + diff --git a/apps/mobile/src/app/flowchart/card.tsx b/apps/mobile/src/app/flowchart/card.tsx new file mode 100644 index 000000000..526ed0ea0 --- /dev/null +++ b/apps/mobile/src/app/flowchart/card.tsx @@ -0,0 +1,49 @@ +import { addCard } from '@debatelab/core/flowchart'; +import { useLocalSearchParams, useRouter } from 'expo-router'; +import { useTranslation } from 'react-i18next'; +import { StyleSheet, View } from 'react-native'; + +import { TextButton } from '@/components/buttons'; +import { CardForm } from '@/components/flowchart-card-form'; +import { SheetScrollView } from '@/components/sheet'; +import { Text } from '@/components/text'; +import { getDraft, updateDraft, useFlowchartDrafts } from '@/lib/flowchart-drafts'; + +/** "+": the website's Add Node dialog as a form sheet. The card joins the map on screen. */ +export default function AddCardSheet() { + const { t } = useTranslation(); + const router = useRouter(); + const { scope, map } = useLocalSearchParams<{ scope: string; map: string }>(); + useFlowchartDrafts(); + const draft = scope && map ? getDraft(scope, map) : undefined; + + return ( + + + + {t('flowchart.addNodeDialog')} + + router.back()} /> + + + {draft ? t('flowchart.addNodeDialogDesc') : t('mobile.flowchart.mapGone')} + + {draft ? ( + { + updateDraft(scope, map, (current) => ({ ...current, doc: addCard(current.doc, fields, Date.now()).doc, edited: true })); + router.back(); + }} + /> + ) : null} + + ); +} + +const styles = StyleSheet.create({ + header: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: 12 }, + title: { fontSize: 21, lineHeight: 26, letterSpacing: -0.3, flexShrink: 1 }, + body: { fontSize: 15, lineHeight: 21, marginTop: -8 }, +}); diff --git a/apps/mobile/src/app/flowchart/claim.tsx b/apps/mobile/src/app/flowchart/claim.tsx new file mode 100644 index 000000000..2d707da7b --- /dev/null +++ b/apps/mobile/src/app/flowchart/claim.tsx @@ -0,0 +1,262 @@ +import { + canDeleteCard, + canLink, + cardLinks, + deleteCard, + editCard, + findNode, + flowNodes, + linkCards, + unlinkCards, + type FlowEdgeKind, + type FlowNodeType, +} from '@debatelab/core/flowchart'; +import { useLocalSearchParams, useRouter } from 'expo-router'; +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Pressable, StyleSheet, View } from 'react-native'; + +import { ActionMenu } from '@/components/action-menu'; +import { TextButton } from '@/components/buttons'; +import { Chip } from '@/components/chip'; +import { CardForm } from '@/components/flowchart-card-form'; +import { useFlowColors } from '@/components/flowchart-views'; +import { Icon } from '@/components/icon'; +import { SegmentedControl } from '@/components/segmented-control'; +import { SheetScrollView } from '@/components/sheet'; +import { Text } from '@/components/text'; +import { confirmDestructive } from '@/lib/confirm'; +import { getDraft, updateDraft, useFlowchartDrafts } from '@/lib/flowchart-drafts'; +import { cardKind, linkLine, linkSummary, typeLabel } from '@/lib/flowchart-text'; +import { radius } from '@/theme/colors'; +import { useTheme } from '@/theme/use-theme'; + +const TYPES: FlowNodeType[] = ['contention', 'attack', 'rebuttal', 'dropped']; + +/** The kind a new link starts on: what this card's type usually does. */ +function defaultKind(type: FlowNodeType): FlowEdgeKind { + return type === 'attack' ? 'attack' : type === 'rebuttal' ? 'rebuttal' : 'support'; +} + +/** + * A card's sheet, as mock A: its type, text and links, and the website's + * actions on a node: link it, edit it, change its type, or delete it. Every + * change lands in the map on screen at once and stays unsaved until Save. + */ +export default function ClaimSheet() { + const { t } = useTranslation(); + const router = useRouter(); + const { colors } = useTheme(); + const flow = useFlowColors(); + const { scope, map, node: nodeId } = useLocalSearchParams<{ scope: string; map: string; node: string }>(); + useFlowchartDrafts(); + const draft = scope && map ? getDraft(scope, map) : undefined; + const node = draft && nodeId ? findNode(draft.doc, nodeId) : undefined; + const [mode, setMode] = useState<'view' | 'link' | 'edit'>('view'); + const [kind, setKind] = useState(() => defaultKind(node?.data.type ?? 'contention')); + const [typeMenu, setTypeMenu] = useState(false); + + if (!draft || !node) { + return ( + + + {t('mobile.flowchart.cardGone')} + + router.back()} /> + + ); + } + + const change = (edit: Parameters[2]) => updateDraft(scope, map, edit); + const { responds, answeredBy } = cardLinks(draft.doc, node.id); + const links = [...responds.map((link) => ({ link, direction: 'responds' as const })), ...answeredBy.map((link) => ({ link, direction: 'answeredBy' as const }))]; + const deletable = canDeleteCard(node.id); + const isThesis = node.data.type === 'thesis'; + + const remove = async () => { + const confirmed = await confirmDestructive({ + title: t('flowchart.deleteNodeTitle'), + message: t('flowchart.deleteNodeDescription', { label: node.data.label }), + confirm: t('flowchart.deleteNodeConfirm'), + cancel: t('flowchart.deleteNodeCancel'), + }); + if (!confirmed) return; + change((current) => ({ ...current, doc: deleteCard(current.doc, node.id), edited: true })); + router.back(); + }; + + const header = ( + + + + {cardKind(t, node)} + + + {node.data.label} + + + router.back()} /> + + ); + + if (mode === 'edit') { + return ( + + {header} + + {t('flowchart.editNode')} + + { + change((current) => ({ ...current, doc: editCard(current.doc, node.id, fields), edited: true })); + setMode('view'); + }} + /> + setMode('view')} /> + + ); + } + + if (mode === 'link') { + const others = flowNodes(draft.doc).filter((other) => other.id !== node.id); + return ( + + {header} + + + {t('mobile.flowchart.linkTitle')} + + + {t('mobile.flowchart.linkHint')} + + + + + {others.map((other, index) => { + const allowed = canLink(draft.doc, other.id, node.id); + return ( + { + change((current) => ({ ...current, doc: linkCards(current.doc, other.id, node.id, kind), edited: true })); + setMode('view'); + }} + style={({ pressed }) => [styles.row, index > 0 && { borderTopWidth: StyleSheet.hairlineWidth, borderTopColor: colors.line }, pressed && styles.pressed, !allowed && styles.disabled]} + testID="link-target"> + + + + {other.data.label} + + + {allowed ? typeLabel(t, other.data.type) : t('mobile.flowchart.alreadyLinked')} + + + + ); + })} + + setMode('view')} /> + + ); + } + + return ( + + {header} + {node.data.description ? ( + + {node.data.description} + + ) : null} + + {linkSummary(t, draft.doc, node)} + + + setMode('link')} testID="claim-link" /> + setMode('edit')} testID="claim-edit" /> + {isThesis ? null : setTypeMenu(true)} testID="claim-type" />} + + {links.length > 0 ? ( + + + {t('mobile.flowchart.linksTitle')} + + + {links.map(({ link, direction }, index) => { + const line = linkLine(t, link.kind, direction, link.other); + return ( + 0 && { borderTopWidth: StyleSheet.hairlineWidth, borderTopColor: colors.line }]}> + + {line} + change((current) => ({ ...current, doc: unlinkCards(current.doc, link.edgeId), edited: true }))} + style={({ pressed }) => [styles.remove, pressed && styles.pressed]} + testID="remove-link"> + + + + ); + })} + + + ) : null} + {deletable ? void remove()} /> : null} + type !== node.data.type).map((type) => ({ key: type, label: typeLabel(t, type) }))} + cancelLabel={t('common.cancel')} + onClose={() => setTypeMenu(false)} + onSelect={(type) => + change((current) => ({ + ...current, + doc: editCard(current.doc, node.id, { + label: node.data.label, + description: node.data.description ?? '', + type: type as FlowNodeType, + strength: node.data.strength ?? 'moderate', + }), + edited: true, + })) + } + /> + + ); +} + +const styles = StyleSheet.create({ + top: { flexDirection: 'row', alignItems: 'flex-start', gap: 12 }, + topText: { flex: 1, gap: 4 }, + who: { fontSize: 14 }, + title: { fontSize: 21, lineHeight: 27, letterSpacing: -0.3 }, + body: { fontSize: 15.5, lineHeight: 22 }, + section: { fontSize: 16 }, + block: { gap: 8 }, + chips: { flexDirection: 'row', flexWrap: 'wrap', gap: 8 }, + list: { borderWidth: 1, borderRadius: radius.field, overflow: 'hidden' }, + row: { flexDirection: 'row', alignItems: 'center', gap: 10, paddingHorizontal: 14, paddingVertical: 11, minHeight: 50 }, + rowText: { flex: 1, gap: 2 }, + rowLabel: { fontSize: 15.5, lineHeight: 21 }, + rowMeta: { fontSize: 13, lineHeight: 17 }, + dot: { width: 10, height: 10, borderRadius: 3 }, + remove: { minWidth: 32, minHeight: 32, alignItems: 'center', justifyContent: 'center' }, + pressed: { opacity: 0.7 }, + disabled: { opacity: 0.45 }, +}); diff --git a/apps/mobile/src/app/flowchart/index.tsx b/apps/mobile/src/app/flowchart/index.tsx new file mode 100644 index 000000000..e64cceb06 --- /dev/null +++ b/apps/mobile/src/app/flowchart/index.tsx @@ -0,0 +1,378 @@ +import { starterFlowchart, type FlowNode } from '@debatelab/core/flowchart'; +import { useLocalSearchParams, useRouter } from 'expo-router'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { ActivityIndicator, Pressable, ScrollView, StyleSheet, View } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +import { ActionMenu } from '@/components/action-menu'; +import { TextButton } from '@/components/buttons'; +import { DocumentHeader, type HeaderAction } from '@/components/document-header'; +import { FlowColumnsView, FlowLegend, MapTreeView } from '@/components/flowchart-views'; +import { Icon } from '@/components/icon'; +import { OneOffNotice } from '@/components/one-off-notice'; +import { SegmentedControl } from '@/components/segmented-control'; +import { Text } from '@/components/text'; +import { useAuth } from '@/lib/auth'; +import { backLabel } from '@/lib/back-label'; +import { confirmDestructive, promptText } from '@/lib/confirm'; +import { formatWhen } from '@/lib/dates'; +import { useDebates } from '@/lib/debate-context'; +import { copyText, sharePdf } from '@/lib/export'; +import { FLOWCHART_EXPORT_FILE_NAME, flowchartExportHtml } from '@/lib/export-html'; +import { + findSavedDraft, + forgetDraft, + newDraft, + openDraft, + savedDraft, + scopeKey, + showDraft, + updateDraft, + useFlowchartDrafts, + type FlowchartDraft, +} from '@/lib/flowchart-drafts'; +import { flowchartToText } from '@/lib/flowchart-text'; +import { deleteFlowchart, fetchFlowchart, fetchFlowchartList, insertFlowchart, updateFlowchart } from '@/lib/flowcharts'; +import { useTheme } from '@/theme/use-theme'; + +const NOTICE_MS = 3500; + +/** + * The website's Flowchart in mock A's phone look: the Current Debate's newest + * saved map (or the starter map), as role columns (Flow) or a tree (Map). + * Tapping a card opens its claim sheet; "+" adds a card; "..." saves, saves + * as, deletes and exports. Edits stay in session memory until saved. + */ +export default function FlowchartScreen() { + const { t } = useTranslation(); + const router = useRouter(); + const insets = useSafeAreaInsets(); + const { colors } = useTheme(); + const params = useLocalSearchParams<{ from?: string; id?: string }>(); + const { session } = useAuth(); + const userId = session?.user.id ?? null; + const { activeDebateId, oneOff } = useDebates(); + useFlowchartDrafts(); + + const [view, setView] = useState<'flow' | 'map'>('flow'); + // A saved map opened from a list; once it is deleted the screen follows the Current Debate again. + const [requestedId, setRequestedId] = useState(params.id ?? null); + // The debate the requested map was found in, for that map only. + const [found, setFound] = useState<{ id: string; scope: string } | null>(null); + const scope = requestedId && found?.id === requestedId ? found.scope : requestedId ? null : scopeKey(activeDebateId); + const [failure, setFailure] = useState<{ scope: string | null; kind: 'failed' | 'missing' } | null>(null); + const [attempt, setAttempt] = useState(0); + const [busy, setBusy] = useState(false); + const [menu, setMenu] = useState<'export' | null>(null); + const [notice, setNoticeState] = useState(''); + + const live = useRef(true); + const owner = useRef(userId); + owner.current = userId; + const loads = useRef(0); + const noticeTimer = useRef | null>(null); + const tRef = useRef(t); + tRef.current = t; + + useEffect(() => () => { + live.current = false; + if (noticeTimer.current) clearTimeout(noticeTimer.current); + }, []); + + const setNotice = useCallback((text: string) => { + if (!live.current) return; + setNoticeState(text); + if (noticeTimer.current) clearTimeout(noticeTimer.current); + noticeTimer.current = setTimeout(() => { + if (live.current) setNoticeState(''); + }, NOTICE_MS); + }, []); + + // Open the requested map, or the debate's newest saved map, or the starter map. A map + // already held this session opens as it was left. Only the latest load may land. + useEffect(() => { + const request = ++loads.current; + if (!userId) return; + const forUser = userId; + const isCurrent = () => loads.current === request && live.current && owner.current === forUser; + setFailure(null); + const run = async () => { + if (requestedId) { + const held = findSavedDraft(requestedId); + if (held) { + showDraft(held.scope, held.draft); + setFound({ id: requestedId, scope: held.scope }); + return; + } + const row = await fetchFlowchart(forUser, requestedId); + if (!isCurrent()) return; + if (!row) { + setFailure({ scope: null, kind: 'missing' }); + return; + } + const target = scopeKey(row.debateId); + showDraft(target, savedDraft(row)); + setFound({ id: requestedId, scope: target }); + return; + } + const target = scopeKey(activeDebateId); + if (openDraft(target)) return; + const list = await fetchFlowchartList(forUser, activeDebateId); + if (!isCurrent() || openDraft(target)) return; + const row = list.length > 0 ? await fetchFlowchart(forUser, list[0].id) : null; + if (!isCurrent() || openDraft(target)) return; + showDraft(target, row ? savedDraft(row) : newDraft(activeDebateId, starterFlowchart(tRef.current))); + }; + run().catch((cause: unknown) => { + if (!isCurrent()) return; + console.warn('Could not load flowcharts:', cause instanceof Error ? cause.message : cause); + setFailure({ scope: requestedId ? null : scopeKey(activeDebateId), kind: 'failed' }); + }); + }, [userId, activeDebateId, requestedId, attempt]); + + const open = scope ? openDraft(scope) : null; + const draft = open?.draft ?? null; + const back = backLabel(t, params.from, 'prepare'); + const mapTitle = draft?.title || t('mobile.flowchart.untitled'); + + const openCard = (node: FlowNode) => { + if (!scope || !open) return; + router.push({ pathname: '/flowchart/claim', params: { scope, map: open.key, node: node.id } }); + }; + + /** Runs a save or delete for the map on screen, as the account and map were when the student chose it. */ + const act = async (work: (forUser: string, at: { scope: string; key: string; draft: FlowchartDraft }) => Promise) => { + if (!userId || !scope || !open || busy) return; + setBusy(true); + try { + await work(userId, { scope, key: open.key, draft: open.draft }); + } finally { + if (live.current) setBusy(false); + } + }; + + const askTitle = (defaultValue: string) => + promptText({ + title: t('flowchart.saveFlowchartDialog'), + message: t('flowchart.saveFlowchartDialogDesc'), + defaultValue, + confirm: t('flowchart.saveFlowchart'), + cancel: t('common.cancel'), + }); + + /** The website's Save: a new row in the map's debate. The held map becomes that row. */ + const saveNew = (forUser: string, at: { scope: string; key: string; draft: FlowchartDraft }, title: string) => + insertFlowchart(forUser, { title, debateId: at.draft.debateId, doc: at.draft.doc }).then((row) => { + if (owner.current !== forUser) return; + const saved = savedDraft(row); + // The held map becomes the new row; the map it was saved from (if any) goes back to its saved + // state. Edits made while it saved stay held and unsaved, and the map on screen stays on screen. + updateDraft(at.scope, at.key, (current) => (current.doc === at.draft.doc ? saved : { ...saved, doc: current.doc, edited: true })); + if (requestedId) { + setFound({ id: row.id, scope: at.scope }); + setRequestedId(row.id); + } + setNotice(t('flowchart.savedToast')); + }); + + const save = () => + void act(async (forUser, at) => { + try { + if (!at.draft.savedId) { + const title = (await askTitle(''))?.trim(); + if (!title || owner.current !== forUser) return; + await saveNew(forUser, at, title); + return; + } + const done = await updateFlowchart(forUser, at.draft.savedId, at.draft.doc); + if (owner.current !== forUser) return; + if (!done) { + setNotice(t('mobile.flowchart.missing')); + return; + } + updateDraft(at.scope, at.key, (current) => ({ ...current, edited: current.doc !== at.draft.doc })); + setNotice(t('flowchart.savedToast')); + } catch (cause) { + console.warn('Could not save the flowchart:', cause instanceof Error ? cause.message : cause); + setNotice(t('flowchart.saveFailedToast')); + } + }); + + const saveAs = () => + void act(async (forUser, at) => { + const title = (await askTitle(at.draft.title))?.trim(); + if (!title || owner.current !== forUser) return; + try { + await saveNew(forUser, at, title); + } catch (cause) { + console.warn('Could not save the flowchart:', cause instanceof Error ? cause.message : cause); + setNotice(t('flowchart.saveFailedToast')); + } + }); + + const remove = () => + void act(async (forUser, at) => { + const savedId = at.draft.savedId; + if (!savedId) return; + const confirmed = await confirmDestructive({ + title: t('mobile.flowchart.deleteMapTitle'), + message: t('mobile.flowchart.deleteMapDesc', { title: at.draft.title }), + confirm: t('common.delete'), + cancel: t('common.cancel'), + }); + if (!confirmed || owner.current !== forUser) return; + try { + await deleteFlowchart(forUser, savedId); + if (owner.current !== forUser) return; + forgetDraft(at.scope, at.key); + setNotice(t('flowchart.deletedToast')); + // The screen then opens the debate's next newest map, or the starter map. + if (requestedId) setRequestedId(null); + else setAttempt((n) => n + 1); + } catch (cause) { + console.warn('Could not delete the flowchart:', cause instanceof Error ? cause.message : cause); + setNotice(t('flowchart.deleteFailedToast')); + } + }); + + const runExport = (work: () => Promise) => { + work().catch((cause: unknown) => { + console.warn('Export failed:', cause instanceof Error ? cause.message : cause); + setNotice(t('flowchart.exportFailedToast')); + }); + }; + + const actions: HeaderAction[] = draft + ? [ + { key: 'save', label: t('flowchart.save'), symbol: 'square.and.arrow.down', icon: 'download', onPress: save }, + { key: 'save-as', label: t('mobile.flowchart.saveAs'), symbol: 'plus.square.on.square', icon: 'copy', onPress: saveAs }, + { key: 'maps', label: t('flowchart.savedFlowcharts'), symbol: 'folder', icon: 'list', onPress: () => scope && router.push({ pathname: '/flowchart/maps', params: { scope } }) }, + { key: 'export', label: t('common.export'), symbol: 'square.and.arrow.up', icon: 'share', onPress: () => setMenu('export') }, + ...(draft.savedId + ? [{ key: 'delete', label: t('mobile.flowchart.deleteMap'), symbol: 'trash' as const, icon: 'trash' as const, destructive: true, onPress: remove }] + : []), + ] + : []; + + const shownFailure = failure && failure.scope === (requestedId ? null : scopeKey(activeDebateId)) ? failure.kind : null; + + return ( + + router.push({ pathname: '/flowchart/card', params: { scope, map: open.key } }) }] : []} + /> + + + + {t('nav.flowchart')} + + {draft && scope ? ( + router.push({ pathname: '/flowchart/maps', params: { scope } })} + style={({ pressed }) => [styles.mapRow, pressed && styles.pressed]} + testID="flowchart-maps"> + + + {mapTitle} + + + {draft.edited + ? t('mobile.flowchart.edited') + : draft.createdAt + ? t('mobile.flowchart.savedWhen', { when: formatWhen(draft.createdAt) }) + : t('mobile.flowchart.notSaved')} + + + {busy ? : } + + ) : null} + {oneOff ? : null} + + + {draft ? ( + view === 'flow' ? ( + <> + + + + ) : ( + + ) + ) : shownFailure ? ( + + + {shownFailure === 'missing' ? t('mobile.flowchart.mapMissing') : t('mobile.flowchart.loadFailed')} + + {shownFailure === 'missing' ? ( + setRequestedId(null)} /> + ) : ( + setAttempt((n) => n + 1)} /> + )} + + ) : ( + + + + )} + + {notice ? ( + + {notice} + + ) : null} + setMenu(null)} + onSelect={(choice) => { + if (!draft) return; + const meta = t('mobile.debate.itemMeta', { kind: t('debate.kinds.flowchart'), when: formatWhen(draft.createdAt ?? Date.now()) }); + if (choice === 'pdf') runExport(() => sharePdf(flowchartExportHtml(mapTitle, meta, draft.doc, t), FLOWCHART_EXPORT_FILE_NAME, mapTitle)); + if (choice === 'copy') + runExport(async () => { + await copyText(flowchartToText(mapTitle, draft.doc, t)); + setNotice(t('generateResults.copiedToClipboard')); + }); + }} + /> + + ); +} + +const styles = StyleSheet.create({ + root: { flex: 1 }, + content: { paddingTop: 4, gap: 14 }, + head: { paddingHorizontal: 16, gap: 12, width: '100%', maxWidth: 980, alignSelf: 'center' }, + title: { fontSize: 32, lineHeight: 38, letterSpacing: -0.9 }, + mapRow: { flexDirection: 'row', alignItems: 'center', gap: 10, marginTop: -6 }, + mapText: { flex: 1, gap: 1 }, + mapTitle: { fontSize: 16.5, lineHeight: 22 }, + mapMeta: { fontSize: 13.5, lineHeight: 18 }, + pressed: { opacity: 0.7 }, + state: { alignItems: 'center', gap: 10, paddingVertical: 40, paddingHorizontal: 24 }, + stateText: { fontSize: 15, lineHeight: 21, textAlign: 'center' }, + notice: { position: 'absolute', left: 24, right: 24, borderRadius: 14, paddingHorizontal: 16, paddingVertical: 11, alignSelf: 'center', maxWidth: 560 }, + noticeText: { fontSize: 15, lineHeight: 20, textAlign: 'center' }, +}); diff --git a/apps/mobile/src/app/flowchart/maps.tsx b/apps/mobile/src/app/flowchart/maps.tsx new file mode 100644 index 000000000..50a0cf859 --- /dev/null +++ b/apps/mobile/src/app/flowchart/maps.tsx @@ -0,0 +1,196 @@ +import { starterFlowchart } from '@debatelab/core/flowchart'; +import { useLocalSearchParams, useRouter } from 'expo-router'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { ActivityIndicator, Pressable, StyleSheet, View } from 'react-native'; + +import { PrimaryButton, TextButton } from '@/components/buttons'; +import { Icon } from '@/components/icon'; +import { SheetScrollView } from '@/components/sheet'; +import { Text } from '@/components/text'; +import { useAuth } from '@/lib/auth'; +import { confirmDestructive } from '@/lib/confirm'; +import { formatRelativeDate } from '@/lib/dates'; +import { getDraft, NEW_MAP, newDraft, openDraft, savedDraft, scopeDebateId, showDraft, useFlowchartDrafts } from '@/lib/flowchart-drafts'; +import { fetchFlowchart, fetchFlowchartList, type FlowchartSummary } from '@/lib/flowcharts'; +import { keepIfSame } from '@/lib/keep-if-same'; +import { radius } from '@/theme/colors'; +import { useTheme } from '@/theme/use-theme'; + +/** + * The website's Saved Flowcharts list as a form sheet: the debate's saved + * maps (or one-off maps), newest first, plus starting a new map from the + * starter. A map opened this session comes back with its unsaved edits. + */ +export default function SavedMapsSheet() { + const { t } = useTranslation(); + const router = useRouter(); + const { colors, scheme } = useTheme(); + const { scope } = useLocalSearchParams<{ scope: string }>(); + const { session } = useAuth(); + const userId = session?.user.id ?? null; + useFlowchartDrafts(); + const debateId = scopeDebateId(scope ?? 'one-off'); + const [list, setList] = useState<{ for: string; items: FlowchartSummary[] | null; failed: boolean } | null>(null); + const [opening, setOpening] = useState(null); + const [failedOpen, setFailedOpen] = useState(false); + const loads = useRef(0); + const live = useRef(true); + const owner = useRef(userId); + owner.current = userId; + + useEffect(() => () => { + live.current = false; + }, []); + + const load = useCallback(async () => { + const request = ++loads.current; + if (!userId || !scope) return; + const forUser = userId; + const forScope = scope; + const isCurrent = () => loads.current === request && live.current && owner.current === forUser; + try { + const items = await fetchFlowchartList(forUser, debateId); + if (isCurrent()) setList((previous) => keepIfSame(previous, { for: `${forUser}:${forScope}`, items, failed: false })); + } catch (cause) { + console.warn('Could not load saved flowcharts:', cause instanceof Error ? cause.message : cause); + if (isCurrent()) setList((previous) => (previous?.for === `${forUser}:${forScope}` && previous.items ? previous : { for: `${forUser}:${forScope}`, items: null, failed: true })); + } + }, [debateId, scope, userId]); + + useEffect(() => { + void load(); + }, [load]); + + const shown = list && list.for === `${userId}:${scope}` ? list : null; + const open = scope ? openDraft(scope) : null; + const unsaved = scope ? getDraft(scope, NEW_MAP) : undefined; + const background = scheme === 'dark' ? colors.card : colors.input; + + const choose = async (item: FlowchartSummary, index: number) => { + if (!scope || !userId) return; + const held = getDraft(scope, item.id); + if (held) { + showDraft(scope, held); + router.back(); + return; + } + const forUser = userId; + setOpening(index); + setFailedOpen(false); + try { + const row = await fetchFlowchart(forUser, item.id); + if (!live.current || owner.current !== forUser) return; + if (!row) { + setFailedOpen(true); + void load(); + return; + } + showDraft(scope, savedDraft(row)); + router.back(); + } catch (cause) { + console.warn('Could not open the flowchart:', cause instanceof Error ? cause.message : cause); + if (live.current) setFailedOpen(true); + } finally { + if (live.current) setOpening(null); + } + }; + + const startNew = async () => { + if (!scope) return; + if (unsaved?.edited) { + const confirmed = await confirmDestructive({ + title: t('mobile.flowchart.discardTitle'), + message: t('mobile.flowchart.discardDesc'), + confirm: t('mobile.flowchart.startNew'), + cancel: t('common.cancel'), + }); + if (!confirmed || !live.current) return; + } + showDraft(scope, newDraft(debateId, starterFlowchart(t)), { replace: true }); + router.back(); + }; + + const row = (key: string, title: string, meta: string, selected: boolean, onPress: () => void, index: number, busy = false) => ( + [styles.row, index > 0 && { borderTopWidth: StyleSheet.hairlineWidth, borderTopColor: colors.line }, pressed && styles.pressed]} + testID="saved-map"> + + + {title} + + + {meta} + + + {busy ? : selected ? : null} + + ); + + const items = shown?.items ?? []; + return ( + + + + {t('flowchart.savedFlowcharts')} + + router.back()} /> + + + {t('flowchart.savedFlowchartsDesc')} + + void startNew()} testID="new-map" /> + {unsaved || items.length > 0 ? ( + + {unsaved + ? row(NEW_MAP, t('mobile.flowchart.untitled'), unsaved.edited ? t('mobile.flowchart.edited') : t('mobile.flowchart.notSaved'), open?.key === NEW_MAP, () => { + showDraft(scope, unsaved); + router.back(); + }, 0) + : null} + {items.map((item, index) => { + const held = scope ? getDraft(scope, item.id) : undefined; + const meta = held?.edited ? `${formatRelativeDate(item.createdAt)} · ${t('mobile.flowchart.edited')}` : formatRelativeDate(item.createdAt); + return row(`${index}`, item.title, meta, open?.key === item.id, () => void choose(item, index), index + (unsaved ? 1 : 0), opening === index); + })} + + ) : null} + {!shown ? ( + + ) : shown.failed ? ( + + + {t('mobile.flowchart.loadFailed')} + + void load()} /> + + ) : items.length === 0 ? ( + + {t('flowchart.noSavedFlowcharts')} + + ) : null} + {failedOpen ? ( + + {t('mobile.flowchart.openFailed')} + + ) : null} + + ); +} + +const styles = StyleSheet.create({ + header: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: 12 }, + title: { fontSize: 21, lineHeight: 26, letterSpacing: -0.3, flexShrink: 1 }, + body: { fontSize: 15, lineHeight: 21 }, + list: { borderWidth: 1, borderRadius: radius.field, overflow: 'hidden' }, + row: { flexDirection: 'row', alignItems: 'center', gap: 10, paddingHorizontal: 15, paddingVertical: 11, minHeight: 56 }, + rowText: { flex: 1, gap: 2 }, + rowTitle: { fontSize: 16 }, + rowMeta: { fontSize: 13.5 }, + pressed: { opacity: 0.7 }, + failed: { gap: 6, alignItems: 'flex-start' }, +}); diff --git a/apps/mobile/src/components/document-header.tsx b/apps/mobile/src/components/document-header.tsx index e300e3da1..e3ba7214e 100644 --- a/apps/mobile/src/components/document-header.tsx +++ b/apps/mobile/src/components/document-header.tsx @@ -19,11 +19,20 @@ export interface HeaderAction { onPress: () => void; } +/** A button of its own in the bar, such as the Flowchart's "+". */ +export interface HeaderButton { + key: string; + label: string; + symbol: SFSymbol; + icon: IconName; + onPress: () => void; +} + /** * The document's navigation bar from mock 03: back to where it was opened, * a share button and a "..." menu, in the native iOS header. */ -export function DocumentHeader({ backLabel, onShare, actions }: { backLabel: string; onShare?: () => void; actions: HeaderAction[] }) { +export function DocumentHeader({ backLabel, onShare, actions, buttons = [] }: { backLabel: string; onShare?: () => void; actions: HeaderAction[]; buttons?: HeaderButton[] }) { const { t } = useTranslation(); const { colors } = useTheme(); return ( @@ -41,6 +50,9 @@ export function DocumentHeader({ backLabel, onShare, actions }: { backLabel: str }} /> + {buttons.map((button) => ( + + ))} {onShare ? : null} {actions.length > 0 ? ( diff --git a/apps/mobile/src/components/document-header.web.tsx b/apps/mobile/src/components/document-header.web.tsx index 194602818..df7f16c2e 100644 --- a/apps/mobile/src/components/document-header.web.tsx +++ b/apps/mobile/src/components/document-header.web.tsx @@ -5,14 +5,14 @@ import { Pressable, StyleSheet, View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { ActionMenu } from './action-menu'; -import type { HeaderAction } from './document-header'; +import type { HeaderAction, HeaderButton } from './document-header'; import { Icon } from './icon'; import { Text } from './text'; import { useTheme } from '@/theme/use-theme'; /** The web preview has no native header, so it draws mock 03's bar and its menu. */ -export function DocumentHeader({ backLabel, onShare, actions }: { backLabel: string; onShare?: () => void; actions: HeaderAction[] }) { +export function DocumentHeader({ backLabel, onShare, actions, buttons = [] }: { backLabel: string; onShare?: () => void; actions: HeaderAction[]; buttons?: HeaderButton[] }) { const { t } = useTranslation(); const router = useRouter(); const insets = useSafeAreaInsets(); @@ -27,6 +27,11 @@ export function DocumentHeader({ backLabel, onShare, actions }: { backLabel: str + {buttons.map((button) => ( + + + + ))} {onShare ? ( diff --git a/apps/mobile/src/components/flowchart-card-form.tsx b/apps/mobile/src/components/flowchart-card-form.tsx new file mode 100644 index 000000000..0498b12e8 --- /dev/null +++ b/apps/mobile/src/components/flowchart-card-form.tsx @@ -0,0 +1,85 @@ +import type { CardFields, FlowNodeType, FlowStrength } from '@debatelab/core/flowchart'; +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { StyleSheet } from 'react-native'; + +import { PrimaryButton } from './buttons'; +import { Field } from './field'; +import { FormGroup, SelectRow } from './form-rows'; + +/** The types the website's add and edit dialog offers; the thesis is never chosen. */ +const CHOOSABLE: FlowNodeType[] = ['contention', 'attack', 'rebuttal', 'dropped']; + +/** + * The website's add and edit node dialog: type, label, description and, for an + * argument, its strength. A thesis keeps its type, as on the website. + */ +export function CardForm({ initial, submitLabel, onSubmit }: { initial: CardFields; submitLabel: string; onSubmit: (fields: CardFields) => void }) { + const { t } = useTranslation(); + const [fields, setFields] = useState(initial); + const set = (patch: Partial) => setFields((previous) => ({ ...previous, ...patch })); + const thesis = initial.type === 'thesis'; + return ( + <> + {thesis ? null : ( + + + first + label={t('flowchart.typeField')} + value={fields.type} + onChange={(type) => set({ type })} + options={CHOOSABLE.map((type) => ({ value: type, label: t(TYPE_OPTION[type]) }))} + testID="card-type" + /> + {fields.type === 'contention' ? ( + + label={t('flowchart.strengthField')} + value={fields.strength} + onChange={(strength) => set({ strength })} + options={[ + { value: 'strong', label: t('flowchart.strengthStrong') }, + { value: 'moderate', label: t('flowchart.strengthModerate') }, + { value: 'weak', label: t('flowchart.strengthWeak') }, + ]} + testID="card-strength" + /> + ) : null} + + )} + set({ label })} + testID="card-label" + /> + set({ description })} + multiline + style={styles.description} + testID="card-description" + /> + onSubmit({ ...fields, label: fields.label.trim() })} + testID="card-submit" + /> + + ); +} + +const TYPE_OPTION: Record = { + thesis: 'flowchart.legendThesis', + contention: 'flowchart.typeArgument', + attack: 'flowchart.typeAttack', + rebuttal: 'flowchart.typeRebuttal', + dropped: 'flowchart.typeDropped', +}; + +const styles = StyleSheet.create({ + description: { minHeight: 110, paddingTop: 13, paddingBottom: 13, textAlignVertical: 'top' }, +}); diff --git a/apps/mobile/src/components/flowchart-views.tsx b/apps/mobile/src/components/flowchart-views.tsx new file mode 100644 index 000000000..2b26ac1a1 --- /dev/null +++ b/apps/mobile/src/components/flowchart-views.tsx @@ -0,0 +1,180 @@ +import { flowColumns, mapRows, type FlowchartDocument, type FlowEdgeKind, type FlowNode, type FlowNodeType } from '@debatelab/core/flowchart'; +import { useTranslation } from 'react-i18next'; +import { Pressable, ScrollView, StyleSheet, View, useWindowDimensions } from 'react-native'; + +import { Text } from './text'; + +import { flowMeta, mapMeta, typeLabel, viaLabel } from '@/lib/flowchart-text'; +import { radius } from '@/theme/colors'; +import { useTheme } from '@/theme/use-theme'; + +/** + * The Flowchart as mock A draws it: the website's cards in role columns + * (Flow) or as an indented tree (Map), each with its type's coloured rule. + */ + +/** The website's node colours (nodeColors); the thesis takes the app's primary blue. */ +export function useFlowColors() { + const { colors, scheme } = useTheme(); + const dark = scheme === 'dark'; + return { + type: { + thesis: colors.primary, + contention: dark ? '#34d399' : '#10b981', + attack: dark ? '#f87171' : '#ef4444', + rebuttal: dark ? '#60a5fa' : '#3b82f6', + dropped: dark ? '#fbbf24' : '#f59e0b', + } satisfies Record, + /** Column headings and link labels, a shade darker than the rules for contrast. */ + heading: { + yourCase: colors.primary, + attacks: dark ? '#f87171' : '#dc2626', + rebuttals: dark ? '#60a5fa' : '#2563eb', + }, + via: { + support: colors.faint, + attack: dark ? '#f87171' : '#dc2626', + rebuttal: dark ? '#60a5fa' : '#2563eb', + } satisfies Record, + cardFill: dark ? colors.card : colors.input, + }; +} + +const COLUMN_WIDTH = 170; +const COLUMN_GAP = 12; +const SIDE = 16; +/** From this width every column fits, as on iPad. */ +const WIDE = 600; + +export function FlowCard({ node, meta, onPress, testID }: { node: FlowNode; meta: string | null; onPress: () => void; testID?: string }) { + const { t } = useTranslation(); + const { colors } = useTheme(); + const flow = useFlowColors(); + return ( + [ + styles.card, + { backgroundColor: flow.cardFill, borderColor: colors.border, borderLeftColor: flow.type[node.data.type] }, + pressed && styles.pressed, + ]}> + + {node.data.label} + + {meta ? ( + + {meta} + + ) : null} + + ); +} + +export function FlowLegend() { + const { t } = useTranslation(); + const flow = useFlowColors(); + const items: [FlowNodeType, string][] = [ + ['thesis', 'flowchart.legendThesis'], + ['contention', 'flowchart.legendArgument'], + ['attack', 'flowchart.legendAttack'], + ['rebuttal', 'flowchart.legendRebuttal'], + ['dropped', 'flowchart.legendDropped'], + ]; + return ( + + {items.map(([type, key]) => ( + + + + {t(key)} + + + ))} + + ); +} + +/** The Flow tab: your case, their attacks and your rebuttals side by side, snapping column by column on a phone. */ +export function FlowColumnsView({ doc, onOpen }: { doc: FlowchartDocument; onOpen: (node: FlowNode) => void }) { + const { t } = useTranslation(); + const flow = useFlowColors(); + const { width } = useWindowDimensions(); + const wide = width >= WIDE; + const groups = flowColumns(doc); + const columns = [ + { key: 'yourCase', title: t('mobile.flowchart.columns.yourCase'), color: flow.heading.yourCase, cards: groups.yourCase }, + { key: 'attacks', title: t('mobile.flowchart.columns.attacks'), color: flow.heading.attacks, cards: groups.attacks }, + { key: 'rebuttals', title: t('mobile.flowchart.columns.rebuttals'), color: flow.heading.rebuttals, cards: groups.rebuttals }, + ]; + const body = columns.map((column) => ( + + + {column.title} + + {column.cards.length === 0 ? ( + + {t('mobile.flowchart.emptyColumn')} + + ) : ( + column.cards.map((node, index) => onOpen(node)} testID="flow-card" />) + )} + + )); + if (wide) return {body}; + return ( + + {body} + + ); +} + +const INDENT = 18; +const MAX_INDENT = 4; + +/** The Map tab: from the thesis down through what supports, attacks and rebuts it. */ +export function MapTreeView({ doc, onOpen }: { doc: FlowchartDocument; onOpen: (node: FlowNode) => void }) { + const { t } = useTranslation(); + const flow = useFlowColors(); + return ( + + {mapRows(doc).map(({ node, depth, via }, index) => ( + + {via ? ( + + {`↳ ${viaLabel(t, via)}`} + + ) : null} + onOpen(node)} testID="map-card" /> + + ))} + + ); +} + +const styles = StyleSheet.create({ + card: { borderWidth: 1, borderLeftWidth: 4, borderRadius: radius.field, paddingHorizontal: 11, paddingVertical: 10, marginBottom: 10 }, + cardLabel: { fontSize: 14.5, lineHeight: 19 }, + cardMeta: { fontSize: 12.5, lineHeight: 17, marginTop: 3 }, + pressed: { opacity: 0.7 }, + legend: { flexDirection: 'row', flexWrap: 'wrap', columnGap: 12, rowGap: 6, paddingHorizontal: SIDE, marginBottom: 12 }, + legendItem: { flexDirection: 'row', alignItems: 'center', gap: 5 }, + swatch: { width: 10, height: 10, borderRadius: 3 }, + legendText: { fontSize: 12.5 }, + columns: { paddingHorizontal: SIDE, gap: COLUMN_GAP }, + column: { width: COLUMN_WIDTH }, + wideRow: { flexDirection: 'row', gap: 16, paddingHorizontal: SIDE + 8 }, + wideColumn: { flex: 1 }, + columnTitle: { fontSize: 13.5, marginBottom: 8, letterSpacing: -0.1 }, + empty: { fontSize: 13, lineHeight: 18 }, + tree: { paddingHorizontal: SIDE }, + via: { fontSize: 11.5, marginBottom: 4 }, +}); diff --git a/apps/mobile/src/components/one-off-notice.tsx b/apps/mobile/src/components/one-off-notice.tsx new file mode 100644 index 000000000..dedd8f784 --- /dev/null +++ b/apps/mobile/src/components/one-off-notice.tsx @@ -0,0 +1,46 @@ +import { useTranslation } from 'react-i18next'; +import { StyleSheet, View, type StyleProp, type ViewStyle } from 'react-native'; + +import { TextButton } from '@/components/buttons'; +import { Text } from '@/components/text'; +import { useDebates } from '@/lib/debate-context'; +import { radius } from '@/theme/colors'; +import { useTheme } from '@/theme/use-theme'; + +/** + * Shown while one-off mode is on, like the website's CurrentDebateStrip: the message always, + * and "Return to debate" only when there is a Current Debate to return to. + */ +export function OneOffNotice({ + message = 'debate.oneOffStrip', + style, +}: { + message?: 'debate.oneOffStrip' | 'debate.oneOffActive'; + style?: StyleProp; +}) { + const { t } = useTranslation(); + const { colors } = useTheme(); + const { oneOff, current, setOneOff } = useDebates(); + if (!oneOff) return null; + return ( + + + {t(message)} + + {current ? setOneOff(false)} /> : null} + + ); +} + +const styles = StyleSheet.create({ + notice: { + borderWidth: 1, + borderStyle: 'dashed', + borderRadius: radius.field, + paddingHorizontal: 14, + paddingTop: 10, + alignItems: 'flex-start', + }, + noAction: { paddingBottom: 10 }, + text: { fontSize: 14, lineHeight: 19 }, +}); diff --git a/apps/mobile/src/components/prepare-tools.ts b/apps/mobile/src/components/prepare-tools.ts index e9be47242..7122a014a 100644 --- a/apps/mobile/src/components/prepare-tools.ts +++ b/apps/mobile/src/components/prepare-tools.ts @@ -2,7 +2,7 @@ import type { Href } from 'expo-router'; import type { IconName } from './icon-paths'; -/** The Prepare tools, in the website's order. Flowchart follows in a later update. */ +/** The Prepare tools, in the website's order. */ export const PREPARE_TOOLS = [ { key: 'generate', icon: 'sparkles' }, { key: 'brainstorm', icon: 'bulb' }, @@ -21,7 +21,7 @@ export function prepareToolHref(tool: PrepareTool, from: 'debate' | 'prepare'): return { pathname: '/brainstorm', params: { from } }; case 'factoids': return { pathname: '/factoids', params: { from } }; - default: - return { pathname: '/coming/[tool]', params: { tool } }; + case 'flowchart': + return { pathname: '/flowchart', params: { from } }; } } diff --git a/apps/mobile/src/components/sheet.tsx b/apps/mobile/src/components/sheet.tsx new file mode 100644 index 000000000..1704df017 --- /dev/null +++ b/apps/mobile/src/components/sheet.tsx @@ -0,0 +1,40 @@ +import type { ReactNode } from 'react'; +import { ScrollView, StyleSheet, type StyleProp, type ViewStyle } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +import { useTheme } from '@/theme/use-theme'; + +/** + * The scrolling body of a form sheet. It must be the sheet's root view: react-native-screens + * sizes a form sheet's scroll view natively, and only a direct child keeps its position. Wrapped + * in a KeyboardAvoidingView or any other view, it falls back to `setFrame(screen.frame)`, which + * copies the sheet's slide-in offset and leaves the content below the visible sheet. + * The sheet rises to its top detent when the keyboard opens; the insets scroll fields clear of it. + */ +export function SheetScrollView({ + children, + contentContainerStyle, + testID, +}: { + children: ReactNode; + contentContainerStyle?: StyleProp; + testID?: string; +}) { + const insets = useSafeAreaInsets(); + const { colors } = useTheme(); + return ( + + {children} + + ); +} + +const styles = StyleSheet.create({ + content: { paddingHorizontal: 20, paddingTop: 22, gap: 18, width: '100%', maxWidth: 640, alignSelf: 'center' }, +}); diff --git a/apps/mobile/src/components/work-row.tsx b/apps/mobile/src/components/work-row.tsx index e4813f9c9..1b575ae63 100644 --- a/apps/mobile/src/components/work-row.tsx +++ b/apps/mobile/src/components/work-row.tsx @@ -27,7 +27,7 @@ export type OpenedFrom = 'debate' | 'prepare' | 'practice'; /** * Where a saved item opens. Cases, attacks, brainstorms and factoids open - * read-only as documents; Flowcharts open their "on its way" screen, and + * read-only as documents, a flowchart opens on the Flowchart screen, and * Practice work opens the Practice tab. */ export function workHref(item: DebateItem, from: OpenedFrom): Href { @@ -41,7 +41,7 @@ export function workHref(item: DebateItem, from: OpenedFrom): Href { case 'factoids': return { pathname: '/factoids/[id]', params: { id: item.id, from } }; case 'flowchart': - return { pathname: '/coming/[tool]', params: { tool: item.kind } }; + return { pathname: '/flowchart', params: { id: item.id, from } }; default: return '/practice'; } diff --git a/apps/mobile/src/lib/export-html.ts b/apps/mobile/src/lib/export-html.ts index f57b2918f..8e85490d3 100644 --- a/apps/mobile/src/lib/export-html.ts +++ b/apps/mobile/src/lib/export-html.ts @@ -2,6 +2,9 @@ import type { AttackOutput } from '@debatelab/core/ai/schemas/attack'; import type { BrainstormOutput } from '@debatelab/core/ai/schemas/brainstorm'; import type { FactoidsOutput } from '@debatelab/core/ai/schemas/factoids'; import type { GenerateOutput } from '@debatelab/core/ai/schemas/generate'; +import { mapRows, type FlowchartDocument, type FlowNodeType } from '@debatelab/core/flowchart'; + +import { mapMeta, typeLabel, viaLabel } from './flowchart-text'; type Translate = (key: string, options?: Record) => string; @@ -192,3 +195,29 @@ export function factoidsExportHtml(result: FactoidsOutput, t: Translate): string if (result.warnings?.length) parts.push(`

${escape(t('export.warnings'))}

${bullets(result.warnings)}`); return page(t('export.researchFactoids'), t('export.factoidsSummary', { count: factoids.length, verified }), parts.join(''), t); } + +/** The website's export file name (exportToPng's debate-flowchart.png), as a PDF. */ +export const FLOWCHART_EXPORT_FILE_NAME = 'debate-flowchart.pdf'; + +/** The website's node colours (nodeColors). */ +const FLOW_COLORS: Record = { thesis: '#0284c7', contention: '#10b981', attack: '#ef4444', rebuttal: '#3b82f6', dropped: '#f59e0b' }; + +const FLOW_STYLE = ``; + +/** The Map tab's tree as a PDF page: every card under the one it answers, with its link. */ +export function flowchartExportHtml(title: string, meta: string, doc: FlowchartDocument, t: Translate): string { + const rows = mapRows(doc).map(({ node, depth, via }) => { + const color = FLOW_COLORS[node.data.type]; + const note = mapMeta(t, doc, node); + return `
${via ? `
\u21b3 ${escape(viaLabel(t, via))}
` : ''}
${escape(typeLabel(t, node.data.type))}
${escape(node.data.label)}
${ + node.data.description ? `

${escape(node.data.description)}

` : ''}${note && note !== typeLabel(t, node.data.type) ? `
${escape(note)}
` : ''}
`; + }); + return page(title, meta, FLOW_STYLE + rows.join(''), t); +} diff --git a/apps/mobile/src/lib/flowchart-drafts.ts b/apps/mobile/src/lib/flowchart-drafts.ts new file mode 100644 index 000000000..0d712a9b0 --- /dev/null +++ b/apps/mobile/src/lib/flowchart-drafts.ts @@ -0,0 +1,139 @@ +import type { FlowchartDocument } from '@debatelab/core/flowchart'; +import { useSyncExternalStore } from 'react'; + +import type { SavedFlowchart } from './flowcharts'; +import { sessionSlot } from './session-memory'; + +/** + * The maps being worked on, kept in session memory until they are saved: per + * debate (one-off work has its own), the map on screen and the edits held for + * each map opened this session. The website keeps one map on its canvas and + * does not warn before a load replaces it; the app keeps every map's edits + * instead, and forgets them all when the account changes. The Flowchart + * screen and its sheets are separate routes, so they share the maps here. + */ + +export interface FlowchartDraft { + /** The saved row, or null for a map not saved yet. */ + savedId: string | null; + title: string; + /** The debate the map belongs to; null for one-off work. */ + debateId: string | null; + createdAt: number | null; + doc: FlowchartDocument; + /** Changed since it was opened or last saved. */ + edited: boolean; +} + +interface Scope { + open: string | null; + drafts: Map; +} + +/** The key of the one map per debate that has not been saved yet. */ +export const NEW_MAP = 'new'; + +const scopes = sessionSlot(() => new Map()); +const listeners = new Set<() => void>(); +let version = 0; + +function emit() { + version += 1; + listeners.forEach((listener) => listener()); +} + +function scopeFor(scope: string): Scope { + const all = scopes.get(); + let entry = all.get(scope); + if (!entry) { + entry = { open: null, drafts: new Map() }; + all.set(scope, entry); + } + return entry; +} + +/** Where a debate's maps are kept: its id, or `one-off` for one-off work. */ +export function scopeKey(debateId: string | null): string { + return debateId ?? 'one-off'; +} + +/** The debate a scope key stands for. */ +export function scopeDebateId(scope: string): string | null { + return scope === 'one-off' ? null : scope; +} + +export function savedDraft(row: SavedFlowchart): FlowchartDraft { + return { savedId: row.id, title: row.title, debateId: row.debateId, createdAt: row.createdAt, doc: row.doc, edited: false }; +} + +/** A map not saved yet: the website's starter map, in the debate (or one-off work) it will be saved to. */ +export function newDraft(debateId: string | null, doc: FlowchartDocument): FlowchartDraft { + return { savedId: null, title: '', debateId, createdAt: null, doc, edited: false }; +} + +export function draftKey(draft: Pick): string { + return draft.savedId ?? NEW_MAP; +} + +/** The map on screen for a debate, if one was opened this session. */ +export function openDraft(scope: string): { key: string; draft: FlowchartDraft } | null { + const entry = scopes.get().get(scope); + const draft = entry?.open ? entry.drafts.get(entry.open) : undefined; + return entry?.open && draft ? { key: entry.open, draft } : null; +} + +/** Where a saved map is held this session, if it is. */ +export function findSavedDraft(savedId: string): { scope: string; draft: FlowchartDraft } | null { + for (const [scope, entry] of scopes.get()) { + const draft = entry.drafts.get(savedId); + if (draft) return { scope, draft }; + } + return null; +} + +export function getDraft(scope: string, key: string): FlowchartDraft | undefined { + return scopes.get().get(scope)?.drafts.get(key); +} + +/** Puts a map on screen. Edits already held for it stay unless `replace` is set. Returns its key. */ +export function showDraft(scope: string, draft: FlowchartDraft, options: { replace?: boolean } = {}): string { + const entry = scopeFor(scope); + const key = draftKey(draft); + if (options.replace || !entry.drafts.has(key)) entry.drafts.set(key, draft); + entry.open = key; + emit(); + return key; +} + +/** Changes one held map. Resolves false when it is no longer held, for example after the account changed. */ +export function updateDraft(scope: string, key: string, change: (draft: FlowchartDraft) => FlowchartDraft): boolean { + const entry = scopes.get().get(scope); + const draft = entry?.drafts.get(key); + if (!entry || !draft) return false; + const next = change(draft); + entry.drafts.delete(key); + entry.drafts.set(draftKey(next), next); + if (entry.open === key) entry.open = draftKey(next); + emit(); + return true; +} + +export function forgetDraft(scope: string, key: string) { + const entry = scopes.get().get(scope); + if (!entry) return; + entry.drafts.delete(key); + if (entry.open === key) entry.open = null; + emit(); +} + +function subscribe(listener: () => void) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +/** Re-renders when any held map changes; read the maps with openDraft and getDraft. */ +export function useFlowchartDrafts(): number { + return useSyncExternalStore(subscribe, () => version); +} diff --git a/apps/mobile/src/lib/flowchart-text.ts b/apps/mobile/src/lib/flowchart-text.ts new file mode 100644 index 000000000..e3f713919 --- /dev/null +++ b/apps/mobile/src/lib/flowchart-text.ts @@ -0,0 +1,97 @@ +import { + answeredClaim, + cardLinks, + isUnanswered, + mapRows, + type FlowchartDocument, + type FlowEdgeKind, + type FlowNode, + type FlowNodeType, +} from '@debatelab/core/flowchart'; + +type Translate = (key: string, options?: Record) => string; + +/** The website's names for each card type (CustomNode's badge). */ +const TYPE_KEYS: Record = { + thesis: 'flowchart.legendThesis', + contention: 'flowchart.typeArgument', + attack: 'flowchart.typeAttack', + rebuttal: 'flowchart.typeRebuttal', + dropped: 'flowchart.typeDropped', +}; + +const STRENGTH_KEYS = { strong: 'flowchart.strengthStrong', moderate: 'flowchart.strengthModerate', weak: 'flowchart.strengthWeak' } as const; + +export function typeLabel(t: Translate, type: FlowNodeType): string { + return t(TYPE_KEYS[type]); +} + +export function strengthLabel(t: Translate, node: FlowNode): string | null { + const strength = node.data.strength; + return typeof strength === 'string' && strength in STRENGTH_KEYS ? t(STRENGTH_KEYS[strength]) : null; +} + +/** The card's type, with its strength for an argument: "Argument · Strong". */ +export function cardKind(t: Translate, node: FlowNode): string { + const strength = node.data.type === 'contention' ? strengthLabel(t, node) : null; + return strength ? `${typeLabel(t, node.data.type)} · ${strength}` : typeLabel(t, node.data.type); +} + +/** The line under a card in the Flow tab: its strength, or what it attacks or rebuts. */ +export function flowMeta(t: Translate, doc: FlowchartDocument, node: FlowNode): string { + if (node.data.type === 'contention') return strengthLabel(t, node) ?? typeLabel(t, 'contention'); + if (node.data.type === 'attack' || node.data.type === 'rebuttal') { + const claim = answeredClaim(doc, node); + if (claim) return t(node.data.type === 'attack' ? 'mobile.flowchart.attacksMeta' : 'mobile.flowchart.rebutsMeta', { claim: claim.data.label }); + } + return typeLabel(t, node.data.type); +} + +/** The line under a card in the Map tab, when it needs one. */ +export function mapMeta(t: Translate, doc: FlowchartDocument, node: FlowNode): string | null { + if (isUnanswered(doc, node)) return t('mobile.flowchart.notAnswered'); + if (node.data.type === 'contention') return strengthLabel(t, node); + if (node.data.type === 'thesis' || node.data.type === 'dropped') return typeLabel(t, node.data.type); + return null; +} + +/** How a card hangs from the one above it in the Map tree. */ +export function viaLabel(t: Translate, via: FlowEdgeKind): string { + return t(`mobile.flowchart.via.${via}`); +} + +const quote = (t: Translate, labels: string[]) => labels.map((label) => t('mobile.flowchart.quoted', { label })).join(', '); + +/** The claim sheet's summary of a card's links: "Attacks “X”. Rebutted by 1 claim." */ +export function linkSummary(t: Translate, doc: FlowchartDocument, node: FlowNode): string { + const { responds, answeredBy } = cardLinks(doc, node.id); + const parts: string[] = []; + const responding: Record = { support: 'supports', attack: 'attacks', rebuttal: 'rebuts' }; + const answered: Record = { support: 'supportedBy', attack: 'attackedBy', rebuttal: 'rebuttedBy' }; + for (const kind of ['support', 'attack', 'rebuttal'] as const) { + const labels = responds.filter((link) => link.kind === kind).map((link) => link.other.data.label); + if (labels.length > 0) parts.push(t(`mobile.flowchart.summary.${responding[kind]}`, { claims: quote(t, labels) })); + } + for (const kind of ['support', 'attack', 'rebuttal'] as const) { + const count = answeredBy.filter((link) => link.kind === kind).length; + if (count > 0) parts.push(t(`mobile.flowchart.summary.${answered[kind]}`, { count })); + } + if (isUnanswered(doc, node)) parts.push(`${t('mobile.flowchart.notAnswered')}.`); + return parts.length > 0 ? parts.join(' ') : t('mobile.flowchart.summary.none'); +} + +/** One link in the claim sheet's list, from this card's side: "Attacks “X”" or "Rebutted by “Y”". */ +export function linkLine(t: Translate, kind: FlowEdgeKind, direction: 'responds' | 'answeredBy', other: FlowNode): string { + return t(`mobile.flowchart.links.${direction}.${kind}`, { claim: t('mobile.flowchart.quoted', { label: other.data.label }) }); +} + +/** "Copy as text": the Map tree as an indented outline. */ +export function flowchartToText(title: string, doc: FlowchartDocument, t: Translate): string { + const lines = mapRows(doc).map(({ node, depth, via }) => { + const meta = mapMeta(t, doc, node); + const head = `${' '.repeat(depth)}${via ? `${viaLabel(t, via)}: ` : ''}${typeLabel(t, node.data.type)}: ${node.data.label}${meta && meta !== typeLabel(t, node.data.type) ? ` (${meta})` : ''}`; + const description = node.data.description?.trim(); + return description ? `${head}\n${' '.repeat(depth + 1)}${description}` : head; + }); + return [title, '', ...lines].join('\n'); +} diff --git a/apps/mobile/src/lib/flowcharts.ts b/apps/mobile/src/lib/flowcharts.ts new file mode 100644 index 000000000..11bff836d --- /dev/null +++ b/apps/mobile/src/lib/flowcharts.ts @@ -0,0 +1,90 @@ +import { readFlowchart, type FlowchartDocument } from '@debatelab/core/flowchart'; + +import { supabase } from './supabase'; + +/** + * Saved flowcharts, read and written the way the website's session store does + * (saveFlowchart, deleteSavedFlowchart and hydrateFromServer in + * src/lib/stores/session-store.ts): the React Flow nodes and edges as they are, + * the title, the debate it belongs to (none for one-off work) and side + * `neutral`. The app adds saving over a map it opened; the website only ever + * saves new rows. + */ + +export interface FlowchartSummary { + id: string; + title: string; + createdAt: number; +} + +export interface SavedFlowchart extends FlowchartSummary { + debateId: string | null; + doc: FlowchartDocument; +} + +interface FlowchartRow { + id: string; + title: string; + debate_id: string | null; + nodes: unknown; + edges: unknown; + created_at: string; +} + +const COLUMNS = 'id,title,debate_id,nodes,edges,created_at'; + +function fromRow(row: FlowchartRow): SavedFlowchart { + return { + id: row.id, + title: row.title, + debateId: row.debate_id ?? null, + doc: readFlowchart(row.nodes, row.edges), + createdAt: new Date(row.created_at).getTime(), + }; +} + +/** The maps saved in one debate, or one-off maps for null, newest first, as the website's saved list shows them. */ +export async function fetchFlowchartList(userId: string, debateId: string | null): Promise { + const query = supabase.from('flowcharts').select('id,title,created_at').eq('user_id', userId); + const { data, error } = await (debateId ? query.eq('debate_id', debateId) : query.is('debate_id', null)).order('created_at', { ascending: false }); + if (error) throw new Error(error.message); + return ((data ?? []) as { id: string; title: string; created_at: string }[]).map((row) => ({ + id: row.id, + title: row.title, + createdAt: new Date(row.created_at).getTime(), + })); +} + +export async function fetchFlowchart(userId: string, id: string): Promise { + const { data, error } = await supabase.from('flowcharts').select(COLUMNS).eq('user_id', userId).eq('id', id).maybeSingle(); + if (error) throw new Error(error.message); + return data ? fromRow(data as FlowchartRow) : null; +} + +/** The website's Save: a new row in the debate (or one-off when null). */ +export async function insertFlowchart(userId: string, input: { title: string; debateId: string | null; doc: FlowchartDocument }): Promise { + const { data, error } = await supabase + .from('flowcharts') + .insert({ user_id: userId, debate_id: input.debateId, side: 'neutral', title: input.title, nodes: input.doc.nodes, edges: input.doc.edges }) + .select(COLUMNS) + .single(); + if (error || !data) throw new Error(error?.message ?? 'Could not save the flowchart'); + return fromRow(data as FlowchartRow); +} + +/** Saves over a map the app opened. Resolves false when the row is gone, for example deleted on the website. */ +export async function updateFlowchart(userId: string, id: string, doc: FlowchartDocument): Promise { + const { data, error } = await supabase + .from('flowcharts') + .update({ nodes: doc.nodes, edges: doc.edges, updated_at: new Date().toISOString() }) + .eq('user_id', userId) + .eq('id', id) + .select('id'); + if (error) throw new Error(error.message); + return (data ?? []).length > 0; +} + +export async function deleteFlowchart(userId: string, id: string): Promise { + const { error } = await supabase.from('flowcharts').delete().eq('user_id', userId).eq('id', id); + if (error) throw new Error(error.message); +} diff --git a/apps/mobile/src/lib/keep-if-same.ts b/apps/mobile/src/lib/keep-if-same.ts new file mode 100644 index 000000000..1c14e942e --- /dev/null +++ b/apps/mobile/src/lib/keep-if-same.ts @@ -0,0 +1,4 @@ +/** Focus refreshes usually reread unchanged data; returning the previous value keeps its identity, so nothing re-renders. */ +export function keepIfSame(previous: T, next: T): T { + return JSON.stringify(previous) === JSON.stringify(next) ? previous : next; +} diff --git a/packages/core/src/flowchart.ts b/packages/core/src/flowchart.ts index f6769e277..c3c94743e 100644 --- a/packages/core/src/flowchart.ts +++ b/packages/core/src/flowchart.ts @@ -19,7 +19,7 @@ export type FlowStrength = (typeof FLOW_STRENGTHS)[number]; export interface FlowNodeData { label: string; - description?: string; + description?: string | null; type: FlowNodeType; strength?: FlowStrength; [key: string]: unknown; @@ -73,6 +73,7 @@ export function isFlowNode(value: unknown): value is FlowNode { typeof value.position.y === 'number' && isRecord(value.data) && typeof value.data.label === 'string' && + (value.data.description === undefined || value.data.description === null || typeof value.data.description === 'string') && isNodeType(value.data.type) ); } diff --git a/src/locales/en.json b/src/locales/en.json index 79e713b93..3793495fb 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -2541,6 +2541,75 @@ }, "factoids": { "claimRequired": "Enter a statement." + }, + "flowchart": { + "untitled": "Unsaved map", + "notSaved": "Not saved yet", + "edited": "Edited, not saved", + "savedWhen": "Saved {{when}}", + "flow": "Flow", + "map": "Map", + "columns": { + "yourCase": "Your case", + "attacks": "Their attacks", + "rebuttals": "Your rebuttals" + }, + "emptyColumn": "No cards yet", + "attacksMeta": "attacks: {{claim}}", + "rebutsMeta": "rebuts: {{claim}}", + "notAnswered": "Not answered yet", + "via": { + "support": "supports", + "attack": "attacked by", + "rebuttal": "rebutted by" + }, + "quoted": "“{{label}}”", + "summary": { + "supports": "Supports {{claims}}.", + "attacks": "Attacks {{claims}}.", + "rebuts": "Rebuts {{claims}}.", + "supportedBy_one": "Supported by {{count}} claim.", + "supportedBy_other": "Supported by {{count}} claims.", + "attackedBy_one": "Attacked by {{count}} claim.", + "attackedBy_other": "Attacked by {{count}} claims.", + "rebuttedBy_one": "Rebutted by {{count}} claim.", + "rebuttedBy_other": "Rebutted by {{count}} claims.", + "none": "Not linked to other cards yet." + }, + "links": { + "responds": { + "support": "Supports {{claim}}", + "attack": "Attacks {{claim}}", + "rebuttal": "Rebuts {{claim}}" + }, + "answeredBy": { + "support": "Supported by {{claim}}", + "attack": "Attacked by {{claim}}", + "rebuttal": "Rebutted by {{claim}}" + } + }, + "linksTitle": "Links", + "linkClaim": "Link claim", + "linkTitle": "Link to a claim", + "linkHint": "Choose the claim this card answers, and how it answers it.", + "alreadyLinked": "Already linked", + "removeLink": "Remove link: {{link}}", + "changeType": "Change type", + "cardGone": "This card is no longer on the map.", + "mapGone": "This map is no longer open. Go back to the Flowchart.", + "saveAs": "Save as…", + "deleteMap": "Delete map", + "deleteMapTitle": "Delete this map?", + "deleteMapDesc": "“{{title}}” will be deleted from your saved flowcharts. This cannot be undone.", + "missing": "This map was deleted elsewhere. Use Save as to keep it.", + "mapMissing": "This flowchart was deleted or is not in your account.", + "openLatest": "Open the newest map", + "loadFailed": "Could not load your flowcharts.", + "openFailed": "Could not open that map. Try again.", + "newMap": "New map", + "discardTitle": "Start a new map?", + "discardDesc": "Your unsaved map will be replaced by the starter map.", + "startNew": "Start new" } } } diff --git a/src/locales/es.json b/src/locales/es.json index f43fa9c5f..953fae7e5 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -2541,6 +2541,75 @@ }, "factoids": { "claimRequired": "Escribe una afirmación." + }, + "flowchart": { + "untitled": "Mapa sin guardar", + "notSaved": "Aún sin guardar", + "edited": "Editado, sin guardar", + "savedWhen": "Guardado {{when}}", + "flow": "Flujo", + "map": "Mapa", + "columns": { + "yourCase": "Tu caso", + "attacks": "Sus ataques", + "rebuttals": "Tus refutaciones" + }, + "emptyColumn": "Aún no hay tarjetas", + "attacksMeta": "ataca: {{claim}}", + "rebutsMeta": "refuta: {{claim}}", + "notAnswered": "Aún sin respuesta", + "via": { + "support": "apoya", + "attack": "atacado por", + "rebuttal": "refutado por" + }, + "quoted": "“{{label}}”", + "summary": { + "supports": "Apoya {{claims}}.", + "attacks": "Ataca {{claims}}.", + "rebuts": "Refuta {{claims}}.", + "supportedBy_one": "Lo apoya {{count}} afirmación.", + "supportedBy_other": "Lo apoyan {{count}} afirmaciones.", + "attackedBy_one": "Lo ataca {{count}} afirmación.", + "attackedBy_other": "Lo atacan {{count}} afirmaciones.", + "rebuttedBy_one": "Lo refuta {{count}} afirmación.", + "rebuttedBy_other": "Lo refutan {{count}} afirmaciones.", + "none": "Aún no está vinculada a otras tarjetas." + }, + "links": { + "responds": { + "support": "Apoya {{claim}}", + "attack": "Ataca {{claim}}", + "rebuttal": "Refuta {{claim}}" + }, + "answeredBy": { + "support": "Apoyada por {{claim}}", + "attack": "Atacada por {{claim}}", + "rebuttal": "Refutada por {{claim}}" + } + }, + "linksTitle": "Vínculos", + "linkClaim": "Vincular afirmación", + "linkTitle": "Vincular a una afirmación", + "linkHint": "Elige la afirmación a la que responde esta tarjeta y cómo responde.", + "alreadyLinked": "Ya vinculada", + "removeLink": "Quitar vínculo: {{link}}", + "changeType": "Cambiar tipo", + "cardGone": "Esta tarjeta ya no está en el mapa.", + "mapGone": "Este mapa ya no está abierto. Vuelve al Diagrama.", + "saveAs": "Guardar como…", + "deleteMap": "Eliminar mapa", + "deleteMapTitle": "¿Eliminar este mapa?", + "deleteMapDesc": "“{{title}}” se eliminará de tus diagramas guardados. No se puede deshacer.", + "missing": "Este mapa se eliminó en otro lugar. Usa Guardar como para conservarlo.", + "mapMissing": "Este diagrama se eliminó o no está en tu cuenta.", + "openLatest": "Abrir el mapa más reciente", + "loadFailed": "No se pudieron cargar tus diagramas.", + "openFailed": "No se pudo abrir ese mapa. Inténtalo de nuevo.", + "newMap": "Nuevo mapa", + "discardTitle": "¿Empezar un mapa nuevo?", + "discardDesc": "Tu mapa sin guardar se sustituirá por el mapa inicial.", + "startNew": "Empezar nuevo" } } } diff --git a/src/locales/hu.json b/src/locales/hu.json index f4661bc0e..74d36fc21 100644 --- a/src/locales/hu.json +++ b/src/locales/hu.json @@ -2541,6 +2541,75 @@ }, "factoids": { "claimRequired": "Írj be egy állítást." + }, + "flowchart": { + "untitled": "Mentetlen térkép", + "notSaved": "Még nincs mentve", + "edited": "Módosítva, nincs mentve", + "savedWhen": "Mentve: {{when}}", + "flow": "Oszlopok", + "map": "Térkép", + "columns": { + "yourCase": "Saját érvelésed", + "attacks": "Támadásaik", + "rebuttals": "Cáfolataid" + }, + "emptyColumn": "Még nincs kártya", + "attacksMeta": "támadja: {{claim}}", + "rebutsMeta": "cáfolja: {{claim}}", + "notAnswered": "Még nincs megválaszolva", + "via": { + "support": "alátámasztás", + "attack": "támadás", + "rebuttal": "cáfolat" + }, + "quoted": "„{{label}}”", + "summary": { + "supports": "Alátámasztja: {{claims}}.", + "attacks": "Támadja: {{claims}}.", + "rebuts": "Cáfolja: {{claims}}.", + "supportedBy_one": "{{count}} állítás támasztja alá.", + "supportedBy_other": "{{count}} állítás támasztja alá.", + "attackedBy_one": "{{count}} állítás támadja.", + "attackedBy_other": "{{count}} állítás támadja.", + "rebuttedBy_one": "{{count}} állítás cáfolja.", + "rebuttedBy_other": "{{count}} állítás cáfolja.", + "none": "Még nincs kapcsolata más kártyákkal." + }, + "links": { + "responds": { + "support": "Alátámasztja: {{claim}}", + "attack": "Támadja: {{claim}}", + "rebuttal": "Cáfolja: {{claim}}" + }, + "answeredBy": { + "support": "Alátámasztó: {{claim}}", + "attack": "Támadó: {{claim}}", + "rebuttal": "Cáfolat: {{claim}}" + } + }, + "linksTitle": "Kapcsolatok", + "linkClaim": "Összekapcsolás", + "linkTitle": "Kapcsolás egy állításhoz", + "linkHint": "Válaszd ki, melyik állításra válaszol ez a kártya, és hogyan.", + "alreadyLinked": "Már össze van kapcsolva", + "removeLink": "Kapcsolat törlése: {{link}}", + "changeType": "Típus módosítása", + "cardGone": "Ez a kártya már nincs a térképen.", + "mapGone": "Ez a térkép már nincs megnyitva. Menj vissza a folyamatábrához.", + "saveAs": "Mentés másként…", + "deleteMap": "Térkép törlése", + "deleteMapTitle": "Törlöd ezt a térképet?", + "deleteMapDesc": "A(z) „{{title}}” törlődik a mentett folyamatábrák közül. Ez nem vonható vissza.", + "missing": "Ezt a térképet máshol törölték. A megtartásához használd a Mentés másként lehetőséget.", + "mapMissing": "Ez a folyamatábra törölve lett, vagy nem a fiókodhoz tartozik.", + "openLatest": "A legújabb térkép megnyitása", + "loadFailed": "Nem sikerült betölteni a folyamatábráidat.", + "openFailed": "Nem sikerült megnyitni a térképet. Próbáld újra.", + "newMap": "Új térkép", + "discardTitle": "Új térképet kezdesz?", + "discardDesc": "A mentetlen térképed helyére a kezdő térkép kerül.", + "startNew": "Új kezdése" } } } From bc80a6b7202e81a224c357d962cc81aba5fbc923 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 9 Oct 2026 16:09:05 +0000 Subject: [PATCH 3/5] Screenshot the Flowchart The screenshot script now shoots Flow, Map, a card's sheet and its link picker, adding a card, the saved maps, the "..." menu, Export and one-off mode, from two made-up saved maps in the website's React Flow shape. It answers flowcharts reads with them (none for one-off work, which shows the starter map) and answers writes locally, so nothing is saved. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/scripts/screenshots.ts | 150 +++++++++++++++++++++++++++++ 1 file changed, 150 insertions(+) diff --git a/apps/mobile/scripts/screenshots.ts b/apps/mobile/scripts/screenshots.ts index c2e7aaea5..8da36f557 100644 --- a/apps/mobile/scripts/screenshots.ts +++ b/apps/mobile/scripts/screenshots.ts @@ -36,6 +36,12 @@ * rendered in a page of its own, and the "Copy as text" output is written * next to the screenshots. Brainstorm and Factoids are shot the same way, * from a fixed brainstorm and fixed factoids, and save nothing. + * + * Flowchart screenshots read fixed saved maps: the page answers every + * flowcharts read with two made-up maps in the website's React Flow shape (or + * none for one-off work, which shows the starter map) and answers writes + * locally. The one-off shot turns one-off mode on in memory, as the switcher + * does, and never taps a debate row. */ import { chromium, type Browser, type BrowserContext, type BrowserContextOptions, type Page } from '@playwright/test'; import { createReadStream, existsSync, statSync } from 'node:fs'; @@ -469,6 +475,148 @@ async function shootTool(context: BrowserContext, prefix: string, language: stri await page.close(); } +// Made-up saved maps in the website's React Flow shape, after mock A. +const FLOW_NODE = (id: string, x: number, y: number, label: string, type: string, strength?: string) => ({ + id, type: 'custom', position: { x, y }, width: 220, height: 96, + data: { label, description: '', type, ...(strength ? { strength } : {}) }, +}); +const FAKE_FLOWCHART = { + nodes: [ + FLOW_NODE('thesis', 400, 50, 'Ban private cars from city centres', 'thesis'), + FLOW_NODE('c1', 150, 200, 'Air quality is a health duty', 'contention', 'strong'), + FLOW_NODE('c2', 400, 200, 'Streets belong to people', 'contention', 'moderate'), + FLOW_NODE('c3', 650, 200, 'Fewer road deaths', 'dropped'), + FLOW_NODE('a1', 150, 350, 'Shift workers have no alternative', 'attack'), + FLOW_NODE('a2', 400, 350, 'Small shops lose customers', 'attack'), + FLOW_NODE('r1', 150, 500, 'Night buses follow demand', 'rebuttal'), + ], + edges: [ + { id: 'e-thesis-c1', source: 'thesis', target: 'c1', animated: true, markerEnd: { type: 'arrowclosed' } }, + { id: 'e-thesis-c2', source: 'thesis', target: 'c2', animated: true, markerEnd: { type: 'arrowclosed' } }, + { id: 'e-thesis-c3', source: 'thesis', target: 'c3', animated: true, markerEnd: { type: 'arrowclosed' } }, + { id: 'reactflow__edge-c1-a1', source: 'c1', sourceHandle: null, target: 'a1', targetHandle: null, animated: true, label: 'Attack', style: { stroke: '#ef4444', strokeWidth: 2 }, markerEnd: { type: 'arrowclosed' } }, + { id: 'e-c2-a2', source: 'c2', target: 'a2', style: { stroke: '#ef4444' }, markerEnd: { type: 'arrowclosed' } }, + { id: 'reactflow__edge-a1-r1', source: 'a1', sourceHandle: null, target: 'r1', targetHandle: null, animated: true, label: 'Rebuttal', style: { stroke: '#3b82f6', strokeWidth: 2 }, markerEnd: { type: 'arrowclosed' } }, + ], +}; +const FAKE_FLOWCHART_ROWS = [ + { id: '00000000-0000-4000-8000-00000000f10a', title: 'Car ban: first speech', created_at: '2026-10-09T09:12:00.000Z', ...FAKE_FLOWCHART }, + { id: '00000000-0000-4000-8000-00000000f10b', title: 'Practice round map', created_at: '2026-10-02T17:40:00.000Z', nodes: FAKE_FLOWCHART.nodes.slice(0, 3), edges: FAKE_FLOWCHART.edges.slice(0, 2) }, +]; + +/** Answers flowcharts reads with the made-up maps, none for one-off work, and writes locally (see the header). */ +async function fakeFlowcharts(page: Page) { + // The maps belong to the debate the last list was read for. + let debateId: string | null = null; + await page.route(`${REST}/flowcharts*`, (route) => { + const request = route.request(); + const url = new URL(request.url()); + const single = (request.headers().accept ?? '').includes('vnd.pgrst.object'); + if (request.method() === 'DELETE') return route.fulfill({ status: 204 }); + if (request.method() !== 'GET') { + const row = { ...FAKE_FLOWCHART_ROWS[0], id: '00000000-0000-4000-8000-00000000f1ff', debate_id: debateId }; + return route.fulfill({ status: 201, json: single ? row : [row] }); + } + const scope = url.searchParams.get('debate_id'); + if (scope) debateId = scope === 'is.null' ? null : scope.replace(/^eq\./, ''); + const id = url.searchParams.get('id')?.replace(/^eq\./, ''); + const rows = (scope === 'is.null' ? [] : FAKE_FLOWCHART_ROWS) + .filter((row) => !id || row.id === id) + .map((row) => ({ ...row, debate_id: debateId })); + return route.fulfill({ status: 200, json: single ? rows[0] ?? null : rows }); + }); +} + +/** Flow, Map, the claim sheet, adding a card, the saved maps, the menu and Export, from fixed maps. Nothing is saved. */ +async function shootFlowchart(context: BrowserContext, prefix: string, language: string, problems: string[]) { + const page = await context.newPage(); + watchErrors(page, problems, `${prefix} flowchart`); + page.on('dialog', (dialog) => void dialog.dismiss()); + await pinSettings(page, language); + await fakeFlowcharts(page); + await page.goto(`${ORIGIN}/prepare`); + await page.getByTestId('prepare-tool-flowchart').waitFor({ timeout: 30_000 }); + await settle(page); + await page.getByTestId('prepare-tool-flowchart').click(); + await page.getByTestId('flow-card').first().waitFor({ timeout: 30_000 }); + await settle(page); + await shoot(page, `${prefix}-flowchart-flow`); + + await page.getByRole('tab', { name: /^(Map|Térkép|Mapa)$/ }).click(); + await page.getByTestId('map-tree').waitFor(); + await shoot(page, `${prefix}-flowchart-map`); + + await page.getByTestId('map-card').filter({ hasText: 'Small shops lose customers' }).click(); + await page.getByTestId('claim-sheet').waitFor(); + await page.waitForTimeout(700); + await shoot(page, `${prefix}-flowchart-claim`); + await page.getByTestId('claim-link').click(); + await page.getByTestId('link-targets').waitFor(); + await shoot(page, `${prefix}-flowchart-link`); + await page.goBack(); + await page.getByTestId('flowchart-screen').waitFor(); + + await page.getByTestId('document-add').click(); + await page.getByTestId('card-sheet').waitFor(); + await page.waitForTimeout(700); + await shoot(page, `${prefix}-flowchart-add-card`); + await page.goBack(); + + await page.getByTestId('flowchart-maps').click(); + await page.getByTestId('maps-sheet').waitFor(); + await page.getByTestId('saved-map').nth(1).waitFor({ timeout: 15_000 }); + await page.waitForTimeout(700); + await shoot(page, `${prefix}-flowchart-maps`); + await page.goBack(); + + await page.getByTestId('document-more').click(); + await page.getByRole('menu').waitFor(); + await shoot(page, `${prefix}-flowchart-menu`); + // Save, Save as, Saved Flowcharts, Export, Delete map. + await page.getByRole('menuitem').nth(3).click(); + await page.getByTestId('export-menu').waitFor(); + await shoot(page, `${prefix}-flowchart-export-menu`); + await page.getByTestId('export-menu').getByRole('menuitem').first().click(); + await page.getByTestId('document-more').click(); + await page.getByRole('menuitem').nth(3).click(); + await page.getByTestId('export-menu').getByRole('menuitem').nth(1).click(); + const exported = await page.evaluate(() => (window as unknown as { __debatelabExport?: { html?: string; fileName?: string; copied?: string } }).__debatelabExport ?? {}); + if (!exported.html || !exported.copied) throw new Error('Flowchart export did not hand over the PDF HTML or the copied text'); + await writeFile(join(OUT, `${prefix}-flowchart-copy.txt`), exported.copied); + const pdf = await context.newPage(); + await pdf.setViewportSize({ width: 794, height: 1123 }); + await pdf.setContent(exported.html); + await pdf.screenshot({ path: join(OUT, `${prefix}-flowchart-pdf-html.png`), fullPage: true }); + await pdf.close(); + console.log(`${prefix} flowchart: PDF file name ${exported.fileName}, copied ${exported.copied.length} characters`); + await page.close(); + + // One-off work has its own maps; with none saved, the starter map shows under the one-off notice. + const oneOff = await context.newPage(); + watchErrors(oneOff, problems, `${prefix} flowchart one-off`); + await pinSettings(oneOff, language); + await fakeFlowcharts(oneOff); + await oneOff.goto(`${ORIGIN}/`); + await oneOff.getByTestId('current-debate-motion').waitFor({ timeout: 30_000 }); + await settle(oneOff); + await oneOff.getByTestId('current-debate-pill').click(); + await oneOff.getByTestId('debates-sheet').waitFor(); + await settle(oneOff); + const toggle = oneOff.getByTestId('debates-sheet').getByRole('switch'); + await toggle.click(); + if (!(await toggle.isChecked())) throw new Error('The one-off switch did not turn on'); + await oneOff.getByTestId('debates-sheet').getByRole('button', { name: /^(Done|Kész|Listo)$/ }).click(); + await oneOff.getByTestId('debates-sheet').waitFor({ state: 'detached' }); + await oneOff.getByRole('tab', { name: /^(Prepare|Felkészülés|Preparar)$/ }).click(); + await oneOff.getByTestId('prepare-tool-flowchart').waitFor({ timeout: 30_000 }); + await oneOff.getByTestId('prepare-tool-flowchart').click(); + await oneOff.getByTestId('flowchart-screen').getByTestId('one-off-notice').waitFor({ timeout: 30_000 }); + await oneOff.getByTestId('flow-card').first().waitFor({ timeout: 30_000 }); + await settle(oneOff); + await shoot(oneOff, `${prefix}-flowchart-oneoff`); + await oneOff.close(); +} + async function shootLibraryScreens(page: Page, prefix: string) { await page.goto(`${ORIGIN}/library`); await page.getByTestId('library-add').waitFor({ timeout: 30_000 }); @@ -678,6 +826,8 @@ async function main() { await shootTool(signedIn, prefix, language, problems, tool); shots += 6; } + await shootFlowchart(signedIn, prefix, language, problems); + shots += 10; await signedIn.close(); } } From d05fda726468bedd14e53f8ccdcc4fbd272ae868 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 9 Oct 2026 17:08:15 +0000 Subject: [PATCH 4/5] Keep Flowchart forms and selections through saves and fast taps - Each held map gets a session handle that survives a save moving it to a new key, and the Add Card and claim sheets find their map by it, so an open form keeps the map and its unsaved text. - Saved Flowcharts lets only the latest tap open a map, and its rows wait while one loads. - Save as follows the new row only while the map it started from is still the one on screen. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/src/app/flowchart/card.tsx | 8 +++--- apps/mobile/src/app/flowchart/claim.tsx | 10 ++++--- apps/mobile/src/app/flowchart/index.tsx | 7 ++--- apps/mobile/src/app/flowchart/maps.tsx | 14 +++++++--- apps/mobile/src/lib/flowchart-drafts.test.ts | 22 +++++++++++++++ apps/mobile/src/lib/flowchart-drafts.ts | 28 +++++++++++++++++--- 6 files changed, 71 insertions(+), 18 deletions(-) create mode 100644 apps/mobile/src/lib/flowchart-drafts.test.ts diff --git a/apps/mobile/src/app/flowchart/card.tsx b/apps/mobile/src/app/flowchart/card.tsx index 526ed0ea0..9dae49dba 100644 --- a/apps/mobile/src/app/flowchart/card.tsx +++ b/apps/mobile/src/app/flowchart/card.tsx @@ -7,7 +7,7 @@ import { TextButton } from '@/components/buttons'; import { CardForm } from '@/components/flowchart-card-form'; import { SheetScrollView } from '@/components/sheet'; import { Text } from '@/components/text'; -import { getDraft, updateDraft, useFlowchartDrafts } from '@/lib/flowchart-drafts'; +import { findDraft, updateDraft, useFlowchartDrafts } from '@/lib/flowchart-drafts'; /** "+": the website's Add Node dialog as a form sheet. The card joins the map on screen. */ export default function AddCardSheet() { @@ -15,7 +15,9 @@ export default function AddCardSheet() { const router = useRouter(); const { scope, map } = useLocalSearchParams<{ scope: string; map: string }>(); useFlowchartDrafts(); - const draft = scope && map ? getDraft(scope, map) : undefined; + // `map` is the map's handle, so a save that changes its key while the sheet is open keeps it. + const held = scope && map ? findDraft(scope, map) : null; + const draft = held?.draft; return ( @@ -33,7 +35,7 @@ export default function AddCardSheet() { initial={{ label: '', description: '', type: 'contention', strength: 'moderate' }} submitLabel={t('flowchart.addNode')} onSubmit={(fields) => { - updateDraft(scope, map, (current) => ({ ...current, doc: addCard(current.doc, fields, Date.now()).doc, edited: true })); + if (held) updateDraft(scope, held.key, (current) => ({ ...current, doc: addCard(current.doc, fields, Date.now()).doc, edited: true })); router.back(); }} /> diff --git a/apps/mobile/src/app/flowchart/claim.tsx b/apps/mobile/src/app/flowchart/claim.tsx index 2d707da7b..bad3d6dad 100644 --- a/apps/mobile/src/app/flowchart/claim.tsx +++ b/apps/mobile/src/app/flowchart/claim.tsx @@ -26,7 +26,7 @@ import { SegmentedControl } from '@/components/segmented-control'; import { SheetScrollView } from '@/components/sheet'; import { Text } from '@/components/text'; import { confirmDestructive } from '@/lib/confirm'; -import { getDraft, updateDraft, useFlowchartDrafts } from '@/lib/flowchart-drafts'; +import { findDraft, updateDraft, useFlowchartDrafts } from '@/lib/flowchart-drafts'; import { cardKind, linkLine, linkSummary, typeLabel } from '@/lib/flowchart-text'; import { radius } from '@/theme/colors'; import { useTheme } from '@/theme/use-theme'; @@ -50,13 +50,15 @@ export default function ClaimSheet() { const flow = useFlowColors(); const { scope, map, node: nodeId } = useLocalSearchParams<{ scope: string; map: string; node: string }>(); useFlowchartDrafts(); - const draft = scope && map ? getDraft(scope, map) : undefined; + // `map` is the map's handle, so a save that changes its key while the sheet is open keeps it. + const held = scope && map ? findDraft(scope, map) : null; + const draft = held?.draft; const node = draft && nodeId ? findNode(draft.doc, nodeId) : undefined; const [mode, setMode] = useState<'view' | 'link' | 'edit'>('view'); const [kind, setKind] = useState(() => defaultKind(node?.data.type ?? 'contention')); const [typeMenu, setTypeMenu] = useState(false); - if (!draft || !node) { + if (!held || !draft || !node) { return ( @@ -67,7 +69,7 @@ export default function ClaimSheet() { ); } - const change = (edit: Parameters[2]) => updateDraft(scope, map, edit); + const change = (edit: Parameters[2]) => updateDraft(scope, held.key, edit); const { responds, answeredBy } = cardLinks(draft.doc, node.id); const links = [...responds.map((link) => ({ link, direction: 'responds' as const })), ...answeredBy.map((link) => ({ link, direction: 'answeredBy' as const }))]; const deletable = canDeleteCard(node.id); diff --git a/apps/mobile/src/app/flowchart/index.tsx b/apps/mobile/src/app/flowchart/index.tsx index e64cceb06..ed145bf4a 100644 --- a/apps/mobile/src/app/flowchart/index.tsx +++ b/apps/mobile/src/app/flowchart/index.tsx @@ -138,7 +138,7 @@ export default function FlowchartScreen() { const openCard = (node: FlowNode) => { if (!scope || !open) return; - router.push({ pathname: '/flowchart/claim', params: { scope, map: open.key, node: node.id } }); + router.push({ pathname: '/flowchart/claim', params: { scope, map: open.draft.handle, node: node.id } }); }; /** Runs a save or delete for the map on screen, as the account and map were when the student chose it. */ @@ -169,7 +169,8 @@ export default function FlowchartScreen() { // The held map becomes the new row; the map it was saved from (if any) goes back to its saved // state. Edits made while it saved stay held and unsaved, and the map on screen stays on screen. updateDraft(at.scope, at.key, (current) => (current.doc === at.draft.doc ? saved : { ...saved, doc: current.doc, edited: true })); - if (requestedId) { + // A list-opened screen follows the new row only while that map is still the one on screen; a map chosen meanwhile stays. + if (requestedId && openDraft(at.scope)?.draft.handle === at.draft.handle) { setFound({ id: row.id, scope: at.scope }); setRequestedId(row.id); } @@ -262,7 +263,7 @@ export default function FlowchartScreen() { router.push({ pathname: '/flowchart/card', params: { scope, map: open.key } }) }] : []} + buttons={draft && scope && open ? [{ key: 'add', label: t('flowchart.addNode'), symbol: 'plus', icon: 'plus', onPress: () => router.push({ pathname: '/flowchart/card', params: { scope, map: open.draft.handle } }) }] : []} /> diff --git a/apps/mobile/src/app/flowchart/maps.tsx b/apps/mobile/src/app/flowchart/maps.tsx index 50a0cf859..f6f1a9aa6 100644 --- a/apps/mobile/src/app/flowchart/maps.tsx +++ b/apps/mobile/src/app/flowchart/maps.tsx @@ -35,6 +35,8 @@ export default function SavedMapsSheet() { const [opening, setOpening] = useState(null); const [failedOpen, setFailedOpen] = useState(false); const loads = useRef(0); + // Only the latest tap may open a map; an earlier one still loading is dropped. + const selections = useRef(0); const live = useRef(true); const owner = useRef(userId); owner.current = userId; @@ -69,6 +71,7 @@ export default function SavedMapsSheet() { const choose = async (item: FlowchartSummary, index: number) => { if (!scope || !userId) return; + const selection = ++selections.current; const held = getDraft(scope, item.id); if (held) { showDraft(scope, held); @@ -80,7 +83,7 @@ export default function SavedMapsSheet() { setFailedOpen(false); try { const row = await fetchFlowchart(forUser, item.id); - if (!live.current || owner.current !== forUser) return; + if (!live.current || owner.current !== forUser || selections.current !== selection) return; if (!row) { setFailedOpen(true); void load(); @@ -90,9 +93,9 @@ export default function SavedMapsSheet() { router.back(); } catch (cause) { console.warn('Could not open the flowchart:', cause instanceof Error ? cause.message : cause); - if (live.current) setFailedOpen(true); + if (live.current && selections.current === selection) setFailedOpen(true); } finally { - if (live.current) setOpening(null); + if (live.current && selections.current === selection) setOpening(null); } }; @@ -107,6 +110,7 @@ export default function SavedMapsSheet() { }); if (!confirmed || !live.current) return; } + selections.current += 1; showDraft(scope, newDraft(debateId, starterFlowchart(t)), { replace: true }); router.back(); }; @@ -115,7 +119,9 @@ export default function SavedMapsSheet() { [styles.row, index > 0 && { borderTopWidth: StyleSheet.hairlineWidth, borderTopColor: colors.line }, pressed && styles.pressed]} testID="saved-map"> diff --git a/apps/mobile/src/lib/flowchart-drafts.test.ts b/apps/mobile/src/lib/flowchart-drafts.test.ts new file mode 100644 index 000000000..5867fcc66 --- /dev/null +++ b/apps/mobile/src/lib/flowchart-drafts.test.ts @@ -0,0 +1,22 @@ +import type { FlowchartDocument } from '@debatelab/core/flowchart'; +import { describe, expect, it } from 'vitest'; + +import { findDraft, getDraft, NEW_MAP, newDraft, openDraft, savedDraft, showDraft, updateDraft } from './flowchart-drafts'; + +const doc = { nodes: [], edges: [] } as unknown as FlowchartDocument; + +describe('flowchart drafts', () => { + it('keeps a map found by its handle when saving moves it to its new row', () => { + const scope = 'debate-1'; + showDraft(scope, newDraft('debate-1', doc)); + const handle = openDraft(scope)!.draft.handle; + + // Save, as the Flowchart screen does: the held new map becomes the saved row. + const row = savedDraft({ id: 'row-1', title: 'Cars', debateId: 'debate-1', createdAt: 1, doc }); + updateDraft(scope, NEW_MAP, () => row); + + expect(getDraft(scope, NEW_MAP)).toBeUndefined(); + expect(findDraft(scope, handle)).toMatchObject({ key: 'row-1', draft: { savedId: 'row-1', handle } }); + expect(openDraft(scope)?.key).toBe('row-1'); + }); +}); diff --git a/apps/mobile/src/lib/flowchart-drafts.ts b/apps/mobile/src/lib/flowchart-drafts.ts index 0d712a9b0..fb8b344be 100644 --- a/apps/mobile/src/lib/flowchart-drafts.ts +++ b/apps/mobile/src/lib/flowchart-drafts.ts @@ -23,6 +23,12 @@ export interface FlowchartDraft { doc: FlowchartDocument; /** Changed since it was opened or last saved. */ edited: boolean; + /** + * Which map this is for the session. Saving changes the key a map is held + * under (a new map becomes its row), but not its handle, so a sheet open on + * the map keeps it and its unsaved text. + */ + handle: string; } interface Scope { @@ -36,6 +42,12 @@ export const NEW_MAP = 'new'; const scopes = sessionSlot(() => new Map()); const listeners = new Set<() => void>(); let version = 0; +let handles = 0; + +function nextHandle(): string { + handles += 1; + return `map-${handles}`; +} function emit() { version += 1; @@ -63,12 +75,12 @@ export function scopeDebateId(scope: string): string | null { } export function savedDraft(row: SavedFlowchart): FlowchartDraft { - return { savedId: row.id, title: row.title, debateId: row.debateId, createdAt: row.createdAt, doc: row.doc, edited: false }; + return { savedId: row.id, title: row.title, debateId: row.debateId, createdAt: row.createdAt, doc: row.doc, edited: false, handle: nextHandle() }; } /** A map not saved yet: the website's starter map, in the debate (or one-off work) it will be saved to. */ export function newDraft(debateId: string | null, doc: FlowchartDocument): FlowchartDraft { - return { savedId: null, title: '', debateId, createdAt: null, doc, edited: false }; + return { savedId: null, title: '', debateId, createdAt: null, doc, edited: false, handle: nextHandle() }; } export function draftKey(draft: Pick): string { @@ -95,6 +107,14 @@ export function getDraft(scope: string, key: string): FlowchartDraft | undefined return scopes.get().get(scope)?.drafts.get(key); } +/** A held map by its handle, under whatever key it is held now. */ +export function findDraft(scope: string, handle: string): { key: string; draft: FlowchartDraft } | null { + for (const [key, draft] of scopes.get().get(scope)?.drafts ?? []) { + if (draft.handle === handle) return { key, draft }; + } + return null; +} + /** Puts a map on screen. Edits already held for it stay unless `replace` is set. Returns its key. */ export function showDraft(scope: string, draft: FlowchartDraft, options: { replace?: boolean } = {}): string { const entry = scopeFor(scope); @@ -105,12 +125,12 @@ export function showDraft(scope: string, draft: FlowchartDraft, options: { repla return key; } -/** Changes one held map. Resolves false when it is no longer held, for example after the account changed. */ +/** Changes one held map, which keeps its handle. Resolves false when it is no longer held, for example after the account changed. */ export function updateDraft(scope: string, key: string, change: (draft: FlowchartDraft) => FlowchartDraft): boolean { const entry = scopes.get().get(scope); const draft = entry?.drafts.get(key); if (!entry || !draft) return false; - const next = change(draft); + const next = { ...change(draft), handle: draft.handle }; entry.drafts.delete(key); entry.drafts.set(draftKey(next), next); if (entry.open === key) entry.open = draftKey(next); From 68d1e6d19e1fafeccd96ecefdc55ae5476e18fc1 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 9 Oct 2026 17:15:21 +0000 Subject: [PATCH 5/5] Find the Flowchart map by its handle when a save or edit lands A save finishing after the student started a new map wrote the new row onto the fresh starter map, because both used the unsaved map's key. Save, Add Card and the claim sheet's edits now find their map by handle when they run, so they change only the map they started from. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/src/app/flowchart/card.tsx | 3 ++- apps/mobile/src/app/flowchart/claim.tsx | 6 +++++- apps/mobile/src/app/flowchart/index.tsx | 7 +++++-- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/apps/mobile/src/app/flowchart/card.tsx b/apps/mobile/src/app/flowchart/card.tsx index 9dae49dba..b41f22446 100644 --- a/apps/mobile/src/app/flowchart/card.tsx +++ b/apps/mobile/src/app/flowchart/card.tsx @@ -35,7 +35,8 @@ export default function AddCardSheet() { initial={{ label: '', description: '', type: 'contention', strength: 'moderate' }} submitLabel={t('flowchart.addNode')} onSubmit={(fields) => { - if (held) updateDraft(scope, held.key, (current) => ({ ...current, doc: addCard(current.doc, fields, Date.now()).doc, edited: true })); + const now = findDraft(scope, map); + if (now) updateDraft(scope, now.key, (current) => ({ ...current, doc: addCard(current.doc, fields, Date.now()).doc, edited: true })); router.back(); }} /> diff --git a/apps/mobile/src/app/flowchart/claim.tsx b/apps/mobile/src/app/flowchart/claim.tsx index bad3d6dad..669c6a687 100644 --- a/apps/mobile/src/app/flowchart/claim.tsx +++ b/apps/mobile/src/app/flowchart/claim.tsx @@ -69,7 +69,11 @@ export default function ClaimSheet() { ); } - const change = (edit: Parameters[2]) => updateDraft(scope, held.key, edit); + // Found again when it runs: a save while a confirm was open may have moved the map to a new key. + const change = (edit: Parameters[2]) => { + const now = findDraft(scope, map); + if (now) updateDraft(scope, now.key, edit); + }; const { responds, answeredBy } = cardLinks(draft.doc, node.id); const links = [...responds.map((link) => ({ link, direction: 'responds' as const })), ...answeredBy.map((link) => ({ link, direction: 'answeredBy' as const }))]; const deletable = canDeleteCard(node.id); diff --git a/apps/mobile/src/app/flowchart/index.tsx b/apps/mobile/src/app/flowchart/index.tsx index ed145bf4a..7393d4863 100644 --- a/apps/mobile/src/app/flowchart/index.tsx +++ b/apps/mobile/src/app/flowchart/index.tsx @@ -21,6 +21,7 @@ import { useDebates } from '@/lib/debate-context'; import { copyText, sharePdf } from '@/lib/export'; import { FLOWCHART_EXPORT_FILE_NAME, flowchartExportHtml } from '@/lib/export-html'; import { + findDraft, findSavedDraft, forgetDraft, newDraft, @@ -168,9 +169,11 @@ export default function FlowchartScreen() { const saved = savedDraft(row); // The held map becomes the new row; the map it was saved from (if any) goes back to its saved // state. Edits made while it saved stay held and unsaved, and the map on screen stays on screen. - updateDraft(at.scope, at.key, (current) => (current.doc === at.draft.doc ? saved : { ...saved, doc: current.doc, edited: true })); + // It is found by its handle, because a new map started meanwhile takes the unsaved map's key. + const now = findDraft(at.scope, at.draft.handle); + if (now) updateDraft(at.scope, now.key, (current) => (current.doc === at.draft.doc ? saved : { ...saved, doc: current.doc, edited: true })); // A list-opened screen follows the new row only while that map is still the one on screen; a map chosen meanwhile stays. - if (requestedId && openDraft(at.scope)?.draft.handle === at.draft.handle) { + if (now && requestedId && openDraft(at.scope)?.draft.handle === at.draft.handle) { setFound({ id: row.id, scope: at.scope }); setRequestedId(row.id); }