diff --git a/apps/mobile/AGENTS.md b/apps/mobile/AGENTS.md index 5ec39825f..c34abfcb3 100644 --- a/apps/mobile/AGENTS.md +++ b/apps/mobile/AGENTS.md @@ -17,14 +17,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); Brainstorm and Factoids reuse it. 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. The switcher turns it on with or without a Current Debate, and `OneOffNotice` shows it on the Debate tab, Prepare, Ask, Library and the Generate, Attack, Brainstorm and Factoids forms (through `WorkingIn`). The Debate tab, Prepare and Library still list the Current Debate's work in one-off mode, as the website's home does. 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. The switcher turns it on with or without a Current Debate, and `OneOffNotice` shows it on the Debate tab, Prepare, Ask, Library and the Generate, Attack, Brainstorm and Factoids forms (through `WorkingIn`) and the Flowchart. The Debate tab, Prepare and Library still list the Current Debate's work in one-off mode, as the website's home does. 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/scripts/screenshots.ts b/apps/mobile/scripts/screenshots.ts index 054026a2f..cf7b5458f 100644 --- a/apps/mobile/scripts/screenshots.ts +++ b/apps/mobile/scripts/screenshots.ts @@ -39,6 +39,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'; @@ -495,6 +501,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 }); @@ -751,6 +899,8 @@ async function main() { await shootTool(signedIn, prefix, language, problems, tool); shots += 6; } + await shootFlowchart(signedIn, prefix, language, problems); + shots += 10; const oneOffPage = await signedIn.newPage(); watchErrors(oneOffPage, problems, `${prefix} one-off tools`); await pinSettings(oneOffPage, language); 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..b41f22446 --- /dev/null +++ b/apps/mobile/src/app/flowchart/card.tsx @@ -0,0 +1,52 @@ +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 { 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() { + const { t } = useTranslation(); + const router = useRouter(); + const { scope, map } = useLocalSearchParams<{ scope: string; map: string }>(); + useFlowchartDrafts(); + // `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 ( + + + + {t('flowchart.addNodeDialog')} + + router.back()} /> + + + {draft ? t('flowchart.addNodeDialogDesc') : t('mobile.flowchart.mapGone')} + + {draft ? ( + { + 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(); + }} + /> + ) : 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..669c6a687 --- /dev/null +++ b/apps/mobile/src/app/flowchart/claim.tsx @@ -0,0 +1,268 @@ +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 { 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'; + +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(); + // `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 (!held || !draft || !node) { + return ( + + + {t('mobile.flowchart.cardGone')} + + router.back()} /> + + ); + } + + // 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); + 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..7393d4863 --- /dev/null +++ b/apps/mobile/src/app/flowchart/index.tsx @@ -0,0 +1,382 @@ +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 { + findDraft, + 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.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. */ + 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. + // 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 (now && requestedId && openDraft(at.scope)?.draft.handle === at.draft.handle) { + 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.draft.handle } }) }] : []} + /> + + + + {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..f6f1a9aa6 --- /dev/null +++ b/apps/mobile/src/app/flowchart/maps.tsx @@ -0,0 +1,202 @@ +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); + // 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; + + 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 selection = ++selections.current; + 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 || selections.current !== selection) 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 && selections.current === selection) setFailedOpen(true); + } finally { + if (live.current && selections.current === selection) 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; + } + selections.current += 1; + 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/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/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.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 new file mode 100644 index 000000000..fb8b344be --- /dev/null +++ b/apps/mobile/src/lib/flowchart-drafts.ts @@ -0,0 +1,159 @@ +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; + /** + * 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 { + 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; +let handles = 0; + +function nextHandle(): string { + handles += 1; + return `map-${handles}`; +} + +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, 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, handle: nextHandle() }; +} + +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); +} + +/** 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); + 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, 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), handle: draft.handle }; + 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/packages/core/src/flowchart.ts b/packages/core/src/flowchart.ts new file mode 100644 index 000000000..c3c94743e --- /dev/null +++ b/packages/core/src/flowchart.ts @@ -0,0 +1,343 @@ +/** + * 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 | null; + 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' && + (value.data.description === undefined || value.data.description === null || typeof value.data.description === '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/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" } } } 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']); + }); +});